@kolisachint/hoocode-agent 0.5.25 → 0.5.27

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 (88) hide show
  1. package/CHANGELOG.md +128 -0
  2. package/dist/core/canvas/lifecycle.d.ts +93 -0
  3. package/dist/core/canvas/lifecycle.d.ts.map +1 -0
  4. package/dist/core/canvas/lifecycle.js +165 -0
  5. package/dist/core/canvas/lifecycle.js.map +1 -0
  6. package/dist/core/canvas/registry.d.ts +89 -0
  7. package/dist/core/canvas/registry.d.ts.map +1 -1
  8. package/dist/core/canvas/registry.js +205 -10
  9. package/dist/core/canvas/registry.js.map +1 -1
  10. package/dist/core/canvas/scaffold.d.ts +123 -0
  11. package/dist/core/canvas/scaffold.d.ts.map +1 -0
  12. package/dist/core/canvas/scaffold.js +376 -0
  13. package/dist/core/canvas/scaffold.js.map +1 -0
  14. package/dist/core/canvas/session.d.ts +39 -1
  15. package/dist/core/canvas/session.d.ts.map +1 -1
  16. package/dist/core/canvas/session.js +83 -1
  17. package/dist/core/canvas/session.js.map +1 -1
  18. package/dist/core/capabilities/lexical.d.ts +4 -0
  19. package/dist/core/capabilities/lexical.d.ts.map +1 -1
  20. package/dist/core/capabilities/lexical.js +104 -4
  21. package/dist/core/capabilities/lexical.js.map +1 -1
  22. package/dist/core/capabilities/registry.d.ts +3 -1
  23. package/dist/core/capabilities/registry.d.ts.map +1 -1
  24. package/dist/core/capabilities/registry.js.map +1 -1
  25. package/dist/core/self-docs.d.ts +103 -0
  26. package/dist/core/self-docs.d.ts.map +1 -0
  27. package/dist/core/self-docs.js +351 -0
  28. package/dist/core/self-docs.js.map +1 -0
  29. package/dist/core/system-prompt.d.ts +12 -0
  30. package/dist/core/system-prompt.d.ts.map +1 -1
  31. package/dist/core/system-prompt.js +11 -1
  32. package/dist/core/system-prompt.js.map +1 -1
  33. package/dist/core/tools/canvas.d.ts +23 -3
  34. package/dist/core/tools/canvas.d.ts.map +1 -1
  35. package/dist/core/tools/canvas.js +99 -4
  36. package/dist/core/tools/canvas.js.map +1 -1
  37. package/dist/extensions/core/canvas.d.ts +20 -2
  38. package/dist/extensions/core/canvas.d.ts.map +1 -1
  39. package/dist/extensions/core/canvas.js +279 -36
  40. package/dist/extensions/core/canvas.js.map +1 -1
  41. package/dist/extensions/core/hoo-core.d.ts +1 -0
  42. package/dist/extensions/core/hoo-core.d.ts.map +1 -1
  43. package/dist/extensions/core/hoo-core.js +3 -0
  44. package/dist/extensions/core/hoo-core.js.map +1 -1
  45. package/dist/extensions/core/mcp-loader.d.ts.map +1 -1
  46. package/dist/extensions/core/mcp-loader.js +8 -2
  47. package/dist/extensions/core/mcp-loader.js.map +1 -1
  48. package/dist/extensions/core/scaffold.d.ts +7 -1
  49. package/dist/extensions/core/scaffold.d.ts.map +1 -1
  50. package/dist/extensions/core/scaffold.js +7 -185
  51. package/dist/extensions/core/scaffold.js.map +1 -1
  52. package/dist/extensions/core/self-knowledge.d.ts +28 -0
  53. package/dist/extensions/core/self-knowledge.d.ts.map +1 -0
  54. package/dist/extensions/core/self-knowledge.js +199 -0
  55. package/dist/extensions/core/self-knowledge.js.map +1 -0
  56. package/docs/canvas.md +117 -0
  57. package/docs/compaction.md +4 -4
  58. package/docs/custom-provider.md +1 -1
  59. package/docs/development.md +1 -1
  60. package/docs/docs.json +27 -2
  61. package/docs/extensions.md +12 -12
  62. package/docs/index.md +8 -0
  63. package/docs/keybindings.md +2 -2
  64. package/docs/mcp.md +97 -0
  65. package/docs/models.md +1 -1
  66. package/docs/modes.md +87 -0
  67. package/docs/packages.md +4 -4
  68. package/docs/plugins.md +124 -0
  69. package/docs/prompt-templates.md +1 -1
  70. package/docs/providers.md +2 -2
  71. package/docs/quickstart.md +2 -2
  72. package/docs/rpc.md +5 -5
  73. package/docs/sdk.md +5 -5
  74. package/docs/session-format.md +3 -3
  75. package/docs/sessions.md +1 -1
  76. package/docs/settings.md +3 -3
  77. package/docs/shell-aliases.md +1 -1
  78. package/docs/skills.md +2 -2
  79. package/docs/terminal-setup.md +1 -1
  80. package/docs/termux.md +2 -2
  81. package/docs/themes.md +3 -3
  82. package/docs/usage.md +93 -4
  83. package/docs/windows.md +1 -1
  84. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  85. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  86. package/examples/extensions/sandbox/package.json +1 -1
  87. package/examples/extensions/with-deps/package.json +1 -1
  88. package/package.json +4 -4
@@ -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"]}
@@ -0,0 +1,351 @@
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
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
24
+ import { basename, dirname, join } from "node:path";
25
+ import { getChangelogPath, getDocsPath, getReadmePath } from "../config.js";
26
+ /** How much of a doc to read when deriving a fallback description. */
27
+ const HEAD_BYTES = 2048;
28
+ /** Cap on a derived description, so one run-on opening line cannot bloat the prompt. */
29
+ const MAX_DESCRIPTION = 110;
30
+ function truncate(text, max = MAX_DESCRIPTION) {
31
+ const clean = text.replace(/\s+/g, " ").trim();
32
+ if (clean.length <= max)
33
+ return clean;
34
+ return `${clean.slice(0, max - 1).trimEnd()}…`;
35
+ }
36
+ /** Strip inline markdown that adds noise but no meaning in a prompt listing. */
37
+ function stripInlineMarkdown(text) {
38
+ return text
39
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") // links → their text
40
+ .replace(/[`*_]/g, "")
41
+ .trim();
42
+ }
43
+ function readHead(path) {
44
+ try {
45
+ // Whole-file read: these are small, and slicing bytes off a UTF-8 file can
46
+ // split a multi-byte character. Truncate after decoding instead.
47
+ return readFileSync(path, "utf-8").slice(0, HEAD_BYTES);
48
+ }
49
+ catch {
50
+ return "";
51
+ }
52
+ }
53
+ /** First `# ` heading, or undefined. */
54
+ function firstHeading(markdown) {
55
+ for (const line of markdown.split(/\r?\n/)) {
56
+ const match = /^#\s+(.+)$/.exec(line.trim());
57
+ if (match?.[1])
58
+ return stripInlineMarkdown(match[1]);
59
+ }
60
+ return undefined;
61
+ }
62
+ /**
63
+ * First real prose line: not a heading, blockquote, list item, fence, or table
64
+ * row. Used only for docs the curated index does not describe.
65
+ */
66
+ function firstParagraph(markdown) {
67
+ let inFence = false;
68
+ for (const raw of markdown.split(/\r?\n/)) {
69
+ const line = raw.trim();
70
+ if (line.startsWith("```")) {
71
+ inFence = !inFence;
72
+ continue;
73
+ }
74
+ if (inFence || line === "")
75
+ continue;
76
+ if (/^[#>|-]/.test(line) || /^\d+\./.test(line))
77
+ continue;
78
+ return stripInlineMarkdown(line);
79
+ }
80
+ return undefined;
81
+ }
82
+ /**
83
+ * Titles and descriptions the docs maintain about themselves, keyed by filename.
84
+ *
85
+ * Matches list entries of the form `- [Title](file.md) - description`, which is
86
+ * how every section of `index.md` is written. Anything that does not match is
87
+ * skipped rather than guessed at.
88
+ */
89
+ function parseCuratedIndex(docsRoot) {
90
+ const curated = new Map();
91
+ const indexPath = join(docsRoot, "index.md");
92
+ if (!existsSync(indexPath))
93
+ return curated;
94
+ let content;
95
+ try {
96
+ content = readFileSync(indexPath, "utf-8");
97
+ }
98
+ catch {
99
+ return curated;
100
+ }
101
+ // `[Title](file.md)` followed by a dash of any width and the description.
102
+ const entry = /^\s*[-*]\s*\[([^\]]+)\]\(([^)#]+\.md)\)\s*[-–—:]\s*(.+?)\s*$/;
103
+ for (const line of content.split(/\r?\n/)) {
104
+ const match = entry.exec(line);
105
+ if (!match)
106
+ continue;
107
+ const [, title, target, description] = match;
108
+ const file = basename(target);
109
+ if (curated.has(file))
110
+ continue; // first mention wins
111
+ curated.set(file, { title: stripInlineMarkdown(title), description: truncate(stripInlineMarkdown(description)) });
112
+ }
113
+ return curated;
114
+ }
115
+ function describe(path, file, curated) {
116
+ const fromIndex = curated.get(file);
117
+ if (fromIndex) {
118
+ return { id: file, path, title: fromIndex.title, description: fromIndex.description };
119
+ }
120
+ // Not in the curated index — derive from the doc itself so new files are
121
+ // still usable the day they land.
122
+ const head = readHead(path);
123
+ return {
124
+ id: file,
125
+ path,
126
+ title: firstHeading(head) ?? file.replace(/\.md$/, ""),
127
+ description: truncate(firstParagraph(head) ?? ""),
128
+ };
129
+ }
130
+ let cached;
131
+ /** Drop the cached listing. Tests, and anything that relocates the package root. */
132
+ export function resetSelfDocs() {
133
+ cached = undefined;
134
+ cachedSections = undefined;
135
+ }
136
+ /**
137
+ * Every shipped doc, sorted with the overview first and the rest alphabetical.
138
+ *
139
+ * Returns `[]` when the docs directory is absent rather than throwing: a source
140
+ * checkout, an odd packaging, or a trimmed container should degrade to "no docs
141
+ * section in the prompt", never to a failed session start.
142
+ */
143
+ export function listSelfDocs() {
144
+ if (cached)
145
+ return cached;
146
+ const docsRoot = getDocsPath();
147
+ const docs = [];
148
+ if (existsSync(docsRoot)) {
149
+ const curated = parseCuratedIndex(docsRoot);
150
+ let files;
151
+ try {
152
+ files = readdirSync(docsRoot).filter((f) => f.endsWith(".md"));
153
+ }
154
+ catch {
155
+ files = [];
156
+ }
157
+ // Overview first: it is the doc that explains the others.
158
+ files.sort((a, b) => (a === "index.md" ? -1 : b === "index.md" ? 1 : a.localeCompare(b)));
159
+ for (const file of files) {
160
+ const path = join(docsRoot, file);
161
+ try {
162
+ if (!statSync(path).isFile())
163
+ continue;
164
+ }
165
+ catch {
166
+ continue;
167
+ }
168
+ docs.push(describe(path, file, curated));
169
+ }
170
+ }
171
+ // README and CHANGELOG sit beside the docs directory, not inside it, but the
172
+ // model needs them for the two questions the docs do not answer: what
173
+ // hoocode is, and what changed in this version.
174
+ const extras = [
175
+ { path: getReadmePath(), title: "README", description: "What hoocode is, install, and a feature overview." },
176
+ {
177
+ path: getChangelogPath(),
178
+ title: "Changelog",
179
+ description: "Released versions and what changed in each.",
180
+ },
181
+ ];
182
+ for (const extra of extras) {
183
+ if (!existsSync(extra.path))
184
+ continue;
185
+ docs.push({ id: basename(extra.path), path: extra.path, title: extra.title, description: extra.description });
186
+ }
187
+ cached = docs;
188
+ return docs;
189
+ }
190
+ /**
191
+ * The system-prompt section, or `""` when there is nothing to point at.
192
+ *
193
+ * Deliberately just filenames. An earlier version carried a one-line summary
194
+ * per doc and cost ~860 tokens on every single turn, which is a poor trade for
195
+ * something most turns never use — and it stopped being necessary once
196
+ * SearchHooCode could retrieve at the heading level. Filenames alone still let
197
+ * the model go straight to `themes.md` or `keybindings.md` for the obvious
198
+ * cases, and anything less obvious is one search away. That is ~180 tokens.
199
+ *
200
+ * Directories are printed once rather than repeated per entry, for the same
201
+ * reason: the path was the single largest term on every line.
202
+ */
203
+ export function formatSelfDocsForPrompt(docs = listSelfDocs()) {
204
+ if (docs.length === 0)
205
+ return "";
206
+ // Insertion order is already meaningful (overview first, then alphabetical,
207
+ // then README/CHANGELOG), so group without re-sorting.
208
+ const groups = new Map();
209
+ for (const doc of docs) {
210
+ const root = dirname(doc.path);
211
+ const bucket = groups.get(root);
212
+ if (bucket)
213
+ bucket.push(doc.id);
214
+ else
215
+ groups.set(root, [doc.id]);
216
+ }
217
+ const sections = [...groups].map(([root, files]) => `${root}/: ${files.join(", ")}`);
218
+ return `
219
+
220
+ # About hoocode itself
221
+
222
+ You 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.
223
+
224
+ ${sections.join("\n")}`;
225
+ }
226
+ /**
227
+ * How much section body to keep.
228
+ *
229
+ * Every character past this is invisible to retrieval, so the cap is a recall
230
+ * limit, not just a size one: at 240 a question about `/grill` missed the
231
+ * section that documents it, because the term sat in the fourth sentence. 400
232
+ * covers the opening of essentially every section here for about 95KB more
233
+ * index across the corpus, which buys back that class of miss.
234
+ */
235
+ const MAX_EXCERPT = 400;
236
+ /** `Custom tools` → `custom-tools`, so ids stay stable and readable. */
237
+ function slugify(heading) {
238
+ return (heading
239
+ .toLowerCase()
240
+ .replace(/[^a-z0-9]+/g, "-")
241
+ .replace(/^-+|-+$/g, "") || "section");
242
+ }
243
+ /** `extensions.md § Extensions › Custom tools` — what a search result is labelled with. */
244
+ export function sectionLabel(section) {
245
+ return section.headings.length > 0 ? `${section.file} § ${section.headings.join(" › ")}` : section.file;
246
+ }
247
+ /**
248
+ * Split one markdown file into sections at its headings.
249
+ *
250
+ * Fenced code is tracked so a `#` comment inside a bash block cannot be
251
+ * mistaken for a heading — which would otherwise split docs at every shell
252
+ * comment. Code *content* still lands in the excerpt: the exact identifiers
253
+ * someone searches for (`pi.registerTool`) usually live in the examples, and
254
+ * dropping them would blind the lexical leg to the best terms in the file.
255
+ */
256
+ export function splitIntoSections(markdown, file, path) {
257
+ const lines = markdown.split(/\r?\n/);
258
+ const sections = [];
259
+ const trail = [];
260
+ const usedIds = new Set();
261
+ let current;
262
+ let body = [];
263
+ let inFence = false;
264
+ const flush = () => {
265
+ if (!current)
266
+ return;
267
+ current.excerpt = truncate(stripInlineMarkdown(body.join(" ")), MAX_EXCERPT);
268
+ sections.push(current);
269
+ body = [];
270
+ };
271
+ for (let i = 0; i < lines.length; i++) {
272
+ const raw = lines[i] ?? "";
273
+ if (raw.trimStart().startsWith("```")) {
274
+ inFence = !inFence;
275
+ continue;
276
+ }
277
+ const heading = inFence ? null : /^(#{1,6})\s+(.+?)\s*$/.exec(raw);
278
+ if (!heading) {
279
+ if (raw.trim() !== "")
280
+ body.push(raw.trim());
281
+ continue;
282
+ }
283
+ flush();
284
+ const depth = heading[1]?.length ?? 1;
285
+ const text = stripInlineMarkdown(heading[2] ?? "");
286
+ while (trail.length > 0 && (trail[trail.length - 1]?.depth ?? 0) >= depth)
287
+ trail.pop();
288
+ trail.push({ depth, text });
289
+ // Disambiguate repeated headings ("Example" appears eleven times in
290
+ // extensions.md) so ids stay unique and the registry does not collapse them.
291
+ let id = `${file}#${slugify(trail.map((t) => t.text).join("-"))}`;
292
+ if (usedIds.has(id)) {
293
+ let n = 2;
294
+ while (usedIds.has(`${id}-${n}`))
295
+ n++;
296
+ id = `${id}-${n}`;
297
+ }
298
+ usedIds.add(id);
299
+ current = { id, file, path, headings: trail.map((t) => t.text), line: i + 1, excerpt: "" };
300
+ }
301
+ flush();
302
+ return sections;
303
+ }
304
+ /**
305
+ * Files kept out of the section index.
306
+ *
307
+ * The changelog is 40% of the corpus by section count and none of it answers
308
+ * "how does X work": it is hundreds of near-identical `Added`/`Fixed`/`Changed`
309
+ * headings under version numbers, which crowd real documentation out of the
310
+ * ranking while matching almost any query about a feature by name.
311
+ *
312
+ * `index.md` is excluded for the mirror-image reason: it is a table of contents,
313
+ * so its "sections" are lists of links whose text is every other doc's title and
314
+ * summary. That makes it match any query those docs would match, while carrying
315
+ * none of the content — a guaranteed false attractor that displaces the page it
316
+ * is pointing at.
317
+ *
318
+ * Both stay in the prompt's filename listing, one read away.
319
+ */
320
+ const SECTION_INDEX_EXCLUDED = new Set(["CHANGELOG.md", "index.md"]);
321
+ let cachedSections;
322
+ /** Drop the cached section index. Tests, and anything that relocates the package root. */
323
+ export function resetSelfDocSections() {
324
+ cachedSections = undefined;
325
+ }
326
+ /**
327
+ * Every section of every shipped doc.
328
+ *
329
+ * Reads each file once per session and caches; the docs are read-only install
330
+ * content, so there is nothing to invalidate on.
331
+ */
332
+ export function listSelfDocSections() {
333
+ if (cachedSections)
334
+ return cachedSections;
335
+ const sections = [];
336
+ for (const doc of listSelfDocs()) {
337
+ if (SECTION_INDEX_EXCLUDED.has(doc.id))
338
+ continue;
339
+ let content;
340
+ try {
341
+ content = readFileSync(doc.path, "utf-8");
342
+ }
343
+ catch {
344
+ continue;
345
+ }
346
+ sections.push(...splitIntoSections(content, doc.id, doc.path));
347
+ }
348
+ cachedSections = sections;
349
+ return sections;
350
+ }
351
+ //# sourceMappingURL=self-docs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"self-docs.js","sourceRoot":"","sources":["../../src/core/self-docs.ts"],"names":[],"mappings":"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","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"]}
@@ -29,6 +29,18 @@ export interface BuildSystemPromptOptions {
29
29
  * agents exist without re-reading the agent registry each turn.
30
30
  */
31
31
  agents?: AgentDefinition[];
32
+ /**
33
+ * Point the model at hoocode's own shipped docs so it can answer questions
34
+ * about hoocode itself.
35
+ *
36
+ * Defaults to true for the built-in prompt and false when `customPrompt`
37
+ * replaces it. Every other appended section (context files, skills, agents)
38
+ * only appears because the caller passed the content in; this one
39
+ * materializes on its own, so a caller who has taken over the system prompt
40
+ * gets it only by asking. That also keeps it out of light mode, whose whole
41
+ * point is a minimal fixed per-turn surface. Needs the read tool either way.
42
+ */
43
+ includeSelfDocs?: boolean;
32
44
  }
33
45
  /** Build the system prompt with tools, guidelines, and context */
34
46
  export declare function buildSystemPrompt(options: BuildSystemPromptOptions): string;