perf(coding-agent): optimized documentation indexing via compressed blob

- Replaced raw TypeScript documentation map with a lazily-inflated gzip blob.
- Reduced bundled binary/npm package size by approximately 0.9MB.
- Encapsulated index logic into `docs-index.ts` to separate header metadata from content.
- Added `postpack` and robust `try/finally` patterns in build scripts to ensure clean artifacts.
- Implemented disk-based fallback during development to maintain existing developer experience.
This commit is contained in:
can1357
2026-06-18 18:57:13 +02:00
parent f5ebab2b83
commit 831b664a4c
10 changed files with 212 additions and 51 deletions
-2
View File
@@ -20,8 +20,6 @@
"ignore": [ "ignore": [
// Generated from `packages/natives/scripts/native-index.template.js` via gen-enums.ts. // Generated from `packages/natives/scripts/native-index.template.js` via gen-enums.ts.
"packages/natives/native/index.js", "packages/natives/native/index.js",
// Generated by `packages/coding-agent/scripts/generate-docs-index.ts`.
"packages/coding-agent/src/internal-urls/docs-index.generated.ts",
// Embedded HTML asset shipped as a static template, not a code module. // Embedded HTML asset shipped as a static template, not a code module.
"packages/coding-agent/src/export/html/template.js", "packages/coding-agent/src/export/html/template.js",
// Generated/owned upstream — see packages/ai/scripts/generate-models.ts. // Generated/owned upstream — see packages/ai/scripts/generate-models.ts.
+3 -1
View File
@@ -3,7 +3,9 @@
## [Unreleased] ## [Unreleased]
### Changed ### Changed
- Optimized `omp://` documentation indexing by compressing doc bodies into a lazily-inflated blob
- Changed Mermaid fenced-block ASCII rendering to use the first-party vendored renderer in `@oh-my-pi/pi-utils` (`src/vendor/mermaid-ascii`), dropping the `beautiful-mermaid` npm package, its transitive `elkjs` (~3.13MB), and the `beautiful-mermaid` `bun patch`; CJK/emoji width handling and the layout-direction override are preserved. - Changed Mermaid fenced-block ASCII rendering to use the first-party vendored renderer in `@oh-my-pi/pi-utils` (`src/vendor/mermaid-ascii`), dropping the `beautiful-mermaid` npm package, its transitive `elkjs` (~3.13MB), and the `beautiful-mermaid` `bun patch`; CJK/emoji width handling and the layout-direction override are preserved.
- Changed `omp://` documentation embedding to a gzipped base64 index (`docs-index.generated.txt`, populated at build time and reset afterward) inflated on first read, instead of a ~1.6MB raw TypeScript map. The compiled binary / npm bundle drops ~0.9MB; the dev tree and source checkouts read `docs/` from disk.
### Fixed ### Fixed
@@ -11954,4 +11956,4 @@ Initial public release.
## [0.7.6] - 2025-11-13 ## [0.7.6] - 2025-11-13
Previous releases did not maintain a changelog. Previous releases did not maintain a changelog.
+4 -3
View File
@@ -36,11 +36,12 @@
"check:types": "tsgo -p tsconfig.json --noEmit", "check:types": "tsgo -p tsconfig.json --noEmit",
"lint": "biome lint .", "lint": "biome lint .",
"test": "bun test --parallel=4", "test": "bun test --parallel=4",
"fix": "biome check --write --unsafe . && bun run format-prompts && bun run generate-docs-index", "fix": "biome check --write --unsafe . && bun run format-prompts",
"fmt": "biome format --write . && bun run format-prompts", "fmt": "biome format --write . && bun run format-prompts",
"format-prompts": "bun scripts/format-prompts.ts", "format-prompts": "bun scripts/format-prompts.ts",
"generate-docs-index": "bun scripts/generate-docs-index.ts", "generate-docs-index": "bun scripts/generate-docs-index.ts --generate",
"prepack": "bun scripts/generate-docs-index.ts && bun --cwd=../collab-web run build:tool-views && bun scripts/bundle-dist.ts", "prepack": "bun scripts/generate-docs-index.ts --generate && bun --cwd=../collab-web run build:tool-views && bun scripts/bundle-dist.ts || ( bun scripts/generate-docs-index.ts --reset; exit 1 )",
"postpack": "bun scripts/generate-docs-index.ts --reset",
"bench:guard": "bun scripts/bench-guard.ts" "bench:guard": "bun scripts/bench-guard.ts"
}, },
"dependencies": { "dependencies": {
@@ -38,8 +38,11 @@ async function runCommand(
} }
async function main(): Promise<void> { async function main(): Promise<void> {
await runCommand(["bun", "--cwd=../stats", "scripts/generate-client-bundle.ts", "--generate"]); // Generate inside the try so the finally always restores the empty checked-in
// placeholders (stats client archive, docs index) even on failure.
try { try {
await runCommand(["bun", "--cwd=../stats", "scripts/generate-client-bundle.ts", "--generate"]);
await runCommand(["bun", "scripts/generate-docs-index.ts", "--generate"]);
await runCommand(["bun", "--cwd=../natives", "run", "embed:native"]); await runCommand(["bun", "--cwd=../natives", "run", "embed:native"]);
try { try {
const buildEnv = shouldAdhocSignDarwinBinary() ? { ...Bun.env, BUN_NO_CODESIGN_MACHO_BINARY: "1" } : Bun.env; const buildEnv = shouldAdhocSignDarwinBinary() ? { ...Bun.env, BUN_NO_CODESIGN_MACHO_BINARY: "1" } : Bun.env;
@@ -98,6 +101,7 @@ async function main(): Promise<void> {
} }
} finally { } finally {
await runCommand(["bun", "--cwd=../stats", "scripts/generate-client-bundle.ts", "--reset"]); await runCommand(["bun", "--cwd=../stats", "scripts/generate-client-bundle.ts", "--reset"]);
await runCommand(["bun", "scripts/generate-docs-index.ts", "--reset"]);
} }
} }
+2 -3
View File
@@ -19,8 +19,8 @@ const ALWAYS_EXTERNAL = ["mupdf", "@oh-my-pi/pi-natives", "@huggingface/transfor
// redundant copy that bloats dist/cli.js. NEVER add a patched dependency here — the // redundant copy that bloats dist/cli.js. NEVER add a patched dependency here — the
// bundle is where a root `patchedDependencies` patch is baked in, so an externalized // bundle is where a root `patchedDependencies` patch is baked in, so an externalized
// import would load the unpatched npm package in users' installs (currently // import would load the unpatched npm package in users' installs (currently
// beautiful-mermaid and @ark/schema are patched, so they — and arktype, which pulls // @ark/schema is patched, so it — and arktype, which pulls @ark/schema — stay
// @ark/schema — stay bundled). // bundled).
const RUNTIME_EXTERNAL = [ const RUNTIME_EXTERNAL = [
"puppeteer-core", "puppeteer-core",
"@puppeteer/browsers", "@puppeteer/browsers",
@@ -30,7 +30,6 @@ const RUNTIME_EXTERNAL = [
"turndown-plugin-gfm", "turndown-plugin-gfm",
"@mozilla/readability", "@mozilla/readability",
"linkedom", "linkedom",
"markit-ai",
"@agentclientprotocol/sdk", "@agentclientprotocol/sdk",
]; ];
+47 -31
View File
@@ -1,40 +1,56 @@
#!/usr/bin/env bun #!/usr/bin/env bun
/**
* Populate (or reset) the embedded harness documentation index for `omp://`.
*
* `--generate` writes `src/internal-urls/docs-index.generated.txt` as two lines:
* a plain JSON array of the sorted `docs/**\/*.md` file names, then a base64
* gzip blob of the index-aligned doc bodies (`string[]`). Keeping the filename
* list out of the blob lets the loader list docs without inflating it.
* Compiled binaries and the prepacked npm bundle inline this (~0.5MB) instead of
* the ~1.6MB raw map; `--reset` restores the checked-in empty placeholder so the
* dev tree reads `docs/` from disk. Mirrors the stats / model-catalog embeds.
*/
import * as path from "node:path"; import * as path from "node:path";
import { gzipSync } from "node:zlib";
import { Glob } from "bun"; import { Glob } from "bun";
const docsDir = path.resolve(import.meta.dir, "../../../docs"); const docsDir = path.resolve(import.meta.dir, "../../../docs");
const outputPath = path.resolve(import.meta.dir, "../src/internal-urls/docs-index.generated.ts"); const outputPath = path.resolve(import.meta.dir, "../src/internal-urls/docs-index.generated.txt");
const GENERATE_FLAG = "--generate";
const RESET_FLAG = "--reset";
const glob = new Glob("**/*.md"); async function main(): Promise<void> {
const entries: string[] = []; const rel = path.relative(process.cwd(), outputPath);
for await (const relativePath of glob.scan(docsDir)) {
entries.push(relativePath.split(path.sep).join("/")); if (process.argv.includes(RESET_FLAG)) {
await Bun.write(outputPath, "");
console.log(`Reset ${rel}`);
return;
}
if (!process.argv.includes(GENERATE_FLAG)) {
console.log(`Skipping ${rel}; pass ${GENERATE_FLAG} to embed docs (the dev tree reads docs/ from disk)`);
return;
}
const glob = new Glob("**/*.md");
const files: string[] = [];
for await (const relativePath of glob.scan(docsDir)) {
files.push(relativePath.split(path.sep).join("/"));
}
files.sort();
// Index-aligned bodies (Promise.all preserves order), kept separate from the
// filename list so the loader can list docs without inflating the blob.
const bodies = await Promise.all(files.map(file => Bun.file(path.join(docsDir, file)).text()));
const bodiesB64 = Buffer.from(gzipSync(Buffer.from(JSON.stringify(bodies)), { level: 9 })).toString("base64");
// Two lines: plain filename array, then the base64 gzip blob.
const payload = `${JSON.stringify(files)}\n${bodiesB64}`;
await Bun.write(outputPath, payload);
console.log(`Generated ${rel} (${files.length} docs, ${payload.length} bytes)`);
} }
entries.sort();
const docsWithContent = await Promise.all( await main();
entries.map(async relativePath => ({
relativePath,
content: await Bun.file(path.join(docsDir, relativePath)).text(),
})),
);
const filenamesLiteral = JSON.stringify(entries);
const mapEntries = docsWithContent
.map(({ relativePath, content }) => `\t${JSON.stringify(relativePath)}: ${JSON.stringify(content)},`)
.join("\n");
const output = [
"// Auto-generated by scripts/generate-docs-index.ts - DO NOT EDIT",
"",
`export const EMBEDDED_DOC_FILENAMES: readonly string[] = ${filenamesLiteral};`,
"",
`export const EMBEDDED_DOCS: Readonly<Record<string, string>> = {`,
`${mapEntries}`,
`};`,
"",
].join("\n");
await Bun.write(outputPath, output);
console.log(`Generated ${path.relative(process.cwd(), outputPath)} (${entries.length} docs)`);
@@ -0,0 +1,102 @@
/**
* Harness documentation index for the `omp://` protocol.
*
* Compiled binaries and the prepacked npm bundle inline a compressed index from
* `docs-index.generated.txt` (populated by `scripts/generate-docs-index.ts
* --generate` at build time). The format is two lines:
* 1. a plain JSON array of the sorted doc file names, and
* 2. a base64 gzip blob of the index-aligned doc bodies (`string[]`).
* Listing/completion (`getDocFilenames`) parses only the small first line and
* never inflates the blob; the bodies are gunzipped off the event loop (via the
* async `node:zlib` threadpool) lazily, once, on the first actual read. When the
* placeholder is empty (dev tree, source checkout), the index is read from the
* repo `docs/` directory on disk instead.
*/
import { readFileSync } from "node:fs";
import * as path from "node:path";
import { promisify } from "node:util";
import { gunzip } from "node:zlib";
import { Glob } from "bun";
import docsEmbed from "./docs-index.generated.txt";
const gunzipAsync = promisify(gunzip);
export interface DocsIndex {
/** Sorted documentation file names, relative to `docs/`. */
readonly filenames: readonly string[];
/** Resolve a doc body by path; inflates the embedded bodies off-thread, lazily, on first call. */
getBody(relativePath: string): Promise<string | undefined>;
}
/**
* Decode a populated two-line embed (`<filenames JSON>\n<base64 gzip of bodies>`)
* into a lazily-inflating index, or `null` when there is no newline separator
* (the empty placeholder, or a malformed payload — the caller decides which).
* Reading `filenames` never touches the blob; the bodies are gunzipped off the
* event loop into a path→content table on the first `getBody` call, and that
* work is shared across concurrent reads.
*/
export function decodeDocsIndex(embed: string): DocsIndex | null {
const newline = embed.indexOf("\n");
if (newline === -1) return null;
const filenames = JSON.parse(embed.slice(0, newline)) as string[];
let bodies: Promise<Record<string, string>> | undefined;
return {
filenames,
getBody(relativePath: string): Promise<string | undefined> {
bodies ??= (async () => {
const inflated = await gunzipAsync(Buffer.from(embed.slice(newline + 1), "base64"));
const decoded = JSON.parse(inflated.toString("utf8")) as string[];
const map: Record<string, string> = {};
for (let i = 0; i < filenames.length; i++) map[filenames[i]] = decoded[i];
return map;
})();
return bodies.then(map => map[relativePath]);
},
};
}
/** Dev tree / source checkout: build the index from the repo `docs/` directory. */
function readDocsFromDisk(): DocsIndex {
const docsDir = path.resolve(import.meta.dir, "../../../../docs");
const filenames: string[] = [];
const bodies: Record<string, string> = {};
for (const relativePath of new Glob("**/*.md").scanSync(docsDir)) {
const normalized = relativePath.split(path.sep).join("/");
filenames.push(normalized);
bodies[normalized] = readFileSync(path.join(docsDir, relativePath), "utf8");
}
filenames.sort();
return { filenames, getBody: relativePath => Promise.resolve(bodies[relativePath]) };
}
let index: DocsIndex | undefined;
function getIndex(): DocsIndex {
if (index !== undefined) return index;
// Empty placeholder → dev tree / source checkout: read docs from disk.
if (docsEmbed.length === 0) {
index = readDocsFromDisk();
return index;
}
// Populated embed in compiled binaries / npm bundle. A non-empty payload with
// no newline is a broken build (truncated/corrupt embed), not a placeholder.
const decoded = decodeDocsIndex(docsEmbed);
if (decoded === null) {
throw new Error(
"Malformed embedded docs index (docs-index.generated.txt): non-empty payload without a newline separator. " +
"Rebuild with `bun --cwd=packages/coding-agent scripts/generate-docs-index.ts --generate`.",
);
}
index = decoded;
return index;
}
/** Sorted list of available documentation file names (relative to `docs/`). */
export function getDocFilenames(): readonly string[] {
return getIndex().filenames;
}
/** Resolve a documentation file's content, or `undefined` when not found. */
export function getEmbeddedDoc(relativePath: string): Promise<string | undefined> {
return getIndex().getBody(relativePath);
}
@@ -8,7 +8,7 @@
* - omp://<file>.md - Reads a specific documentation file * - omp://<file>.md - Reads a specific documentation file
*/ */
import * as path from "node:path"; import * as path from "node:path";
import { EMBEDDED_DOC_FILENAMES, EMBEDDED_DOCS } from "./docs-index.generated"; import { getDocFilenames, getEmbeddedDoc } from "./docs-index";
import type { InternalResource, InternalUrl, ProtocolHandler, UrlCompletion } from "./types"; import type { InternalResource, InternalUrl, ProtocolHandler, UrlCompletion } from "./types";
/** /**
@@ -34,16 +34,17 @@ export class OmpProtocolHandler implements ProtocolHandler {
} }
async complete(): Promise<UrlCompletion[]> { async complete(): Promise<UrlCompletion[]> {
return EMBEDDED_DOC_FILENAMES.map(value => ({ value })); return getDocFilenames().map(value => ({ value }));
} }
async #listDocs(url: InternalUrl): Promise<InternalResource> { async #listDocs(url: InternalUrl): Promise<InternalResource> {
if (EMBEDDED_DOC_FILENAMES.length === 0) { const filenames = getDocFilenames();
if (filenames.length === 0) {
throw new Error("No documentation files found"); throw new Error("No documentation files found");
} }
const listing = EMBEDDED_DOC_FILENAMES.map(f => `- [${f}](omp://${f})`).join("\n"); const listing = filenames.map(f => `- [${f}](omp://${f})`).join("\n");
const content = `# Documentation\n\n${EMBEDDED_DOC_FILENAMES.length} files available:\n\n${listing}\n`; const content = `# Documentation\n\n${filenames.length} files available:\n\n${listing}\n`;
return { return {
url: url.href, url: url.href,
@@ -70,12 +71,12 @@ export class OmpProtocolHandler implements ProtocolHandler {
return this.#listDocs(url); return this.#listDocs(url);
} }
const content = EMBEDDED_DOCS[docPath]; const content = await getEmbeddedDoc(docPath);
if (content === undefined) { if (content === undefined) {
const lookup = docPath.replace(/\.md$/, ""); const lookup = docPath.replace(/\.md$/, "");
const suggestions = EMBEDDED_DOC_FILENAMES.filter( const suggestions = getDocFilenames()
f => f.includes(lookup) || lookup.includes(f.replace(/\.md$/, "")), .filter(f => f.includes(lookup) || lookup.includes(f.replace(/\.md$/, "")))
).slice(0, 5); .slice(0, 5);
const suffix = const suffix =
suggestions.length > 0 suggestions.length > 0
? `\nDid you mean: ${suggestions.join(", ")}` ? `\nDid you mean: ${suggestions.join(", ")}`
@@ -0,0 +1,32 @@
import { describe, expect, it } from "bun:test";
import { gzipSync } from "node:zlib";
import { decodeDocsIndex } from "@oh-my-pi/pi-coding-agent/internal-urls/docs-index";
// The embed path only runs in compiled binaries / the npm bundle; dev tests
// otherwise exercise the disk fallback (empty placeholder), so a regression in
// the two-line `<filenames>\n<gzip bodies>` parsing would ship broken `omp://`
// docs undetected. These cover the populated-embed decode directly.
describe("decodeDocsIndex (embedded docs path)", () => {
const files = ["agent.md", "tools/read.md"];
const bodies = ["agent body", "read body"];
const embed = `${JSON.stringify(files)}\n${Buffer.from(gzipSync(Buffer.from(JSON.stringify(bodies)))).toString("base64")}`;
it("lists filenames from the first line without inflating the blob", () => {
// A deliberately corrupt blob: filenames must resolve anyway, proving the
// listing path never decodes the gzip body.
const index = decodeDocsIndex(`${JSON.stringify(files)}\n@@@not-a-valid-gzip-blob@@@`);
expect(index?.filenames).toEqual(files);
});
it("resolves bodies by index-aligned path, lazily, on first read", async () => {
const index = decodeDocsIndex(embed);
expect(index).not.toBeNull();
expect(await index?.getBody("agent.md")).toBe("agent body");
expect(await index?.getBody("tools/read.md")).toBe("read body");
expect(await index?.getBody("missing.md")).toBeUndefined();
});
it("returns null when there is no newline separator (empty placeholder)", () => {
expect(decodeDocsIndex("")).toBeNull();
});
});
+7 -1
View File
@@ -153,19 +153,23 @@ async function buildBinary(target: BinaryTarget): Promise<void> {
async function generateBundle(): Promise<void> { async function generateBundle(): Promise<void> {
if (isDryRun) { if (isDryRun) {
console.log("DRY RUN bun --cwd=packages/stats scripts/generate-client-bundle.ts --generate"); console.log("DRY RUN bun --cwd=packages/stats scripts/generate-client-bundle.ts --generate");
console.log("DRY RUN bun --cwd=packages/coding-agent scripts/generate-docs-index.ts --generate");
return; return;
} }
await runCommand(["bun", "--cwd=packages/stats", "scripts/generate-client-bundle.ts", "--generate"], repoRoot); await runCommand(["bun", "--cwd=packages/stats", "scripts/generate-client-bundle.ts", "--generate"], repoRoot);
await runCommand(["bun", "--cwd=packages/coding-agent", "scripts/generate-docs-index.ts", "--generate"], repoRoot);
} }
async function resetArtifacts(): Promise<void> { async function resetArtifacts(): Promise<void> {
if (isDryRun) { if (isDryRun) {
console.log("DRY RUN bun --cwd=packages/natives run embed:native --reset"); console.log("DRY RUN bun --cwd=packages/natives run embed:native --reset");
console.log("DRY RUN bun --cwd=packages/stats scripts/generate-client-bundle.ts --reset"); console.log("DRY RUN bun --cwd=packages/stats scripts/generate-client-bundle.ts --reset");
console.log("DRY RUN bun --cwd=packages/coding-agent scripts/generate-docs-index.ts --reset");
return; return;
} }
await runCommand(["bun", "--cwd=packages/natives", "run", "embed:native", "--reset"], repoRoot); await runCommand(["bun", "--cwd=packages/natives", "run", "embed:native", "--reset"], repoRoot);
await runCommand(["bun", "--cwd=packages/stats", "scripts/generate-client-bundle.ts", "--reset"], repoRoot); await runCommand(["bun", "--cwd=packages/stats", "scripts/generate-client-bundle.ts", "--reset"], repoRoot);
await runCommand(["bun", "--cwd=packages/coding-agent", "scripts/generate-docs-index.ts", "--reset"], repoRoot);
} }
async function main(): Promise<void> { async function main(): Promise<void> {
@@ -188,8 +192,10 @@ async function main(): Promise<void> {
} }
await fs.mkdir(binariesDir, { recursive: true }); await fs.mkdir(binariesDir, { recursive: true });
await generateBundle(); // Generate inside the try so resetArtifacts() always restores the empty
// checked-in placeholders, even if a generate or build step throws.
try { try {
await generateBundle();
for (const target of selectedTargets) { for (const target of selectedTargets) {
await buildBinary(target); await buildBinary(target);
} }