{"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 `SearchCodebase` there discovers 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 (`hoo.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"]}