@kolisachint/hoocode-agent 0.5.26 → 0.5.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/dist/core/capabilities/lexical.d.ts +4 -0
  3. package/dist/core/capabilities/lexical.d.ts.map +1 -1
  4. package/dist/core/capabilities/lexical.js +104 -4
  5. package/dist/core/capabilities/lexical.js.map +1 -1
  6. package/dist/core/capabilities/registry.d.ts +3 -1
  7. package/dist/core/capabilities/registry.d.ts.map +1 -1
  8. package/dist/core/capabilities/registry.js.map +1 -1
  9. package/dist/core/self-docs.d.ts +103 -0
  10. package/dist/core/self-docs.d.ts.map +1 -0
  11. package/dist/core/self-docs.js +351 -0
  12. package/dist/core/self-docs.js.map +1 -0
  13. package/dist/core/system-prompt.d.ts +12 -0
  14. package/dist/core/system-prompt.d.ts.map +1 -1
  15. package/dist/core/system-prompt.js +11 -1
  16. package/dist/core/system-prompt.js.map +1 -1
  17. package/dist/extensions/core/hoo-core.d.ts +1 -0
  18. package/dist/extensions/core/hoo-core.d.ts.map +1 -1
  19. package/dist/extensions/core/hoo-core.js +3 -0
  20. package/dist/extensions/core/hoo-core.js.map +1 -1
  21. package/dist/extensions/core/mcp-loader.d.ts.map +1 -1
  22. package/dist/extensions/core/mcp-loader.js +8 -2
  23. package/dist/extensions/core/mcp-loader.js.map +1 -1
  24. package/dist/extensions/core/self-knowledge.d.ts +28 -0
  25. package/dist/extensions/core/self-knowledge.d.ts.map +1 -0
  26. package/dist/extensions/core/self-knowledge.js +199 -0
  27. package/dist/extensions/core/self-knowledge.js.map +1 -0
  28. package/docs/canvas.md +117 -0
  29. package/docs/compaction.md +4 -4
  30. package/docs/custom-provider.md +1 -1
  31. package/docs/development.md +1 -1
  32. package/docs/docs.json +27 -2
  33. package/docs/extensions.md +12 -12
  34. package/docs/index.md +8 -0
  35. package/docs/keybindings.md +2 -2
  36. package/docs/mcp.md +97 -0
  37. package/docs/models.md +1 -1
  38. package/docs/modes.md +87 -0
  39. package/docs/packages.md +4 -4
  40. package/docs/plugins.md +124 -0
  41. package/docs/prompt-templates.md +1 -1
  42. package/docs/providers.md +2 -2
  43. package/docs/quickstart.md +2 -2
  44. package/docs/rpc.md +5 -5
  45. package/docs/sdk.md +5 -5
  46. package/docs/session-format.md +3 -3
  47. package/docs/sessions.md +1 -1
  48. package/docs/settings.md +3 -3
  49. package/docs/shell-aliases.md +1 -1
  50. package/docs/skills.md +2 -2
  51. package/docs/terminal-setup.md +1 -1
  52. package/docs/termux.md +2 -2
  53. package/docs/themes.md +3 -3
  54. package/docs/usage.md +93 -4
  55. package/docs/windows.md +1 -1
  56. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  57. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  58. package/examples/extensions/sandbox/package.json +1 -1
  59. package/examples/extensions/with-deps/package.json +1 -1
  60. package/package.json +4 -4
package/CHANGELOG.md CHANGED
@@ -1,5 +1,56 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.28] - 2026-08-22
4
+
5
+ ## [0.5.27] - 2026-08-22
6
+
7
+ ### Added
8
+
9
+ - hoocode can answer questions about itself. The startup banner already promised
10
+ "hoocode can explain its own features and look up its docs", but nothing put a
11
+ docs path into model context — `getDocsPath()`'s only caller printed paths to
12
+ the human on an auth failure. The agent could not find them on its own either:
13
+ its cwd is your project, so searching there turns up your docs, never
14
+ hoocode's.
15
+
16
+ The system prompt now lists the shipped docs (~215 tokens, filenames only),
17
+ and a `SearchHooCode` tool retrieves them at the heading level, returning a
18
+ file path and line number to read. It also indexes the session's skills,
19
+ slash commands, and subagents, so "what can you do?" has one place that
20
+ answers it. MCP tools remain with `ResolveMcpTools`.
21
+
22
+ - New documentation for features that had none: `plugins.md` (marketplaces,
23
+ the trust model, plugin formats), `mcp.md` (server config, transports,
24
+ deferred schemas), `modes.md` (ask/plan/build/debug and the plan → grill →
25
+ approve → goal workflow), and `canvas.md`. `usage.md` gains the 13 slash
26
+ commands that were shipping undocumented, including `/plugin`, `/mode`,
27
+ `/loop`, `/canvas`, `/cost`, and the `/new-*` scaffolds, plus the 12 CLI
28
+ flags that were missing from the reference — the optional tool bundles
29
+ (`--enable-todowrite`, `--enable-webtools`, `--enable-search-tool`,
30
+ `--enable-plugintools`), the subagent flags, `--light`,
31
+ `--print-token-surface`, `--platform`, `--team`, and `--disallowed-tools`.
32
+ Every slash command and CLI flag hoocode accepts is now documented.
33
+
34
+ ### Fixed
35
+
36
+ - Capability search could not match a plural: a query for "theme" missed a
37
+ heading reading "Themes". Singular forms are now indexed alongside the
38
+ originals, so exact tool names still match exactly.
39
+
40
+ - Capability search ranked on question filler. "how do I add a custom theme"
41
+ was won by a section matching "add" and "how" over the one titled "Creating a
42
+ Custom Theme". Function words are now dropped from queries.
43
+
44
+ - The dense capability index is a shared store keyed on a content hash, but the
45
+ MCP loader seeded it from only its own tools, evicting any other producer's
46
+ vectors. It now seeds from the full registered set.
47
+
48
+ - Corrected the global config directory throughout the docs. Paths were written
49
+ as `~/.pi/agent/` or `~/.hoocode/agent/`; the real directory is `~/.hoocode`,
50
+ with no `agent/` segment. Every global path — `settings.json`,
51
+ `keybindings.json`, `auth.json`, `sessions/`, `skills/`, `extensions/`,
52
+ `themes/` — pointed somewhere that does not exist.
53
+
3
54
  ## [0.5.26] - 2026-08-21
4
55
 
5
56
  ### Added
@@ -20,6 +20,10 @@ import type { CapabilityDoc } from "./registry.js";
20
20
  * Split identifiers the way a person reads them: `mcp_github_create_pr` and
21
21
  * `createPullRequest` both yield their parts, and the original token is kept so
22
22
  * an exact name still scores as an exact match.
23
+ *
24
+ * Singular forms are emitted *alongside* the originals rather than replacing
25
+ * them, which is the same bargain the identifier splitting makes: an exact
26
+ * token still matches exactly, and a near miss now matches too.
23
27
  */
24
28
  export declare function tokenize(text: string): string[];
25
29
  export interface LexicalHit {
@@ -1 +1 @@
1
- {"version":3,"file":"lexical.d.ts","sourceRoot":"","sources":["../../../src/core/capabilities/lexical.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAMnD;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAc/C;AAOD,MAAM,WAAW,UAAU;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;CACd;AAQD;;;;GAIG;AACH,qBAAa,YAAY;IACxB,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAiB;IAC1C,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA6B;IACrD,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IAEnC,YAAY,IAAI,EAAE,SAAS,aAAa,EAAE,EAWzC;IAED,IAAI,IAAI,IAAI,MAAM,CAEjB;IAED,qFAAqF;IACrF,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,CAAC,SAAK,GAAG,UAAU,EAAE,CA2B1C;CACD","sourcesContent":["/**\n * In-memory BM25 over the capability index — the leg that always works.\n *\n * Deliberately not the repo's lexical retriever: that one shells out to ripgrep\n * over files on disk, and capabilities are a few hundred short strings held in\n * memory. It is also not optional. The dense leg needs a binary that may not be\n * installed and a store that may still be building, so if retrieval depended on\n * it, \"find me a tool that sends email\" would work on some machines and not\n * others. This leg makes the floor deterministic and dependency-free; dense is\n * strictly additive on top.\n *\n * The tokenizer does the load-bearing work here. Tool names are the query terms\n * that matter most and they arrive as `mcp_github_create_pull_request` or\n * `createPullRequest`, so a naive whitespace split would make the single most\n * common query shape — a name the model half-remembers — the one thing BM25\n * cannot match.\n */\n\nimport type { CapabilityDoc } from \"./registry.js\";\n\n/** Standard Okapi BM25 parameters. Nothing here justifies tuning them. */\nconst K1 = 1.2;\nconst B = 0.75;\n\n/**\n * Split identifiers the way a person reads them: `mcp_github_create_pr` and\n * `createPullRequest` both yield their parts, and the original token is kept so\n * an exact name still scores as an exact match.\n */\nexport function tokenize(text: string): string[] {\n\tconst out: string[] = [];\n\tfor (const raw of text.toLowerCase().match(/[a-z0-9]+(?:[_-][a-z0-9]+)*/gi) ?? []) {\n\t\tconst token = raw.toLowerCase();\n\t\tout.push(token);\n\t\t// Split on separators, then on camelCase boundaries in the source text.\n\t\tconst parts = token.split(/[_-]+/).filter(Boolean);\n\t\tif (parts.length > 1) out.push(...parts);\n\t}\n\tfor (const camel of text.match(/[a-z][a-z0-9]*|[A-Z][a-z0-9]*|[A-Z]+(?![a-z])/g) ?? []) {\n\t\tconst lower = camel.toLowerCase();\n\t\tif (lower.length > 1) out.push(lower);\n\t}\n\treturn out;\n}\n\n/** The text a document is matched on: name first, since that is what queries name. */\nfunction documentText(doc: CapabilityDoc): string {\n\treturn `${doc.name} ${doc.name} ${doc.source ?? \"\"} ${doc.description}`;\n}\n\nexport interface LexicalHit {\n\tid: string;\n\tscore: number;\n}\n\ninterface Posting {\n\tid: string;\n\tlength: number;\n\tcounts: Map<string, number>;\n}\n\n/**\n * A built BM25 index. Cheap enough to rebuild whenever the capability set\n * changes — a few hundred short documents — so there is no invalidation story\n * to get wrong.\n */\nexport class LexicalIndex {\n\tprivate readonly postings: Posting[] = [];\n\tprivate readonly docFreq = new Map<string, number>();\n\tprivate readonly avgLength: number;\n\n\tconstructor(docs: readonly CapabilityDoc[]) {\n\t\tlet total = 0;\n\t\tfor (const doc of docs) {\n\t\t\tconst tokens = tokenize(documentText(doc));\n\t\t\tconst counts = new Map<string, number>();\n\t\t\tfor (const t of tokens) counts.set(t, (counts.get(t) ?? 0) + 1);\n\t\t\tfor (const t of counts.keys()) this.docFreq.set(t, (this.docFreq.get(t) ?? 0) + 1);\n\t\t\tthis.postings.push({ id: doc.id, length: tokens.length, counts });\n\t\t\ttotal += tokens.length;\n\t\t}\n\t\tthis.avgLength = this.postings.length > 0 ? total / this.postings.length : 0;\n\t}\n\n\tget size(): number {\n\t\treturn this.postings.length;\n\t}\n\n\t/** Top `k` documents for `query`, best first. Documents scoring zero are omitted. */\n\tsearch(query: string, k = 10): LexicalHit[] {\n\t\tconst terms = tokenize(query);\n\t\tif (terms.length === 0 || this.postings.length === 0) return [];\n\t\tconst n = this.postings.length;\n\n\t\tconst hits: LexicalHit[] = [];\n\t\tfor (const posting of this.postings) {\n\t\t\tlet score = 0;\n\t\t\tfor (const term of new Set(terms)) {\n\t\t\t\tconst tf = posting.counts.get(term);\n\t\t\t\tif (!tf) continue;\n\t\t\t\tconst df = this.docFreq.get(term) ?? 0;\n\t\t\t\t// Okapi IDF, floored at zero: a term in every document carries no\n\t\t\t\t// signal, and the raw formula would make it actively negative.\n\t\t\t\tconst idf = Math.max(0, Math.log(1 + (n - df + 0.5) / (df + 0.5)));\n\t\t\t\tconst norm = tf * (K1 + 1);\n\t\t\t\tconst denom = tf + K1 * (1 - B + (B * posting.length) / (this.avgLength || 1));\n\t\t\t\tscore += idf * (norm / denom);\n\t\t\t}\n\t\t\tif (score > 0) hits.push({ id: posting.id, score });\n\t\t}\n\n\t\t// Ties break by id so the same query always returns the same order — a\n\t\t// retrieval tool that reshuffles equal-scoring results is a reproducibility\n\t\t// problem disguised as a ranking one.\n\t\thits.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id));\n\t\treturn hits.slice(0, k);\n\t}\n}\n"]}
1
+ {"version":3,"file":"lexical.d.ts","sourceRoot":"","sources":["../../../src/core/capabilities/lexical.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AA4BnD;;;;;;;;GAQG;AACH,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAmB/C;AAsED,MAAM,WAAW,UAAU;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;CACd;AAQD;;;;GAIG;AACH,qBAAa,YAAY;IACxB,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAiB;IAC1C,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA6B;IACrD,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IAEnC,YAAY,IAAI,EAAE,SAAS,aAAa,EAAE,EAWzC;IAED,IAAI,IAAI,IAAI,MAAM,CAEjB;IAED,qFAAqF;IACrF,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,CAAC,SAAK,GAAG,UAAU,EAAE,CA+B1C;CACD","sourcesContent":["/**\n * In-memory BM25 over the capability index — the leg that always works.\n *\n * Deliberately not the repo's lexical retriever: that one shells out to ripgrep\n * over files on disk, and capabilities are a few hundred short strings held in\n * memory. It is also not optional. The dense leg needs a binary that may not be\n * installed and a store that may still be building, so if retrieval depended on\n * it, \"find me a tool that sends email\" would work on some machines and not\n * others. This leg makes the floor deterministic and dependency-free; dense is\n * strictly additive on top.\n *\n * The tokenizer does the load-bearing work here. Tool names are the query terms\n * that matter most and they arrive as `mcp_github_create_pull_request` or\n * `createPullRequest`, so a naive whitespace split would make the single most\n * common query shape — a name the model half-remembers — the one thing BM25\n * cannot match.\n */\n\nimport type { CapabilityDoc } from \"./registry.js\";\n\n/** Standard Okapi BM25 parameters. Nothing here justifies tuning them. */\nconst K1 = 1.2;\nconst B = 0.75;\n\n/**\n * Fold a regular English plural to its singular, or return undefined.\n *\n * Deliberately crude: only the endings that are unambiguous without a\n * dictionary. Documentation headings are written in whichever number reads best\n * (\"Themes\", \"Custom Providers\") while questions are asked in the other (\"how\n * do I add a theme\"), and with no folding at all those two never meet — the\n * single most useful term in the query is the one term that cannot match.\n *\n * Irregulars are left alone. Getting \"indices\" wrong costs a missed hit;\n * inventing a stemmer that mangles \"status\" into \"statu\" would cost matches\n * that work today.\n */\nfunction singularize(token: string): string | undefined {\n\tif (token.length > 4 && token.endsWith(\"ies\")) return `${token.slice(0, -3)}y`;\n\tif (token.length > 4 && /(?:ss|sh|ch|x|z)es$/.test(token)) return token.slice(0, -2);\n\tif (token.length > 3 && token.endsWith(\"s\") && !token.endsWith(\"ss\") && !token.endsWith(\"us\")) {\n\t\treturn token.slice(0, -1);\n\t}\n\treturn undefined;\n}\n\n/**\n * Split identifiers the way a person reads them: `mcp_github_create_pr` and\n * `createPullRequest` both yield their parts, and the original token is kept so\n * an exact name still scores as an exact match.\n *\n * Singular forms are emitted *alongside* the originals rather than replacing\n * them, which is the same bargain the identifier splitting makes: an exact\n * token still matches exactly, and a near miss now matches too.\n */\nexport function tokenize(text: string): string[] {\n\tconst out: string[] = [];\n\tconst push = (token: string): void => {\n\t\tout.push(token);\n\t\tconst singular = singularize(token);\n\t\tif (singular && singular !== token) out.push(singular);\n\t};\n\tfor (const raw of text.toLowerCase().match(/[a-z0-9]+(?:[_-][a-z0-9]+)*/gi) ?? []) {\n\t\tconst token = raw.toLowerCase();\n\t\tpush(token);\n\t\t// Split on separators, then on camelCase boundaries in the source text.\n\t\tconst parts = token.split(/[_-]+/).filter(Boolean);\n\t\tif (parts.length > 1) for (const part of parts) push(part);\n\t}\n\tfor (const camel of text.match(/[a-z][a-z0-9]*|[A-Z][a-z0-9]*|[A-Z]+(?![a-z])/g) ?? []) {\n\t\tconst lower = camel.toLowerCase();\n\t\tif (lower.length > 1) push(lower);\n\t}\n\treturn out;\n}\n\n/**\n * Function words dropped from *queries* only.\n *\n * Questions arrive as \"how do I add a custom theme\", and in a corpus of a few\n * hundred short documents the filler carries real weight: a section whose prose\n * happens to say \"Add an AGENTS.md file ... to tell it how to work\" outscores\n * the section actually titled \"Creating a Custom Theme\", because it matched\n * three throwaway words to the target's one meaningful one.\n *\n * Query-side only, deliberately. Stripping these from documents too would\n * change every document length and every average, re-tuning a ranking that\n * works; dropping a term from the query just stops it contributing, which is\n * the whole intent.\n */\nconst QUERY_STOPWORDS = new Set([\n\t\"a\",\n\t\"an\",\n\t\"and\",\n\t\"are\",\n\t\"as\",\n\t\"at\",\n\t\"be\",\n\t\"by\",\n\t\"can\",\n\t\"do\",\n\t\"does\",\n\t\"for\",\n\t\"from\",\n\t\"get\",\n\t\"how\",\n\t\"i\",\n\t\"in\",\n\t\"is\",\n\t\"it\",\n\t\"its\",\n\t\"me\",\n\t\"my\",\n\t\"of\",\n\t\"on\",\n\t\"or\",\n\t\"so\",\n\t\"that\",\n\t\"the\",\n\t\"then\",\n\t\"there\",\n\t\"this\",\n\t\"to\",\n\t\"use\",\n\t\"using\",\n\t\"want\",\n\t\"was\",\n\t\"what\",\n\t\"when\",\n\t\"where\",\n\t\"which\",\n\t\"who\",\n\t\"why\",\n\t\"will\",\n\t\"with\",\n\t\"you\",\n\t\"your\",\n]);\n\n/** The text a document is matched on: name first, since that is what queries name. */\nfunction documentText(doc: CapabilityDoc): string {\n\treturn `${doc.name} ${doc.name} ${doc.source ?? \"\"} ${doc.description}`;\n}\n\nexport interface LexicalHit {\n\tid: string;\n\tscore: number;\n}\n\ninterface Posting {\n\tid: string;\n\tlength: number;\n\tcounts: Map<string, number>;\n}\n\n/**\n * A built BM25 index. Cheap enough to rebuild whenever the capability set\n * changes — a few hundred short documents — so there is no invalidation story\n * to get wrong.\n */\nexport class LexicalIndex {\n\tprivate readonly postings: Posting[] = [];\n\tprivate readonly docFreq = new Map<string, number>();\n\tprivate readonly avgLength: number;\n\n\tconstructor(docs: readonly CapabilityDoc[]) {\n\t\tlet total = 0;\n\t\tfor (const doc of docs) {\n\t\t\tconst tokens = tokenize(documentText(doc));\n\t\t\tconst counts = new Map<string, number>();\n\t\t\tfor (const t of tokens) counts.set(t, (counts.get(t) ?? 0) + 1);\n\t\t\tfor (const t of counts.keys()) this.docFreq.set(t, (this.docFreq.get(t) ?? 0) + 1);\n\t\t\tthis.postings.push({ id: doc.id, length: tokens.length, counts });\n\t\t\ttotal += tokens.length;\n\t\t}\n\t\tthis.avgLength = this.postings.length > 0 ? total / this.postings.length : 0;\n\t}\n\n\tget size(): number {\n\t\treturn this.postings.length;\n\t}\n\n\t/** Top `k` documents for `query`, best first. Documents scoring zero are omitted. */\n\tsearch(query: string, k = 10): LexicalHit[] {\n\t\tconst raw = tokenize(query);\n\t\t// Fall back to the unfiltered terms when a query is nothing but function\n\t\t// words, so \"what is it\" still searches rather than silently matching all.\n\t\tconst filtered = raw.filter((t) => !QUERY_STOPWORDS.has(t));\n\t\tconst terms = filtered.length > 0 ? filtered : raw;\n\t\tif (terms.length === 0 || this.postings.length === 0) return [];\n\t\tconst n = this.postings.length;\n\n\t\tconst hits: LexicalHit[] = [];\n\t\tfor (const posting of this.postings) {\n\t\t\tlet score = 0;\n\t\t\tfor (const term of new Set(terms)) {\n\t\t\t\tconst tf = posting.counts.get(term);\n\t\t\t\tif (!tf) continue;\n\t\t\t\tconst df = this.docFreq.get(term) ?? 0;\n\t\t\t\t// Okapi IDF, floored at zero: a term in every document carries no\n\t\t\t\t// signal, and the raw formula would make it actively negative.\n\t\t\t\tconst idf = Math.max(0, Math.log(1 + (n - df + 0.5) / (df + 0.5)));\n\t\t\t\tconst norm = tf * (K1 + 1);\n\t\t\t\tconst denom = tf + K1 * (1 - B + (B * posting.length) / (this.avgLength || 1));\n\t\t\t\tscore += idf * (norm / denom);\n\t\t\t}\n\t\t\tif (score > 0) hits.push({ id: posting.id, score });\n\t\t}\n\n\t\t// Ties break by id so the same query always returns the same order — a\n\t\t// retrieval tool that reshuffles equal-scoring results is a reproducibility\n\t\t// problem disguised as a ranking one.\n\t\thits.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id));\n\t\treturn hits.slice(0, k);\n\t}\n}\n"]}
@@ -18,28 +18,124 @@
18
18
  /** Standard Okapi BM25 parameters. Nothing here justifies tuning them. */
19
19
  const K1 = 1.2;
20
20
  const B = 0.75;
21
+ /**
22
+ * Fold a regular English plural to its singular, or return undefined.
23
+ *
24
+ * Deliberately crude: only the endings that are unambiguous without a
25
+ * dictionary. Documentation headings are written in whichever number reads best
26
+ * ("Themes", "Custom Providers") while questions are asked in the other ("how
27
+ * do I add a theme"), and with no folding at all those two never meet — the
28
+ * single most useful term in the query is the one term that cannot match.
29
+ *
30
+ * Irregulars are left alone. Getting "indices" wrong costs a missed hit;
31
+ * inventing a stemmer that mangles "status" into "statu" would cost matches
32
+ * that work today.
33
+ */
34
+ function singularize(token) {
35
+ if (token.length > 4 && token.endsWith("ies"))
36
+ return `${token.slice(0, -3)}y`;
37
+ if (token.length > 4 && /(?:ss|sh|ch|x|z)es$/.test(token))
38
+ return token.slice(0, -2);
39
+ if (token.length > 3 && token.endsWith("s") && !token.endsWith("ss") && !token.endsWith("us")) {
40
+ return token.slice(0, -1);
41
+ }
42
+ return undefined;
43
+ }
21
44
  /**
22
45
  * Split identifiers the way a person reads them: `mcp_github_create_pr` and
23
46
  * `createPullRequest` both yield their parts, and the original token is kept so
24
47
  * an exact name still scores as an exact match.
48
+ *
49
+ * Singular forms are emitted *alongside* the originals rather than replacing
50
+ * them, which is the same bargain the identifier splitting makes: an exact
51
+ * token still matches exactly, and a near miss now matches too.
25
52
  */
26
53
  export function tokenize(text) {
27
54
  const out = [];
55
+ const push = (token) => {
56
+ out.push(token);
57
+ const singular = singularize(token);
58
+ if (singular && singular !== token)
59
+ out.push(singular);
60
+ };
28
61
  for (const raw of text.toLowerCase().match(/[a-z0-9]+(?:[_-][a-z0-9]+)*/gi) ?? []) {
29
62
  const token = raw.toLowerCase();
30
- out.push(token);
63
+ push(token);
31
64
  // Split on separators, then on camelCase boundaries in the source text.
32
65
  const parts = token.split(/[_-]+/).filter(Boolean);
33
66
  if (parts.length > 1)
34
- out.push(...parts);
67
+ for (const part of parts)
68
+ push(part);
35
69
  }
36
70
  for (const camel of text.match(/[a-z][a-z0-9]*|[A-Z][a-z0-9]*|[A-Z]+(?![a-z])/g) ?? []) {
37
71
  const lower = camel.toLowerCase();
38
72
  if (lower.length > 1)
39
- out.push(lower);
73
+ push(lower);
40
74
  }
41
75
  return out;
42
76
  }
77
+ /**
78
+ * Function words dropped from *queries* only.
79
+ *
80
+ * Questions arrive as "how do I add a custom theme", and in a corpus of a few
81
+ * hundred short documents the filler carries real weight: a section whose prose
82
+ * happens to say "Add an AGENTS.md file ... to tell it how to work" outscores
83
+ * the section actually titled "Creating a Custom Theme", because it matched
84
+ * three throwaway words to the target's one meaningful one.
85
+ *
86
+ * Query-side only, deliberately. Stripping these from documents too would
87
+ * change every document length and every average, re-tuning a ranking that
88
+ * works; dropping a term from the query just stops it contributing, which is
89
+ * the whole intent.
90
+ */
91
+ const QUERY_STOPWORDS = new Set([
92
+ "a",
93
+ "an",
94
+ "and",
95
+ "are",
96
+ "as",
97
+ "at",
98
+ "be",
99
+ "by",
100
+ "can",
101
+ "do",
102
+ "does",
103
+ "for",
104
+ "from",
105
+ "get",
106
+ "how",
107
+ "i",
108
+ "in",
109
+ "is",
110
+ "it",
111
+ "its",
112
+ "me",
113
+ "my",
114
+ "of",
115
+ "on",
116
+ "or",
117
+ "so",
118
+ "that",
119
+ "the",
120
+ "then",
121
+ "there",
122
+ "this",
123
+ "to",
124
+ "use",
125
+ "using",
126
+ "want",
127
+ "was",
128
+ "what",
129
+ "when",
130
+ "where",
131
+ "which",
132
+ "who",
133
+ "why",
134
+ "will",
135
+ "with",
136
+ "you",
137
+ "your",
138
+ ]);
43
139
  /** The text a document is matched on: name first, since that is what queries name. */
44
140
  function documentText(doc) {
45
141
  return `${doc.name} ${doc.name} ${doc.source ?? ""} ${doc.description}`;
@@ -72,7 +168,11 @@ export class LexicalIndex {
72
168
  }
73
169
  /** Top `k` documents for `query`, best first. Documents scoring zero are omitted. */
74
170
  search(query, k = 10) {
75
- const terms = tokenize(query);
171
+ const raw = tokenize(query);
172
+ // Fall back to the unfiltered terms when a query is nothing but function
173
+ // words, so "what is it" still searches rather than silently matching all.
174
+ const filtered = raw.filter((t) => !QUERY_STOPWORDS.has(t));
175
+ const terms = filtered.length > 0 ? filtered : raw;
76
176
  if (terms.length === 0 || this.postings.length === 0)
77
177
  return [];
78
178
  const n = this.postings.length;
@@ -1 +1 @@
1
- {"version":3,"file":"lexical.js","sourceRoot":"","sources":["../../../src/core/capabilities/lexical.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAIH,0EAA0E;AAC1E,MAAM,EAAE,GAAG,GAAG,CAAC;AACf,MAAM,CAAC,GAAG,IAAI,CAAC;AAEf;;;;GAIG;AACH,MAAM,UAAU,QAAQ,CAAC,IAAY,EAAY;IAChD,MAAM,GAAG,GAAa,EAAE,CAAC;IACzB,KAAK,MAAM,GAAG,IAAI,IAAI,CAAC,WAAW,EAAE,CAAC,KAAK,CAAC,+BAA+B,CAAC,IAAI,EAAE,EAAE,CAAC;QACnF,MAAM,KAAK,GAAG,GAAG,CAAC,WAAW,EAAE,CAAC;QAChC,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QAChB,wEAAwE;QACxE,MAAM,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;QACnD,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC;YAAE,GAAG,CAAC,IAAI,CAAC,GAAG,KAAK,CAAC,CAAC;IAC1C,CAAC;IACD,KAAK,MAAM,KAAK,IAAI,IAAI,CAAC,KAAK,CAAC,gDAAgD,CAAC,IAAI,EAAE,EAAE,CAAC;QACxF,MAAM,KAAK,GAAG,KAAK,CAAC,WAAW,EAAE,CAAC;QAClC,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC;YAAE,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IACvC,CAAC;IACD,OAAO,GAAG,CAAC;AAAA,CACX;AAED,sFAAsF;AACtF,SAAS,YAAY,CAAC,GAAkB,EAAU;IACjD,OAAO,GAAG,GAAG,CAAC,IAAI,IAAI,GAAG,CAAC,IAAI,IAAI,GAAG,CAAC,MAAM,IAAI,EAAE,IAAI,GAAG,CAAC,WAAW,EAAE,CAAC;AAAA,CACxE;AAaD;;;;GAIG;AACH,MAAM,OAAO,YAAY;IACP,QAAQ,GAAc,EAAE,CAAC;IACzB,OAAO,GAAG,IAAI,GAAG,EAAkB,CAAC;IACpC,SAAS,CAAS;IAEnC,YAAY,IAA8B,EAAE;QAC3C,IAAI,KAAK,GAAG,CAAC,CAAC;QACd,KAAK,MAAM,GAAG,IAAI,IAAI,EAAE,CAAC;YACxB,MAAM,MAAM,GAAG,QAAQ,CAAC,YAAY,CAAC,GAAG,CAAC,CAAC,CAAC;YAC3C,MAAM,MAAM,GAAG,IAAI,GAAG,EAAkB,CAAC;YACzC,KAAK,MAAM,CAAC,IAAI,MAAM;gBAAE,MAAM,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;YAChE,KAAK,MAAM,CAAC,IAAI,MAAM,CAAC,IAAI,EAAE;gBAAE,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;YACnF,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,GAAG,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;YAClE,KAAK,IAAI,MAAM,CAAC,MAAM,CAAC;QACxB,CAAC;QACD,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC,QAAQ,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,KAAK,GAAG,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC;IAAA,CAC7E;IAED,IAAI,IAAI,GAAW;QAClB,OAAO,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC;IAAA,CAC5B;IAED,qFAAqF;IACrF,MAAM,CAAC,KAAa,EAAE,CAAC,GAAG,EAAE,EAAgB;QAC3C,MAAM,KAAK,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAC;QAC9B,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,IAAI,IAAI,CAAC,QAAQ,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,EAAE,CAAC;QAChE,MAAM,CAAC,GAAG,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC;QAE/B,MAAM,IAAI,GAAiB,EAAE,CAAC;QAC9B,KAAK,MAAM,OAAO,IAAI,IAAI,CAAC,QAAQ,EAAE,CAAC;YACrC,IAAI,KAAK,GAAG,CAAC,CAAC;YACd,KAAK,MAAM,IAAI,IAAI,IAAI,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC;gBACnC,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;gBACpC,IAAI,CAAC,EAAE;oBAAE,SAAS;gBAClB,MAAM,EAAE,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;gBACvC,kEAAkE;gBAClE,+DAA+D;gBAC/D,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,GAAG,GAAG,CAAC,GAAG,CAAC,EAAE,GAAG,GAAG,CAAC,CAAC,CAAC,CAAC;gBACnE,MAAM,IAAI,GAAG,EAAE,GAAG,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC;gBAC3B,MAAM,KAAK,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,SAAS,IAAI,CAAC,CAAC,CAAC,CAAC;gBAC/E,KAAK,IAAI,GAAG,GAAG,CAAC,IAAI,GAAG,KAAK,CAAC,CAAC;YAC/B,CAAC;YACD,IAAI,KAAK,GAAG,CAAC;gBAAE,IAAI,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,OAAO,CAAC,EAAE,EAAE,KAAK,EAAE,CAAC,CAAC;QACrD,CAAC;QAED,yEAAuE;QACvE,4EAA4E;QAC5E,sCAAsC;QACtC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC,KAAK,IAAI,CAAC,CAAC,EAAE,CAAC,aAAa,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;QACnE,OAAO,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;IAAA,CACxB;CACD","sourcesContent":["/**\n * In-memory BM25 over the capability index — the leg that always works.\n *\n * Deliberately not the repo's lexical retriever: that one shells out to ripgrep\n * over files on disk, and capabilities are a few hundred short strings held in\n * memory. It is also not optional. The dense leg needs a binary that may not be\n * installed and a store that may still be building, so if retrieval depended on\n * it, \"find me a tool that sends email\" would work on some machines and not\n * others. This leg makes the floor deterministic and dependency-free; dense is\n * strictly additive on top.\n *\n * The tokenizer does the load-bearing work here. Tool names are the query terms\n * that matter most and they arrive as `mcp_github_create_pull_request` or\n * `createPullRequest`, so a naive whitespace split would make the single most\n * common query shape — a name the model half-remembers — the one thing BM25\n * cannot match.\n */\n\nimport type { CapabilityDoc } from \"./registry.js\";\n\n/** Standard Okapi BM25 parameters. Nothing here justifies tuning them. */\nconst K1 = 1.2;\nconst B = 0.75;\n\n/**\n * Split identifiers the way a person reads them: `mcp_github_create_pr` and\n * `createPullRequest` both yield their parts, and the original token is kept so\n * an exact name still scores as an exact match.\n */\nexport function tokenize(text: string): string[] {\n\tconst out: string[] = [];\n\tfor (const raw of text.toLowerCase().match(/[a-z0-9]+(?:[_-][a-z0-9]+)*/gi) ?? []) {\n\t\tconst token = raw.toLowerCase();\n\t\tout.push(token);\n\t\t// Split on separators, then on camelCase boundaries in the source text.\n\t\tconst parts = token.split(/[_-]+/).filter(Boolean);\n\t\tif (parts.length > 1) out.push(...parts);\n\t}\n\tfor (const camel of text.match(/[a-z][a-z0-9]*|[A-Z][a-z0-9]*|[A-Z]+(?![a-z])/g) ?? []) {\n\t\tconst lower = camel.toLowerCase();\n\t\tif (lower.length > 1) out.push(lower);\n\t}\n\treturn out;\n}\n\n/** The text a document is matched on: name first, since that is what queries name. */\nfunction documentText(doc: CapabilityDoc): string {\n\treturn `${doc.name} ${doc.name} ${doc.source ?? \"\"} ${doc.description}`;\n}\n\nexport interface LexicalHit {\n\tid: string;\n\tscore: number;\n}\n\ninterface Posting {\n\tid: string;\n\tlength: number;\n\tcounts: Map<string, number>;\n}\n\n/**\n * A built BM25 index. Cheap enough to rebuild whenever the capability set\n * changes — a few hundred short documents — so there is no invalidation story\n * to get wrong.\n */\nexport class LexicalIndex {\n\tprivate readonly postings: Posting[] = [];\n\tprivate readonly docFreq = new Map<string, number>();\n\tprivate readonly avgLength: number;\n\n\tconstructor(docs: readonly CapabilityDoc[]) {\n\t\tlet total = 0;\n\t\tfor (const doc of docs) {\n\t\t\tconst tokens = tokenize(documentText(doc));\n\t\t\tconst counts = new Map<string, number>();\n\t\t\tfor (const t of tokens) counts.set(t, (counts.get(t) ?? 0) + 1);\n\t\t\tfor (const t of counts.keys()) this.docFreq.set(t, (this.docFreq.get(t) ?? 0) + 1);\n\t\t\tthis.postings.push({ id: doc.id, length: tokens.length, counts });\n\t\t\ttotal += tokens.length;\n\t\t}\n\t\tthis.avgLength = this.postings.length > 0 ? total / this.postings.length : 0;\n\t}\n\n\tget size(): number {\n\t\treturn this.postings.length;\n\t}\n\n\t/** Top `k` documents for `query`, best first. Documents scoring zero are omitted. */\n\tsearch(query: string, k = 10): LexicalHit[] {\n\t\tconst terms = tokenize(query);\n\t\tif (terms.length === 0 || this.postings.length === 0) return [];\n\t\tconst n = this.postings.length;\n\n\t\tconst hits: LexicalHit[] = [];\n\t\tfor (const posting of this.postings) {\n\t\t\tlet score = 0;\n\t\t\tfor (const term of new Set(terms)) {\n\t\t\t\tconst tf = posting.counts.get(term);\n\t\t\t\tif (!tf) continue;\n\t\t\t\tconst df = this.docFreq.get(term) ?? 0;\n\t\t\t\t// Okapi IDF, floored at zero: a term in every document carries no\n\t\t\t\t// signal, and the raw formula would make it actively negative.\n\t\t\t\tconst idf = Math.max(0, Math.log(1 + (n - df + 0.5) / (df + 0.5)));\n\t\t\t\tconst norm = tf * (K1 + 1);\n\t\t\t\tconst denom = tf + K1 * (1 - B + (B * posting.length) / (this.avgLength || 1));\n\t\t\t\tscore += idf * (norm / denom);\n\t\t\t}\n\t\t\tif (score > 0) hits.push({ id: posting.id, score });\n\t\t}\n\n\t\t// Ties break by id so the same query always returns the same order — a\n\t\t// retrieval tool that reshuffles equal-scoring results is a reproducibility\n\t\t// problem disguised as a ranking one.\n\t\thits.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id));\n\t\treturn hits.slice(0, k);\n\t}\n}\n"]}
1
+ {"version":3,"file":"lexical.js","sourceRoot":"","sources":["../../../src/core/capabilities/lexical.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * In-memory BM25 over the capability index — the leg that always works.\n *\n * Deliberately not the repo's lexical retriever: that one shells out to ripgrep\n * over files on disk, and capabilities are a few hundred short strings held in\n * memory. It is also not optional. The dense leg needs a binary that may not be\n * installed and a store that may still be building, so if retrieval depended on\n * it, \"find me a tool that sends email\" would work on some machines and not\n * others. This leg makes the floor deterministic and dependency-free; dense is\n * strictly additive on top.\n *\n * The tokenizer does the load-bearing work here. Tool names are the query terms\n * that matter most and they arrive as `mcp_github_create_pull_request` or\n * `createPullRequest`, so a naive whitespace split would make the single most\n * common query shape — a name the model half-remembers — the one thing BM25\n * cannot match.\n */\n\nimport type { CapabilityDoc } from \"./registry.js\";\n\n/** Standard Okapi BM25 parameters. Nothing here justifies tuning them. */\nconst K1 = 1.2;\nconst B = 0.75;\n\n/**\n * Fold a regular English plural to its singular, or return undefined.\n *\n * Deliberately crude: only the endings that are unambiguous without a\n * dictionary. Documentation headings are written in whichever number reads best\n * (\"Themes\", \"Custom Providers\") while questions are asked in the other (\"how\n * do I add a theme\"), and with no folding at all those two never meet — the\n * single most useful term in the query is the one term that cannot match.\n *\n * Irregulars are left alone. Getting \"indices\" wrong costs a missed hit;\n * inventing a stemmer that mangles \"status\" into \"statu\" would cost matches\n * that work today.\n */\nfunction singularize(token: string): string | undefined {\n\tif (token.length > 4 && token.endsWith(\"ies\")) return `${token.slice(0, -3)}y`;\n\tif (token.length > 4 && /(?:ss|sh|ch|x|z)es$/.test(token)) return token.slice(0, -2);\n\tif (token.length > 3 && token.endsWith(\"s\") && !token.endsWith(\"ss\") && !token.endsWith(\"us\")) {\n\t\treturn token.slice(0, -1);\n\t}\n\treturn undefined;\n}\n\n/**\n * Split identifiers the way a person reads them: `mcp_github_create_pr` and\n * `createPullRequest` both yield their parts, and the original token is kept so\n * an exact name still scores as an exact match.\n *\n * Singular forms are emitted *alongside* the originals rather than replacing\n * them, which is the same bargain the identifier splitting makes: an exact\n * token still matches exactly, and a near miss now matches too.\n */\nexport function tokenize(text: string): string[] {\n\tconst out: string[] = [];\n\tconst push = (token: string): void => {\n\t\tout.push(token);\n\t\tconst singular = singularize(token);\n\t\tif (singular && singular !== token) out.push(singular);\n\t};\n\tfor (const raw of text.toLowerCase().match(/[a-z0-9]+(?:[_-][a-z0-9]+)*/gi) ?? []) {\n\t\tconst token = raw.toLowerCase();\n\t\tpush(token);\n\t\t// Split on separators, then on camelCase boundaries in the source text.\n\t\tconst parts = token.split(/[_-]+/).filter(Boolean);\n\t\tif (parts.length > 1) for (const part of parts) push(part);\n\t}\n\tfor (const camel of text.match(/[a-z][a-z0-9]*|[A-Z][a-z0-9]*|[A-Z]+(?![a-z])/g) ?? []) {\n\t\tconst lower = camel.toLowerCase();\n\t\tif (lower.length > 1) push(lower);\n\t}\n\treturn out;\n}\n\n/**\n * Function words dropped from *queries* only.\n *\n * Questions arrive as \"how do I add a custom theme\", and in a corpus of a few\n * hundred short documents the filler carries real weight: a section whose prose\n * happens to say \"Add an AGENTS.md file ... to tell it how to work\" outscores\n * the section actually titled \"Creating a Custom Theme\", because it matched\n * three throwaway words to the target's one meaningful one.\n *\n * Query-side only, deliberately. Stripping these from documents too would\n * change every document length and every average, re-tuning a ranking that\n * works; dropping a term from the query just stops it contributing, which is\n * the whole intent.\n */\nconst QUERY_STOPWORDS = new Set([\n\t\"a\",\n\t\"an\",\n\t\"and\",\n\t\"are\",\n\t\"as\",\n\t\"at\",\n\t\"be\",\n\t\"by\",\n\t\"can\",\n\t\"do\",\n\t\"does\",\n\t\"for\",\n\t\"from\",\n\t\"get\",\n\t\"how\",\n\t\"i\",\n\t\"in\",\n\t\"is\",\n\t\"it\",\n\t\"its\",\n\t\"me\",\n\t\"my\",\n\t\"of\",\n\t\"on\",\n\t\"or\",\n\t\"so\",\n\t\"that\",\n\t\"the\",\n\t\"then\",\n\t\"there\",\n\t\"this\",\n\t\"to\",\n\t\"use\",\n\t\"using\",\n\t\"want\",\n\t\"was\",\n\t\"what\",\n\t\"when\",\n\t\"where\",\n\t\"which\",\n\t\"who\",\n\t\"why\",\n\t\"will\",\n\t\"with\",\n\t\"you\",\n\t\"your\",\n]);\n\n/** The text a document is matched on: name first, since that is what queries name. */\nfunction documentText(doc: CapabilityDoc): string {\n\treturn `${doc.name} ${doc.name} ${doc.source ?? \"\"} ${doc.description}`;\n}\n\nexport interface LexicalHit {\n\tid: string;\n\tscore: number;\n}\n\ninterface Posting {\n\tid: string;\n\tlength: number;\n\tcounts: Map<string, number>;\n}\n\n/**\n * A built BM25 index. Cheap enough to rebuild whenever the capability set\n * changes — a few hundred short documents — so there is no invalidation story\n * to get wrong.\n */\nexport class LexicalIndex {\n\tprivate readonly postings: Posting[] = [];\n\tprivate readonly docFreq = new Map<string, number>();\n\tprivate readonly avgLength: number;\n\n\tconstructor(docs: readonly CapabilityDoc[]) {\n\t\tlet total = 0;\n\t\tfor (const doc of docs) {\n\t\t\tconst tokens = tokenize(documentText(doc));\n\t\t\tconst counts = new Map<string, number>();\n\t\t\tfor (const t of tokens) counts.set(t, (counts.get(t) ?? 0) + 1);\n\t\t\tfor (const t of counts.keys()) this.docFreq.set(t, (this.docFreq.get(t) ?? 0) + 1);\n\t\t\tthis.postings.push({ id: doc.id, length: tokens.length, counts });\n\t\t\ttotal += tokens.length;\n\t\t}\n\t\tthis.avgLength = this.postings.length > 0 ? total / this.postings.length : 0;\n\t}\n\n\tget size(): number {\n\t\treturn this.postings.length;\n\t}\n\n\t/** Top `k` documents for `query`, best first. Documents scoring zero are omitted. */\n\tsearch(query: string, k = 10): LexicalHit[] {\n\t\tconst raw = tokenize(query);\n\t\t// Fall back to the unfiltered terms when a query is nothing but function\n\t\t// words, so \"what is it\" still searches rather than silently matching all.\n\t\tconst filtered = raw.filter((t) => !QUERY_STOPWORDS.has(t));\n\t\tconst terms = filtered.length > 0 ? filtered : raw;\n\t\tif (terms.length === 0 || this.postings.length === 0) return [];\n\t\tconst n = this.postings.length;\n\n\t\tconst hits: LexicalHit[] = [];\n\t\tfor (const posting of this.postings) {\n\t\t\tlet score = 0;\n\t\t\tfor (const term of new Set(terms)) {\n\t\t\t\tconst tf = posting.counts.get(term);\n\t\t\t\tif (!tf) continue;\n\t\t\t\tconst df = this.docFreq.get(term) ?? 0;\n\t\t\t\t// Okapi IDF, floored at zero: a term in every document carries no\n\t\t\t\t// signal, and the raw formula would make it actively negative.\n\t\t\t\tconst idf = Math.max(0, Math.log(1 + (n - df + 0.5) / (df + 0.5)));\n\t\t\t\tconst norm = tf * (K1 + 1);\n\t\t\t\tconst denom = tf + K1 * (1 - B + (B * posting.length) / (this.avgLength || 1));\n\t\t\t\tscore += idf * (norm / denom);\n\t\t\t}\n\t\t\tif (score > 0) hits.push({ id: posting.id, score });\n\t\t}\n\n\t\t// Ties break by id so the same query always returns the same order — a\n\t\t// retrieval tool that reshuffles equal-scoring results is a reproducibility\n\t\t// problem disguised as a ranking one.\n\t\thits.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id));\n\t\treturn hits.slice(0, k);\n\t}\n}\n"]}
@@ -17,7 +17,9 @@
17
17
  *
18
18
  * See docs/plugin-system-architecture.md §6.
19
19
  */
20
- export type CapabilityKind = "mcp-tool" | "skill" | "command" | "agent" | "plugin-available" | "plugin-installed";
20
+ export type CapabilityKind = "mcp-tool" | "skill" | "command" | "agent" | "plugin-available" | "plugin-installed"
21
+ /** A heading-level slice of hoocode's own shipped documentation. */
22
+ | "doc";
21
23
  export interface CapabilityDoc {
22
24
  /** Unique and stable within a session; `<kind>:<name>` by convention. */
23
25
  id: string;
@@ -1 +1 @@
1
- {"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../../src/core/capabilities/registry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAIH,MAAM,MAAM,cAAc,GAAG,UAAU,GAAG,OAAO,GAAG,SAAS,GAAG,OAAO,GAAG,kBAAkB,GAAG,kBAAkB,CAAC;AAElH,MAAM,WAAW,aAAa;IAC7B,yEAAyE;IACzE,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,cAAc,CAAC;IACrB,yEAAuE;IACvE,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,mEAAmE;IACnE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;;;;OAQG;IACH,QAAQ,EAAE,OAAO,CAAC;CAClB;AAWD,mFAAmF;AACnF,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,cAAc,EAAE,IAAI,EAAE,SAAS,aAAa,EAAE,GAAG,IAAI,CAG/F;AAED,oEAAoE;AACpE,wBAAgB,eAAe,CAAC,KAAK,CAAC,EAAE,SAAS,cAAc,EAAE,GAAG,aAAa,EAAE,CAQlF;AAED,wDAAwD;AACxD,wBAAgB,iBAAiB,IAAI,IAAI,CAExC;AAED;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,GAAE,SAAS,aAAa,EAAsB,GAAG,MAAM,CAI5F","sourcesContent":["/**\n * One index of everything the agent can *acquire* — MCP tools, skills, slash\n * commands, subagents, and plugins available or installed.\n *\n * The problem this exists for: deferral already withholds MCP tool schemas, but\n * the model still needs some way to find a withheld tool, and the only one it\n * had was an exact name match against a catalog dumped in full into the\n * resolver's description. Those two are locked together — an exact-only matcher\n * *forces* the full dump, because a name you cannot guess is a tool you cannot\n * reach. Give the matcher retrieval and the dump becomes optional, which is\n * where the context saving actually lives.\n *\n * The producers are deliberately not coupled to it: each one hands over a flat\n * list of documents for its own kind and knows nothing about retrieval. That is\n * what lets skills and commands join later (§6.4 leaves them eager for now)\n * without touching the search side.\n *\n * See docs/plugin-system-architecture.md §6.\n */\n\nimport { createHash } from \"node:crypto\";\n\nexport type CapabilityKind = \"mcp-tool\" | \"skill\" | \"command\" | \"agent\" | \"plugin-available\" | \"plugin-installed\";\n\nexport interface CapabilityDoc {\n\t/** Unique and stable within a session; `<kind>:<name>` by convention. */\n\tid: string;\n\tkind: CapabilityKind;\n\t/** How the model refers to it — a tool name, skill name, plugin id. */\n\tname: string;\n\tdescription: string;\n\t/** Where it came from: MCP server, plugin id, marketplace name. */\n\tsource?: string;\n\t/**\n\t * Whether the expensive part (a JSON schema, a skill body) is currently\n\t * withheld from context.\n\t *\n\t * Recorded rather than derived because it is the policy knob of §6.3: it says\n\t * which entries retrieval is actually *for*. An eager capability is already\n\t * visible to the model, so surfacing it in search results is a convenience;\n\t * a deferred one is otherwise unreachable.\n\t */\n\tdeferred: boolean;\n}\n\n/**\n * Registered documents, by kind.\n *\n * Keyed by kind rather than a flat map because a producer owns its whole kind:\n * the MCP loader knows every MCP tool there is, and on reload it should replace\n * that set, not merge into a pile where a removed server's tools linger.\n */\nconst byKind = new Map<CapabilityKind, CapabilityDoc[]>();\n\n/** Replace every document of `kind`. Producers call this on load and on reload. */\nexport function registerCapabilities(kind: CapabilityKind, docs: readonly CapabilityDoc[]): void {\n\tif (docs.length === 0) byKind.delete(kind);\n\telse byKind.set(kind, [...docs]);\n}\n\n/** Every registered document, in a stable order (kind, then id). */\nexport function getCapabilities(kinds?: readonly CapabilityKind[]): CapabilityDoc[] {\n\tconst wanted = kinds && kinds.length > 0 ? new Set(kinds) : undefined;\n\tconst out: CapabilityDoc[] = [];\n\tfor (const kind of [...byKind.keys()].sort()) {\n\t\tif (wanted && !wanted.has(kind)) continue;\n\t\tout.push(...(byKind.get(kind) ?? []).slice().sort((a, b) => a.id.localeCompare(b.id)));\n\t}\n\treturn out;\n}\n\n/** Drop everything. Tests, and a full session reset. */\nexport function clearCapabilities(): void {\n\tbyKind.clear();\n}\n\n/**\n * Content hash of the current capability set — the key a persistent index is\n * stored under.\n *\n * Hashes the text that gets embedded, not the count: two sessions with the same\n * tools should share an index, and one where a server changed a description\n * should not silently reuse vectors describing the old one.\n */\nexport function capabilitySetHash(docs: readonly CapabilityDoc[] = getCapabilities()): string {\n\tconst h = createHash(\"sha256\");\n\tfor (const d of docs) h.update(`${d.id}\u0000${d.name}\u0000${d.description}\u0000`);\n\treturn h.digest(\"hex\").slice(0, 16);\n}\n"]}
1
+ {"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../../src/core/capabilities/registry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAIH,MAAM,MAAM,cAAc,GACvB,UAAU,GACV,OAAO,GACP,SAAS,GACT,OAAO,GACP,kBAAkB,GAClB,kBAAkB;AACpB,oEAAoE;GAClE,KAAK,CAAC;AAET,MAAM,WAAW,aAAa;IAC7B,yEAAyE;IACzE,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,cAAc,CAAC;IACrB,yEAAuE;IACvE,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,mEAAmE;IACnE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;;;;OAQG;IACH,QAAQ,EAAE,OAAO,CAAC;CAClB;AAWD,mFAAmF;AACnF,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,cAAc,EAAE,IAAI,EAAE,SAAS,aAAa,EAAE,GAAG,IAAI,CAG/F;AAED,oEAAoE;AACpE,wBAAgB,eAAe,CAAC,KAAK,CAAC,EAAE,SAAS,cAAc,EAAE,GAAG,aAAa,EAAE,CAQlF;AAED,wDAAwD;AACxD,wBAAgB,iBAAiB,IAAI,IAAI,CAExC;AAED;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,GAAE,SAAS,aAAa,EAAsB,GAAG,MAAM,CAI5F","sourcesContent":["/**\n * One index of everything the agent can *acquire* — MCP tools, skills, slash\n * commands, subagents, and plugins available or installed.\n *\n * The problem this exists for: deferral already withholds MCP tool schemas, but\n * the model still needs some way to find a withheld tool, and the only one it\n * had was an exact name match against a catalog dumped in full into the\n * resolver's description. Those two are locked together — an exact-only matcher\n * *forces* the full dump, because a name you cannot guess is a tool you cannot\n * reach. Give the matcher retrieval and the dump becomes optional, which is\n * where the context saving actually lives.\n *\n * The producers are deliberately not coupled to it: each one hands over a flat\n * list of documents for its own kind and knows nothing about retrieval. That is\n * what lets skills and commands join later (§6.4 leaves them eager for now)\n * without touching the search side.\n *\n * See docs/plugin-system-architecture.md §6.\n */\n\nimport { createHash } from \"node:crypto\";\n\nexport type CapabilityKind =\n\t| \"mcp-tool\"\n\t| \"skill\"\n\t| \"command\"\n\t| \"agent\"\n\t| \"plugin-available\"\n\t| \"plugin-installed\"\n\t/** A heading-level slice of hoocode's own shipped documentation. */\n\t| \"doc\";\n\nexport interface CapabilityDoc {\n\t/** Unique and stable within a session; `<kind>:<name>` by convention. */\n\tid: string;\n\tkind: CapabilityKind;\n\t/** How the model refers to it — a tool name, skill name, plugin id. */\n\tname: string;\n\tdescription: string;\n\t/** Where it came from: MCP server, plugin id, marketplace name. */\n\tsource?: string;\n\t/**\n\t * Whether the expensive part (a JSON schema, a skill body) is currently\n\t * withheld from context.\n\t *\n\t * Recorded rather than derived because it is the policy knob of §6.3: it says\n\t * which entries retrieval is actually *for*. An eager capability is already\n\t * visible to the model, so surfacing it in search results is a convenience;\n\t * a deferred one is otherwise unreachable.\n\t */\n\tdeferred: boolean;\n}\n\n/**\n * Registered documents, by kind.\n *\n * Keyed by kind rather than a flat map because a producer owns its whole kind:\n * the MCP loader knows every MCP tool there is, and on reload it should replace\n * that set, not merge into a pile where a removed server's tools linger.\n */\nconst byKind = new Map<CapabilityKind, CapabilityDoc[]>();\n\n/** Replace every document of `kind`. Producers call this on load and on reload. */\nexport function registerCapabilities(kind: CapabilityKind, docs: readonly CapabilityDoc[]): void {\n\tif (docs.length === 0) byKind.delete(kind);\n\telse byKind.set(kind, [...docs]);\n}\n\n/** Every registered document, in a stable order (kind, then id). */\nexport function getCapabilities(kinds?: readonly CapabilityKind[]): CapabilityDoc[] {\n\tconst wanted = kinds && kinds.length > 0 ? new Set(kinds) : undefined;\n\tconst out: CapabilityDoc[] = [];\n\tfor (const kind of [...byKind.keys()].sort()) {\n\t\tif (wanted && !wanted.has(kind)) continue;\n\t\tout.push(...(byKind.get(kind) ?? []).slice().sort((a, b) => a.id.localeCompare(b.id)));\n\t}\n\treturn out;\n}\n\n/** Drop everything. Tests, and a full session reset. */\nexport function clearCapabilities(): void {\n\tbyKind.clear();\n}\n\n/**\n * Content hash of the current capability set — the key a persistent index is\n * stored under.\n *\n * Hashes the text that gets embedded, not the count: two sessions with the same\n * tools should share an index, and one where a server changed a description\n * should not silently reuse vectors describing the old one.\n */\nexport function capabilitySetHash(docs: readonly CapabilityDoc[] = getCapabilities()): string {\n\tconst h = createHash(\"sha256\");\n\tfor (const d of docs) h.update(`${d.id}\u0000${d.name}\u0000${d.description}\u0000`);\n\treturn h.digest(\"hex\").slice(0, 16);\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"registry.js","sourceRoot":"","sources":["../../../src/core/capabilities/registry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAyBzC;;;;;;GAMG;AACH,MAAM,MAAM,GAAG,IAAI,GAAG,EAAmC,CAAC;AAE1D,mFAAmF;AACnF,MAAM,UAAU,oBAAoB,CAAC,IAAoB,EAAE,IAA8B,EAAQ;IAChG,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC;QAAE,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;;QACtC,MAAM,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,GAAG,IAAI,CAAC,CAAC,CAAC;AAAA,CACjC;AAED,oEAAoE;AACpE,MAAM,UAAU,eAAe,CAAC,KAAiC,EAAmB;IACnF,MAAM,MAAM,GAAG,KAAK,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;IACtE,MAAM,GAAG,GAAoB,EAAE,CAAC;IAChC,KAAK,MAAM,IAAI,IAAI,CAAC,GAAG,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC;QAC9C,IAAI,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC;YAAE,SAAS;QAC1C,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC,KAAK,EAAE,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,aAAa,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;IACxF,CAAC;IACD,OAAO,GAAG,CAAC;AAAA,CACX;AAED,wDAAwD;AACxD,MAAM,UAAU,iBAAiB,GAAS;IACzC,MAAM,CAAC,KAAK,EAAE,CAAC;AAAA,CACf;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,iBAAiB,CAAC,IAAI,GAA6B,eAAe,EAAE,EAAU;IAC7F,MAAM,CAAC,GAAG,UAAU,CAAC,QAAQ,CAAC,CAAC;IAC/B,KAAK,MAAM,CAAC,IAAI,IAAI;QAAE,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC,IAAI,IAAI,CAAC,CAAC,WAAW,GAAG,CAAC,CAAC;IACtE,OAAO,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;AAAA,CACpC","sourcesContent":["/**\n * One index of everything the agent can *acquire* — MCP tools, skills, slash\n * commands, subagents, and plugins available or installed.\n *\n * The problem this exists for: deferral already withholds MCP tool schemas, but\n * the model still needs some way to find a withheld tool, and the only one it\n * had was an exact name match against a catalog dumped in full into the\n * resolver's description. Those two are locked together — an exact-only matcher\n * *forces* the full dump, because a name you cannot guess is a tool you cannot\n * reach. Give the matcher retrieval and the dump becomes optional, which is\n * where the context saving actually lives.\n *\n * The producers are deliberately not coupled to it: each one hands over a flat\n * list of documents for its own kind and knows nothing about retrieval. That is\n * what lets skills and commands join later (§6.4 leaves them eager for now)\n * without touching the search side.\n *\n * See docs/plugin-system-architecture.md §6.\n */\n\nimport { createHash } from \"node:crypto\";\n\nexport type CapabilityKind = \"mcp-tool\" | \"skill\" | \"command\" | \"agent\" | \"plugin-available\" | \"plugin-installed\";\n\nexport interface CapabilityDoc {\n\t/** Unique and stable within a session; `<kind>:<name>` by convention. */\n\tid: string;\n\tkind: CapabilityKind;\n\t/** How the model refers to it — a tool name, skill name, plugin id. */\n\tname: string;\n\tdescription: string;\n\t/** Where it came from: MCP server, plugin id, marketplace name. */\n\tsource?: string;\n\t/**\n\t * Whether the expensive part (a JSON schema, a skill body) is currently\n\t * withheld from context.\n\t *\n\t * Recorded rather than derived because it is the policy knob of §6.3: it says\n\t * which entries retrieval is actually *for*. An eager capability is already\n\t * visible to the model, so surfacing it in search results is a convenience;\n\t * a deferred one is otherwise unreachable.\n\t */\n\tdeferred: boolean;\n}\n\n/**\n * Registered documents, by kind.\n *\n * Keyed by kind rather than a flat map because a producer owns its whole kind:\n * the MCP loader knows every MCP tool there is, and on reload it should replace\n * that set, not merge into a pile where a removed server's tools linger.\n */\nconst byKind = new Map<CapabilityKind, CapabilityDoc[]>();\n\n/** Replace every document of `kind`. Producers call this on load and on reload. */\nexport function registerCapabilities(kind: CapabilityKind, docs: readonly CapabilityDoc[]): void {\n\tif (docs.length === 0) byKind.delete(kind);\n\telse byKind.set(kind, [...docs]);\n}\n\n/** Every registered document, in a stable order (kind, then id). */\nexport function getCapabilities(kinds?: readonly CapabilityKind[]): CapabilityDoc[] {\n\tconst wanted = kinds && kinds.length > 0 ? new Set(kinds) : undefined;\n\tconst out: CapabilityDoc[] = [];\n\tfor (const kind of [...byKind.keys()].sort()) {\n\t\tif (wanted && !wanted.has(kind)) continue;\n\t\tout.push(...(byKind.get(kind) ?? []).slice().sort((a, b) => a.id.localeCompare(b.id)));\n\t}\n\treturn out;\n}\n\n/** Drop everything. Tests, and a full session reset. */\nexport function clearCapabilities(): void {\n\tbyKind.clear();\n}\n\n/**\n * Content hash of the current capability set — the key a persistent index is\n * stored under.\n *\n * Hashes the text that gets embedded, not the count: two sessions with the same\n * tools should share an index, and one where a server changed a description\n * should not silently reuse vectors describing the old one.\n */\nexport function capabilitySetHash(docs: readonly CapabilityDoc[] = getCapabilities()): string {\n\tconst h = createHash(\"sha256\");\n\tfor (const d of docs) h.update(`${d.id}\u0000${d.name}\u0000${d.description}\u0000`);\n\treturn h.digest(\"hex\").slice(0, 16);\n}\n"]}
1
+ {"version":3,"file":"registry.js","sourceRoot":"","sources":["../../../src/core/capabilities/registry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAiCzC;;;;;;GAMG;AACH,MAAM,MAAM,GAAG,IAAI,GAAG,EAAmC,CAAC;AAE1D,mFAAmF;AACnF,MAAM,UAAU,oBAAoB,CAAC,IAAoB,EAAE,IAA8B,EAAQ;IAChG,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC;QAAE,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;;QACtC,MAAM,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,GAAG,IAAI,CAAC,CAAC,CAAC;AAAA,CACjC;AAED,oEAAoE;AACpE,MAAM,UAAU,eAAe,CAAC,KAAiC,EAAmB;IACnF,MAAM,MAAM,GAAG,KAAK,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;IACtE,MAAM,GAAG,GAAoB,EAAE,CAAC;IAChC,KAAK,MAAM,IAAI,IAAI,CAAC,GAAG,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC;QAC9C,IAAI,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC;YAAE,SAAS;QAC1C,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC,KAAK,EAAE,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,aAAa,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;IACxF,CAAC;IACD,OAAO,GAAG,CAAC;AAAA,CACX;AAED,wDAAwD;AACxD,MAAM,UAAU,iBAAiB,GAAS;IACzC,MAAM,CAAC,KAAK,EAAE,CAAC;AAAA,CACf;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,iBAAiB,CAAC,IAAI,GAA6B,eAAe,EAAE,EAAU;IAC7F,MAAM,CAAC,GAAG,UAAU,CAAC,QAAQ,CAAC,CAAC;IAC/B,KAAK,MAAM,CAAC,IAAI,IAAI;QAAE,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC,IAAI,IAAI,CAAC,CAAC,WAAW,GAAG,CAAC,CAAC;IACtE,OAAO,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;AAAA,CACpC","sourcesContent":["/**\n * One index of everything the agent can *acquire* — MCP tools, skills, slash\n * commands, subagents, and plugins available or installed.\n *\n * The problem this exists for: deferral already withholds MCP tool schemas, but\n * the model still needs some way to find a withheld tool, and the only one it\n * had was an exact name match against a catalog dumped in full into the\n * resolver's description. Those two are locked together — an exact-only matcher\n * *forces* the full dump, because a name you cannot guess is a tool you cannot\n * reach. Give the matcher retrieval and the dump becomes optional, which is\n * where the context saving actually lives.\n *\n * The producers are deliberately not coupled to it: each one hands over a flat\n * list of documents for its own kind and knows nothing about retrieval. That is\n * what lets skills and commands join later (§6.4 leaves them eager for now)\n * without touching the search side.\n *\n * See docs/plugin-system-architecture.md §6.\n */\n\nimport { createHash } from \"node:crypto\";\n\nexport type CapabilityKind =\n\t| \"mcp-tool\"\n\t| \"skill\"\n\t| \"command\"\n\t| \"agent\"\n\t| \"plugin-available\"\n\t| \"plugin-installed\"\n\t/** A heading-level slice of hoocode's own shipped documentation. */\n\t| \"doc\";\n\nexport interface CapabilityDoc {\n\t/** Unique and stable within a session; `<kind>:<name>` by convention. */\n\tid: string;\n\tkind: CapabilityKind;\n\t/** How the model refers to it — a tool name, skill name, plugin id. */\n\tname: string;\n\tdescription: string;\n\t/** Where it came from: MCP server, plugin id, marketplace name. */\n\tsource?: string;\n\t/**\n\t * Whether the expensive part (a JSON schema, a skill body) is currently\n\t * withheld from context.\n\t *\n\t * Recorded rather than derived because it is the policy knob of §6.3: it says\n\t * which entries retrieval is actually *for*. An eager capability is already\n\t * visible to the model, so surfacing it in search results is a convenience;\n\t * a deferred one is otherwise unreachable.\n\t */\n\tdeferred: boolean;\n}\n\n/**\n * Registered documents, by kind.\n *\n * Keyed by kind rather than a flat map because a producer owns its whole kind:\n * the MCP loader knows every MCP tool there is, and on reload it should replace\n * that set, not merge into a pile where a removed server's tools linger.\n */\nconst byKind = new Map<CapabilityKind, CapabilityDoc[]>();\n\n/** Replace every document of `kind`. Producers call this on load and on reload. */\nexport function registerCapabilities(kind: CapabilityKind, docs: readonly CapabilityDoc[]): void {\n\tif (docs.length === 0) byKind.delete(kind);\n\telse byKind.set(kind, [...docs]);\n}\n\n/** Every registered document, in a stable order (kind, then id). */\nexport function getCapabilities(kinds?: readonly CapabilityKind[]): CapabilityDoc[] {\n\tconst wanted = kinds && kinds.length > 0 ? new Set(kinds) : undefined;\n\tconst out: CapabilityDoc[] = [];\n\tfor (const kind of [...byKind.keys()].sort()) {\n\t\tif (wanted && !wanted.has(kind)) continue;\n\t\tout.push(...(byKind.get(kind) ?? []).slice().sort((a, b) => a.id.localeCompare(b.id)));\n\t}\n\treturn out;\n}\n\n/** Drop everything. Tests, and a full session reset. */\nexport function clearCapabilities(): void {\n\tbyKind.clear();\n}\n\n/**\n * Content hash of the current capability set — the key a persistent index is\n * stored under.\n *\n * Hashes the text that gets embedded, not the count: two sessions with the same\n * tools should share an index, and one where a server changed a description\n * should not silently reuse vectors describing the old one.\n */\nexport function capabilitySetHash(docs: readonly CapabilityDoc[] = getCapabilities()): string {\n\tconst h = createHash(\"sha256\");\n\tfor (const d of docs) h.update(`${d.id}\u0000${d.name}\u0000${d.description}\u0000`);\n\treturn h.digest(\"hex\").slice(0, 16);\n}\n"]}
@@ -0,0 +1,103 @@
1
+ /**
2
+ * The agent's index of hoocode's *own* documentation.
3
+ *
4
+ * The startup banner promises "hoocode can explain its own features and look up
5
+ * its docs", and the docs really do ship with the install (`package.json`
6
+ * `files` includes `docs`, and `copy-binary-assets` copies them into `dist/` for
7
+ * the pkg binaries). What was missing is the only part that makes the promise
8
+ * true: telling the model they exist. `getDocsPath()` had exactly one consumer —
9
+ * `auth-guidance.ts`, which prints paths to the *human* — so nothing ever put a
10
+ * docs path into model context.
11
+ *
12
+ * That gap is not one the model can close by itself. Its cwd is the user's
13
+ * project, so `grep`/`find` there discover the user's docs, never hoocode's,
14
+ * which live in an install directory whose path it cannot derive.
15
+ *
16
+ * Descriptions come from `docs/index.md` rather than being duplicated here.
17
+ * That file is a curated, human-maintained table of contents, and a second
18
+ * hand-written list is how an index goes stale the first week nobody updates
19
+ * it. The directory listing stays the source of truth for *what exists*, so a
20
+ * new doc still shows up (described from its own first paragraph) on the day it
21
+ * lands, with or without an index entry.
22
+ */
23
+ export interface SelfDoc {
24
+ /** Stable id: the filename, e.g. `skills.md`. Also how the model refers to it. */
25
+ id: string;
26
+ /** Absolute path, ready to hand to the read tool verbatim. */
27
+ path: string;
28
+ /** Human title, e.g. "Skills". */
29
+ title: string;
30
+ /** One line on what the doc covers. May be empty if nothing could be derived. */
31
+ description: string;
32
+ }
33
+ /** Drop the cached listing. Tests, and anything that relocates the package root. */
34
+ export declare function resetSelfDocs(): void;
35
+ /**
36
+ * Every shipped doc, sorted with the overview first and the rest alphabetical.
37
+ *
38
+ * Returns `[]` when the docs directory is absent rather than throwing: a source
39
+ * checkout, an odd packaging, or a trimmed container should degrade to "no docs
40
+ * section in the prompt", never to a failed session start.
41
+ */
42
+ export declare function listSelfDocs(): SelfDoc[];
43
+ /**
44
+ * The system-prompt section, or `""` when there is nothing to point at.
45
+ *
46
+ * Deliberately just filenames. An earlier version carried a one-line summary
47
+ * per doc and cost ~860 tokens on every single turn, which is a poor trade for
48
+ * something most turns never use — and it stopped being necessary once
49
+ * SearchHooCode could retrieve at the heading level. Filenames alone still let
50
+ * the model go straight to `themes.md` or `keybindings.md` for the obvious
51
+ * cases, and anything less obvious is one search away. That is ~180 tokens.
52
+ *
53
+ * Directories are printed once rather than repeated per entry, for the same
54
+ * reason: the path was the single largest term on every line.
55
+ */
56
+ export declare function formatSelfDocsForPrompt(docs?: readonly SelfDoc[]): string;
57
+ /**
58
+ * A single heading's worth of a doc.
59
+ *
60
+ * Doc-level retrieval would add nothing the prompt listing above does not
61
+ * already give: thirty files with a summary each are cheap enough to list in
62
+ * full, so a search that answers "read extensions.md" is a round trip for
63
+ * information the model already had. The questions that actually need
64
+ * retrieval are the ones inside a 1,100-line file — "how do I register a
65
+ * tool?" should land on `extensions.md § Custom tools` with a line number, not
66
+ * on the file.
67
+ */
68
+ export interface SelfDocSection {
69
+ /** `<file>#<slug>`, unique across the corpus. */
70
+ id: string;
71
+ /** Filename, e.g. `extensions.md`. */
72
+ file: string;
73
+ /** Absolute path to the file. */
74
+ path: string;
75
+ /** Heading trail from the document title down, e.g. `["Extensions", "Custom tools"]`. */
76
+ headings: string[];
77
+ /** 1-based line of the heading, so a reader can jump straight to it. */
78
+ line: number;
79
+ /** Start of the section body, for ranking and for showing why a hit matched. */
80
+ excerpt: string;
81
+ }
82
+ /** `extensions.md § Extensions › Custom tools` — what a search result is labelled with. */
83
+ export declare function sectionLabel(section: SelfDocSection): string;
84
+ /**
85
+ * Split one markdown file into sections at its headings.
86
+ *
87
+ * Fenced code is tracked so a `#` comment inside a bash block cannot be
88
+ * mistaken for a heading — which would otherwise split docs at every shell
89
+ * comment. Code *content* still lands in the excerpt: the exact identifiers
90
+ * someone searches for (`pi.registerTool`) usually live in the examples, and
91
+ * dropping them would blind the lexical leg to the best terms in the file.
92
+ */
93
+ export declare function splitIntoSections(markdown: string, file: string, path: string): SelfDocSection[];
94
+ /** Drop the cached section index. Tests, and anything that relocates the package root. */
95
+ export declare function resetSelfDocSections(): void;
96
+ /**
97
+ * Every section of every shipped doc.
98
+ *
99
+ * Reads each file once per session and caches; the docs are read-only install
100
+ * content, so there is nothing to invalidate on.
101
+ */
102
+ export declare function listSelfDocSections(): SelfDocSection[];
103
+ //# sourceMappingURL=self-docs.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"self-docs.d.ts","sourceRoot":"","sources":["../../src/core/self-docs.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAMH,MAAM,WAAW,OAAO;IACvB,kFAAkF;IAClF,EAAE,EAAE,MAAM,CAAC;IACX,8DAA8D;IAC9D,IAAI,EAAE,MAAM,CAAC;IACb,kCAAkC;IAClC,KAAK,EAAE,MAAM,CAAC;IACd,iFAAiF;IACjF,WAAW,EAAE,MAAM,CAAC;CACpB;AA8GD,oFAAoF;AACpF,wBAAgB,aAAa,IAAI,IAAI,CAGpC;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,IAAI,OAAO,EAAE,CA6CxC;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,uBAAuB,CAAC,IAAI,GAAE,SAAS,OAAO,EAAmB,GAAG,MAAM,CAsBzF;AAMD;;;;;;;;;;GAUG;AACH,MAAM,WAAW,cAAc;IAC9B,iDAAiD;IACjD,EAAE,EAAE,MAAM,CAAC;IACX,sCAAsC;IACtC,IAAI,EAAE,MAAM,CAAC;IACb,iCAAiC;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,yFAAyF;IACzF,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,wEAAwE;IACxE,IAAI,EAAE,MAAM,CAAC;IACb,gFAAgF;IAChF,OAAO,EAAE,MAAM,CAAC;CAChB;AAuBD,gGAA2F;AAC3F,wBAAgB,YAAY,CAAC,OAAO,EAAE,cAAc,GAAG,MAAM,CAE5D;AAED;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,cAAc,EAAE,CAmDhG;AAsBD,0FAA0F;AAC1F,wBAAgB,oBAAoB,IAAI,IAAI,CAE3C;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,IAAI,cAAc,EAAE,CAiBtD","sourcesContent":["/**\n * The agent's index of hoocode's *own* documentation.\n *\n * The startup banner promises \"hoocode can explain its own features and look up\n * its docs\", and the docs really do ship with the install (`package.json`\n * `files` includes `docs`, and `copy-binary-assets` copies them into `dist/` for\n * the pkg binaries). What was missing is the only part that makes the promise\n * true: telling the model they exist. `getDocsPath()` had exactly one consumer —\n * `auth-guidance.ts`, which prints paths to the *human* — so nothing ever put a\n * docs path into model context.\n *\n * That gap is not one the model can close by itself. Its cwd is the user's\n * project, so `grep`/`find` there discover the user's docs, never hoocode's,\n * which live in an install directory whose path it cannot derive.\n *\n * Descriptions come from `docs/index.md` rather than being duplicated here.\n * That file is a curated, human-maintained table of contents, and a second\n * hand-written list is how an index goes stale the first week nobody updates\n * it. The directory listing stays the source of truth for *what exists*, so a\n * new doc still shows up (described from its own first paragraph) on the day it\n * lands, with or without an index entry.\n */\n\nimport { existsSync, readdirSync, readFileSync, statSync } from \"node:fs\";\nimport { basename, dirname, join } from \"node:path\";\nimport { getChangelogPath, getDocsPath, getReadmePath } from \"../config.js\";\n\nexport interface SelfDoc {\n\t/** Stable id: the filename, e.g. `skills.md`. Also how the model refers to it. */\n\tid: string;\n\t/** Absolute path, ready to hand to the read tool verbatim. */\n\tpath: string;\n\t/** Human title, e.g. \"Skills\". */\n\ttitle: string;\n\t/** One line on what the doc covers. May be empty if nothing could be derived. */\n\tdescription: string;\n}\n\n/** How much of a doc to read when deriving a fallback description. */\nconst HEAD_BYTES = 2048;\n\n/** Cap on a derived description, so one run-on opening line cannot bloat the prompt. */\nconst MAX_DESCRIPTION = 110;\n\nfunction truncate(text: string, max = MAX_DESCRIPTION): string {\n\tconst clean = text.replace(/\\s+/g, \" \").trim();\n\tif (clean.length <= max) return clean;\n\treturn `${clean.slice(0, max - 1).trimEnd()}…`;\n}\n\n/** Strip inline markdown that adds noise but no meaning in a prompt listing. */\nfunction stripInlineMarkdown(text: string): string {\n\treturn text\n\t\t.replace(/\\[([^\\]]+)\\]\\([^)]*\\)/g, \"$1\") // links → their text\n\t\t.replace(/[`*_]/g, \"\")\n\t\t.trim();\n}\n\nfunction readHead(path: string): string {\n\ttry {\n\t\t// Whole-file read: these are small, and slicing bytes off a UTF-8 file can\n\t\t// split a multi-byte character. Truncate after decoding instead.\n\t\treturn readFileSync(path, \"utf-8\").slice(0, HEAD_BYTES);\n\t} catch {\n\t\treturn \"\";\n\t}\n}\n\n/** First `# ` heading, or undefined. */\nfunction firstHeading(markdown: string): string | undefined {\n\tfor (const line of markdown.split(/\\r?\\n/)) {\n\t\tconst match = /^#\\s+(.+)$/.exec(line.trim());\n\t\tif (match?.[1]) return stripInlineMarkdown(match[1]);\n\t}\n\treturn undefined;\n}\n\n/**\n * First real prose line: not a heading, blockquote, list item, fence, or table\n * row. Used only for docs the curated index does not describe.\n */\nfunction firstParagraph(markdown: string): string | undefined {\n\tlet inFence = false;\n\tfor (const raw of markdown.split(/\\r?\\n/)) {\n\t\tconst line = raw.trim();\n\t\tif (line.startsWith(\"```\")) {\n\t\t\tinFence = !inFence;\n\t\t\tcontinue;\n\t\t}\n\t\tif (inFence || line === \"\") continue;\n\t\tif (/^[#>|-]/.test(line) || /^\\d+\\./.test(line)) continue;\n\t\treturn stripInlineMarkdown(line);\n\t}\n\treturn undefined;\n}\n\n/**\n * Titles and descriptions the docs maintain about themselves, keyed by filename.\n *\n * Matches list entries of the form `- [Title](file.md) - description`, which is\n * how every section of `index.md` is written. Anything that does not match is\n * skipped rather than guessed at.\n */\nfunction parseCuratedIndex(docsRoot: string): Map<string, { title: string; description: string }> {\n\tconst curated = new Map<string, { title: string; description: string }>();\n\tconst indexPath = join(docsRoot, \"index.md\");\n\tif (!existsSync(indexPath)) return curated;\n\n\tlet content: string;\n\ttry {\n\t\tcontent = readFileSync(indexPath, \"utf-8\");\n\t} catch {\n\t\treturn curated;\n\t}\n\n\t// `[Title](file.md)` followed by a dash of any width and the description.\n\tconst entry = /^\\s*[-*]\\s*\\[([^\\]]+)\\]\\(([^)#]+\\.md)\\)\\s*[-–—:]\\s*(.+?)\\s*$/;\n\tfor (const line of content.split(/\\r?\\n/)) {\n\t\tconst match = entry.exec(line);\n\t\tif (!match) continue;\n\t\tconst [, title, target, description] = match;\n\t\tconst file = basename(target);\n\t\tif (curated.has(file)) continue; // first mention wins\n\t\tcurated.set(file, { title: stripInlineMarkdown(title), description: truncate(stripInlineMarkdown(description)) });\n\t}\n\treturn curated;\n}\n\nfunction describe(path: string, file: string, curated: Map<string, { title: string; description: string }>): SelfDoc {\n\tconst fromIndex = curated.get(file);\n\tif (fromIndex) {\n\t\treturn { id: file, path, title: fromIndex.title, description: fromIndex.description };\n\t}\n\t// Not in the curated index — derive from the doc itself so new files are\n\t// still usable the day they land.\n\tconst head = readHead(path);\n\treturn {\n\t\tid: file,\n\t\tpath,\n\t\ttitle: firstHeading(head) ?? file.replace(/\\.md$/, \"\"),\n\t\tdescription: truncate(firstParagraph(head) ?? \"\"),\n\t};\n}\n\nlet cached: SelfDoc[] | undefined;\n\n/** Drop the cached listing. Tests, and anything that relocates the package root. */\nexport function resetSelfDocs(): void {\n\tcached = undefined;\n\tcachedSections = undefined;\n}\n\n/**\n * Every shipped doc, sorted with the overview first and the rest alphabetical.\n *\n * Returns `[]` when the docs directory is absent rather than throwing: a source\n * checkout, an odd packaging, or a trimmed container should degrade to \"no docs\n * section in the prompt\", never to a failed session start.\n */\nexport function listSelfDocs(): SelfDoc[] {\n\tif (cached) return cached;\n\n\tconst docsRoot = getDocsPath();\n\tconst docs: SelfDoc[] = [];\n\n\tif (existsSync(docsRoot)) {\n\t\tconst curated = parseCuratedIndex(docsRoot);\n\t\tlet files: string[];\n\t\ttry {\n\t\t\tfiles = readdirSync(docsRoot).filter((f) => f.endsWith(\".md\"));\n\t\t} catch {\n\t\t\tfiles = [];\n\t\t}\n\t\t// Overview first: it is the doc that explains the others.\n\t\tfiles.sort((a, b) => (a === \"index.md\" ? -1 : b === \"index.md\" ? 1 : a.localeCompare(b)));\n\t\tfor (const file of files) {\n\t\t\tconst path = join(docsRoot, file);\n\t\t\ttry {\n\t\t\t\tif (!statSync(path).isFile()) continue;\n\t\t\t} catch {\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tdocs.push(describe(path, file, curated));\n\t\t}\n\t}\n\n\t// README and CHANGELOG sit beside the docs directory, not inside it, but the\n\t// model needs them for the two questions the docs do not answer: what\n\t// hoocode is, and what changed in this version.\n\tconst extras: Array<{ path: string; title: string; description: string }> = [\n\t\t{ path: getReadmePath(), title: \"README\", description: \"What hoocode is, install, and a feature overview.\" },\n\t\t{\n\t\t\tpath: getChangelogPath(),\n\t\t\ttitle: \"Changelog\",\n\t\t\tdescription: \"Released versions and what changed in each.\",\n\t\t},\n\t];\n\tfor (const extra of extras) {\n\t\tif (!existsSync(extra.path)) continue;\n\t\tdocs.push({ id: basename(extra.path), path: extra.path, title: extra.title, description: extra.description });\n\t}\n\n\tcached = docs;\n\treturn docs;\n}\n\n/**\n * The system-prompt section, or `\"\"` when there is nothing to point at.\n *\n * Deliberately just filenames. An earlier version carried a one-line summary\n * per doc and cost ~860 tokens on every single turn, which is a poor trade for\n * something most turns never use — and it stopped being necessary once\n * SearchHooCode could retrieve at the heading level. Filenames alone still let\n * the model go straight to `themes.md` or `keybindings.md` for the obvious\n * cases, and anything less obvious is one search away. That is ~180 tokens.\n *\n * Directories are printed once rather than repeated per entry, for the same\n * reason: the path was the single largest term on every line.\n */\nexport function formatSelfDocsForPrompt(docs: readonly SelfDoc[] = listSelfDocs()): string {\n\tif (docs.length === 0) return \"\";\n\n\t// Insertion order is already meaningful (overview first, then alphabetical,\n\t// then README/CHANGELOG), so group without re-sorting.\n\tconst groups = new Map<string, string[]>();\n\tfor (const doc of docs) {\n\t\tconst root = dirname(doc.path);\n\t\tconst bucket = groups.get(root);\n\t\tif (bucket) bucket.push(doc.id);\n\t\telse groups.set(root, [doc.id]);\n\t}\n\n\tconst sections = [...groups].map(([root, files]) => `${root}/: ${files.join(\", \")}`);\n\n\treturn `\n\n# About hoocode itself\n\nYou are running inside hoocode. Its own docs ship with the install, listed below; hoocode is actively developed, so answer questions about it from these files rather than from memory. They sit outside the working directory, so searching the project will not find them. Use SearchHooCode to locate a specific heading, or read a file directly.\n\n${sections.join(\"\\n\")}`;\n}\n\n// ---------------------------------------------------------------------------\n// Section index\n// ---------------------------------------------------------------------------\n\n/**\n * A single heading's worth of a doc.\n *\n * Doc-level retrieval would add nothing the prompt listing above does not\n * already give: thirty files with a summary each are cheap enough to list in\n * full, so a search that answers \"read extensions.md\" is a round trip for\n * information the model already had. The questions that actually need\n * retrieval are the ones inside a 1,100-line file — \"how do I register a\n * tool?\" should land on `extensions.md § Custom tools` with a line number, not\n * on the file.\n */\nexport interface SelfDocSection {\n\t/** `<file>#<slug>`, unique across the corpus. */\n\tid: string;\n\t/** Filename, e.g. `extensions.md`. */\n\tfile: string;\n\t/** Absolute path to the file. */\n\tpath: string;\n\t/** Heading trail from the document title down, e.g. `[\"Extensions\", \"Custom tools\"]`. */\n\theadings: string[];\n\t/** 1-based line of the heading, so a reader can jump straight to it. */\n\tline: number;\n\t/** Start of the section body, for ranking and for showing why a hit matched. */\n\texcerpt: string;\n}\n\n/**\n * How much section body to keep.\n *\n * Every character past this is invisible to retrieval, so the cap is a recall\n * limit, not just a size one: at 240 a question about `/grill` missed the\n * section that documents it, because the term sat in the fourth sentence. 400\n * covers the opening of essentially every section here for about 95KB more\n * index across the corpus, which buys back that class of miss.\n */\nconst MAX_EXCERPT = 400;\n\n/** `Custom tools` → `custom-tools`, so ids stay stable and readable. */\nfunction slugify(heading: string): string {\n\treturn (\n\t\theading\n\t\t\t.toLowerCase()\n\t\t\t.replace(/[^a-z0-9]+/g, \"-\")\n\t\t\t.replace(/^-+|-+$/g, \"\") || \"section\"\n\t);\n}\n\n/** `extensions.md § Extensions › Custom tools` — what a search result is labelled with. */\nexport function sectionLabel(section: SelfDocSection): string {\n\treturn section.headings.length > 0 ? `${section.file} § ${section.headings.join(\" › \")}` : section.file;\n}\n\n/**\n * Split one markdown file into sections at its headings.\n *\n * Fenced code is tracked so a `#` comment inside a bash block cannot be\n * mistaken for a heading — which would otherwise split docs at every shell\n * comment. Code *content* still lands in the excerpt: the exact identifiers\n * someone searches for (`pi.registerTool`) usually live in the examples, and\n * dropping them would blind the lexical leg to the best terms in the file.\n */\nexport function splitIntoSections(markdown: string, file: string, path: string): SelfDocSection[] {\n\tconst lines = markdown.split(/\\r?\\n/);\n\tconst sections: SelfDocSection[] = [];\n\tconst trail: Array<{ depth: number; text: string }> = [];\n\tconst usedIds = new Set<string>();\n\n\tlet current: SelfDocSection | undefined;\n\tlet body: string[] = [];\n\tlet inFence = false;\n\n\tconst flush = (): void => {\n\t\tif (!current) return;\n\t\tcurrent.excerpt = truncate(stripInlineMarkdown(body.join(\" \")), MAX_EXCERPT);\n\t\tsections.push(current);\n\t\tbody = [];\n\t};\n\n\tfor (let i = 0; i < lines.length; i++) {\n\t\tconst raw = lines[i] ?? \"\";\n\t\tif (raw.trimStart().startsWith(\"```\")) {\n\t\t\tinFence = !inFence;\n\t\t\tcontinue;\n\t\t}\n\t\tconst heading = inFence ? null : /^(#{1,6})\\s+(.+?)\\s*$/.exec(raw);\n\t\tif (!heading) {\n\t\t\tif (raw.trim() !== \"\") body.push(raw.trim());\n\t\t\tcontinue;\n\t\t}\n\n\t\tflush();\n\n\t\tconst depth = heading[1]?.length ?? 1;\n\t\tconst text = stripInlineMarkdown(heading[2] ?? \"\");\n\t\twhile (trail.length > 0 && (trail[trail.length - 1]?.depth ?? 0) >= depth) trail.pop();\n\t\ttrail.push({ depth, text });\n\n\t\t// Disambiguate repeated headings (\"Example\" appears eleven times in\n\t\t// extensions.md) so ids stay unique and the registry does not collapse them.\n\t\tlet id = `${file}#${slugify(trail.map((t) => t.text).join(\"-\"))}`;\n\t\tif (usedIds.has(id)) {\n\t\t\tlet n = 2;\n\t\t\twhile (usedIds.has(`${id}-${n}`)) n++;\n\t\t\tid = `${id}-${n}`;\n\t\t}\n\t\tusedIds.add(id);\n\n\t\tcurrent = { id, file, path, headings: trail.map((t) => t.text), line: i + 1, excerpt: \"\" };\n\t}\n\tflush();\n\n\treturn sections;\n}\n\n/**\n * Files kept out of the section index.\n *\n * The changelog is 40% of the corpus by section count and none of it answers\n * \"how does X work\": it is hundreds of near-identical `Added`/`Fixed`/`Changed`\n * headings under version numbers, which crowd real documentation out of the\n * ranking while matching almost any query about a feature by name.\n *\n * `index.md` is excluded for the mirror-image reason: it is a table of contents,\n * so its \"sections\" are lists of links whose text is every other doc's title and\n * summary. That makes it match any query those docs would match, while carrying\n * none of the content — a guaranteed false attractor that displaces the page it\n * is pointing at.\n *\n * Both stay in the prompt's filename listing, one read away.\n */\nconst SECTION_INDEX_EXCLUDED = new Set([\"CHANGELOG.md\", \"index.md\"]);\n\nlet cachedSections: SelfDocSection[] | undefined;\n\n/** Drop the cached section index. Tests, and anything that relocates the package root. */\nexport function resetSelfDocSections(): void {\n\tcachedSections = undefined;\n}\n\n/**\n * Every section of every shipped doc.\n *\n * Reads each file once per session and caches; the docs are read-only install\n * content, so there is nothing to invalidate on.\n */\nexport function listSelfDocSections(): SelfDocSection[] {\n\tif (cachedSections) return cachedSections;\n\n\tconst sections: SelfDocSection[] = [];\n\tfor (const doc of listSelfDocs()) {\n\t\tif (SECTION_INDEX_EXCLUDED.has(doc.id)) continue;\n\t\tlet content: string;\n\t\ttry {\n\t\t\tcontent = readFileSync(doc.path, \"utf-8\");\n\t\t} catch {\n\t\t\tcontinue;\n\t\t}\n\t\tsections.push(...splitIntoSections(content, doc.id, doc.path));\n\t}\n\n\tcachedSections = sections;\n\treturn sections;\n}\n"]}