diff --git a/packages/plugins/plugin-llm-wiki/skills/index-refresh/SKILL.md b/packages/plugins/plugin-llm-wiki/skills/index-refresh/SKILL.md index c1e7f32ee5..aa8d01e601 100644 --- a/packages/plugins/plugin-llm-wiki/skills/index-refresh/SKILL.md +++ b/packages/plugins/plugin-llm-wiki/skills/index-refresh/SKILL.md @@ -15,7 +15,7 @@ Keep `wiki/index.md` accurate and scannable. The index is the maintainer's first ## Workflow 1. **Read the target space's `wiki/index.md`** as it currently stands. -2. **Walk the target space's `wiki/`.** `wiki/projects//standup.md` entries are current-state companions for durable `wiki/projects//index.md` pages; index them only as links attached to the matching project entry. Walk `wiki/` by category (`sources/`, `projects/`, `entities/`, `concepts/`, `synthesis/`, plus any custom subdirectories the wiki schema added). +2. **Walk the target space's `wiki/`.** Call `wiki_list_pages` repeatedly, passing each `pageInfo.nextCursor`, until `pageInfo.complete` is true. Never treat one page of results as the full tree. `wiki/projects//standup.md` entries are current-state companions for durable `wiki/projects//index.md` pages; index them only as links attached to the matching project entry. Walk `wiki/` by category (`sources/`, `projects/`, `entities/`, `concepts/`, `synthesis/`, plus any custom subdirectories the wiki schema added). 3. **Read the target space's last ~50 entries of `wiki/log.md`** to spot pages that were created or substantially changed but never made it to the index. 4. **Per category, produce sorted entries** of the form: ``` @@ -62,4 +62,4 @@ Before closing the operation issue: ## Tools -`wiki_search`, `wiki_read_page`, `wiki_write_page` (for `wiki/index.md` and `wiki/log.md` only). Always include the operation issue's `wikiId` and `spaceSlug`. +`wiki_list_pages`, `wiki_search`, `wiki_read_page`, `wiki_write_page` (for `wiki/index.md` and `wiki/log.md` only). Always include the operation issue's `wikiId` and `spaceSlug`. diff --git a/packages/plugins/plugin-llm-wiki/skills/wiki-lint/SKILL.md b/packages/plugins/plugin-llm-wiki/skills/wiki-lint/SKILL.md index 345ca995f9..7461020ec9 100644 --- a/packages/plugins/plugin-llm-wiki/skills/wiki-lint/SKILL.md +++ b/packages/plugins/plugin-llm-wiki/skills/wiki-lint/SKILL.md @@ -14,7 +14,7 @@ Audit, do not edit. Return findings the maintainer (human or agent) can triage. ## Workflow -1. **Walk the target space's `wiki/index.md` and wiki tree** with `wiki_search` and `wiki_read_page`, always passing the operation issue's `wikiId` and `spaceSlug`. Build a mental map of: pages that exist, pages referenced from `index.md`, pages referenced from other pages, and raw sources. +1. **Walk the target space's `wiki/index.md` and wiki tree.** Call `wiki_list_pages` repeatedly, passing each `pageInfo.nextCursor`, until `pageInfo.complete` is true; a single page is never proof of a complete inventory. Use `wiki_search` and `wiki_read_page` for content, always passing the operation issue's `wikiId` and `spaceSlug`. Build a mental map of: pages that exist, pages referenced from `index.md`, pages referenced from other pages, and raw sources. 2. **Check for the seven recurring issues**, in this order: 1. **Contradictions** — two pages making incompatible claims about the same entity, decision, or status. Flag both pages, name the conflicting claims, and quote evidence. 2. **Stale claims** — a page asserts X, but a newer source under `raw/` has superseded it. Flag the older page; never overwrite. @@ -54,4 +54,4 @@ Before closing the operation issue: ## Tools -`wiki_search`, `wiki_read_page`, `wiki_list_sources`, `wiki_read_source`, `wiki_write_page` (only `wiki/log.md`). Always include the operation issue's `wikiId` and `spaceSlug`. +`wiki_list_pages`, `wiki_search`, `wiki_read_page`, `wiki_list_sources`, `wiki_read_source`, `wiki_write_page` (only `wiki/log.md`). Always include the operation issue's `wikiId` and `spaceSlug`. diff --git a/packages/plugins/plugin-llm-wiki/src/manifest.ts b/packages/plugins/plugin-llm-wiki/src/manifest.ts index ec235d38fe..09aed3b60d 100644 --- a/packages/plugins/plugin-llm-wiki/src/manifest.ts +++ b/packages/plugins/plugin-llm-wiki/src/manifest.ts @@ -462,13 +462,15 @@ const manifest: PaperclipPluginManifestV1 = { { name: "wiki_list_pages", displayName: "List Wiki Pages", - description: "Return the known page index from one wiki space's plugin metadata. Operation agents should pass the issue's spaceSlug; omitting it uses the default space.", + description: "Return one explicitly paginated page of the known wiki inventory. Follow nextCursor until complete is true before treating the result as the full tree. Operation agents should pass the issue's spaceSlug; omitting it uses the default space.", parametersSchema: { type: "object", properties: { companyId: { type: "string" }, wikiId: { type: "string" }, - spaceSlug: { type: "string" } + spaceSlug: { type: "string" }, + cursor: { type: "string", description: "Opaque continuation cursor returned by the previous page." }, + limit: { type: "number", description: "Page size from 1 to 500 (default 200)." } }, required: ["companyId", "wikiId"] } diff --git a/packages/plugins/plugin-llm-wiki/src/wiki/core.ts b/packages/plugins/plugin-llm-wiki/src/wiki/core.ts index b907f3bc48..7c2c7f7baa 100644 --- a/packages/plugins/plugin-llm-wiki/src/wiki/core.ts +++ b/packages/plugins/plugin-llm-wiki/src/wiki/core.ts @@ -4257,20 +4257,50 @@ export async function registerWikiTools(ctx: PluginContext) { ctx.tools.register("wiki_list_pages", { displayName: "List Wiki Pages", - description: "Return the known page index from plugin metadata.", + description: "Return one page of the known wiki inventory. Follow nextCursor until complete is true before treating it as the full tree.", parametersSchema: ctx.manifest.tools?.find((tool) => tool.name === "wiki_list_pages")?.parametersSchema ?? { type: "object" }, }, async (params: unknown): Promise => { const input = params as ToolParams; const companyId = requireString(input.companyId, "companyId"); const wikiId = normalizeWikiId(input.wikiId); const space = await resolveSpace(ctx, { companyId, wikiId, spaceSlug: input.spaceSlug as string | null | undefined }); + const limit = normalizeLimit(input.limit, 200, 500); + const cursor = typeof input.cursor === "string" && input.cursor.length > 0 + ? Buffer.from(input.cursor, "base64url").toString("utf8") + : null; + if (cursor != null && !cursor.startsWith("wiki/")) { + throw new Error("Invalid wiki page inventory cursor."); + } const rows = await ctx.db.query<{ path: string; title: string | null; page_type: string | null }>( - `SELECT path, title, page_type FROM ${tableName(ctx.db.namespace, "wiki_pages")} WHERE company_id = $1 AND wiki_id = $2 AND space_id = $3 ORDER BY path LIMIT 200`, - [companyId, wikiId, space.id], + `SELECT path, title, page_type + FROM ${tableName(ctx.db.namespace, "wiki_pages")} + WHERE company_id = $1 + AND wiki_id = $2 + AND space_id = $3 + AND ($4::text IS NULL OR path > $4) + ORDER BY path + LIMIT $5`, + [companyId, wikiId, space.id, cursor, limit + 1], ); + const pages = rows.slice(0, limit); + const hasMore = rows.length > limit; + const nextCursor = hasMore && pages.length > 0 + ? Buffer.from(pages[pages.length - 1].path, "utf8").toString("base64url") + : null; return { - content: rows.length ? rows.map((row) => `${row.path}${row.title ? ` - ${row.title}` : ""}`).join("\n") : "No pages indexed yet.", - data: { companyId, wikiId, spaceSlug: space.slug, pages: rows }, + content: pages.length ? pages.map((row) => `${row.path}${row.title ? ` - ${row.title}` : ""}`).join("\n") : "No pages indexed yet.", + data: { + companyId, + wikiId, + spaceSlug: space.slug, + pages, + pageInfo: { + limit, + returned: pages.length, + complete: !hasMore, + nextCursor, + }, + }, }; }); } diff --git a/packages/plugins/plugin-llm-wiki/tests/plugin.spec.ts b/packages/plugins/plugin-llm-wiki/tests/plugin.spec.ts index df52153a0a..b31a6538d9 100644 --- a/packages/plugins/plugin-llm-wiki/tests/plugin.spec.ts +++ b/packages/plugins/plugin-llm-wiki/tests/plugin.spec.ts @@ -46,6 +46,8 @@ const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.me devDependencies?: Record; peerDependencies?: Record; }; +const INDEX_REFRESH_SKILL_MARKDOWN = readFileSync(new URL("../skills/index-refresh/SKILL.md", import.meta.url), "utf8"); +const WIKI_LINT_SKILL_MARKDOWN = readFileSync(new URL("../skills/wiki-lint/SKILL.md", import.meta.url), "utf8"); const DEFAULT_MANAGED_SKILL = { status: "resolved", skillId: "skill-1", @@ -1530,6 +1532,55 @@ Duplicate headings receive stable suffixes. expect(pages.content).toBe("No pages indexed yet."); }); + it("paginates wiki page inventory and makes completeness explicit", async () => { + const harness = createTestHarness({ manifest }); + const allPages = [ + { path: "wiki/concepts/a.md", title: "A", page_type: "concepts" }, + { path: "wiki/concepts/b.md", title: "B", page_type: "concepts" }, + { path: "wiki/concepts/c.md", title: "C", page_type: "concepts" }, + ]; + harness.ctx.db.query = async >(sql: string, params?: unknown[]) => { + harness.dbQueries.push({ sql, params }); + if (!sql.includes("wiki_pages")) return []; + const cursor = params?.[3] as string | null; + const requested = Number(params?.[4]); + return allPages.filter((page) => cursor == null || page.path > cursor).slice(0, requested) as T[]; + }; + await plugin.definition.setup(harness.ctx); + + const first = await harness.executeTool<{ + data: { pages: Array<{ path: string }>; pageInfo: { complete: boolean; nextCursor: string | null; returned: number } }; + }>("wiki_list_pages", { companyId: COMPANY_ID, wikiId: "default", limit: 2 }); + expect(first.data.pages.map((page) => page.path)).toEqual([ + "wiki/concepts/a.md", + "wiki/concepts/b.md", + ]); + expect(first.data.pageInfo).toMatchObject({ complete: false, returned: 2 }); + + const second = await harness.executeTool<{ + data: { pages: Array<{ path: string }>; pageInfo: { complete: boolean; nextCursor: string | null; returned: number } }; + }>("wiki_list_pages", { + companyId: COMPANY_ID, + wikiId: "default", + limit: 2, + cursor: first.data.pageInfo.nextCursor, + }); + expect(second.data.pages.map((page) => page.path)).toEqual(["wiki/concepts/c.md"]); + expect(second.data.pageInfo).toEqual({ limit: 2, returned: 1, complete: true, nextCursor: null }); + await expect(harness.executeTool("wiki_list_pages", { + companyId: COMPANY_ID, + wikiId: "default", + cursor: Buffer.from("raw/not-a-page.md", "utf8").toString("base64url"), + })).rejects.toThrow("Invalid wiki page inventory cursor."); + + const listPagesTool = manifest.tools?.find((tool) => tool.name === "wiki_list_pages"); + expect(listPagesTool?.description).toContain("until complete is true"); + expect(JSON.stringify(listPagesTool?.parametersSchema)).toContain('"cursor":{"type":"string"'); + expect(JSON.stringify(listPagesTool?.parametersSchema)).toContain('"limit":{"type":"number"'); + expect(INDEX_REFRESH_SKILL_MARKDOWN).toContain("pageInfo.nextCursor"); + expect(WIKI_LINT_SKILL_MARKDOWN).toContain("pageInfo.nextCursor"); + }); + it("filters stale page and raw source rows out of browse data", async () => { const harness = createTestHarness({ manifest }); const files = new Map([