Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/wiki-search-tool.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"openwiki": minor
---

Add `openwiki search` / `openwiki personal search` and an `openwiki_search_wiki` agent tool for full-text wiki retrieval.
9 changes: 8 additions & 1 deletion src/agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
type GlobResult,
} from "deepagents";
import { createOpenWikiConnectorTools } from "../connectors/tools.js";
import { createWikiSearchTool } from "../search/index.js";
import {
DEBUG_ENV_KEYS,
loadOpenWikiEnv,
Expand Down Expand Up @@ -366,7 +367,13 @@ function createOpenWikiAgentGraph(

return createDeepAgent({
model: options.model,
tools: createOpenWikiConnectorTools(),
tools: [
...createOpenWikiConnectorTools(),
createWikiSearchTool({
cwd: options.cwd,
outputMode: options.outputMode,
}),
],
checkpointer: options.checkpointer,
backend,
middleware:
Expand Down
6 changes: 5 additions & 1 deletion src/agent/prompts/code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ Run discipline:
- Inspect the repository tree, workspace and package manifests, existing docs, entrypoints, routing and schema files, public surfaces, and representative implementation and tests.{OPENWIKIIGNORE_INSTRUCTIONS}

Wiki-first question answering:
- For ordinary chat questions, inspect the generated wiki under /openwiki first. Use quickstart/index pages, section pages, and targeted grep/glob over the wiki before looking at source files.
- For ordinary chat questions, call openwiki_search_wiki first to locate relevant pages under /openwiki, then read only the best matching virtualPath hits. Prefer search over broad ls/grep crawls.
- If search is unavailable or returns nothing useful, fall back to quickstart/index pages and targeted grep/glob over the wiki before looking at source files.
- If the user asks you to "look at the wiki", answer "based on the wiki", report "what the wiki says", or otherwise frames the request around the wiki, use only /openwiki pages unless the wiki cannot support the answer.
- Assume the generated wiki contains the answer most of the time. Do not exhaustively read source files just because they exist.

Expand All @@ -43,6 +44,9 @@ OpenWiki CLI reference:
- \`openwiki --update [message]\` updates repository documentation under openwiki/ (code mode).
- \`openwiki personal --init [message]\` initializes the local personal brain wiki under ~/.openwiki/wiki.
- \`openwiki code --init [message]\` initializes repository documentation under openwiki/.
- \`openwiki personal search <query>\` full-text searches the local personal brain without starting an agent.
- \`openwiki code search <query>\` full-text searches repository documentation under openwiki/.
- \`openwiki search [--mode personal|code] [--limit <n>] <query>\` full-text searches a wiki (defaults to personal).
- \`openwiki --mode code --init [message]\` initializes repository documentation under openwiki/.
- \`openwiki --mode personal --init [message]\` initializes the local personal brain wiki under ~/.openwiki/wiki.
- \`openwiki -p "message"\` or \`openwiki --print "message"\` runs once, prints the final assistant output, and exits.
Expand Down
6 changes: 5 additions & 1 deletion src/agent/prompts/personal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ Connector ingestion discipline:


Wiki-first question answering:
- For ordinary chat questions, inspect the generated wiki under the virtual root / first. Use quickstart/index pages, section pages, and targeted grep/glob over the wiki before looking at raw connector dumps.
- For ordinary chat questions, call openwiki_search_wiki first to locate relevant wiki pages, then read only the best matching virtualPath hits. Prefer search over broad ls/grep crawls across the wiki.
- If search is unavailable or returns nothing useful, fall back to quickstart/index pages and targeted grep/glob over the wiki before looking at raw connector dumps.
- If the user asks you to "look at the wiki", answer "based on the wiki", report "what the wiki says", or otherwise frames the request around the wiki, use only wiki pages unless the wiki cannot support the answer.
- Assume the synthesized wiki contains the answer most of the time. Do not inspect raw connector data just because it exists.
- Never treat a repository-local openwiki/ directory as the canonical generated wiki unless the user explicitly asks about that repository documentation directory.
Expand Down Expand Up @@ -75,6 +76,9 @@ OpenWiki CLI reference:
- \`openwiki --update [message]\` updates repository documentation under openwiki/ (code mode).
- \`openwiki personal --init [message]\` initializes the local personal brain wiki under ~/.openwiki/wiki.
- \`openwiki code --init [message]\` initializes repository documentation under openwiki/.
- \`openwiki personal search <query>\` full-text searches the local personal brain without starting an agent.
- \`openwiki code search <query>\` full-text searches repository documentation under openwiki/.
- \`openwiki search [--mode personal|code] [--limit <n>] <query>\` full-text searches a wiki (defaults to personal).
- \`openwiki --mode code --init [message]\` initializes repository documentation under openwiki/.
- \`openwiki --mode personal --init [message]\` initializes the local personal brain wiki under ~/.openwiki/wiki.
- \`openwiki -p "message"\` or \`openwiki --print "message"\` runs once, prints the final assistant output, and exits.
Expand Down
37 changes: 37 additions & 0 deletions src/cli.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ import {
saveOpenWikiOnboardingConfig,
} from "./onboarding.js";
import { openWikiLocalWikiDir } from "./openwiki-home.js";
import {
resolveWikiRoot,
searchWiki,
virtualRootForRunMode,
} from "./search/index.js";
import {
deleteConnectorSchedules,
getSavedPowerScheduleStatus,
Expand Down Expand Up @@ -3795,6 +3800,8 @@ if (command.kind === "auth") {
await runCronCommand(command);
} else if (command.kind === "ingest") {
await runIngestCommand(command);
} else if (command.kind === "search") {
await runSearchCommand(command);
} else if (command.kind === "visualize") {
await runVisualizeCommand(command);
} else if (shouldPrintStartupError(argv, parsedCommand, command)) {
Expand Down Expand Up @@ -3853,6 +3860,36 @@ async function runVisualizeCommand(
}
}

async function runSearchCommand(
command: Extract<CliCommand, { kind: "search" }>,
): Promise<void> {
try {
const rootDir = resolveWikiRoot(command.mode, process.cwd());
const hits = await searchWiki({
rootDir,
query: command.query,
virtualRoot: virtualRootForRunMode(command.mode),
maxResults: command.limit ?? undefined,
});

if (hits.length === 0) {
process.stdout.write(
`No matches for ${JSON.stringify(command.query)} in ${rootDir}\n`,
);
process.exitCode = 0;
return;
}

for (const hit of hits) {
process.stdout.write(`${hit.path}:${hit.line}: ${hit.snippet}\n`);
}
process.exitCode = 0;
} catch (error) {
process.stderr.write(`${getErrorMessage(error)}\n`);
process.exitCode = 1;
}
}

async function runCronCommand(
command: Extract<CliCommand, { kind: "cron" }>,
): Promise<void> {
Expand Down
165 changes: 165 additions & 0 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,13 @@ export type CliCommand =
exitCode: 0;
target: CronTarget | null;
}
| {
kind: "search";
exitCode: 0;
limit: number | null;
mode: OpenWikiRunMode;
query: string;
}
| { kind: "help"; exitCode: 0 }
| {
kind: "run";
Expand Down Expand Up @@ -393,13 +400,147 @@ export function parseCommand(argv: string[]): CliCommand {
}
}

if (argv[0] === "search") {
return parseSearchCommand(argv.slice(1), null);
}

if (isOpenWikiRunMode(argv[0]) && argv[1] === "search") {
return parseSearchCommand(argv.slice(2), argv[0]);
}

if (isOpenWikiRunMode(argv[0])) {
return parseRunCommand(argv.slice(1), argv[0], "positional");
}

return parseRunCommand(argv, "code", "default");
}

function parseSearchCommand(
argv: string[],
initialMode: OpenWikiRunMode | null,
): CliCommand {
let mode = initialMode;
let limit: number | null = null;
const queryParts: string[] = [];

for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];

if (arg === "--help" || arg === "-h") {
return { kind: "help", exitCode: 0 };
}

if (arg === "--mode") {
if (initialMode !== null) {
return {
kind: "error",
exitCode: 1,
message:
"Do not pass --mode when using openwiki personal search or openwiki code search.",
};
}

const nextArg = argv[index + 1];
if (!nextArg || nextArg.startsWith("-")) {
return {
kind: "error",
exitCode: 1,
message: "--mode requires personal or code.",
};
}
if (!isOpenWikiRunMode(nextArg)) {
return {
kind: "error",
exitCode: 1,
message: `Invalid mode: ${nextArg}. Expected personal or code.`,
};
}
mode = nextArg;
index += 1;
continue;
}

if (arg.startsWith("--mode=")) {
if (initialMode !== null) {
return {
kind: "error",
exitCode: 1,
message:
"Do not pass --mode when using openwiki personal search or openwiki code search.",
};
}
const nextArg = arg.slice("--mode=".length);
if (!isOpenWikiRunMode(nextArg)) {
return {
kind: "error",
exitCode: 1,
message: `Invalid mode: ${nextArg}. Expected personal or code.`,
};
}
mode = nextArg;
continue;
}

if (arg === "--limit") {
const nextArg = argv[index + 1];
if (!nextArg || nextArg.startsWith("-")) {
return {
kind: "error",
exitCode: 1,
message: "--limit requires a number.",
};
}
limit = Number(nextArg);
index += 1;
continue;
}

if (arg.startsWith("--limit=")) {
limit = Number(arg.slice("--limit=".length));
continue;
}

if (arg.startsWith("-")) {
return {
kind: "error",
exitCode: 1,
message: `Unknown option for search: ${arg}`,
};
}

queryParts.push(arg);
}

if (
limit !== null &&
(!Number.isInteger(limit) || limit < 1 || limit > 100)
) {
return {
kind: "error",
exitCode: 1,
message: "--limit must be an integer between 1 and 100.",
};
}

const query = queryParts.join(" ").trim();
if (!query) {
return {
kind: "error",
exitCode: 1,
message:
"Usage: openwiki personal search <query> | openwiki search [--mode personal|code] [--limit <n>] <query>",
};
}

return {
kind: "search",
exitCode: 0,
limit,
mode: mode ?? "personal",
query,
};
}

function parseRunCommand(
argv: string[],
initialMode: OpenWikiRunMode,
Expand Down Expand Up @@ -747,6 +888,9 @@ export const helpContent: HelpContent = {
"openwiki cron pause all",
"openwiki cron resume all",
"openwiki cron delete all",
"openwiki personal search <query>",
"openwiki code search <query>",
"openwiki search [--mode personal|code] [--limit <n>] <query>",
"openwiki ngrok start [url] [--port <port>]",
"openwiki visualize [path] [--port <port>] [--no-open]",
],
Expand All @@ -761,6 +905,21 @@ export const helpContent: HelpContent = {
description:
"Run OpenWiki as your local personal brain over configured sources, writing to ~/.openwiki/wiki.",
},
{
label: "openwiki personal search <query>",
description:
"Full-text search the local personal brain wiki under ~/.openwiki/wiki without starting an agent.",
},
{
label: "openwiki code search <query>",
description:
"Full-text search repository documentation under ./openwiki without starting an agent.",
},
{
label: "openwiki search <query>",
description:
"Full-text search a wiki (defaults to personal mode; pass --mode code for repository docs).",
},
{
label: "openwiki",
description:
Expand Down Expand Up @@ -859,6 +1018,10 @@ export const helpContent: HelpContent = {
description:
"Write the exact anonymous telemetry payload to a local JSON file.",
},
{
label: "--limit <n>",
description: "For search: maximum number of hits to print (1-100).",
},
{
label: "--port <port>",
description:
Expand Down Expand Up @@ -895,6 +1058,8 @@ export const helpContent: HelpContent = {
"openwiki cron pause all",
"openwiki cron resume all",
"openwiki cron delete all",
'openwiki personal search "middleware"',
'openwiki search --mode personal --limit 10 "filesystem backends"',
"openwiki auth slack",
"openwiki auth gmail",
"openwiki auth notion",
Expand Down
10 changes: 10 additions & 0 deletions src/search/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export type { WikiSearchHit, WikiSearchOptions } from "./types.js";
export { searchWiki, tokenizeQuery } from "./search-wiki.js";
export {
resolveWikiRoot,
resolveWikiRootFromOutputMode,
runModeToOutputMode,
virtualRootForOutputMode,
virtualRootForRunMode,
} from "./resolve-wiki-root.js";
export { createWikiSearchTool } from "./tools.js";
46 changes: 46 additions & 0 deletions src/search/resolve-wiki-root.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import path from "node:path";
import { openWikiLocalWikiDir } from "../openwiki-home.js";
import type { OpenWikiOutputMode } from "../agent/types.js";

const OPEN_WIKI_DIR = "openwiki";

export type WikiSearchRunMode = "personal" | "code";

export function runModeToOutputMode(
mode: WikiSearchRunMode,
): OpenWikiOutputMode {
return mode === "code" ? "repository" : "local-wiki";
}

export function resolveWikiRoot(
mode: WikiSearchRunMode,
cwd: string = process.cwd(),
): string {
if (mode === "personal") {
return openWikiLocalWikiDir;
}

return path.join(cwd, OPEN_WIKI_DIR);
}

export function resolveWikiRootFromOutputMode(
outputMode: OpenWikiOutputMode,
cwd: string,
): string {
if (outputMode === "local-wiki") {
// Personal-brain agent runs use the wiki directory as cwd.
return cwd;
}

return path.join(cwd, OPEN_WIKI_DIR);
}

export function virtualRootForOutputMode(
outputMode: OpenWikiOutputMode,
): string {
return outputMode === "local-wiki" ? "/" : "/openwiki/";
}

export function virtualRootForRunMode(mode: WikiSearchRunMode): string {
return mode === "personal" ? "/" : "/openwiki/";
}
Loading