From 8bba9fcf14cb0a7eb1559f4512ba8d7aab32ce2e Mon Sep 17 00:00:00 2001 From: Laith Al-Saadoon Date: Tue, 7 Jul 2026 12:47:50 +0000 Subject: [PATCH] feat(repo)!: retire the summarizer package and LLM symbol-summary feature Removes the LLM code-summarization capability end to end. A capable coding agent regenerates task-conditioned summaries better than an index-time generic summary, so OpenCodeHub now hands agents deterministic structure (locations, edges, blast-radius, owners, license facts) and leaves prose to the agent. Removing summaries also drops the one non-deterministic ingestion phase, so every OpenCodeHub output is now re-derivable. BREAKING CHANGE: removes the `@opencodehub/summarizer` package, the ingestion `summarize` phase, the `symbol_summaries` storage table + SymbolSummaryRow type + the four SymbolSummary ITemporalStore methods, MCP + CLI query summary-fusion, the `codehub analyze` flags `--summaries` / `--no-summaries` / `--max-summaries` / `--summary-model` (and `CODEHUB_BEDROCK_SUMMARIES` / `CODEHUB_BEDROCK_DISABLED` env vars), and the `codehub wiki` `--llm` / `--max-llm-calls` / `--llm-model` flags. `codehub wiki` is now deterministic-only. Notes: - Summaries never entered graphHash/packHash, so the determinism + parity contract is unchanged (parity suite stays green). - The embeddings phase gains a direct dep on `confidence-demote` to preserve its canonical last-in-pipeline ordering (previously arrived transitively via summarize). - Drops the now-orphaned `@aws-sdk/client-bedrock-runtime` dependency from `@opencodehub/ingestion` and `@opencodehub/wiki`. --- CLAUDE.md | 4 +- commitlint.config.mjs | 1 - packages/cli/src/agent-context.ts | 2 +- packages/cli/src/commands/analyze.test.ts | 141 -- packages/cli/src/commands/analyze.ts | 230 +--- packages/cli/src/commands/ci-init.test.ts | 4 - packages/cli/src/commands/query.test.ts | 156 +-- packages/cli/src/commands/query.ts | 110 +- packages/cli/src/commands/status.test.ts | 15 +- packages/cli/src/commands/status.ts | 23 +- packages/cli/src/commands/wiki.test.ts | 24 - packages/cli/src/commands/wiki.ts | 52 +- packages/cli/src/index.ts | 72 +- .../docs/agents/tool-decision-matrix.mdx | 2 +- .../content/docs/architecture/determinism.md | 6 +- .../content/docs/architecture/embeddings.md | 11 +- .../content/docs/architecture/monorepo-map.md | 11 +- .../src/content/docs/architecture/overview.md | 14 +- .../docs/architecture/scip-reconciliation.md | 2 +- .../docs/architecture/storage-backend.md | 9 +- .../architecture/summarization-and-fusion.md | 202 --- .../docs/contributing/commit-conventions.md | 1 - .../docs/contributing/release-process.md | 2 +- .../content/docs/guides/indexing-a-repo.md | 9 +- packages/docs/src/content/docs/mcp/tools.md | 2 +- .../docs/src/content/docs/reference/cli.md | 16 +- .../content/docs/reference/configuration.md | 4 +- .../src/content/docs/reference/error-codes.md | 2 +- .../docs/start-here/what-is-opencodehub.md | 2 +- packages/ingestion/package.json | 2 - packages/ingestion/src/pipeline/index.ts | 11 - .../src/pipeline/orchestrator.test.ts | 5 - .../ingestion/src/pipeline/orchestrator.ts | 43 +- .../src/pipeline/phases/default-set.ts | 8 - .../src/pipeline/phases/embeddings.test.ts | 37 +- .../src/pipeline/phases/embeddings.ts | 122 +- .../src/pipeline/phases/summarize.test.ts | 725 ----------- .../src/pipeline/phases/summarize.ts | 564 -------- packages/ingestion/src/pipeline/types.ts | 24 - packages/ingestion/tsconfig.json | 3 +- packages/mcp/src/test-utils.ts | 6 - packages/mcp/src/tools/query.test.ts | 187 +-- packages/mcp/src/tools/query.ts | 109 +- packages/mcp/src/tools/sql.test.ts | 1 - packages/mcp/src/tools/sql.ts | 9 +- packages/storage/src/index.ts | 1 - packages/storage/src/interface.test.ts | 14 +- packages/storage/src/interface.ts | 103 +- packages/storage/src/sqlite-adapter.ts | 141 +- packages/summarizer/.npmignore | 5 - packages/summarizer/CHANGELOG.md | 25 - packages/summarizer/README.md | 80 -- packages/summarizer/package.json | 67 - packages/summarizer/reference/README.md | 57 - .../spike-01-anthropic-sdk-bedrock.py | 669 ---------- .../reference/spike-02-boto3-converse.py | 1137 ----------------- .../spike-03-sdk-v3-zod4-converse.ts | 1037 --------------- packages/summarizer/src/client.test.ts | 210 --- packages/summarizer/src/client.ts | 257 ---- packages/summarizer/src/index.ts | 35 - packages/summarizer/src/prompt.ts | 377 ------ packages/summarizer/src/schema.test.ts | 227 ---- packages/summarizer/src/schema.ts | 141 -- packages/summarizer/tsconfig.json | 10 - packages/wiki/package.json | 2 - packages/wiki/src/index.test.ts | 146 +-- packages/wiki/src/index.ts | 189 +-- .../wiki/src/wiki-render/llm-overview.test.ts | 198 --- packages/wiki/src/wiki-render/llm-overview.ts | 266 ---- packages/wiki/tsconfig.json | 3 +- pnpm-lock.yaml | 28 - tsconfig.json | 1 - 72 files changed, 145 insertions(+), 8266 deletions(-) delete mode 100644 packages/cli/src/commands/wiki.test.ts delete mode 100644 packages/docs/src/content/docs/architecture/summarization-and-fusion.md delete mode 100644 packages/ingestion/src/pipeline/phases/summarize.test.ts delete mode 100644 packages/ingestion/src/pipeline/phases/summarize.ts delete mode 100644 packages/summarizer/.npmignore delete mode 100644 packages/summarizer/CHANGELOG.md delete mode 100644 packages/summarizer/README.md delete mode 100644 packages/summarizer/package.json delete mode 100644 packages/summarizer/reference/README.md delete mode 100644 packages/summarizer/reference/spike-01-anthropic-sdk-bedrock.py delete mode 100644 packages/summarizer/reference/spike-02-boto3-converse.py delete mode 100644 packages/summarizer/reference/spike-03-sdk-v3-zod4-converse.ts delete mode 100644 packages/summarizer/src/client.test.ts delete mode 100644 packages/summarizer/src/client.ts delete mode 100644 packages/summarizer/src/index.ts delete mode 100644 packages/summarizer/src/prompt.ts delete mode 100644 packages/summarizer/src/schema.test.ts delete mode 100644 packages/summarizer/src/schema.ts delete mode 100644 packages/summarizer/tsconfig.json delete mode 100644 packages/wiki/src/wiki-render/llm-overview.test.ts delete mode 100644 packages/wiki/src/wiki-render/llm-overview.ts diff --git a/CLAUDE.md b/CLAUDE.md index 67042268..6eb605d0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,7 +11,7 @@ tiers. - `impact` — dependents of a target up to a configurable depth, with a risk tier. - `detect_changes` — map an uncommitted or committed diff to affected symbols. - `list_findings` — browse SARIF findings from the latest scan by severity and rule. -- `sql` — read-only SQL against the local temporal store (cochanges + symbol_summaries), 5 s timeout; the node/edge graph is queried via the typed tools or Cypher via the MCP `sql` tool. +- `sql` — read-only SQL against the local temporal store (cochanges), 5 s timeout; the node/edge graph is queried via the typed tools or Cypher via the MCP `sql` tool. Run `codehub analyze` after pulling new commits so the index stays aligned with the working tree. `codehub status` reports staleness. @@ -84,7 +84,7 @@ provides a `code-analyst` subagent and 11 skills. Install via The entire index lives in ONE `/.codehub/store.sqlite` file (WAL), via Node's built-in `node:sqlite` — graph nodes, edges, embeddings, and -the temporal tables (cochanges, symbol summaries, the +the temporal tables (cochanges and the `codehub query --sql` escape hatch). One `SqliteStore` class implements **both** `IGraphStore` and `ITemporalStore`; `openStore()` returns that single instance as both the `graph` and `temporal` views, so call sites diff --git a/commitlint.config.mjs b/commitlint.config.mjs index 037c9636..11784476 100644 --- a/commitlint.config.mjs +++ b/commitlint.config.mjs @@ -54,7 +54,6 @@ export default { "scip-ingest", "search", "storage", - "summarizer", "wiki", "plugin", "deps", diff --git a/packages/cli/src/agent-context.ts b/packages/cli/src/agent-context.ts index 4e822b5e..bb665fb8 100644 --- a/packages/cli/src/agent-context.ts +++ b/packages/cli/src/agent-context.ts @@ -38,7 +38,7 @@ tiers. - \`impact\` — dependents of a target up to a configurable depth, with a risk tier. - \`detect_changes\` — map an uncommitted or committed diff to affected symbols. - \`list_findings\` — browse SARIF findings from the latest scan by severity and rule. -- \`sql\` — read-only SQL against the local temporal store (cochanges + symbol_summaries), 5 s timeout; the node/edge graph is queried via the typed tools or Cypher via the MCP \`sql\` tool. +- \`sql\` — read-only SQL against the local temporal store (cochanges), 5 s timeout; the node/edge graph is queried via the typed tools or Cypher via the MCP \`sql\` tool. Run \`codehub analyze\` after pulling new commits so the index stays aligned with the working tree. \`codehub status\` reports staleness. diff --git a/packages/cli/src/commands/analyze.test.ts b/packages/cli/src/commands/analyze.test.ts index 7c52612f..4a5508f3 100644 --- a/packages/cli/src/commands/analyze.test.ts +++ b/packages/cli/src/commands/analyze.test.ts @@ -1,15 +1,5 @@ /** * Unit tests for `codehub analyze` helpers. - * - * P04 deliverables covered here: - * - `resolveMaxSummariesCap` arithmetic for `--max-summaries auto`: - * prior-run seed → floor(count × 0.1) clamped at 500; first-run - * fallback → 50; explicit numbers pass through; summaries disabled - * → 0 regardless of input. - * - `CODEHUB_BEDROCK_DISABLED=1` env kill-switch semantics: when the - * caller short-circuits `summariesEnabled` to false (mirroring how - * `runAnalyze` and the CLI entry point honor the env var) the cap - * collapses to 0 even under `auto`. */ import assert from "node:assert/strict"; @@ -25,10 +15,8 @@ import { detectCoverageReport, isWorkingTreeDirty, resolveCoverageEnabled, - resolveMaxSummariesCap, resolveSbomEnabled, resolveScanEnabled, - resolveSummariesEnabled, } from "./analyze.js"; import { selectScannerIds } from "./scan.js"; import { computeScanFingerprint, shouldSkipScan } from "./scan-fingerprint.js"; @@ -88,135 +76,6 @@ async function initGitRepo(dir: string): Promise { return headPromise; } -test("resolveMaxSummariesCap: auto resolves to floor(count × 0.1) when seed is known", async () => { - // 1234 callables → 10% = 123.4 → floor = 123. - const cap = await resolveMaxSummariesCap("/unused", "auto", true, async () => 1234); - assert.equal(cap, 123); -}); - -test("resolveMaxSummariesCap: auto clamps at the 500 cap for large repos", async () => { - // 10_000 callables would be 1000 uncapped; the policy clamps at 500. - const cap = await resolveMaxSummariesCap("/unused", "auto", true, async () => 10_000); - assert.equal(cap, 500); -}); - -test("resolveMaxSummariesCap: auto falls back to 50 on first run (no prior seed)", async () => { - // `undefined` models "no prior store at the expected path". - const cap = await resolveMaxSummariesCap("/unused", "auto", true, async () => undefined); - assert.equal(cap, 50); -}); - -test("resolveMaxSummariesCap: undefined defaults to the auto path", async () => { - // A caller that doesn't pass --max-summaries at all gets the same - // behavior as explicit `auto`. - const cap = await resolveMaxSummariesCap("/unused", undefined, true, async () => 200); - assert.equal(cap, 20); -}); - -test("resolveMaxSummariesCap: explicit numbers pass through unchanged", async () => { - const cap = await resolveMaxSummariesCap("/unused", 42, true, async () => 999); - assert.equal(cap, 42); -}); - -test("resolveMaxSummariesCap: negative integers clamp to 0 (dry-run)", async () => { - const cap = await resolveMaxSummariesCap("/unused", -5, true, async () => 999); - assert.equal(cap, 0); -}); - -test("resolveMaxSummariesCap: non-integer numbers are floored", async () => { - const cap = await resolveMaxSummariesCap( - "/unused", - 7.9 as unknown as number, - true, - async () => 0, - ); - assert.equal(cap, 7); -}); - -test("resolveMaxSummariesCap: summariesEnabled=false collapses to 0 regardless of input", async () => { - // This is the behavior chain the CLI depends on when either - // `--no-summaries` or `CODEHUB_BEDROCK_DISABLED=1` short-circuits - // `summariesEnabled` upstream. - const autoCap = await resolveMaxSummariesCap("/unused", "auto", false, async () => 1_000); - assert.equal(autoCap, 0, "auto + disabled must dry-run"); - const numericCap = await resolveMaxSummariesCap("/unused", 100, false, async () => 1_000); - assert.equal(numericCap, 0, "explicit cap + disabled must dry-run"); -}); - -test("resolveMaxSummariesCap: seed of 0 yields a cap of 0 (no callables to summarize)", async () => { - const cap = await resolveMaxSummariesCap("/unused", "auto", true, async () => 0); - assert.equal(cap, 0); -}); - -test("resolveMaxSummariesCap: seed of 5 yields a cap of 0 under the 10% rule", async () => { - // 5 × 0.1 = 0.5 → floor = 0. Tiny repos dry-run until they grow. - const cap = await resolveMaxSummariesCap("/unused", "auto", true, async () => 5); - assert.equal(cap, 0); -}); - -// --------------------------------------------------------------------------- -// resolveSummariesEnabled — fast-default contract: LLM summaries are opt-in. -// `codehub analyze` runs tree-sitter + SCIP + cochange phases only by default, -// so a fresh invocation never spends on Bedrock or blocks on a network hop. -// --------------------------------------------------------------------------- - -test("resolveSummariesEnabled: default-off when both env and flag are absent", () => { - assert.equal(resolveSummariesEnabled(undefined, {}), false); -}); - -test("resolveSummariesEnabled: explicit --summaries opts in", () => { - assert.equal(resolveSummariesEnabled(true, {}), true); -}); - -test("resolveSummariesEnabled: explicit --no-summaries stays off", () => { - assert.equal(resolveSummariesEnabled(false, {}), false); -}); - -test("resolveSummariesEnabled: CODEHUB_BEDROCK_SUMMARIES=1 opts in (env-only)", () => { - // Operators can enable summaries for a whole CI job without editing every - // invocation. Only the literal "1" triggers — anything else is treated as - // absent, mirroring the kill-switch semantics below. - assert.equal(resolveSummariesEnabled(undefined, { CODEHUB_BEDROCK_SUMMARIES: "1" }), true); - assert.equal(resolveSummariesEnabled(undefined, { CODEHUB_BEDROCK_SUMMARIES: "0" }), false); - assert.equal(resolveSummariesEnabled(undefined, { CODEHUB_BEDROCK_SUMMARIES: "" }), false); -}); - -test("resolveSummariesEnabled: CODEHUB_BEDROCK_DISABLED=1 kills the phase", () => { - assert.equal(resolveSummariesEnabled(undefined, { CODEHUB_BEDROCK_DISABLED: "1" }), false); -}); - -test("resolveSummariesEnabled: kill-switch wins over --summaries=true", () => { - // Operator passed --summaries explicitly but the env var forces off. - // Required so CI / restricted environments can lock out Bedrock without - // auditing every invocation site. - assert.equal(resolveSummariesEnabled(true, { CODEHUB_BEDROCK_DISABLED: "1" }), false); -}); - -test("resolveSummariesEnabled: kill-switch wins over CODEHUB_BEDROCK_SUMMARIES=1", () => { - // Both env vars set → disable wins. This lets a CI environment pin the - // opt-in globally while still allowing per-job kill-switch overrides. - assert.equal( - resolveSummariesEnabled(undefined, { - CODEHUB_BEDROCK_SUMMARIES: "1", - CODEHUB_BEDROCK_DISABLED: "1", - }), - false, - ); -}); - -test("resolveSummariesEnabled: --no-summaries wins over CODEHUB_BEDROCK_SUMMARIES=1", () => { - // Explicit CLI false beats env opt-in. Matches how --no-flag usually - // wins against ambient config everywhere else in the CLI. - assert.equal(resolveSummariesEnabled(false, { CODEHUB_BEDROCK_SUMMARIES: "1" }), false); -}); - -test("resolveSummariesEnabled: CODEHUB_BEDROCK_DISABLED=0 does not enable the phase", () => { - // Only the literal "1" on the opt-in var flips this; anything else leaves - // summaries in their (fast, off) default. - assert.equal(resolveSummariesEnabled(undefined, { CODEHUB_BEDROCK_DISABLED: "0" }), false); - assert.equal(resolveSummariesEnabled(undefined, { CODEHUB_BEDROCK_DISABLED: "" }), false); -}); - // --------------------------------------------------------------------------- // Dirty-tree bypass on the analyze fast-path. // --------------------------------------------------------------------------- diff --git a/packages/cli/src/commands/analyze.ts b/packages/cli/src/commands/analyze.ts index 669e84ee..b278bab3 100644 --- a/packages/cli/src/commands/analyze.ts +++ b/packages/cli/src/commands/analyze.ts @@ -104,32 +104,6 @@ export interface AnalyzeOptions { * graph pipeline runs unchanged. */ readonly scan?: boolean; - /** - * Opt into the `summarize` phase — walks LSP-confirmed callable symbols - * and invokes Bedrock to generate structured summaries within the - * resolved cost cap. **Off by default**: a bare `codehub analyze` is - * fast, local, deterministic, and never spends on LLM calls. Enable - * per-invocation with `true` (CLI: `--summaries`) or environment-wide - * with `CODEHUB_BEDROCK_SUMMARIES=1`. `CODEHUB_BEDROCK_DISABLED=1` - * force-disables regardless of flag state. - */ - readonly summaries?: boolean; - /** - * Upper bound on Bedrock calls per run. Accepts either a non-negative - * integer or the literal string `"auto"`. Default `"auto"` resolves to - * `min(floor(scipConfirmedCallableCount × 0.1), 500)` at run time, using - * a prior-run heuristic seeded from `store_meta.stats["embeddingsCount"]` - * when available and falling back to 50 on first run. Any positive - * integer caps the batch size at that value; `0` runs the phase in - * dry-run mode. - */ - readonly maxSummariesPerRun?: number | "auto"; - /** - * Override the Bedrock model id used by the summarize phase. When - * undefined, the phase uses `DEFAULT_MODEL_ID` from - * `@opencodehub/summarizer`. - */ - readonly summaryModel?: string; /** * When true, walk Communities with `symbolCount >= 5` after analyze * completes and emit one `SKILL.md` per cluster under @@ -218,14 +192,6 @@ export async function runAnalyze(path: string, opts: AnalyzeOptions = {}): Promi // reports mode="full" with reason="no-prior-graph". const incrementalFrom = opts.force === true ? undefined : await loadPreviousGraph(repoPath); - // Resolve the effective `summaries` flag. Summaries are opt-in: a bare - // `codehub analyze` runs the fast, local, deterministic pipeline - // (tree-sitter + SCIP + cochanges) and skips the Bedrock summarize phase - // entirely. Opt in via `--summaries` or `CODEHUB_BEDROCK_SUMMARIES=1`. - // The `CODEHUB_BEDROCK_DISABLED=1` env kill-switch forces off regardless - // of the flag; `offline` is enforced later inside the phase itself. - const summariesEnabled = resolveSummariesEnabled(opts.summaries, process.env); - // Resolve sbom/coverage/scan defaults. SBOM and scan default ON (cheap, // local, and they feed the MCP surface agents actually use). Coverage // auto-detects: probe the known report paths and only enable the phase @@ -235,17 +201,7 @@ export async function runAnalyze(path: string, opts: AnalyzeOptions = {}): Promi const scanEnabled = resolveScanEnabled(opts.scan); const coverageResolved = await resolveCoverageEnabled(opts.coverage, repoPath); - // Open a read-only store upfront so the `summarize` phase can probe the - // prior summary rows before work is queued AND so we can inspect the - // prior run's `storeMeta.stats` to resolve `--max-summaries auto`. We - // keep the handle open for the duration of `runIngestion` and close it - // in a finally block. `summaries` must be enabled for the adapter to - // matter; skip the cost of a read-only open when the flag is off. - const summaryCacheAdapter = summariesEnabled - ? await openSummaryCacheAdapter(repoPath) - : undefined; - - // Mirror the same pattern for the embeddings phase's content-hash skip. + // Open a read-only store for the embeddings phase's content-hash skip. // Only open when `--embeddings` is on AND `--force` is off — force // re-embeds everything, so the adapter would do no useful work. When the // prior DB is absent the adapter returns undefined and the phase @@ -266,18 +222,6 @@ export async function runAnalyze(path: string, opts: AnalyzeOptions = {}): Promi ? await openEmbeddingHashCacheAdapter(repoPath, activeEmbedderModelId) : undefined; - // Resolve `--max-summaries auto` against the prior run's callable count, - // if any. `auto` bounds the cap at 10% of the SCIP-confirmed callable - // symbols (capped at 500); on a cold first run the prior meta is absent - // and we fall back to a conservative 50. `0` and positive integers pass - // through unchanged. Unknown inputs (string without the "auto" literal) - // are treated as "auto" for forward compatibility. - const resolvedMaxSummaries = await resolveMaxSummariesCap( - repoPath, - opts.maxSummariesPerRun, - summariesEnabled, - ); - const pipelineOptions: Parameters[1] = { ...(opts.force !== undefined ? { force: opts.force } : {}), ...(opts.offline !== undefined ? { offline: opts.offline } : {}), @@ -296,14 +240,8 @@ export async function runAnalyze(path: string, opts: AnalyzeOptions = {}): Promi : {}), sbom: sbomEnabled, ...(coverageResolved !== undefined ? { coverage: coverageResolved } : {}), - summaries: summariesEnabled, - maxSummariesPerRun: resolvedMaxSummaries, - ...(opts.summaryModel !== undefined ? { summaryModel: opts.summaryModel } : {}), ...(opts.strictDetectors !== undefined ? { strictDetectors: opts.strictDetectors } : {}), ...(opts.allowBuildScripts !== undefined ? { allowBuildScripts: opts.allowBuildScripts } : {}), - ...(summaryCacheAdapter !== undefined - ? { summaryCacheAdapter: summaryCacheAdapter.adapter } - : {}), ...(embeddingHashAdapter !== undefined ? { embeddingHashCacheAdapter: embeddingHashAdapter.adapter } : {}), @@ -318,7 +256,6 @@ export async function runAnalyze(path: string, opts: AnalyzeOptions = {}): Promi try { result = await pipeline.runIngestion(repoPath, pipelineOptions); } finally { - await summaryCacheAdapter?.close(); await embeddingHashAdapter?.close(); } @@ -336,8 +273,8 @@ export async function runAnalyze(path: string, opts: AnalyzeOptions = {}): Promi } // Persist to the composed graph + temporal store. Post-ADR 0019 both views - // are one `store.sqlite`; the temporal-tier writes (`bulkLoadCochanges`, - // `bulkLoadSymbolSummaries`) still route through `store.temporal`. + // are one `store.sqlite`; the temporal-tier write (`bulkLoadCochanges`) + // still routes through `store.temporal`. await mkdir(resolveRepoMetaDir(repoPath), { recursive: true }); const dbPath = resolveGraphPath(repoPath); const store: Store = await openStore({ path: dbPath }); @@ -355,30 +292,6 @@ export async function runAnalyze(path: string, opts: AnalyzeOptions = {}): Promi if (result.cochange !== undefined) { await store.temporal.bulkLoadCochanges(result.cochange.rows); } - // Persist freshly produced summary rows. The phase returns an empty - // `rows` array in the common gated-off / dry-run case so this is a - // cheap no-op. A non-empty payload means the operator explicitly ran - // with `--summaries --max-summaries > 0` and accepted the Bedrock - // cost; we persist under the temporal-tier surface. - if (result.summarize !== undefined && result.summarize.rows.length > 0) { - await store.temporal.bulkLoadSymbolSummaries(result.summarize.rows); - log( - `codehub analyze: persisted ${result.summarize.rows.length} symbol summaries ` + - `(promptVersion=${result.summarize.promptVersion})`, - ); - } - // Surface the summarize-phase counters whenever the flag was enabled — - // even in dry-run (maxSummaries=0) mode — so operators can inspect how - // many symbols WOULD have been summarized before unlocking Bedrock. - if (summariesEnabled && result.summarize !== undefined) { - const s = result.summarize; - log( - `codehub analyze: summarize — considered=${s.considered}, ` + - `skippedUnconfirmed=${s.skippedUnconfirmed}, cacheHits=${s.cacheHits}, ` + - `summarized=${s.summarized}, wouldHaveSummarized=${s.wouldHaveSummarized}, ` + - `failed=${s.failed} [promptVersion=${s.promptVersion}]`, - ); - } // Persist embeddings emitted by the `embeddings` phase (if any). The // phase returns an empty `rows` array when `opts.embeddings` is false // or when weights are missing, so this call is a cheap no-op in the @@ -665,35 +578,6 @@ export async function loadPreviousGraph( } } -/** - * Resolve the effective `summaries` flag, honoring the - * `CODEHUB_BEDROCK_DISABLED=1` env kill-switch. - * - * `codehub analyze` is a fast, local, deterministic index by default — - * tree-sitter + SCIP + cochanges + graph phases only. The Bedrock-backed - * summarize phase is opt-in via `--summaries` (or `CODEHUB_BEDROCK_SUMMARIES=1`) - * so a fresh `codehub analyze` never spends on LLM calls, blocks on a - * network hop, or needs AWS creds. - * - * Truth table: - * - env kill-switch set (any flag state) → false (kill-switch wins) - * - env opt-in set + flag undefined → true (env opts in) - * - flag true → true (explicit --summaries) - * - flag false → false (explicit --no-summaries) - * - flag undefined + no env → false (default off — fast path) - * - * Exported for unit tests; the production call site reads `process.env`. - */ -export function resolveSummariesEnabled( - flag: boolean | undefined, - env: NodeJS.ProcessEnv | Record, -): boolean { - if (env["CODEHUB_BEDROCK_DISABLED"] === "1") return false; - if (flag === true) return true; - if (flag === false) return false; - return env["CODEHUB_BEDROCK_SUMMARIES"] === "1"; -} - /** * Resolve the effective `sbom` flag. Default ON — serializing Dependency * nodes to CycloneDX + SPDX is cheap, local, and every supply-chain audit @@ -832,114 +716,6 @@ export async function resolveCoverageEnabled( return detected !== undefined ? true : undefined; } -/** - * Resolve `--max-summaries auto` / explicit numeric caps into a concrete - * numeric budget the pipeline can consume. - * - * Pre-run heuristic (P04): `auto` bounds the cap at - * `min(floor(scipConfirmedCallableCount × 0.1), 500)`. We cannot cheaply - * compute that before the pipeline runs (LSP phases haven't yielded - * yet), so we use the prior run's stored counts when available: - * - * - If a SQLite store is readable at the expected path, count nodes - * whose kind is Function/Method/Class. That count is the best proxy - * for "SCIP-confirmed callables" we can get before the parse phase. - * - If no prior store exists (fresh clone, first analyze), fall back - * to a conservative first-run cap of 50. The next invocation has - * the prior counts and can resolve `auto` accurately. - * - * Explicit numeric caps pass through unchanged; negative values clamp to - * 0 (dry-run). When summaries are disabled we short-circuit to 0 so the - * phase's cost-cap branch is hit regardless. - * - * Exported for unit tests; the production call site passes - * `countPriorCallableSymbols` for the seed lookup. - */ -export async function resolveMaxSummariesCap( - repoPath: string, - raw: number | "auto" | undefined, - summariesEnabled: boolean, - seedLookup: (repoPath: string) => Promise = countPriorCallableSymbols, -): Promise { - if (!summariesEnabled) return 0; - if (typeof raw === "number" && Number.isFinite(raw)) { - return Math.max(0, Math.floor(raw)); - } - // Default or explicit "auto" — consult prior graph counts. - const seed = await seedLookup(repoPath); - if (seed === undefined) { - // First run: give Bedrock a bounded foothold so the operator sees - // the feature light up without the phase sitting idle in dry-run. - return 50; - } - return Math.min(Math.floor(seed * 0.1), 500); -} - -/** - * Count callable symbols (Function / Method / Class) recorded by the - * prior run. Returns `undefined` when no prior SQLite index exists or - * the count query fails — callers treat that as "no prior run" and fall - * back to the first-run heuristic. - */ -async function countPriorCallableSymbols(repoPath: string): Promise { - const dbPath = resolveGraphPath(repoPath); - const store = await openStore({ path: dbPath, readOnly: true }).catch(() => undefined); - if (store === undefined) return undefined; - try { - await store.graph.open(); - } catch { - await store.close().catch(() => {}); - return undefined; - } - try { - // `countNodesByKind` is the typed equivalent of `SELECT COUNT(*) - // GROUP BY kind`. We sum the three callable kinds in TS so cli stays - // off the raw-SQL surface. - const counts = await store.graph.countNodesByKind(["Function", "Method", "Class"]); - let n = 0; - for (const c of counts.values()) n += c; - return Number.isFinite(n) && n >= 0 ? n : undefined; - } catch { - return undefined; - } finally { - await store.close(); - } -} - -/** - * Open a read-only SQLite store scoped to the `symbol_summaries` cache - * probe. The returned object carries a cache adapter the `summarize` - * phase uses to short-circuit candidates whose content hash already has - * a row on disk, plus a `close()` the caller invokes to release the - * native handle. Returns `undefined` when the store cannot be opened — - * the phase degrades gracefully to "every candidate is a miss". - */ -async function openSummaryCacheAdapter( - repoPath: string, -): Promise<{ adapter: pipeline.SummaryCacheAdapter; close: () => Promise } | undefined> { - const dbPath = resolveGraphPath(repoPath); - const store = await openStore({ path: dbPath, readOnly: true }).catch(() => undefined); - if (store === undefined) return undefined; - try { - // The summary cache lives on the temporal tier. Open both views so - // the close() symmetry holds. - await store.graph.open(); - await store.temporal.open(); - } catch { - await store.close().catch(() => {}); - return undefined; - } - return { - adapter: { - lookup: async (nodeId, contentHash, promptVersion) => - store.temporal.lookupSymbolSummary(nodeId, contentHash, promptVersion), - }, - close: async () => { - await store.close(); - }, - }; -} - /** * Open a read-only SQLite store scoped to the `embeddings` content-hash * probe. The returned adapter's `list()` loads every prior diff --git a/packages/cli/src/commands/ci-init.test.ts b/packages/cli/src/commands/ci-init.test.ts index 8cd89a4c..0a931aaa 100644 --- a/packages/cli/src/commands/ci-init.test.ts +++ b/packages/cli/src/commands/ci-init.test.ts @@ -45,10 +45,6 @@ const DECLARED_FLAGS: Readonly>> = { "--no-coverage", "--scan", "--no-scan", - "--summaries", - "--no-summaries", - "--max-summaries", - "--summary-model", "--skills", "--strict-detectors", "--allow-build-scripts", diff --git a/packages/cli/src/commands/query.test.ts b/packages/cli/src/commands/query.test.ts index c4666abf..8087eee9 100644 --- a/packages/cli/src/commands/query.test.ts +++ b/packages/cli/src/commands/query.test.ts @@ -26,7 +26,6 @@ import type { SearchQuery, SearchResult, Store, - SymbolSummaryRow, VectorQuery, VectorResult, } from "@opencodehub/storage"; @@ -43,8 +42,6 @@ interface FakeStoreOptions { readonly searchRows?: readonly SearchResult[]; readonly vectorRows?: readonly VectorResult[]; readonly nodes?: ReadonlyMap; - /** P04 symbol summaries — keyed by nodeId. Omit to simulate legacy indexes. */ - readonly summaryRows?: ReadonlyMap; /** * Persisted embedder model id returned by `getMeta()`. Omit to simulate a * legacy / untagged store (the compatibility check passes). Set to a value @@ -68,7 +65,6 @@ function makeFakeStore(opts: FakeStoreOptions = {}): FakeStoreHandle { const searchRows = opts.searchRows ?? []; const vectorRows = opts.vectorRows ?? []; const nodes = opts.nodes ?? new Map(); - const summaryRows = opts.summaryRows; const metaModelId = opts.metaModelId; const handle: FakeStoreHandle = { @@ -130,25 +126,9 @@ function makeFakeStore(opts: FakeStoreOptions = {}): FakeStoreHandle { : undefined, }; - // The temporal-tier surface the query path touches is just - // `lookupSymbolSummariesByNode`. Older tests can omit it entirely so - // the join transparently degrades to "no summaries", matching the - // production fall-back. - const temporal: Partial = - summaryRows !== undefined - ? { - lookupSymbolSummariesByNode: async ( - nodeIds: readonly string[], - ): Promise => { - const out: SymbolSummaryRow[] = []; - for (const id of nodeIds) { - const row = summaryRows.get(id); - if (row !== undefined) out.push(row); - } - return out; - }, - } - : {}; + // The CLI query path does not touch the temporal tier, so an empty stub + // is enough to satisfy the composed `Store` shape. + const temporal: Partial = {}; const composed: Store = { graph: graph as unknown as IGraphStore, @@ -524,136 +504,6 @@ test("cli query: --bm25-only skips the embedder probe entirely", async () => { assert.deepEqual(parsed.results[0]?.sources, ["bm25"]); }); -// --------------------------------------------------------------------------- -// P04 summary join — `symbol_summaries` rows flow into query hits -// --------------------------------------------------------------------------- - -test("cli query: summary rows are joined onto each hit in --json output (P04)", async () => { - const summaryRows: ReadonlyMap = new Map([ - [ - "F:foo", - { - nodeId: "F:foo", - contentHash: "c0ffee", - promptVersion: "1", - modelId: "global.anthropic.claude-haiku-4-5-v1:0", - summaryText: "Greet the user by name.", - signatureSummary: "name: string", - returnsTypeSummary: "a greeting string", - createdAt: "2026-04-22T00:00:00.000Z", - }, - ], - ]); - const handle = makeFakeStore({ - searchRows: [ - { nodeId: "F:foo", score: 2, filePath: "src/foo.ts", name: "foo", kind: "Function" }, - { nodeId: "F:bar", score: 1, filePath: "src/bar.ts", name: "bar", kind: "Function" }, - ], - summaryRows, - }); - const stdout = await captureStdout(async () => { - await runQuery("foo", { json: true }, hooksFor(handle, "/tmp/fake")); - }); - const parsed = JSON.parse(stdout) as { - results: Array<{ nodeId: string; summary?: string; signatureSummary?: string }>; - }; - const foo = parsed.results.find((r) => r.nodeId === "F:foo"); - const bar = parsed.results.find((r) => r.nodeId === "F:bar"); - assert.ok(foo, "F:foo must be present"); - assert.equal(foo.summary, "Greet the user by name."); - assert.equal(foo.signatureSummary, "name: string"); - assert.ok(bar, "F:bar must be present"); - assert.equal(bar.summary, undefined, "bar has no summary row; field must be absent"); -}); - -test("cli query: summary join renders a SUMMARY column in the text formatter", async () => { - const summaryRows: ReadonlyMap = new Map([ - [ - "F:foo", - { - nodeId: "F:foo", - contentHash: "c0ffee", - promptVersion: "1", - modelId: "m", - summaryText: "Greet the user by name.", - createdAt: "2026-04-22T00:00:00.000Z", - }, - ], - ]); - const handle = makeFakeStore({ - searchRows: [ - { nodeId: "F:foo", score: 2, filePath: "src/foo.ts", name: "foo", kind: "Function" }, - ], - summaryRows, - }); - const stdout = await captureStdout(async () => { - await runQuery("foo", {}, hooksFor(handle, "/tmp/fake")); - }); - assert.ok(stdout.includes("SUMMARY"), "SUMMARY column header must render when summaries present"); - assert.ok(stdout.includes("Greet the user by name."), "summary text must appear in the row"); -}); - -test("cli query: text formatter suppresses SUMMARY column when no summary row exists", async () => { - const handle = makeFakeStore({ - searchRows: [ - { nodeId: "F:foo", score: 2, filePath: "src/foo.ts", name: "foo", kind: "Function" }, - ], - }); - const stdout = await captureStdout(async () => { - await runQuery("foo", {}, hooksFor(handle, "/tmp/fake")); - }); - assert.ok( - !stdout.includes("SUMMARY"), - "SUMMARY column must not render when no hit carries a summary", - ); -}); - -test("cli query: summary text is truncated to 120 chars in the text table", async () => { - const longText = "x".repeat(500); - const summaryRows: ReadonlyMap = new Map([ - [ - "F:foo", - { - nodeId: "F:foo", - contentHash: "c0ffee", - promptVersion: "1", - modelId: "m", - summaryText: longText, - createdAt: "2026-04-22T00:00:00.000Z", - }, - ], - ]); - const handle = makeFakeStore({ - searchRows: [ - { nodeId: "F:foo", score: 2, filePath: "src/foo.ts", name: "foo", kind: "Function" }, - ], - summaryRows, - }); - const stdout = await captureStdout(async () => { - await runQuery("foo", {}, hooksFor(handle, "/tmp/fake")); - }); - // Text formatter cap is 120 chars; the last row must not carry the full - // 500-char body. - assert.ok(!stdout.includes(longText), "full untruncated summary must not appear"); - assert.ok(stdout.includes("…"), "truncation ellipsis must mark the cap"); -}); - -test("cli query: store without lookupSymbolSummariesByNode degrades silently", async () => { - // When `summaryRows` is omitted, the fake store does not install the - // lookup method — the CLI probe must short-circuit to an empty join - // without throwing. - const handle = makeFakeStore({ - searchRows: [ - { nodeId: "F:foo", score: 2, filePath: "src/foo.ts", name: "foo", kind: "Function" }, - ], - }); - const stdout = await captureStdout(async () => { - await runQuery("foo", { json: true }, hooksFor(handle, "/tmp/fake")); - }); - const parsed = JSON.parse(stdout) as { results: Array<{ summary?: string }> }; - assert.equal(parsed.results[0]?.summary, undefined); -}); - // --------------------------------------------------------------------------- // Embedder fingerprint mismatch — the abort path must run cleanup, not leak. // --------------------------------------------------------------------------- diff --git a/packages/cli/src/commands/query.ts b/packages/cli/src/commands/query.ts index 38f955eb..2fc50c50 100644 --- a/packages/cli/src/commands/query.ts +++ b/packages/cli/src/commands/query.ts @@ -41,13 +41,11 @@ import { type SymbolHit, tryOpenEmbedder, } from "@opencodehub/search"; -import type { Store, SymbolSummaryRow } from "@opencodehub/storage"; +import type { Store } from "@opencodehub/storage"; import { type OpenStoreResult, openStoreForCommand } from "./open-store.js"; /** Per-symbol cap for `--content`. Matches the MCP `query` tool contract. */ const INCLUDE_CONTENT_CHAR_CAP = 2000; -/** Truncation cap for the text-mode SUMMARY column. Matches the MCP snippet cap. */ -const SUMMARY_COLUMN_CHAR_CAP = 120; /** * Hook for tests to inject a pre-built store without touching SQLite. The @@ -118,10 +116,6 @@ interface QueryRow { readonly score: number; readonly sources: readonly ("bm25" | "vector")[]; readonly content?: string; - /** Present iff a `symbol_summaries` row exists for this node (P04). */ - readonly summary?: string; - /** Compact one-line signature summary from the same row. */ - readonly signatureSummary?: string; } export async function runQuery( @@ -205,29 +199,6 @@ export async function runQuery( mode = "bm25"; } - // Merge P04 summary-hydration onto the P02 hybrid/BM25 rows. Single - // round trip via the temporal-tier `lookupSymbolSummariesByNode` - // finder; missing table / missing rows / lookup failures all degrade - // silently — summaries are enrichment, not load-bearing. - const summaryMap = await joinSummaries( - store, - ranked.map((r) => r.nodeId), - ); - const rows: readonly QueryRow[] = - summaryMap.size === 0 - ? ranked - : ranked.map((r) => { - const row = summaryMap.get(r.nodeId); - if (row === undefined) return r; - return { - ...r, - summary: row.summaryText, - ...(row.signatureSummary !== undefined - ? { signatureSummary: row.signatureSummary } - : {}), - }; - }); - // Best-effort `--content` attachment runs the same way for BM25 and // hybrid; the store-native BM25 path already surfaces filePath but not // line ranges, so the CLI reads the whole file (capped) — matching the @@ -235,12 +206,12 @@ export async function runQuery( const withContent: readonly QueryRow[] = opts.content === true ? await Promise.all( - rows.map(async (r): Promise => { + ranked.map(async (r): Promise => { const content = await readSymbolContent(repoPath, r); return content !== null ? { ...r, content } : r; }), ) - : rows; + : ranked; if (opts.json === true) { console.log(JSON.stringify({ repoPath, mode, results: withContent }, null, 2)); @@ -321,45 +292,6 @@ async function hydrateFused( return out; } -/** - * Fetch `symbol_summaries` rows for every hit nodeId in a single query. - * Collapses multiple prompt-version rows per node by keeping the last - * row in the storage layer's documented `(node_id ASC, prompt_version - * ASC, content_hash ASC)` order, which deterministically selects the - * newest prompt version. Returns an empty map on any failure so a - * missing `symbol_summaries` table never blocks a query. Test fakes - * without `lookupSymbolSummariesByNode` get an empty join transparently. - */ -async function joinSummaries( - store: Store, - nodeIds: readonly string[], -): Promise> { - const out = new Map(); - if (nodeIds.length === 0) return out; - // Test fakes that omit a real temporal view (or set it to a partial - // shape) get an empty join transparently — `lookupSymbolSummariesByNode` - // is required on `ITemporalStore` but we still duck-check at runtime so - // a hand-rolled mock without the method doesn't blow up. - const temporal = store.temporal as unknown as { - readonly lookupSymbolSummariesByNode?: ( - ids: readonly string[], - ) => Promise; - }; - if (typeof temporal.lookupSymbolSummariesByNode !== "function") return out; - const uniqIds = Array.from(new Set(nodeIds)); - try { - const rows = await temporal.lookupSymbolSummariesByNode.call(store.temporal, uniqIds); - for (const row of rows) { - // Overwriting per node id keeps the newest prompt version because of - // the storage layer's ORDER BY contract on `lookupSymbolSummariesByNode`. - out.set(row.nodeId, row); - } - } catch { - // Degrade silently — summaries are enrichment, not load-bearing. - } - return out; -} - /** * Join `context — goal — text` with whitespace-safe em-dash separators. * Missing / blank parts are dropped so the ranker never sees a dangling @@ -405,19 +337,16 @@ function printResults( const label = mode === "hybrid" ? "hybrid" : "BM25"; console.warn(`query: "${text}" in ${repoPath} (${results.length} ${label} results)`); if (results.length === 0) return; - // Only render the SUMMARY column when at least one hit carries one — - // skip the extra whitespace on indexes that haven't run the summarize - // phase yet. SOURCES stays on every row so agents can tell which ranker - // contributed to each hit. - const anySummary = results.some((r) => typeof r.summary === "string" && r.summary.length > 0); - const header = anySummary - ? ["SCORE", "KIND", "NAME", "FILE", "SOURCES", "SUMMARY"] - : ["SCORE", "KIND", "NAME", "FILE", "SOURCES"]; - const rows = results.map((r) => { - const base = [r.score.toFixed(3), r.kind, r.name, r.filePath, r.sources.join("+")]; - if (!anySummary) return base; - return [...base, truncateSummary(r.summary)]; - }); + // SOURCES stays on every row so agents can tell which ranker contributed + // to each hit. + const header = ["SCORE", "KIND", "NAME", "FILE", "SOURCES"]; + const rows = results.map((r) => [ + r.score.toFixed(3), + r.kind, + r.name, + r.filePath, + r.sources.join("+"), + ]); const widths = header.map((h, i) => Math.max(h.length, ...rows.map((row) => (row[i] ?? "").length)), ); @@ -434,16 +363,3 @@ function printResults( console.log(r.content); } } - -/** - * Render a summary string to fit the single-line SUMMARY column. Newlines - * collapse to spaces so the column width survives; anything past the cap - * is trimmed and closed with an ellipsis. Absent summaries render as an - * empty string so the column aligns. - */ -function truncateSummary(summary: string | undefined): string { - if (summary === undefined || summary.length === 0) return ""; - const flattened = summary.replace(/\s+/g, " ").trim(); - if (flattened.length <= SUMMARY_COLUMN_CHAR_CAP) return flattened; - return `${flattened.slice(0, SUMMARY_COLUMN_CHAR_CAP - 1)}…`; -} diff --git a/packages/cli/src/commands/status.test.ts b/packages/cli/src/commands/status.test.ts index ba063696..ae2a953d 100644 --- a/packages/cli/src/commands/status.test.ts +++ b/packages/cli/src/commands/status.test.ts @@ -117,23 +117,22 @@ test("status surfaces every group the repo belongs to, alphabetical", async () = assert.doesNotMatch(groupsLine, /unrelated/); }); -test("status reports bm25-only + summaries count from the retrieval probe", async () => { +test("status reports bm25-only vectors from the retrieval probe", async () => { const home = await scratch(); const repoPath = await seedRepo(home, "bm25repo"); const cap = captureStdout(); try { await runStatus(repoPath, { home, - probeRetrieval: async () => ({ summaries: 0, vectors: "bm25-only" }), + probeRetrieval: async () => ({ vectors: "bm25-only" }), }); } finally { cap.restore(); } assert.ok( - cap.lines.some((l) => /^summaries:\s+0$/.test(l)), - `expected 'summaries: 0'; got:\n${cap.lines.join("\n")}`, + cap.lines.some((l) => /^vectors:\s+bm25-only$/.test(l)), + `expected 'vectors: bm25-only'; got:\n${cap.lines.join("\n")}`, ); - assert.ok(cap.lines.some((l) => /^vectors:\s+bm25-only$/.test(l))); }); test("status reports populated vectors when the probe says so", async () => { @@ -143,16 +142,15 @@ test("status reports populated vectors when the probe says so", async () => { try { await runStatus(repoPath, { home, - probeRetrieval: async () => ({ summaries: 42, vectors: "populated" }), + probeRetrieval: async () => ({ vectors: "populated" }), }); } finally { cap.restore(); } - assert.ok(cap.lines.some((l) => /^summaries:\s+42$/.test(l))); assert.ok(cap.lines.some((l) => /^vectors:\s+populated$/.test(l))); }); -test("status degrades to summaries:- / vectors:unknown when the store can't open", async () => { +test("status degrades to vectors:unknown when the store can't open", async () => { const home = await scratch(); const repoPath = await seedRepo(home, "degraded"); const cap = captureStdout(); @@ -162,7 +160,6 @@ test("status degrades to summaries:- / vectors:unknown when the store can't open } finally { cap.restore(); } - assert.ok(cap.lines.some((l) => /^summaries:\s+-$/.test(l))); assert.ok(cap.lines.some((l) => /^vectors:\s+unknown$/.test(l))); // The rest of status still renders (groups line present). assert.ok(cap.lines.some((l) => l.startsWith("groups:"))); diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts index a04c608d..5c9f424a 100644 --- a/packages/cli/src/commands/status.ts +++ b/packages/cli/src/commands/status.ts @@ -16,13 +16,11 @@ import { readRegistry } from "../registry.js"; import { openStoreForCommand } from "./open-store.js"; /** - * Retrieval-mode probe result for the status output. `summaries` is the count - * of distinct nodes with an LLM summary (dense-leg input); `vectors` reports - * whether the embeddings table is populated. Both are best-effort: a degraded - * or absent store yields `summaries: null`. + * Retrieval-mode probe result for the status output. `vectors` reports + * whether the embeddings table is populated. Best-effort: a degraded or + * absent store yields `undefined`. */ export interface RetrievalState { - readonly summaries: number | null; readonly vectors: "populated" | "bm25-only"; } @@ -41,9 +39,8 @@ async function defaultProbeRetrieval(repoPath: string): Promise { - await assert.rejects( - () => - runWiki({ - output: "/tmp/does-not-matter", - llm: true, - offline: true, - }), - /summarizer requires network|remove --offline or drop --llm/, - ); -}); diff --git a/packages/cli/src/commands/wiki.ts b/packages/cli/src/commands/wiki.ts index 19cfebb6..43f10c48 100644 --- a/packages/cli/src/commands/wiki.ts +++ b/packages/cli/src/commands/wiki.ts @@ -1,20 +1,13 @@ /** - * `codehub wiki` — emit a graph-only Markdown wiki (default) or, with `--llm`, - * route the top-ranked modules through `@opencodehub/summarizer`'s Bedrock - * Converse client to generate narrative prose. + * `codehub wiki` — emit a graph-only Markdown wiki. * - * Gating rules (mirrored in `@opencodehub/analysis`): - * - `--llm` absent: exact existing behavior (deterministic, no network). - * - `--llm` + `--offline`: hard error. The summarizer requires network. - * - `--llm --max-llm-calls 0`: dry-run — enumerate candidate modules but - * never contact Bedrock. - * - `--llm --max-llm-calls `: call the summarizer for the top N modules - * by symbolCount. Per-module failures fall back to a deterministic - * substitute for that module without aborting the run. + * Fully deterministic: renders the 5 page families plus a top-level index + * from the persisted graph. No network, no clock, no LLM calls — two runs + * against the same graph produce byte-identical output. */ import { computeRiskTrends, loadSnapshots } from "@opencodehub/analysis"; -import { generateWiki, type WikiLlmOptions } from "@opencodehub/wiki"; +import { generateWiki } from "@opencodehub/wiki"; import { openStoreForCommand } from "./open-store.js"; export interface WikiCommandOptions { @@ -23,40 +16,18 @@ export interface WikiCommandOptions { readonly home?: string; readonly json?: boolean; readonly offline?: boolean; - readonly llm?: boolean; - readonly maxLlmCalls?: number; - readonly llmModel?: string; } export async function runWiki(opts: WikiCommandOptions): Promise { - const llmRequested = opts.llm === true; - if (llmRequested && opts.offline === true) { - throw new Error("codehub wiki: --llm requires network access; remove --offline or drop --llm"); - } - const { store, repoPath } = await openStoreForCommand({ ...(opts.repo !== undefined ? { repo: opts.repo } : {}), ...(opts.home !== undefined ? { home: opts.home } : {}), }); try { - const maxLlmCalls = Math.max( - 0, - typeof opts.maxLlmCalls === "number" && Number.isFinite(opts.maxLlmCalls) - ? Math.floor(opts.maxLlmCalls) - : 0, - ); - const llm: WikiLlmOptions | undefined = llmRequested - ? { - enabled: true, - maxCalls: maxLlmCalls, - ...(opts.llmModel !== undefined ? { modelId: opts.llmModel } : {}), - } - : undefined; const result = await generateWiki(store.graph, { outputDir: opts.output, repoPath, loadTrends: async (p) => computeRiskTrends(await loadSnapshots(p)), - ...(llm !== undefined ? { llm } : {}), }); if (opts.json === true) { console.log( @@ -66,15 +37,6 @@ export async function runWiki(opts: WikiCommandOptions): Promise { fileCount: result.filesWritten.length, totalBytes: result.totalBytes, files: result.filesWritten, - llm: - llm === undefined - ? { enabled: false } - : { - enabled: true, - maxCalls: llm.maxCalls, - ...(llm.modelId !== undefined ? { modelId: llm.modelId } : {}), - dryRun: llm.maxCalls === 0, - }, }, null, 2, @@ -85,10 +47,6 @@ export async function runWiki(opts: WikiCommandOptions): Promise { console.warn( `wiki: wrote ${result.filesWritten.length} files (${result.totalBytes} bytes) to ${opts.output}`, ); - if (llm !== undefined) { - const mode = llm.maxCalls === 0 ? "dry-run (0 calls)" : `cap ${llm.maxCalls}`; - console.warn(`wiki: llm mode active — ${mode}`); - } for (const f of result.filesWritten) { console.log(f); } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index fe68156a..fdcfdade 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -124,23 +124,6 @@ program "--no-scan", "Skip the post-analyze scan step. The graph pipeline runs unchanged; `codehub verdict` / `list_findings` work against the last SARIF on disk.", ) - .option( - "--summaries", - "Opt into the summarize phase (structured Bedrock summaries per callable). Default OFF — `codehub analyze` is fast, local, deterministic by default. Also enabled by CODEHUB_BEDROCK_SUMMARIES=1.", - ) - .option( - "--no-summaries", - "Explicitly disable the summarize phase (equivalent to CODEHUB_BEDROCK_DISABLED=1). Only meaningful when combined with CODEHUB_BEDROCK_SUMMARIES=1.", - ) - .option( - "--max-summaries ", - 'Cap on Bedrock summarize calls per run. "auto" (default) scales the cap to 10% of the SCIP-confirmed callable count (max 500).', - "auto", - ) - .option( - "--summary-model ", - "Override the Bedrock model id used by the summarize phase (defaults to DEFAULT_MODEL_ID).", - ) .option( "--skills", "After analyze, emit one SKILL.md per Community (symbolCount >= 5) under .codehub/skills/", @@ -155,34 +138,6 @@ program ) .action(async (path: string | undefined, opts: Record) => { const mod = await import("./commands/analyze.js"); - // Pass the raw flag straight through to `runAnalyze`. The env - // kill-switch (`CODEHUB_BEDROCK_DISABLED=1`) and the env opt-in - // (`CODEHUB_BEDROCK_SUMMARIES=1`) are re-checked inside `runAnalyze` - // via `resolveSummariesEnabled` so tests that call `runAnalyze` - // directly honor the same truth table. Summaries are OFF by default - // — the fast, local, deterministic analyze path. Pass `--summaries` - // or set `CODEHUB_BEDROCK_SUMMARIES=1` to opt in. - let summaries: boolean | undefined; - if (opts["summaries"] === true) summaries = true; - else if (opts["summaries"] === false) summaries = false; - else summaries = undefined; - - // --max-summaries accepts either a positive integer or the literal - // string "auto". Unknown strings fall back to "auto" so the CLI never - // refuses a run over flag syntax. - const rawMax = opts["maxSummaries"]; - let maxSummariesPerRun: number | "auto"; - if (rawMax === "auto" || rawMax === undefined) { - maxSummariesPerRun = "auto"; - } else if (typeof rawMax === "number" && Number.isFinite(rawMax)) { - maxSummariesPerRun = Math.max(0, Math.floor(rawMax)); - } else if (typeof rawMax === "string") { - const parsed = Number.parseInt(rawMax, 10); - maxSummariesPerRun = Number.isFinite(parsed) ? Math.max(0, parsed) : "auto"; - } else { - maxSummariesPerRun = "auto"; - } - const granularity = parseGranularityCsv(opts["granularity"]); const allowBuildScripts = parseAllowBuildScripts(opts["allowBuildScripts"]); // When --embeddings is on and the user didn't pick a worker count, default @@ -217,9 +172,6 @@ program ...(opts["coverage"] === true ? { coverage: true as const } : {}), ...(opts["scan"] === false ? { scan: false as const } : {}), ...(opts["scan"] === true ? { scan: true as const } : {}), - ...(summaries !== undefined ? { summaries } : {}), - maxSummariesPerRun, - ...(typeof opts["summaryModel"] === "string" ? { summaryModel: opts["summaryModel"] } : {}), skills: opts["skills"] === true, strictDetectors: opts["strictDetectors"] === true, ...(allowBuildScripts !== undefined ? { allowBuildScripts } : {}), @@ -913,40 +865,22 @@ program program .command("wiki") - .description( - "Emit a Markdown wiki under --output (deterministic by default; --llm for LLM prose)", - ) + .description("Emit a deterministic Markdown wiki under --output") .requiredOption("--output ", "Target directory for rendered pages") .option("--repo ", "Registered repo name (default: current directory)") .option("--json", "Emit a JSON summary on stdout") - .option("--offline", "Assert no network access (incompatible with --llm)") - .option("--llm", "Route top-ranked modules through @opencodehub/summarizer for narrative prose") - .option( - "--max-llm-calls ", - "Cap on Bedrock summarizer calls when --llm is set. 0 (default) runs in dry-run mode", - (v) => Number.parseInt(v, 10), - 0, - ) - .option("--llm-model ", "Override the Bedrock model id passed to the summarizer") + .option("--offline", "Assert no network access") .action(async (opts: Record) => { const mod = await import("./commands/wiki.js"); const output = typeof opts["output"] === "string" ? opts["output"] : ""; if (output.length === 0) { throw new Error("--output is required"); } - const maxLlmCallsRaw = opts["maxLlmCalls"]; - const maxLlmCalls = - typeof maxLlmCallsRaw === "number" && Number.isFinite(maxLlmCallsRaw) - ? Math.max(0, Math.floor(maxLlmCallsRaw)) - : 0; await mod.runWiki({ output, ...(typeof opts["repo"] === "string" ? { repo: opts["repo"] } : {}), json: opts["json"] === true, offline: opts["offline"] === true, - llm: opts["llm"] === true, - maxLlmCalls, - ...(typeof opts["llmModel"] === "string" ? { llmModel: opts["llmModel"] } : {}), }); }); @@ -993,7 +927,7 @@ program program .command("sql ") .description( - "Run a read-only SQL query against the temporal store (cochanges + symbol_summaries); the node/edge graph is queried via the typed tools or Cypher", + "Run a read-only SQL query against the temporal store (cochanges); the node/edge graph is queried via the typed tools or Cypher", ) .option("--repo ", "Registered repo name (default: current directory)") .option("--timeout ", "Per-query timeout in ms", (v) => Number.parseInt(v, 10), 5_000) diff --git a/packages/docs/src/content/docs/agents/tool-decision-matrix.mdx b/packages/docs/src/content/docs/agents/tool-decision-matrix.mdx index ebc8e254..3c00e708 100644 --- a/packages/docs/src/content/docs/agents/tool-decision-matrix.mdx +++ b/packages/docs/src/content/docs/agents/tool-decision-matrix.mdx @@ -35,7 +35,7 @@ anti-pattern column says what _not_ to reach for first. | "What's the license tier of my deps?" | `license_audit` | Tiers each transitive dep: permissive / weak-copyleft / strong-copyleft / proprietary / unknown. | `license-checker` raw output. | | "Which areas are getting riskier?" | `risk_trends` | Per-community trend lines + 30-day projection from temporal data. | One-off risk snapshots. | | "Who is changing what most, and where" | `risk_trends` + `owners` | Trends point to communities; `owners` names the people. | Either alone. | -| "Bespoke temporal query (cochanges / summaries)" | `sql` | Read-only SQL against the temporal store (cochanges + symbol_summaries), 5s timeout. NOT the node/edge graph. | A typed tool covers it; or you need the graph — use Cypher (MCP `sql` `cypher` arg). | +| "Bespoke temporal query (cochanges)" | `sql` | Read-only SQL against the temporal store (cochanges), 5s timeout. NOT the node/edge graph. | A typed tool covers it; or you need the graph — use Cypher (MCP `sql` `cypher` arg). | ## Cross-repo group intents diff --git a/packages/docs/src/content/docs/architecture/determinism.md b/packages/docs/src/content/docs/architecture/determinism.md index aebc64d4..35480abb 100644 --- a/packages/docs/src/content/docs/architecture/determinism.md +++ b/packages/docs/src/content/docs/architecture/determinism.md @@ -46,8 +46,8 @@ inode ordering — must not influence the hash. The ingestion phases are pure: inputs in, relations out, no ambient state. The `graphHash` invariant covers the graph nodes and edges in -`store.sqlite`; the temporal signals in the same file (cochanges, -symbol summaries) are statistical and never enter the hash. A parity +`store.sqlite`; the statistical signals in the same file (cochanges, +embeddings) never enter the hash. A parity gate in CI asserts the invariant on every PR that touches the storage layer. @@ -86,7 +86,7 @@ incremental byte-identical" invariant called out in ADR 0002. `codehub analyze --offline` is a separate but related guarantee: **zero sockets opened** during the run. The flag disables every non-filesystem I/O path in the pipeline (no SCIP indexer downloads, -no remote embedder, no Bedrock summarize calls). +no remote embedder calls). "Zero sockets" is the literal, measurable claim. It is testable by running under `strace -e connect` or the equivalent on macOS diff --git a/packages/docs/src/content/docs/architecture/embeddings.md b/packages/docs/src/content/docs/architecture/embeddings.md index 10735b7e..bc566de9 100644 --- a/packages/docs/src/content/docs/architecture/embeddings.md +++ b/packages/docs/src/content/docs/architecture/embeddings.md @@ -111,17 +111,16 @@ The `EmbeddingGranularity` discriminator is `"symbol" | "file" | | Tier | Unit | Character cap | |-----------|------------------------------------------------------|----------------------------------| -| symbol | Callable or declaration (Function, Method, Constructor, Route, Tool, Class, Interface) | 1200 (body only; fused signature + summary add on top) | +| symbol | Callable or declaration (Function, Method, Constructor, Route, Tool, Class, Interface) | 1200 (body only; the fused signature adds on top) | | file | One vector per scanned file | 8192 tokens (`FILE_CHAR_CAP = 8192 * 4`) | | community | One vector per Community node | N/A — built from member symbols | The default is `["symbol"]` to preserve v1.0 behavior. File and community tiers opt in via `PipelineOptions.embeddingsGranularity`. -Symbol-tier fusion combines `signature + summary + body` into the -embedded text when an LLM summary exists for the node. See -[Summarization and fusion](/opencodehub/architecture/summarization-and-fusion/) -for the formula. +Symbol-tier text fuses the symbol's signature with its body so the +embedded vector captures both the callable's shape and its +implementation. ## Single embeddings table @@ -187,8 +186,6 @@ that is a deferred fast-follow, not a shipping requirement. — where the `embeddings` table lives and why there is no native binding. - [ADR 0004 — Hierarchical embeddings](https://github.com/theagenticguy/opencodehub/blob/main/docs/adr/0004-hierarchical-embeddings.md) — one table, three granularities, one discriminator column. -- [Summarization and fusion](/opencodehub/architecture/summarization-and-fusion/) - — where the symbol-tier text comes from. - Durable lesson: `api-patterns/sagemaker-embedder-backend.md` — dynamic-import + credential soft-fail + structural-typing seam + modelId stamping + 413 split-retry. diff --git a/packages/docs/src/content/docs/architecture/monorepo-map.md b/packages/docs/src/content/docs/architecture/monorepo-map.md index cd85193d..a84fe1b4 100644 --- a/packages/docs/src/content/docs/architecture/monorepo-map.md +++ b/packages/docs/src/content/docs/architecture/monorepo-map.md @@ -5,8 +5,8 @@ sidebar: order: 20 --- -OpenCodeHub is a pnpm workspace under `packages/*`. Seventeen -TypeScript library packages plus the documentation site (eighteen +OpenCodeHub is a pnpm workspace under `packages/*`. Sixteen +TypeScript library packages plus the documentation site (seventeen package directories in all). The CLI is the only binary; every other package is a library imported by `cli`, `mcp`, `ingestion`, or `analysis`. @@ -21,7 +21,7 @@ package is a library imported by `cli`, `mcp`, `ingestion`, or | `@opencodehub/core-types` | `packages/core-types` | Shared graph schema, `LanguageId`, `RelationType`, determinism primitives. | | `@opencodehub/embedder` | `packages/embedder` | Deterministic ONNX embedder (`F2LLM-v2-80M`, 320-dim), modelId fingerprint, three-backend cascade. | | `@opencodehub/frameworks` | `packages/frameworks` | Five-stage framework detector (manifest → lockfile → config-AST → folder → import/SCIP) over a curated registry. | -| `@opencodehub/ingestion` | `packages/ingestion` | The indexing pipeline (parse, resolve, scip-index, embeddings, communities, processes, summaries, ...). | +| `@opencodehub/ingestion` | `packages/ingestion` | The indexing pipeline (parse, resolve, scip-index, embeddings, communities, processes, ...). | | `@opencodehub/mcp` | `packages/mcp` | The stdio MCP server, 29 tool registrations (all read-only with respect to user source), 7 resources, the error envelope, the staleness `_meta` block. | | `@opencodehub/pack` | `packages/pack` | Deterministic 8-item code-pack BOM (the artifact attached to every release). | | `@opencodehub/policy` | `packages/policy` | `opencodehub.policy.yaml` loader, validator, evaluator. | @@ -30,7 +30,6 @@ package is a library imported by `cli`, `mcp`, `ingestion`, or | `@opencodehub/scip-ingest` | `packages/scip-ingest` | `.scip` protobuf reader + per-language indexer runners (TypeScript, Python, Go, Rust, Java, .NET, clang, Kotlin, Ruby). | | `@opencodehub/search` | `packages/search` | Hybrid BM25 + RRF search. | | `@opencodehub/storage` | `packages/storage` | The `IGraphStore` / `ITemporalStore` interface segregation, the `SqliteStore` class that implements both over one `store.sqlite` via `node:sqlite`, and `openStore()` that returns it as both views. | -| `@opencodehub/summarizer` | `packages/summarizer` | Structured per-symbol summarizer (Haiku 4.5 via Bedrock Converse + Zod 4). | | `@opencodehub/wiki` | `packages/wiki` | Markdown wiki renderer (architecture, api-surface, dependency-map, ownership-map, risk-atlas) over the graph. | | `@opencodehub/docs` | `packages/docs` | This Starlight documentation site. | @@ -45,7 +44,7 @@ or `analysis`. Think of it as two layers: - **Leaf libraries.** `core-types`, `sarif`, `embedder`, `storage`, - `search`, `summarizer`, `scip-ingest`, `frameworks`, `pack`, + `search`, `scip-ingest`, `frameworks`, `pack`, `policy`, `cobol-proleap`. - **Orchestrators.** `ingestion`, `analysis`, `scanners`, `mcp`, `wiki`, `cli`. @@ -57,7 +56,7 @@ TypeScript project-references graph enforces this via `tsc --noEmit`. `@opencodehub/storage` exposes two narrow interfaces: `IGraphStore` (graph workload: nodes, edges, embeddings, multi-hop traversal) and -`ITemporalStore` (temporal workload: cochanges, summary cache). The +`ITemporalStore` (temporal workload: cochanges). The single shipping class implements both: - **`SqliteStore` over one `store.sqlite`** — always. One artifact on diff --git a/packages/docs/src/content/docs/architecture/overview.md b/packages/docs/src/content/docs/architecture/overview.md index 3ede11e5..bf17194f 100644 --- a/packages/docs/src/content/docs/architecture/overview.md +++ b/packages/docs/src/content/docs/architecture/overview.md @@ -36,7 +36,7 @@ agent queries. The entire index lives in one **`store.sqlite`** file (WAL mode) under `.codehub/`, via Node's built-in `node:sqlite`. It holds graph nodes, edges, embeddings, the FTS5 search index, and the temporal tables -(cochanges, summary cache). There is no selection knob, no native +(cochanges). There is no selection knob, no native binding, and no fallback: ADR 0019 removed both `@ladybugdb/core` and `@duckdb/node-api`, leaving zero native storage bindings. See [Storage backend](/opencodehub/architecture/storage-backend/). @@ -44,9 +44,9 @@ binding, and no fallback: ADR 0019 removed both `@ladybugdb/core` and ```mermaid flowchart LR subgraph store[".codehub/"] - db[(store.sqlite
nodes + edges + embeddings
+ cochanges, summary cache)] + db[(store.sqlite
nodes + edges + embeddings
+ cochanges)] end - fts["BM25 (FTS5) over names + summaries"] --- db + fts["BM25 (FTS5) over names + signatures"] --- db vec["vector search over embeddings"] --- db ``` @@ -106,7 +106,7 @@ See [SCIP reconciliation](/opencodehub/architecture/scip-reconciliation/). One job: persist the graph into `store.sqlite` with search indexes wired up. -- **BM25** — over symbol names, signatures, and summaries via an FTS5 +- **BM25** — over symbol names and signatures via an FTS5 virtual table. - **Vector search** — filter-aware, with the granularity discriminator pushed into the predicate so all three tiers (symbol / file / @@ -129,12 +129,6 @@ One job: group related symbols into communities (Louvain) and walk call chains to produce processes (handler → service → data access). Both are precomputed so MCP tools read them directly. -Symbol-level LLM summaries are produced here when enabled. Summaries -are fused into the symbol-tier embedding text at ingestion time (not -query time) so retrieval runs against a pre-fused vector. - -See [Summarization and fusion](/opencodehub/architecture/summarization-and-fusion/). - ### 6. Serve — MCP over stdio One job: expose the graph through an stdio MCP server (`codehub diff --git a/packages/docs/src/content/docs/architecture/scip-reconciliation.md b/packages/docs/src/content/docs/architecture/scip-reconciliation.md index 9b8f8382..9ef1398b 100644 --- a/packages/docs/src/content/docs/architecture/scip-reconciliation.md +++ b/packages/docs/src/content/docs/architecture/scip-reconciliation.md @@ -104,7 +104,7 @@ on confidence; the information is not lost. Every oracle-derived edge carries a reason of the form `scip:@`, e.g. `scip:scip-python@0.6.6`. The prefix set is declared once in `@opencodehub/core-types` and consumers -(summarizer trust filter, `verdict`, MCP tools) test against the +(`verdict`, MCP tools) test against the exported list rather than string-matching ad hoc. New indexers (scip-clang, scip-dotnet, scip-kotlin, scip-ruby) are appended to the same list as they land. diff --git a/packages/docs/src/content/docs/architecture/storage-backend.md b/packages/docs/src/content/docs/architecture/storage-backend.md index ab901455..b97611de 100644 --- a/packages/docs/src/content/docs/architecture/storage-backend.md +++ b/packages/docs/src/content/docs/architecture/storage-backend.md @@ -21,9 +21,8 @@ storage bindings. - **`IGraphStore`** — graph workload. Nodes, edges, embeddings, multi-hop traversal. -- **`ITemporalStore`** — temporal workload. Cochanges, the - symbol-summary cache. Statistical signals over git history that - never enter `graphHash`. +- **`ITemporalStore`** — temporal workload. Cochanges: statistical + signals over git history that never enter `graphHash`. The interfaces stay segregated so a community adapter can implement only the half it has an engine for. A graph-only Neo4j adapter does not have @@ -41,7 +40,7 @@ One artifact on disk, always present after `codehub analyze`: | File | Holds | |---|---| -| `/.codehub/store.sqlite` | Nodes, edges, embeddings, BM25 (FTS5) indexes, and the temporal tables (cochanges, symbol-summary cache). The entire index. | +| `/.codehub/store.sqlite` | Nodes, edges, embeddings, BM25 (FTS5) indexes, and the temporal tables (cochanges). The entire index. | `node:sqlite` (`DatabaseSync`, enabled by default on Node ≥24.15, the engines floor) provides every primitive the store needs: BLOB storage @@ -126,7 +125,7 @@ survive the move to a single store for exactly this reason. The `graphHash` invariant covers everything `IGraphStore` owns and is asserted by a CI gate on every PR that touches `packages/storage`. The -temporal signals in `store.sqlite` (cochanges, symbol summaries) are +temporal signals in `store.sqlite` (cochanges) are statistical and never enter `graphHash`. The migration's hard gate was that a `KnowledgeGraph` rebuilt from `listNodes({})` + `listEdges({})` must hash byte-identically to the original; diff --git a/packages/docs/src/content/docs/architecture/summarization-and-fusion.md b/packages/docs/src/content/docs/architecture/summarization-and-fusion.md deleted file mode 100644 index bea4306d..00000000 --- a/packages/docs/src/content/docs/architecture/summarization-and-fusion.md +++ /dev/null @@ -1,202 +0,0 @@ ---- -title: Summarization and fusion -description: Per-symbol LLM summaries via Bedrock + Haiku with ReAct retry, and how summaries fuse into the symbol-tier embedding at ingest time — not query time. -sidebar: - order: 60 ---- - -`@opencodehub/summarizer` produces per-symbol natural-language -summaries grounded in source. The ingestion `summarize` phase -persists them; the downstream `embeddings` phase fuses each summary -into the symbol-tier embedding text so retrieval runs against a -pre-fused vector. - -This page covers the schema, the Bedrock caching shape, the ReAct -retry loop, and where fusion happens. - -## Schema - -`SymbolSummary` is a Zod 4 schema with strict field bounds and a -SuperRefine that enforces citation completeness — every populated -field must carry ≥1 citation. - -| Field | Shape | -|---------------|-----------------------------------------------------------------| -| `purpose` | string (30-400 chars); becomes `summaryText` in the row. | -| `inputs` | `InputSpec[]`: name + type + description per input. | -| `returns` | `{type, type_summary (10-80), details (20-400)}`. | -| `side_effects`| array; each entry contains one of `reads|writes|emits|raises|mutates`. | -| `invariants` | array (nullable). | -| `citations` | ≥1; each has `field_name` enum + `line_start` + `line_end`. | - -`buildToolInputSchema()` runs `z.toJSONSchema(SymbolSummary)` and -strips `$schema` before handing it to Bedrock — any post-processing -that re-adds `$schema` breaks the cacheable prefix. A runtime -`validateCitationLines()` pass checks every citation range sits -inside the source span. - -## Model + caching - -Two constants govern the model choice: - -``` -DEFAULT_MODEL_ID = "global.anthropic.claude-haiku-4-5-20251001-v1:0" -DEFAULT_MAX_ATTEMPTS = 3 -``` - -`summarizeSymbol(client, input, options)` issues a Bedrock -`ConverseCommand` with structured output via tool use. Key knobs: - -- `toolChoice` is forced to `emit_symbol_summary` — the model MUST - call this tool; a text-only response is a retry. -- `inferenceConfig = {temperature: 0, maxTokens: 2048}`. -- `cachePoint` is placed **twice**: after the system prompt, and - after the tool spec inside `toolConfig.tools`. - -The dual `cachePoint` placement matters because Haiku 4.5's -cacheable-prefix floor is 4,096 tokens. `SYSTEM_PROMPT` is sized to -clear that floor with three worked examples baked in (`normalize_path` -as a pure function, `register_handler` as a side-effectful handler, -`LRUCache` as a constructor). The tool spec's cache point covers the -JSON Schema itself, which is stable as long as `$schema` is stripped -and `SUMMARIZER_PROMPT_VERSION` is unchanged. - -## ReAct retry - -The retry loop handles two failure modes: - -- **Schema-invalid tool call.** The model returns a tool use that - fails Zod validation. The Zod error text is fed back as - `toolResult(status: "error")` and the model retries. -- **No tool call at all.** The model returned text only. Same fix — - feed back an error and retry. - -`maxAttempts=3` is the default; three tries is enough in practice. A -third failure throws `SummarizerError` to the caller. - -## Ingestion invocation - -The ingestion call site is -`packages/ingestion/src/pipeline/phases/summarize.ts`. Its deps -include `confidence-demote`, so the trust filter (SCIP-touched -symbols only) sees finalized confidence scores. - -The phase applies four gates in strict order: - -1. **Offline** — `PipelineOptions.offline === true` is a hard no-op. -2. **Flag** — `PipelineOptions.summaries === true` required. -3. **Trust filter** — only symbols touched by a SCIP oracle - (confidence 1.0 with a reason prefixed by `scip:`) are candidates. - A repo without SCIP produces zero summaries even with - `summaries=true`. -4. **Cost cap** — `maxSummariesPerRun` (default 0) slices the - candidate list. A default run is a dry-run: it counts - `wouldHaveSummarized` without issuing a single Bedrock call. - -Reordering any gate silently changes cost behavior, so the order is -deliberately rigid. See the phase docstring for the full precedence -contract. - -Credential soft-fail is handled twice — once on client factory -construction, once on the first `send()` — so an SSO token that -expires mid-run produces `skippedReason: "no-credentials"` rather -than an uncaught exception. - -Successful rows persist as `SymbolSummaryRow`: -`{nodeId, contentHash, promptVersion, modelId, summaryText, -signatureSummary, returnsTypeSummary, createdAt}`. - -## Fusion at ingestion, not query time - -This is the bit to internalize: **fusion happens at ingestion, not at -query**. When the `embeddings` phase builds a symbol's vector, it -calls `symbolText(node, summary, body)`. If a summary row exists, -the embedded text is: - -``` -\n\n -``` - -with `bodyPiece` capped at `SYMBOL_BODY_CHAR_CAP = 1200`. Without a -summary, the fallback is `\n`. - -The resulting vector already encodes the signature, the summary, and -the body. Retrieval does not re-fuse at query time — it searches -against the pre-fused vector. This keeps query latency low and keeps -the query path free of LLM calls. - -```mermaid -sequenceDiagram - participant Summ as summarize phase - participant Bedrock - participant Emb as embeddings phase - participant Vec as embeddings table - - Summ->>Summ: filter by SCIP-trust - Summ->>Summ: cache probe (nodeId, contentHash, promptVersion) - alt cache miss - Summ->>Bedrock: Converse with dual cachePoint - Bedrock-->>Summ: tool_use purpose, inputs, returns, ... - Summ->>Summ: Zod validate, ReAct retry on error - end - Summ->>Summ: persist SymbolSummaryRow - Emb->>Emb: symbolText(node, summary, body) — fuse - Emb->>Vec: upsert symbol-tier vector -``` - -## Cache-key discriminator - -The cache key is `(nodeId, contentHash, promptVersion)`: - -- `contentHash` is `sha256` of the raw UTF-8 span `[startLine, - endLine]`. A whitespace-only edit inside the span changes the hash - and invalidates the cached summary for that symbol. -- `promptVersion` is `SUMMARIZER_PROMPT_VERSION = "1"`. Bumping this - constant invalidates every cached summary in one shot — the - prior rows survive in the cache (no deletion), but lookups miss. - Planned rollout is the new version coexisting with the old so a - rollback is cheap. - -## Cost profile - -Haiku 4.5 calls happen once per callable symbol at ingest time. A -re-ingest without a prompt-version bump is a cache hit. With the -default `maxSummariesPerRun=0`, the phase never contacts Bedrock — -the dry-run mode is the production default until an operator opts in. - -## Configuration knobs - -- `PipelineOptions.summaries: boolean` — master enable (default - false). -- `PipelineOptions.maxSummariesPerRun` — default 0 (dry-run). Counts - `wouldHaveSummarized` without calling Bedrock. -- `PipelineOptions.summaryModel` — override the default model id. -- `SummarizeOptions.maxAttempts` (default 3) / `maxTokens` (default - 2048). -- AWS SDK credentials via default chain — expired SSO soft-fails to - `skippedReason: "no-credentials"`. - -## Gotchas - -- **Trust filter excludes non-SCIP repos.** A repo without any SCIP - indexer configured produces zero summaries because no symbol is - SCIP-confirmed. This is intentional: summaries over uncertain - edges would pollute the downstream retrieval vector. -- **Whitespace-only edits bust the cache.** `contentHash` is over - the raw span, not a normalized form. A reformatter run will - re-summarize every touched symbol. This is a deliberate trade — - normalization would require per-language logic and is not worth it - for a once-per-symbol call. -- **`signatureSummary` appears in both `SymbolSummaryRow` and - `SearchResult`.** The two are populated by different paths: the - summarize phase writes one, the MCP query layer post-joins the - other. Storage-layer `search()` never fills it directly. - -## Further reading - -- [Embeddings](/opencodehub/architecture/embeddings/) — where the - symbol-tier fused text lands. -- [SCIP reconciliation](/opencodehub/architecture/scip-reconciliation/) - — the trust filter source. -- `@opencodehub/summarizer` package README — the schema field - bounds in one page. diff --git a/packages/docs/src/content/docs/contributing/commit-conventions.md b/packages/docs/src/content/docs/contributing/commit-conventions.md index 05cbab63..de4617b8 100644 --- a/packages/docs/src/content/docs/contributing/commit-conventions.md +++ b/packages/docs/src/content/docs/contributing/commit-conventions.md @@ -80,7 +80,6 @@ Workspace-package scopes map 1:1 to `packages//`: | `scip-ingest` | `@opencodehub/scip-ingest` | | `search` | `@opencodehub/search` | | `storage` | `@opencodehub/storage` | -| `summarizer` | `@opencodehub/summarizer` | | `wiki` | `@opencodehub/wiki` | Meta-scopes cover cross-cutting changes: diff --git a/packages/docs/src/content/docs/contributing/release-process.md b/packages/docs/src/content/docs/contributing/release-process.md index c0e20065..096c224b 100644 --- a/packages/docs/src/content/docs/contributing/release-process.md +++ b/packages/docs/src/content/docs/contributing/release-process.md @@ -43,7 +43,7 @@ tagged as `root-vN.N.N`. The current versioned set covers the externally-consumable surface: `analysis`, `cli`, `core-types`, `embedder`, `ingestion`, `mcp`, `sarif`, `scanners`, `search`, `storage`. Auxiliary packages -(`scip-ingest`, `summarizer`, `frameworks`, `pack`, `policy`, `wiki`, +(`scip-ingest`, `frameworks`, `pack`, `policy`, `wiki`, `cobol-proleap`, `docs`) ride along with the monorepo version but do not publish independent tags — they are internal-only or have no external contract yet. They will start versioning once a public diff --git a/packages/docs/src/content/docs/guides/indexing-a-repo.md b/packages/docs/src/content/docs/guides/indexing-a-repo.md index a8163f31..35f9ea1b 100644 --- a/packages/docs/src/content/docs/guides/indexing-a-repo.md +++ b/packages/docs/src/content/docs/guides/indexing-a-repo.md @@ -89,7 +89,7 @@ Node's built-in `node:sqlite`: | Path | Purpose | |---|---| -| `store.sqlite` | The whole index (WAL mode) — symbols, edges, embeddings, the FTS5 search index, and the temporal tables (cochanges, symbol-summary cache). | +| `store.sqlite` | The whole index (WAL mode) — symbols, edges, embeddings, the FTS5 search index, and the temporal tables (cochanges). | | `store.sqlite-wal` / `store.sqlite-shm` | WAL companions present while a writer is open; collapse into `store.sqlite` at close. | | `meta.json` | Index metadata (graph hash, node counts, CLI version, toolchain pins, embedder modelId). | | `scan.sarif` | SARIF scan output when `codehub scan` has run. | @@ -114,17 +114,12 @@ in one command: Silent no-op otherwise. Force with `--coverage`; force off with `--no-coverage`. -Everything else — embeddings, summaries, skills — is opt-in. +Everything else — embeddings, skills — is opt-in. ## Opt-in flags - `--embeddings` — compute semantic vectors for queries by meaning. Requires `codehub setup --embeddings` first. -- `--summaries` / `--no-summaries` — LLM-generated symbol summaries - (default off — `codehub analyze` is fast, local, deterministic by - default; opt in with `--summaries` or `CODEHUB_BEDROCK_SUMMARIES=1`). - When enabled, the budget is capped by `--max-summaries`, default - `auto` = 10% of callables, hard cap 500. - `--skills` — generate Claude Code skills from the graph. - `--strict-detectors` — fail the build if a detector (DET-O-001) regresses. diff --git a/packages/docs/src/content/docs/mcp/tools.md b/packages/docs/src/content/docs/mcp/tools.md index 7fb3eeb1..aa24c966 100644 --- a/packages/docs/src/content/docs/mcp/tools.md +++ b/packages/docs/src/content/docs/mcp/tools.md @@ -73,7 +73,7 @@ The high-frequency tools. Most agent loops live here. | | | |---|---| -| **Use when** | You need a custom view of `store.sqlite` that no other tool exposes. Everything is directly SQL-queryable: `nodes`, `edges`, `embeddings`, `cochanges`, `symbol_summaries`, and `store_meta` (ADR 0019); reach kind-specific fields via SQLite JSON1, `payload->>'$.field'`. Read-only. 5-second timeout. | +| **Use when** | You need a custom view of `store.sqlite` that no other tool exposes. Everything is directly SQL-queryable: `nodes`, `edges`, `embeddings`, `cochanges`, and `store_meta` (ADR 0019); reach kind-specific fields via SQLite JSON1, `payload->>'$.field'`. Read-only. 5-second timeout. | | **Avoid when** | A typed tool (`context`, `impact`, `query`) already covers the question. The typed tools stay the high-level path; the `cypher` arg is reserved for community-fork graph adapters and is not supported by the default backend. | | **Inputs** | `query` (required), `repo?`, `repo_uri?` | | **Returns** | `{ rows: [...], row_count, next_steps }` | diff --git a/packages/docs/src/content/docs/reference/cli.md b/packages/docs/src/content/docs/reference/cli.md index 31291a20..723d8bf1 100644 --- a/packages/docs/src/content/docs/reference/cli.md +++ b/packages/docs/src/content/docs/reference/cli.md @@ -33,9 +33,6 @@ codehub analyze [path] | `--sbom` / `--no-sbom` | **on** | Emit `sbom.cyclonedx.json` + `sbom.spdx.json` from `Dependency` nodes. Use `--no-sbom` to suppress. | | `--scan` / `--no-scan` | **on** | Run Priority-1 scanners, write `.codehub/scan.sarif`, and ingest findings into the graph. Network-backed scanners (osv-scanner, grype, npm/pip audit) self-skip under `--offline`. Use `--no-scan` to suppress. | | `--coverage` / `--no-coverage` | **auto** | Overlay lcov / cobertura / jacoco / coverage.py reports onto `File` nodes. `auto` probes `coverage/lcov.info`, `lcov.info`, `coverage.xml`, `build/reports/jacoco/test/jacocoTestReport.xml`, `coverage.json` in that order and enables the phase when one exists (silent no-op otherwise). `--coverage` forces on and warns if nothing is found; `--no-coverage` forces off. | -| `--summaries` / `--no-summaries` | off | LLM symbol summaries (Bedrock). Opt in with `--summaries` or `CODEHUB_BEDROCK_SUMMARIES=1`; kill with `--no-summaries` or `CODEHUB_BEDROCK_DISABLED=1`. | -| `--max-summaries ` | `auto` (10% of SCIP-confirmed callables, cap 500) | Summary budget. | -| `--summary-model ` | — | Override the Bedrock summary model id. | | `--skills` | off | Emit one `SKILL.md` per Community (≥5 symbols) under `.codehub/skills/`. | | `--strict-detectors` | off | Drop heuristic-only matches from route / ORM detectors (DET-O-001). | | `--allow-build-scripts ` | — | Comma-separated build-script opt-ins (e.g. `proleap` for the JVM COBOL deep-parse). | @@ -413,9 +410,9 @@ codehub bench ## `wiki` -Emit a Markdown wiki for the repo under `--output`. Deterministic by -default; `--llm` routes top-ranked modules through the summarizer for -narrative prose. +Emit a Markdown wiki for the repo under `--output`. Deterministic: +every page is rendered from the graph, so the same commit produces the +same wiki. ```bash title="usage" codehub wiki --output @@ -426,10 +423,7 @@ codehub wiki --output | `--output ` | — | **Required.** Target directory for rendered pages. | | `--repo ` | current | Target repo. | | `--json` | off | Emit a JSON summary on stdout. | -| `--offline` | off | Assert no network access (incompatible with `--llm`). | -| `--llm` | off | Route top-ranked modules through the summarizer. | -| `--max-llm-calls ` | 0 (dry-run) | LLM call budget when `--llm` is set. | -| `--llm-model ` | — | Override the Bedrock summary model id. | +| `--offline` | off | Assert no network access. | ```bash title="example" codehub wiki --output docs/wiki @@ -468,7 +462,7 @@ codehub augment Read-only SQL against the single-file store, `/.codehub/store.sqlite` (WAL, via Node's built-in `node:sqlite`, ADR 0019). Every table lives in this one file and is directly queryable: `nodes`, `edges`, `embeddings`, -`cochanges`, `symbol_summaries`, and `store_meta`. Reach kind-specific +`cochanges`, and `store_meta`. Reach kind-specific fields on `nodes` via SQLite JSON1, e.g. `payload->>'$.field'`. The guard rejects any mutation. 5-second timeout by default. diff --git a/packages/docs/src/content/docs/reference/configuration.md b/packages/docs/src/content/docs/reference/configuration.md index b9210ae3..ce19d76a 100644 --- a/packages/docs/src/content/docs/reference/configuration.md +++ b/packages/docs/src/content/docs/reference/configuration.md @@ -62,8 +62,6 @@ When none of the above are set, the local ONNX backend |---|---| | `CODEHUB_DISABLE_SCIP` | Set to `1` to make the `scip-index` ingestion phase a no-op. Heuristic edges still flow. | | `CODEHUB_ALLOW_BUILD_SCRIPTS` | Set to `1` to allow SCIP indexers that require a build (Rust, Java) to run. Off by default for clean-room safety. | -| `CODEHUB_BEDROCK_SUMMARIES` | Set to `1` to opt the LLM summarize phase in. Equivalent to `--summaries`. Off by default — `codehub analyze` runs fast, local, deterministic phases only. | -| `CODEHUB_BEDROCK_DISABLED` | Set to `1` to force-disable the LLM summarize phase. Equivalent to `--no-summaries`. Wins over `CODEHUB_BEDROCK_SUMMARIES=1` and `--summaries`. | | `NO_COLOR` | Standard convention; disables colored console output. | ## On-disk layout: `.codehub/` @@ -73,7 +71,7 @@ layout is fixed: one `store.sqlite` file backs the whole index. | Path | Purpose | |---|---| -| `store.sqlite` | The whole index (WAL mode, `node:sqlite`) — nodes, edges, embeddings, the FTS5 search index, and the temporal tables (cochanges, symbol-summary cache). | +| `store.sqlite` | The whole index (WAL mode, `node:sqlite`) — nodes, edges, embeddings, the FTS5 search index, and the temporal tables (cochanges). | | `store.sqlite-wal` / `store.sqlite-shm` | WAL companions present while a writer is open; collapse into `store.sqlite` at close. | | `meta.json` | Index metadata: graph hash, node counts, CLI version, embedder model id. | | `scan.sarif` | SARIF output from `codehub scan`. | diff --git a/packages/docs/src/content/docs/reference/error-codes.md b/packages/docs/src/content/docs/reference/error-codes.md index 795e7d79..0f7aa968 100644 --- a/packages/docs/src/content/docs/reference/error-codes.md +++ b/packages/docs/src/content/docs/reference/error-codes.md @@ -23,7 +23,7 @@ The canonical list lives at | `NOT_FOUND` | The target symbol, repo, or group does not exist. | Confirm the name; run `codehub list` for repos. | | `DB_ERROR` | The store returned an error during the query. | Check `codehub doctor` (it runs a `node:sqlite` import + WAL round-trip); inspect `.codehub/store.sqlite`. | | `SCHEMA_MISMATCH` | The index was produced by a different CLI version with an incompatible schema. | `codehub analyze --force` to rebuild. | -| `RATE_LIMITED` | A downstream service (embedder, summariser) rate-limited the request. | Retry with backoff; reduce concurrency. | +| `RATE_LIMITED` | A downstream service (embedder) rate-limited the request. | Retry with backoff; reduce concurrency. | | `INTERNAL` | Catch-all for unhandled exceptions reaching the tool boundary. | File an issue with the error `message`. | | `NO_INDEX` | The repo has no `.codehub/` directory. | `codehub analyze `. | | `AMBIGUOUS_REPO` | More than one repo is indexed and neither `repo` nor `repo_uri` was supplied. | Retry with one of the `choices[].repo_uri` values. | diff --git a/packages/docs/src/content/docs/start-here/what-is-opencodehub.md b/packages/docs/src/content/docs/start-here/what-is-opencodehub.md index ffbccf89..80fcb741 100644 --- a/packages/docs/src/content/docs/start-here/what-is-opencodehub.md +++ b/packages/docs/src/content/docs/start-here/what-is-opencodehub.md @@ -30,7 +30,7 @@ plus SCIP indexers for TypeScript, Python, Go, Rust, and Java), resolves imports and inheritance, and materialises a **typed symbol graph**. That graph is stored in one `store.sqlite` file via Node's built-in `node:sqlite`, which also carries the temporal tables -(cochanges and the symbol-summary cache). There is no backend toggle and +(cochanges). There is no backend toggle and no native storage binding: ADR 0019 removed both `@ladybugdb/core` and `@duckdb/node-api`, so the whole index is one file. BM25 lexical search and filter-aware vector search sit on the same store. A local MCP diff --git a/packages/ingestion/package.json b/packages/ingestion/package.json index 7edde384..fb6fde91 100644 --- a/packages/ingestion/package.json +++ b/packages/ingestion/package.json @@ -41,7 +41,6 @@ }, "dependencies": { "@apidevtools/swagger-parser": "12.1.0", - "@aws-sdk/client-bedrock-runtime": "3.1079.0", "@cyclonedx/cyclonedx-library": "10.1.0", "@iarna/toml": "2.2.5", "@opencodehub/analysis": "workspace:*", @@ -50,7 +49,6 @@ "@opencodehub/frameworks": "workspace:*", "@opencodehub/scip-ingest": "workspace:*", "@opencodehub/storage": "workspace:*", - "@opencodehub/summarizer": "workspace:*", "fast-xml-parser": "5.9.3", "graphology": "0.26.0", "graphology-dag": "0.4.1", diff --git a/packages/ingestion/src/pipeline/index.ts b/packages/ingestion/src/pipeline/index.ts index c7a5e5f0..2b08da89 100644 --- a/packages/ingestion/src/pipeline/index.ts +++ b/packages/ingestion/src/pipeline/index.ts @@ -90,17 +90,6 @@ export type { ScannedFile, ScanOutput } from "./phases/scan.js"; export { SCAN_PHASE_NAME, scanPhase } from "./phases/scan.js"; export type { StructureOutput } from "./phases/structure.js"; export { STRUCTURE_PHASE_NAME, structurePhase } from "./phases/structure.js"; -export type { - SummarizePhaseOutput, - SummarizerAdapter, - SummaryCacheAdapter, -} from "./phases/summarize.js"; -export { - __setSummarizePhaseTestHooks__, - SUMMARIZE_PHASE_NAME, - SUMMARY_CACHE_OPTIONS_KEY, - summarizePhase, -} from "./phases/summarize.js"; export type { TemporalCommitManifest, TemporalOptions, diff --git a/packages/ingestion/src/pipeline/orchestrator.test.ts b/packages/ingestion/src/pipeline/orchestrator.test.ts index 264c5363..571095b1 100644 --- a/packages/ingestion/src/pipeline/orchestrator.test.ts +++ b/packages/ingestion/src/pipeline/orchestrator.test.ts @@ -69,7 +69,6 @@ describe("runIngestion (end-to-end)", () => { "risk-snapshot", "scip-index", "confidence-demote", - "summarize", "embeddings", ], ); @@ -130,9 +129,6 @@ describe("runIngestion option normalization", () => { sbom: true, reproducibleSbom: false, coverage: true, - summaries: true, - maxSummariesPerRun: 7, - summaryModel: "model-x", strictDetectors: true, allowBuildScripts: ["proleap"], }; @@ -174,7 +170,6 @@ describe("runIngestion option normalization", () => { skipGit: true, phases: [probe], onProgress: () => {}, - summaryCacheAdapter: { lookup: () => Promise.resolve(undefined) }, embeddingHashCacheAdapter: { list: () => Promise.resolve(new Map()) }, }); diff --git a/packages/ingestion/src/pipeline/orchestrator.ts b/packages/ingestion/src/pipeline/orchestrator.ts index 637eb41b..d0c90368 100644 --- a/packages/ingestion/src/pipeline/orchestrator.ts +++ b/packages/ingestion/src/pipeline/orchestrator.ts @@ -24,12 +24,6 @@ import { } from "./phases/incremental-scope.js"; import { PARSE_PHASE_NAME, type ParseOutput } from "./phases/parse.js"; import { SCAN_PHASE_NAME, type ScanOutput } from "./phases/scan.js"; -import { - SUMMARIZE_PHASE_NAME, - SUMMARY_CACHE_OPTIONS_KEY, - type SummarizePhaseOutput, - type SummaryCacheAdapter, -} from "./phases/summarize.js"; import { runPipeline } from "./runner.js"; import type { PhaseResult, @@ -89,13 +83,6 @@ export interface RunPipelineResult { * after `bulkLoad`. Absent only when a custom phase set omits cochange. */ readonly cochange?: CochangeOutput; - /** - * Output of the `summarize` phase. Carries any fresh `SymbolSummaryRow` - * entries the CLI persists into the `symbol_summaries` table. Absent only - * when a custom phase set omits summarize; the default phase set always - * runs it (the phase internally short-circuits when gated off). - */ - readonly summarize?: SummarizePhaseOutput; /** * Set when the parse phase routed a non-trivial number of files to * tree-sitter grammars but extracted ZERO code symbols — the signature of a @@ -131,15 +118,6 @@ export function shouldTripZeroSymbolGuard( export interface RunIngestionOptions extends PipelineOptions { readonly phases?: readonly PipelinePhase[]; readonly onProgress?: (ev: ProgressEvent) => void; - /** - * Optional adapter the summarize phase probes before issuing work. - * Production wires this to the SQLite store's `lookupSymbolSummary` - * implementation so re-indexes become free when source hasn't drifted. - * Tests inject an in-memory fake. Absent by default — the phase degrades - * to "every candidate is a miss" which is still correct, just more - * expensive. - */ - readonly summaryCacheAdapter?: SummaryCacheAdapter; /** * Optional adapter the embeddings phase probes before issuing embedder * calls. Production wires this to the SQLite store's @@ -161,18 +139,10 @@ export async function runIngestion( ): Promise { const phases = options.phases ?? DEFAULT_PHASES; const normalizedOptions: PipelineOptions = stripPhaseKeys(options); - // Attach the optional summary-cache adapter onto the options bag via a - // well-known key. The `summarize` phase reads it back via an unchecked - // cast; keeping the attach-point here (rather than inside stripPhaseKeys) - // keeps the typed fields in stripPhaseKeys honest. - if (options.summaryCacheAdapter !== undefined) { - (normalizedOptions as unknown as Record)[SUMMARY_CACHE_OPTIONS_KEY] = - options.summaryCacheAdapter; - } - // Same trick for the embeddings phase's content-hash cache. - // Attached here (not in stripPhaseKeys) so the typed option shape stays - // minimal — this is a well-known extension point, not a first-class - // `PipelineOptions` field. + // Attach the embeddings phase's content-hash cache adapter onto the + // options bag via a well-known key. Attached here (not in stripPhaseKeys) + // so the typed option shape stays minimal: this is a well-known extension + // point, not a first-class `PipelineOptions` field. if (options.embeddingHashCacheAdapter !== undefined) { (normalizedOptions as unknown as Record)[EMBEDDING_HASH_CACHE_OPTIONS_KEY] = options.embeddingHashCacheAdapter; @@ -224,9 +194,6 @@ export async function runIngestion( const cochange = results.find((r) => r.name === COCHANGE_PHASE_NAME)?.output as | CochangeOutput | undefined; - const summarize = results.find((r) => r.name === SUMMARIZE_PHASE_NAME)?.output as - | SummarizePhaseOutput - | undefined; const parseCache = parse !== undefined @@ -299,7 +266,6 @@ export async function runIngestion( ...(incrementalScope !== undefined ? { incrementalScope } : {}), ...(scan !== undefined ? { scan } : {}), ...(cochange !== undefined ? { cochange } : {}), - ...(summarize !== undefined ? { summarize } : {}), ...(zeroSymbolGuardTripped ? { zeroSymbolGuardTripped: true } : {}), }; } @@ -318,7 +284,6 @@ function stripPhaseKeys(options: RunIngestionOptions): PipelineOptions { const { phases: _phases, onProgress: _onProgress, - summaryCacheAdapter: _summaryCacheAdapter, embeddingHashCacheAdapter: _embeddingHashCacheAdapter, ...pipelineOptions } = options; diff --git a/packages/ingestion/src/pipeline/phases/default-set.ts b/packages/ingestion/src/pipeline/phases/default-set.ts index c38e1143..ed03e79e 100644 --- a/packages/ingestion/src/pipeline/phases/default-set.ts +++ b/packages/ingestion/src/pipeline/phases/default-set.ts @@ -50,7 +50,6 @@ import { sbomPhase } from "./sbom.js"; import { scanPhase } from "./scan.js"; import { scipIndexPhase } from "./scip-index.js"; import { structurePhase } from "./structure.js"; -import { summarizePhase } from "./summarize.js"; import { temporalPhase } from "./temporal.js"; import { toolsPhase } from "./tools.js"; @@ -136,13 +135,6 @@ export const DEFAULT_PHASES: readonly PipelinePhase[] = [ // findings-histogram snapshot under `.codehub/history/` for trend analysis. // Rotation keeps the last 100 snapshots. riskSnapshotPhase, - // `summarize` runs after every LSP phase (via its dep on - // `confidence-demote`) so trust filtering observes finalised edge - // provenance, and BEFORE `embeddings` so a future follow-up can embed - // summary text alongside the existing signature/description vectors. - // The phase is a silent no-op unless `options.summaries === true`, and - // it is a hard no-op whenever `options.offline === true`. - summarizePhase, // `embeddings` depends on `annotate` so it observes the final graph. The // phase is a silent no-op unless `options.embeddings === true`. Keeping it // at the tail means downstream hashing can key embeddings to graph state. diff --git a/packages/ingestion/src/pipeline/phases/embeddings.test.ts b/packages/ingestion/src/pipeline/phases/embeddings.test.ts index 10c90005..e6e3e1ed 100644 --- a/packages/ingestion/src/pipeline/phases/embeddings.test.ts +++ b/packages/ingestion/src/pipeline/phases/embeddings.test.ts @@ -22,12 +22,10 @@ import { join } from "node:path"; import { after, before, describe, it } from "node:test"; import { type GraphNode, KnowledgeGraph, makeNodeId } from "@opencodehub/core-types"; -import type { SymbolSummaryRow } from "@opencodehub/storage"; import type { PipelineContext, PipelineOptions, ProgressEvent } from "../types.js"; import { embeddingsPhase } from "./embeddings.js"; import { SCAN_PHASE_NAME } from "./scan.js"; -import { SUMMARIZE_PHASE_NAME } from "./summarize.js"; function ctxFor( graph: KnowledgeGraph, @@ -120,10 +118,12 @@ describe("embeddingsPhase", () => { assert.equal(a.embeddingsHash, b.embeddingsHash); }); - it("is registered with annotate/summarize/communities as its dependencies", () => { + it("is registered with annotate/communities/confidence-demote as its dependencies", () => { // `communities` was added in P03 so the community tier observes the - // emitted Community nodes + MEMBER_OF edges. - assert.deepEqual([...embeddingsPhase.deps], ["annotate", "summarize", "communities"]); + // emitted Community nodes + MEMBER_OF edges. `confidence-demote` pins the + // phase after SCIP reconciliation so embeddings observe the final graph + // (this dep formerly arrived transitively via the removed summarize phase). + assert.deepEqual([...embeddingsPhase.deps], ["annotate", "communities", "confidence-demote"]); assert.equal(embeddingsPhase.name, "embeddings"); }); @@ -346,33 +346,6 @@ describe("embeddingsPhase — hierarchical tiers (P03)", () => { for (const r of out.rows) assert.equal(r.granularity ?? "symbol", "symbol"); }); - it("fuses summary text into the symbol-tier input when a summary is present", async () => { - const { repoPath, relPath } = makeRepo(); - const graph = buildGraph(relPath); - const fnId = makeNodeId("Function", relPath, "hello"); - const summary: SymbolSummaryRow = { - nodeId: fnId, - contentHash: "h", - promptVersion: "v1", - modelId: "m", - summaryText: "Returns the meaning of life.", - signatureSummary: "hello() -> number", - returnsTypeSummary: "number", - createdAt: new Date().toISOString(), - }; - const ctx: PipelineContext = { - repoPath, - options: { - embeddings: true, - embeddingsGranularity: ["symbol"], - } as unknown as PipelineOptions, - graph, - phaseOutputs: new Map([[SUMMARIZE_PHASE_NAME, { rows: [summary] }]]), - }; - const out = await embeddingsPhase.run(ctx, new Map()); - assert.equal(out.summaryFused, true, "phase flags that summaries fused"); - }); - it("embeds identical inputs deterministically (stable embeddingsHash)", async () => { const { repoPath, relPath } = makeRepo(); const ctxA: PipelineContext = { diff --git a/packages/ingestion/src/pipeline/phases/embeddings.ts b/packages/ingestion/src/pipeline/phases/embeddings.ts index df5cfc72..1c22c60c 100644 --- a/packages/ingestion/src/pipeline/phases/embeddings.ts +++ b/packages/ingestion/src/pipeline/phases/embeddings.ts @@ -4,10 +4,8 @@ * array of `EmbeddingRow`s the CLI upserts into the SQLite store. * * Granularity tiers (P03): - * - `"symbol"` — one vector per callable/declaration symbol. When a - * `SymbolSummaryRow` exists for the node the text is fused - * `signature\nsummary\nbody`; otherwise we fall back to the raw - * signature/description pair. + * - `"symbol"` — one vector per callable/declaration symbol. The text is + * the raw `signature\ndescription` pair. * - `"file"` — one vector per scanned file. Coarse tier used by the * `--zoom` retrieval path. Files larger than ~8192 tokens are * truncated to the first `N` chars so a single outlier never blows @@ -46,9 +44,9 @@ import type { EmbeddingGranularity, EmbeddingRow } from "@opencodehub/storage"; import type { PipelineContext, PipelinePhase } from "../types.js"; import { ANNOTATE_PHASE_NAME } from "./annotate.js"; import { COMMUNITIES_PHASE_NAME } from "./communities.js"; +import { CONFIDENCE_DEMOTE_PHASE_NAME } from "./confidence-demote.js"; import { openOnnxEmbedderPool } from "./embedder-pool.js"; import { SCAN_PHASE_NAME, type ScanOutput } from "./scan.js"; -import { SUMMARIZE_PHASE_NAME, type SummarizePhaseOutput } from "./summarize.js"; /** * Default batch size for cross-node inference. Picked so a single batch @@ -126,14 +124,6 @@ const EMBEDDABLE_KINDS: ReadonlySet = new Set([ "Interface", ]); -/** - * Max body chars to fuse into a summary-fused symbol embedding. Keeps the - * fused text well under the embedder's ~500-token window even after - * signature + summary join. The chunker downstream still wraps any - * overflow, so this cap is a belt-and-braces guard. - */ -const SYMBOL_BODY_CHAR_CAP = 1200; - /** * File-level truncation cap. 8192 tokens × ~4 chars/token on code * (conservative WordPiece approximation) ≈ 32_768 chars. Rarely hit in @@ -208,13 +198,6 @@ export interface EmbedderPhaseOutput { * null-checks. */ readonly byGranularity: Readonly>; - /** - * Whether the symbol tier fused `signature + summary + body` for at - * least one row. Diagnostic flag consumers can surface — summaries - * drive the biggest quality lift, so CLI output calls it out when it - * actually kicked in. - */ - readonly summaryFused: boolean; /** * Chunks short-circuited by the content-hash skip. Counts * chunks whose `(granularity, node_id, chunk_index)` had a prior row @@ -235,44 +218,21 @@ function emptyOutput(): EmbedderPhaseOutput { rows: [], ranEmbedder: false, byGranularity: { symbol: 0, file: 0, community: 0 }, - summaryFused: false, chunksSkipped: 0, }; } /** - * Fuse text for the symbol tier. When a summary is present the layout is - * `signature\nsummary\nbody`; otherwise we fall back to - * `signature\ndescription`. Body is length-capped so a long function's - * source never overwhelms the 500-token embedder window even before the - * chunker runs. + * Fuse text for the symbol tier: `signature\ndescription` (falling back to + * the symbol name when no signature is present). */ -function symbolText( - node: { - readonly name: string; - readonly signature?: string; - readonly description?: string; - }, - summary: { readonly summaryText: string; readonly signatureSummary?: string } | undefined, - body: string | undefined, -): string { +function symbolText(node: { + readonly name: string; + readonly signature?: string; + readonly description?: string; +}): string { const head = node.signature !== undefined && node.signature.length > 0 ? node.signature : node.name; - if (summary !== undefined) { - const sigLine = - summary.signatureSummary !== undefined && summary.signatureSummary.length > 0 - ? summary.signatureSummary - : head; - const bodyPiece = - body !== undefined && body.length > 0 - ? body.length > SYMBOL_BODY_CHAR_CAP - ? body.slice(0, SYMBOL_BODY_CHAR_CAP) - : body - : ""; - const parts: string[] = [sigLine, summary.summaryText]; - if (bodyPiece.length > 0) parts.push(bodyPiece); - return parts.join("\n"); - } const tail = node.description ?? ""; return tail.length > 0 ? `${head}\n${tail}` : head; } @@ -450,25 +410,6 @@ function normalizeGranularities( * file condition. Tests patch readFileSync via module state; the fallback * is `fs.readFileSync`. */ -function readSourceSpan( - repoPath: string, - filePath: string, - startLine: number, - endLine: number, -): string | undefined { - try { - const abs = path.isAbsolute(filePath) ? filePath : path.join(repoPath, filePath); - const all = readFileSync(abs, "utf-8"); - const lines = all.split(/\r?\n/); - const from = Math.max(0, startLine - 1); - const to = Math.min(lines.length, endLine); - if (to <= from) return undefined; - return lines.slice(from, to).join("\n"); - } catch { - return undefined; - } -} - function readFileWhole(repoPath: string, relPath: string): string | undefined { try { const abs = path.isAbsolute(relPath) ? relPath : path.join(repoPath, relPath); @@ -559,7 +500,6 @@ async function runEmbeddings(ctx: PipelineContext): Promise let skipped = 0; let chunksTotal = 0; let chunksSkipped = 0; - let summaryFused = false; const byGranularity: Record = { symbol: 0, file: 0, @@ -585,27 +525,6 @@ async function runEmbeddings(ctx: PipelineContext): Promise // meaning; 500 mirrors the long-standing chunking granularity. const maxUserTokens = 500; - // Lookup summaries by nodeId (the newest `createdAt` wins when multiple - // prompt versions coexist). Summaries live in the `summarize` phase's - // output; absent phase / disabled flag → empty map, which simply means - // raw-body fallback. - const summarizeOut = ctx.phaseOutputs.get(SUMMARIZE_PHASE_NAME) as - | SummarizePhaseOutput - | undefined; - const summaryByNode = new Map< - string, - { readonly summaryText: string; readonly signatureSummary?: string } - >(); - if (summarizeOut !== undefined && summarizeOut.rows.length > 0) { - for (const s of summarizeOut.rows) { - const entry: { summaryText: string; signatureSummary?: string } = { - summaryText: s.summaryText, - }; - if (s.signatureSummary !== undefined) entry.signatureSummary = s.signatureSummary; - summaryByNode.set(s.nodeId, entry); - } - } - // Job-collection phase. Walk all requested tiers in canonical order // (symbol → file → community) and accumulate one `EmbedJob` per chunk // we'd like to embed. Each job knows how to emit its row once a @@ -631,22 +550,11 @@ async function runEmbeddings(ctx: PipelineContext): Promise eligible.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); for (const node of eligible) { - const summary = summaryByNode.get(node.id); - let body: string | undefined; - if ( - summary !== undefined && - node.startLine !== undefined && - node.endLine !== undefined && - node.filePath.length > 0 - ) { - body = readSourceSpan(ctx.repoPath, node.filePath, node.startLine, node.endLine); - } - const text = symbolText(node, summary, body); + const text = symbolText(node); if (text.length === 0) { skipped += 1; continue; } - if (summary !== undefined) summaryFused = true; const chunks = splitIntoChunks(text, maxUserTokens); if (chunks.length === 0) { skipped += 1; @@ -857,7 +765,6 @@ async function runEmbeddings(ctx: PipelineContext): Promise rows, ranEmbedder: true, byGranularity, - summaryFused, chunksSkipped, }; } finally { @@ -871,7 +778,12 @@ export const embeddingsPhase: PipelinePhase = { // `communities` so the community tier sees the emitted Community nodes // and MEMBER_OF edges; depend on `scan` transitively via `annotate` // (annotate → structure → scan) for the file tier. - deps: [ANNOTATE_PHASE_NAME, SUMMARIZE_PHASE_NAME, COMMUNITIES_PHASE_NAME], + // Depend on confidence-demote (in addition to annotate + communities) so + // embeddings runs after SCIP reconciliation has finalised edge provenance — + // it must observe the final graph. This dep formerly arrived transitively + // via the summarize phase (now removed); pinning it directly preserves the + // canonical phase ordering (embeddings last). + deps: [ANNOTATE_PHASE_NAME, COMMUNITIES_PHASE_NAME, CONFIDENCE_DEMOTE_PHASE_NAME], async run(ctx): Promise { return runEmbeddings(ctx); }, diff --git a/packages/ingestion/src/pipeline/phases/summarize.test.ts b/packages/ingestion/src/pipeline/phases/summarize.test.ts deleted file mode 100644 index 9a76143d..00000000 --- a/packages/ingestion/src/pipeline/phases/summarize.test.ts +++ /dev/null @@ -1,725 +0,0 @@ -/** - * Unit tests for the `summarize` ingestion phase. - * - * All tests substitute a fake `SummarizerAdapter` via the phase's test - * hooks, so Bedrock is never contacted. The fake source reader avoids - * touching the filesystem — we feed in inline strings keyed by file path. - */ - -import { strict as assert } from "node:assert"; -import { afterEach, describe, it } from "node:test"; -import { - KnowledgeGraph, - makeNodeId, - type NodeId, - type RelationType, -} from "@opencodehub/core-types"; -import type { SymbolSummaryRow } from "@opencodehub/storage"; -import type { SummarizeInput, SummarizerResult } from "@opencodehub/summarizer"; -import type { PipelineContext } from "../types.js"; -import { - __setSummarizePhaseTestHooks__, - SUMMARIZE_PHASE_NAME, - SUMMARY_CACHE_OPTIONS_KEY, - type SummarizerAdapter, - summarizePhase, -} from "./summarize.js"; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -interface HarnessOptions { - readonly summaries?: boolean; - readonly offline?: boolean; - readonly maxSummariesPerRun?: number; - readonly summaryCache?: { - readonly lookup: ( - nodeId: string, - contentHash: string, - promptVersion: string, - ) => Promise; - }; -} - -function buildHarnessContext(graph: KnowledgeGraph, opts: HarnessOptions = {}): PipelineContext { - const options: Record = {}; - if (opts.summaries !== undefined) options["summaries"] = opts.summaries; - if (opts.offline !== undefined) options["offline"] = opts.offline; - if (opts.maxSummariesPerRun !== undefined) { - options["maxSummariesPerRun"] = opts.maxSummariesPerRun; - } - if (opts.summaryCache !== undefined) { - options[SUMMARY_CACHE_OPTIONS_KEY] = opts.summaryCache; - } - return { - repoPath: "/unused", - options: options as PipelineContext["options"], - graph, - phaseOutputs: new Map(), - }; -} - -/** - * Helper that adds an LSP-confirmed edge (confidence 1.0, `pyright@` - * reason) so the trust filter lets the node through. - */ -function addConfirmedEdge( - graph: KnowledgeGraph, - from: NodeId, - to: NodeId, - type: RelationType = "CALLS", -): void { - graph.addEdge({ - from, - to, - type, - confidence: 1.0, - reason: "scip:scip-python@0.6.6", - }); -} - -/** - * Build the smallest well-formed `SummarizerResult` the phase needs. - */ -function okResult(purpose: string, typeSummary: string): SummarizerResult { - return { - summary: { - purpose, - inputs: [{ name: "x", type: "int", description: "the thing to process deeply" }], - returns: { - type: "int", - type_summary: typeSummary, - details: "The computed value after processing.", - }, - side_effects: [], - invariants: null, - citations: [ - { field_name: "purpose", line_start: 1, line_end: 2 }, - { field_name: "returns", line_start: 3, line_end: 4 }, - ], - }, - attempts: 1, - usageByAttempt: [{ inputTokens: 100, outputTokens: 50, cacheRead: 0, cacheWrite: 0 }], - wallClockMs: 10, - validationFailures: [], - }; -} - -/** - * Fake summarizer that records each call so tests can assert invocations. - */ -function makeFakeSummarizer(resultForInput: (input: SummarizeInput) => SummarizerResult): { - adapter: SummarizerAdapter; - calls: SummarizeInput[]; -} { - const calls: SummarizeInput[] = []; - const adapter: SummarizerAdapter = { - summarize: async (input) => { - calls.push(input); - return resultForInput(input); - }, - }; - return { adapter, calls }; -} - -function makeFixedSourceReader(bySpan: ReadonlyMap): (absPath: string) => string { - return (absPath: string) => { - // The phase builds the lookup path with `path.join(repoPath, filePath)`, - // which emits backslashes on Windows, while the fixture map is keyed with - // POSIX `/`. Normalize the separator so the lookup is platform-agnostic. - const key = absPath.replace(/\\/g, "/"); - const hit = bySpan.get(key); - if (hit === undefined) { - throw new Error(`no fixture source for ${absPath}`); - } - return hit; - }; -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -afterEach(() => { - __setSummarizePhaseTestHooks__(undefined); -}); - -describe("summarizePhase — gating", () => { - it("skips with reason=offline when ctx.options.offline is true", async () => { - const graph = new KnowledgeGraph(); - const ctx = buildHarnessContext(graph, { summaries: true, offline: true }); - - const { adapter, calls } = makeFakeSummarizer(() => okResult("x", "n int")); - __setSummarizePhaseTestHooks__({ summarizerFactory: () => adapter }); - - const out = await summarizePhase.run(ctx, new Map()); - assert.equal(out.enabled, false); - assert.equal(out.skippedReason, "offline"); - assert.equal(out.summarized, 0); - assert.equal(out.wouldHaveSummarized, 0); - assert.equal(calls.length, 0); - }); - - it("skips with reason=not-enabled when summaries flag is false", async () => { - const graph = new KnowledgeGraph(); - const ctx = buildHarnessContext(graph, { summaries: false }); - - const { adapter, calls } = makeFakeSummarizer(() => okResult("x", "n int")); - __setSummarizePhaseTestHooks__({ summarizerFactory: () => adapter }); - - const out = await summarizePhase.run(ctx, new Map()); - assert.equal(out.enabled, false); - assert.equal(out.skippedReason, "not-enabled"); - assert.equal(calls.length, 0); - }); - - it("records a stable promptVersion on skip outputs", async () => { - const graph = new KnowledgeGraph(); - const ctx = buildHarnessContext(graph, { offline: true }); - const out = await summarizePhase.run(ctx, new Map()); - assert.equal(typeof out.promptVersion, "string"); - assert.ok(out.promptVersion.length > 0); - }); -}); - -describe("summarizePhase — dry run (maxSummariesPerRun=0)", () => { - it("reports wouldHaveSummarized for every eligible symbol without calling Bedrock", async () => { - const graph = new KnowledgeGraph(); - const funcId = makeNodeId("Function", "src/a.py", "alpha") as NodeId; - const callerId = makeNodeId("Function", "src/b.py", "driver") as NodeId; - graph.addNode({ - id: funcId, - kind: "Function", - name: "alpha", - filePath: "src/a.py", - startLine: 1, - endLine: 3, - }); - graph.addNode({ - id: callerId, - kind: "Function", - name: "driver", - filePath: "src/b.py", - startLine: 1, - endLine: 2, - }); - addConfirmedEdge(graph, callerId, funcId); - - const sourceMap = new Map([ - ["/unused/src/a.py", "def alpha(x):\n return x\n # tail\n"], - ["/unused/src/b.py", "def driver():\n alpha(1)\n"], - ]); - const { adapter, calls } = makeFakeSummarizer(() => okResult("x", "n int")); - __setSummarizePhaseTestHooks__({ - summarizerFactory: () => adapter, - sourceReader: makeFixedSourceReader(sourceMap), - }); - - const ctx = buildHarnessContext(graph, { summaries: true, maxSummariesPerRun: 0 }); - const out = await summarizePhase.run(ctx, new Map()); - - assert.equal(out.enabled, true); - assert.equal(out.considered, 2); // alpha (Function) + driver (Function) - // driver has an outgoing LSP edge so it's also confirmed. - assert.equal(out.skippedUnconfirmed, 0); - assert.equal(out.wouldHaveSummarized, 2); - assert.equal(out.summarized, 0); - assert.equal(calls.length, 0, "Bedrock must not be called in dry-run mode"); - assert.equal(out.rows.length, 0); - }); -}); - -describe("summarizePhase — live summarize with cap", () => { - it("calls the summarizer for each batched candidate and returns rows", async () => { - const graph = new KnowledgeGraph(); - const funcId = makeNodeId("Function", "src/a.py", "alpha") as NodeId; - const callerId = makeNodeId("Function", "src/b.py", "driver") as NodeId; - graph.addNode({ - id: funcId, - kind: "Function", - name: "alpha", - filePath: "src/a.py", - startLine: 1, - endLine: 4, - }); - graph.addNode({ - id: callerId, - kind: "Function", - name: "driver", - filePath: "src/b.py", - startLine: 1, - endLine: 4, - }); - addConfirmedEdge(graph, callerId, funcId); - - const sourceMap = new Map([ - ["/unused/src/a.py", "def alpha(x):\n return x\n # tail\n # tail\n"], - ["/unused/src/b.py", "def driver():\n alpha(1)\n return 0\n # tail\n"], - ]); - const { adapter, calls } = makeFakeSummarizer((input) => - okResult(`purpose for ${input.filePath}`, `gist for ${input.filePath}`), - ); - __setSummarizePhaseTestHooks__({ - summarizerFactory: () => adapter, - sourceReader: makeFixedSourceReader(sourceMap), - now: () => new Date("2026-04-22T12:00:00.000Z"), - }); - - const ctx = buildHarnessContext(graph, { summaries: true, maxSummariesPerRun: 10 }); - const out = await summarizePhase.run(ctx, new Map()); - - assert.equal(out.enabled, true); - assert.equal(out.summarized, 2); - assert.equal(out.failed, 0); - assert.equal(out.wouldHaveSummarized, 0); - assert.equal(out.rows.length, 2); - assert.equal(calls.length, 2); - // Rows carry deterministic metadata from the fake summarizer. - for (const row of out.rows) { - assert.equal(row.promptVersion, out.promptVersion); - assert.equal(row.createdAt, "2026-04-22T12:00:00.000Z"); - assert.ok(row.summaryText.startsWith("purpose for ")); - assert.ok((row.returnsTypeSummary ?? "").startsWith("gist for ")); - // structured_json carries the validated fields the flat columns drop: - // per-input descriptions, returns.details, and line-range citations. - // It must be present (okResult has inputs + details + citations) and - // be canonical (sorted keys) so the temporal-store content is stable. - assert.ok(row.structuredJson !== undefined, "structuredJson is persisted"); - const parsed = JSON.parse(row.structuredJson) as Record; - assert.deepEqual(Object.keys(parsed), [ - "citations", - "inputs", - "invariants", - "returns", - "side_effects", - ]); - assert.equal( - (parsed["inputs"] as { description: string }[])[0]?.description, - "the thing to process deeply", - ); - assert.equal( - (parsed["returns"] as { details: string }).details, - "The computed value after processing.", - ); - assert.equal((parsed["citations"] as unknown[]).length, 2); - } - }); -}); - -describe("summarizePhase — trust filter", () => { - it("skips symbols without an LSP-confirmed edge", async () => { - const graph = new KnowledgeGraph(); - const confirmedId = makeNodeId("Function", "src/a.py", "good") as NodeId; - const unconfirmedId = makeNodeId("Function", "src/c.py", "bad") as NodeId; - const callerId = makeNodeId("Function", "src/b.py", "driver") as NodeId; - graph.addNode({ - id: confirmedId, - kind: "Function", - name: "good", - filePath: "src/a.py", - startLine: 1, - endLine: 3, - }); - graph.addNode({ - id: unconfirmedId, - kind: "Function", - name: "bad", - filePath: "src/c.py", - startLine: 1, - endLine: 3, - }); - graph.addNode({ - id: callerId, - kind: "Function", - name: "driver", - filePath: "src/b.py", - startLine: 1, - endLine: 3, - }); - addConfirmedEdge(graph, callerId, confirmedId); - // Low-confidence heuristic edge on the unconfirmed node. - graph.addEdge({ - from: callerId, - to: unconfirmedId, - type: "CALLS", - confidence: 0.5, - reason: "tree-sitter", - }); - - const sourceMap = new Map([ - ["/unused/src/a.py", "def good():\n pass\n pass\n"], - ["/unused/src/b.py", "def driver():\n good()\n bad()\n"], - ]); - const { adapter, calls } = makeFakeSummarizer(() => okResult("p", "ts")); - __setSummarizePhaseTestHooks__({ - summarizerFactory: () => adapter, - sourceReader: makeFixedSourceReader(sourceMap), - }); - - const ctx = buildHarnessContext(graph, { summaries: true, maxSummariesPerRun: 10 }); - const out = await summarizePhase.run(ctx, new Map()); - - assert.equal(out.considered, 3); - assert.equal(out.skippedUnconfirmed, 1); - // bad (unconfirmed) is dropped; good + driver are both LSP-confirmed. - assert.equal(out.summarized, 2); - assert.equal(calls.length, 2); - }); -}); - -describe("summarizePhase — symbol kind filter", () => { - it("summarizes Function/Method/Class but skips other kinds", async () => { - const graph = new KnowledgeGraph(); - const classId = makeNodeId("Class", "src/a.py", "Alpha") as NodeId; - const methodId = makeNodeId("Method", "src/a.py", "Alpha.run") as NodeId; - const interfaceId = makeNodeId("Interface", "src/a.ts", "IThing") as NodeId; - const variableId = makeNodeId("Variable", "src/a.py", "TOP_LEVEL") as NodeId; - const callerId = makeNodeId("Function", "src/b.py", "driver") as NodeId; - graph.addNode({ - id: classId, - kind: "Class", - name: "Alpha", - filePath: "src/a.py", - startLine: 1, - endLine: 4, - }); - graph.addNode({ - id: methodId, - kind: "Method", - name: "run", - filePath: "src/a.py", - startLine: 2, - endLine: 3, - owner: "Alpha", - }); - graph.addNode({ - id: interfaceId, - kind: "Interface", - name: "IThing", - filePath: "src/a.ts", - startLine: 1, - endLine: 3, - }); - graph.addNode({ - id: variableId, - kind: "Variable", - name: "TOP_LEVEL", - filePath: "src/a.py", - startLine: 5, - endLine: 5, - }); - graph.addNode({ - id: callerId, - kind: "Function", - name: "driver", - filePath: "src/b.py", - startLine: 1, - endLine: 3, - }); - // Confirm every summarizable kind so the trust filter doesn't mask the - // kind filter we're testing here. - addConfirmedEdge(graph, callerId, classId); - addConfirmedEdge(graph, callerId, methodId); - addConfirmedEdge(graph, callerId, interfaceId); - addConfirmedEdge(graph, callerId, variableId); - - const sourceMap = new Map([ - ["/unused/src/a.py", "class Alpha:\n def run(self):\n return 1\n # tail\n"], - ["/unused/src/b.py", "def driver():\n Alpha().run()\n return 0\n"], - ["/unused/src/a.ts", "interface IThing {\n x: number;\n}\n"], - ]); - const { adapter, calls } = makeFakeSummarizer(() => okResult("p", "ts")); - __setSummarizePhaseTestHooks__({ - summarizerFactory: () => adapter, - sourceReader: makeFixedSourceReader(sourceMap), - }); - - const ctx = buildHarnessContext(graph, { summaries: true, maxSummariesPerRun: 20 }); - const out = await summarizePhase.run(ctx, new Map()); - - // considered counts only summarizable kinds. - assert.equal(out.considered, 3); // Class, Method, Function(driver) - assert.equal(out.summarized, 3); - assert.equal(calls.length, 3); - // Interface + Variable never appeared in the summarizer call list. - const filePaths = new Set(calls.map((c) => c.filePath)); - assert.ok(filePaths.has("src/a.py")); - assert.ok(filePaths.has("src/b.py")); - assert.ok(!filePaths.has("src/a.ts")); - }); -}); - -describe("summarizePhase — cache hits", () => { - it("does not call Bedrock when a prior summary row covers the content hash", async () => { - const graph = new KnowledgeGraph(); - const funcId = makeNodeId("Function", "src/a.py", "alpha") as NodeId; - const callerId = makeNodeId("Function", "src/b.py", "driver") as NodeId; - graph.addNode({ - id: funcId, - kind: "Function", - name: "alpha", - filePath: "src/a.py", - startLine: 1, - endLine: 3, - }); - graph.addNode({ - id: callerId, - kind: "Function", - name: "driver", - filePath: "src/b.py", - startLine: 1, - endLine: 3, - }); - addConfirmedEdge(graph, callerId, funcId); - - const sourceMap = new Map([ - ["/unused/src/a.py", "def alpha():\n return 1\n # tail\n"], - ["/unused/src/b.py", "def driver():\n alpha()\n return 0\n"], - ]); - - // Pre-populate the cache with a row for EVERY candidate, so the phase - // short-circuits each of them. - const cachedRow: SymbolSummaryRow = { - nodeId: "unused", - contentHash: "unused", - promptVersion: "1", - modelId: "m", - summaryText: "cached", - createdAt: "2026-04-22T00:00:00.000Z", - }; - const cache = { - lookup: async (): Promise => cachedRow, - }; - - const { adapter, calls } = makeFakeSummarizer(() => okResult("p", "ts")); - __setSummarizePhaseTestHooks__({ - summarizerFactory: () => adapter, - sourceReader: makeFixedSourceReader(sourceMap), - }); - - const ctx = buildHarnessContext(graph, { - summaries: true, - maxSummariesPerRun: 10, - summaryCache: cache, - }); - const out = await summarizePhase.run(ctx, new Map()); - - assert.equal(out.cacheHits, 2); - assert.equal(out.summarized, 0); - assert.equal(out.wouldHaveSummarized, 0); - assert.equal(calls.length, 0); - }); -}); - -describe("summarizePhase — cap enforcement", () => { - it("summarizes exactly maxSummariesPerRun candidates and records overflow", async () => { - const graph = new KnowledgeGraph(); - const callerId = makeNodeId("Function", "src/caller.py", "driver") as NodeId; - graph.addNode({ - id: callerId, - kind: "Function", - name: "driver", - filePath: "src/caller.py", - startLine: 1, - endLine: 30, - }); - const sourceMap = new Map([ - ["/unused/src/caller.py", Array.from({ length: 30 }, () => " pass").join("\n")], - ]); - - // 20 eligible Function symbols, each LSP-confirmed via an edge from - // the shared driver node. - for (let i = 0; i < 20; i += 1) { - const id = makeNodeId("Function", `src/f${i}.py`, `f${i}`) as NodeId; - graph.addNode({ - id, - kind: "Function", - name: `f${i}`, - filePath: `src/f${i}.py`, - startLine: 1, - endLine: 3, - }); - addConfirmedEdge(graph, callerId, id); - sourceMap.set(`/unused/src/f${i}.py`, `def f${i}():\n return ${i}\n # ${i}\n`); - } - - const { adapter, calls } = makeFakeSummarizer(() => okResult("p", "ts")); - __setSummarizePhaseTestHooks__({ - summarizerFactory: () => adapter, - sourceReader: makeFixedSourceReader(sourceMap), - }); - - const ctx = buildHarnessContext(graph, { summaries: true, maxSummariesPerRun: 5 }); - const out = await summarizePhase.run(ctx, new Map()); - - assert.equal(calls.length, 5, "cap must bound Bedrock invocations exactly"); - assert.equal(out.summarized, 5); - // 20 candidates + 1 driver = 21 total; 16 remain beyond the cap. - assert.equal(out.wouldHaveSummarized, 16); - }); -}); - -describe("summarizePhase — phase name constant", () => { - it("exports a stable string literal for the phase name", () => { - assert.equal(SUMMARIZE_PHASE_NAME, "summarize"); - assert.equal(summarizePhase.name, "summarize"); - }); -}); - -describe("summarizePhase — credential soft-fail", () => { - it("returns skippedReason=no-credentials when the summarizer throws NoCredentialsError", async () => { - const graph = new KnowledgeGraph(); - const funcId = makeNodeId("Function", "src/a.py", "alpha") as NodeId; - const callerId = makeNodeId("Function", "src/b.py", "driver") as NodeId; - graph.addNode({ - id: funcId, - kind: "Function", - name: "alpha", - filePath: "src/a.py", - startLine: 1, - endLine: 3, - }); - graph.addNode({ - id: callerId, - kind: "Function", - name: "driver", - filePath: "src/b.py", - startLine: 1, - endLine: 3, - }); - addConfirmedEdge(graph, callerId, funcId); - - const sourceMap = new Map([ - ["/unused/src/a.py", "def alpha():\n return 1\n # tail\n"], - ["/unused/src/b.py", "def driver():\n alpha()\n return 0\n"], - ]); - - // Fake summarizer whose first call throws a credential-missing error. - // The phase must convert that into a soft-fail (no rows, no failure - // counter) so analyze stays green for contributors without AWS - // credentials. - const credErr = new Error("Could not load credentials from any providers"); - (credErr as { name: string }).name = "CredentialsProviderError"; - const adapter: SummarizerAdapter = { - summarize: async () => { - throw credErr; - }, - }; - __setSummarizePhaseTestHooks__({ - summarizerFactory: () => adapter, - sourceReader: makeFixedSourceReader(sourceMap), - }); - - const ctx = buildHarnessContext(graph, { summaries: true, maxSummariesPerRun: 5 }); - const out = await summarizePhase.run(ctx, new Map()); - - assert.equal(out.enabled, false, "credential failure must surface as enabled=false"); - assert.equal(out.skippedReason, "no-credentials"); - assert.equal(out.summarized, 0); - assert.equal(out.failed, 0, "soft-fail must not bump the failure counter"); - assert.equal(out.rows.length, 0); - }); - - it("converts a credential error thrown by the factory itself into soft-fail", async () => { - // When AWS_PROFILE is empty / unset and the SDK cannot resolve a - // provider chain, the failure surfaces at `BedrockRuntimeClient` - // construction rather than on the first .send(). Exercise that path - // by having the factory itself throw. - const graph = new KnowledgeGraph(); - const funcId = makeNodeId("Function", "src/a.py", "alpha") as NodeId; - const callerId = makeNodeId("Function", "src/b.py", "driver") as NodeId; - graph.addNode({ - id: funcId, - kind: "Function", - name: "alpha", - filePath: "src/a.py", - startLine: 1, - endLine: 3, - }); - graph.addNode({ - id: callerId, - kind: "Function", - name: "driver", - filePath: "src/b.py", - startLine: 1, - endLine: 3, - }); - addConfirmedEdge(graph, callerId, funcId); - - const sourceMap = new Map([ - ["/unused/src/a.py", "def alpha():\n return 1\n # tail\n"], - ["/unused/src/b.py", "def driver():\n alpha()\n return 0\n"], - ]); - - __setSummarizePhaseTestHooks__({ - summarizerFactory: () => { - const err = new Error("Unable to load credentials from any providers"); - (err as { name: string }).name = "CredentialsProviderError"; - throw err; - }, - sourceReader: makeFixedSourceReader(sourceMap), - }); - - const ctx = buildHarnessContext(graph, { summaries: true, maxSummariesPerRun: 5 }); - const out = await summarizePhase.run(ctx, new Map()); - - assert.equal(out.enabled, false); - assert.equal(out.skippedReason, "no-credentials"); - assert.equal(out.summarized, 0); - assert.equal(out.failed, 0); - assert.equal(out.rows.length, 0); - }); -}); - -describe("summarizePhase — summaryModel override", () => { - it("threads opts.summaryModel through to the row.modelId", async () => { - const graph = new KnowledgeGraph(); - const funcId = makeNodeId("Function", "src/a.py", "alpha") as NodeId; - const callerId = makeNodeId("Function", "src/b.py", "driver") as NodeId; - graph.addNode({ - id: funcId, - kind: "Function", - name: "alpha", - filePath: "src/a.py", - startLine: 1, - endLine: 3, - }); - graph.addNode({ - id: callerId, - kind: "Function", - name: "driver", - filePath: "src/b.py", - startLine: 1, - endLine: 3, - }); - addConfirmedEdge(graph, callerId, funcId); - - const sourceMap = new Map([ - ["/unused/src/a.py", "def alpha():\n return 1\n # tail\n"], - ["/unused/src/b.py", "def driver():\n alpha()\n return 0\n"], - ]); - // Capture the modelId the factory received so we can assert the flag - // is plumbed end-to-end. - let seenModelId: string | undefined; - const { adapter } = makeFakeSummarizer(() => okResult("p", "ts")); - __setSummarizePhaseTestHooks__({ - summarizerFactory: ({ modelId }) => { - seenModelId = modelId; - return adapter; - }, - sourceReader: makeFixedSourceReader(sourceMap), - }); - - // Build a context with the override attached via the options bag — - // mirrors how the CLI plumbs `--summary-model ` through - // `PipelineOptions.summaryModel`. - const ctx = buildHarnessContext(graph, { summaries: true, maxSummariesPerRun: 5 }); - (ctx.options as unknown as Record)["summaryModel"] = "override.test-model-1"; - - const out = await summarizePhase.run(ctx, new Map()); - assert.equal(seenModelId, "override.test-model-1"); - assert.equal(out.modelId, "override.test-model-1"); - for (const row of out.rows) assert.equal(row.modelId, "override.test-model-1"); - }); -}); diff --git a/packages/ingestion/src/pipeline/phases/summarize.ts b/packages/ingestion/src/pipeline/phases/summarize.ts deleted file mode 100644 index c02d5231..00000000 --- a/packages/ingestion/src/pipeline/phases/summarize.ts +++ /dev/null @@ -1,564 +0,0 @@ -/** - * Summarize phase — emit structured LLM summaries for callable symbols. - * - * This is the first hop of the SACL-style two-lane retrieval stack. Every - * Function / Method / Class that the LSP oracle has independently confirmed - * gets a Zod 4-validated `SymbolSummary` generated by `@opencodehub/summarizer` - * (Bedrock Haiku 4.5 via Converse + tool_use). The summaries live in a - * separate `symbol_summaries` storage table; they never participate in the - * graph edge set. - * - * The phase is gated four ways, in strict precedence: - * 1. `opts.offline === true` → skip with `skippedReason: "offline"`. The - * offline invariant is non-negotiable; no summarizer call path may - * execute when offline is set. - * 2. `opts.summaries !== true` → skip with `skippedReason: "not-enabled"`. - * The flag defaults off so re-index runs stay free until the operator - * explicitly opts in. - * 3. Trust filter — a symbol is summarized only when at least one edge - * incident on it is SCIP-confirmed (confidence 1.0 and - * `reason` starts with an {@link SCIP_PROVENANCE_PREFIXES} entry). This - * cuts noise from tree-sitter-only resolution where the symbol's - * existence is uncertain. - * 4. Cost cap — `maxSummariesPerRun` bounds how many Bedrock calls we - * actually issue. `maxSummariesPerRun = 0` (the default) runs the - * phase in dry-run mode: we record how many symbols WOULD have been - * summarized, but never contact Bedrock. Session A ships in this - * mode so the operator can inspect the counter before spending - * anything on Session B. - * - * The LSP phases run upstream, so by the time we get here every high- - * confidence edge carries its `scip:scip-python@…` / `scip:scip-typescript@…` - * / `scip:scip-go@…` / `scip:rust-analyzer@…` provenance. The trust filter reads that - * directly off `ctx.graph`. - * - * Cache semantics: - * - Content hash = sha256 hex of the UTF-8 bytes of the symbol's source - * span `[startLine, endLine]`. A whitespace-only edit to a different - * symbol leaves the target symbol's hash stable → cache hit. - * - The cache key is `(nodeId, contentHash, promptVersion)`. Bumping - * `SUMMARIZER_PROMPT_VERSION` invalidates prior rows without deleting - * them; multiple prompt versions can coexist during rollout. - * - Cache hits are counted but do NOT re-enter the output `rows` array - * — the CLI only persists freshly produced rows. The prior row is - * already on disk. - * - * Provenance: - * - The phase never emits graph edges. - * - The phase never mutates graph nodes (no summary text stored inline). - * - All output is side-channel, keyed by node id. - */ - -import { createHash } from "node:crypto"; -import { readFileSync } from "node:fs"; -import path from "node:path"; -import { canonicalJson, SCIP_PROVENANCE_PREFIXES } from "@opencodehub/core-types"; -import type { SymbolSummaryRow } from "@opencodehub/storage"; -import { - DEFAULT_MODEL_ID, - SUMMARIZER_PROMPT_VERSION, - type SummarizeInput, - type SummarizerResult, - type SymbolSummaryT, -} from "@opencodehub/summarizer"; -import type { PipelineContext, PipelinePhase } from "../types.js"; -import { CONFIDENCE_DEMOTE_PHASE_NAME } from "./confidence-demote.js"; - -export const SUMMARIZE_PHASE_NAME = "summarize" as const; - -const LSP_CONFIDENCE = 1.0; -const SUMMARIZABLE_KINDS: ReadonlySet = new Set(["Function", "Method", "Class"]); - -export interface SummarizePhaseOutput { - /** `true` when the phase actually considered symbols for summarization. */ - readonly enabled: boolean; - /** Populated when `enabled=false`; a human-readable hint for logs. */ - readonly skippedReason?: string; - /** Prompt version that gated the cache key for this run. */ - readonly promptVersion: string; - /** Model id that WOULD (or did) be invoked — resolved for diagnostics. */ - readonly modelId: string; - /** Symbols walked after kind filtering. */ - readonly considered: number; - /** Symbols dropped by the LSP trust filter. */ - readonly skippedUnconfirmed: number; - /** Symbols whose cached summary already covered the content hash. */ - readonly cacheHits: number; - /** - * Symbols eligible but dropped by the cost cap (dry-run or when the - * batch exceeds `maxSummariesPerRun`). - */ - readonly wouldHaveSummarized: number; - /** Bedrock calls that returned a validated summary. */ - readonly summarized: number; - /** Bedrock calls that threw (retry loop exhausted or transport error). */ - readonly failed: number; - /** Fresh rows the CLI will persist via `store.bulkLoadSymbolSummaries`. */ - readonly rows: readonly SymbolSummaryRow[]; - readonly durationMs: number; -} - -/** - * Shape the phase uses to talk to the summarizer. Narrower than the full - * Bedrock client so tests can substitute a fake without instantiating - * `@aws-sdk/client-bedrock-runtime`. The production path wraps - * {@link summarizeSymbol} against a real `BedrockRuntimeClient`. - */ -export interface SummarizerAdapter { - summarize(input: SummarizeInput): Promise; -} - -export interface SummarizePhaseTestHooks { - /** - * Override the summarizer adapter. When provided, the phase uses this - * instead of instantiating a real Bedrock client. Tests MUST set this - * so Bedrock is never contacted. - */ - readonly summarizerFactory?: (opts: { readonly modelId: string }) => SummarizerAdapter; - /** Override the source-text reader (test-only). */ - readonly sourceReader?: (absPath: string) => string; - /** Override the clock so `createdAt` is deterministic in tests. */ - readonly now?: () => Date; -} - -let testHooks: SummarizePhaseTestHooks | undefined; -export function __setSummarizePhaseTestHooks__(hooks: SummarizePhaseTestHooks | undefined): void { - testHooks = hooks; -} - -export const summarizePhase: PipelinePhase = { - name: SUMMARIZE_PHASE_NAME, - // Depend on confidence-demote so every LSP phase (and the demotion pass) - // has finished before we inspect edge provenance. The dep graph guarantees - // all four lsp-* phases ran upstream, so trust filtering sees the final - // confidence scores. - deps: [CONFIDENCE_DEMOTE_PHASE_NAME], - async run(ctx) { - return runSummarize(ctx); - }, -}; - -async function runSummarize(ctx: PipelineContext): Promise { - const start = Date.now(); - // Accept `summaryModel` via the options bag — the CLI surface threads - // `--summary-model ` through `PipelineOptions.summaryModel`. A string - // override replaces the compile-time default; anything else falls back to - // `DEFAULT_MODEL_ID` so production deployments stay pinned. - const summaryModelOpt = (ctx.options as { readonly summaryModel?: unknown }).summaryModel; - const modelId = - typeof summaryModelOpt === "string" && summaryModelOpt.length > 0 - ? summaryModelOpt - : DEFAULT_MODEL_ID; - const promptVersion = SUMMARIZER_PROMPT_VERSION; - - // ---- Offline gate (INVARIANT) ---------------------------------------- - if (ctx.options.offline === true) { - return emptyOutput(start, { - skippedReason: "offline", - promptVersion, - modelId, - }); - } - - // ---- Feature flag gate ----------------------------------------------- - if (ctx.options.summaries !== true) { - return emptyOutput(start, { - skippedReason: "not-enabled", - promptVersion, - modelId, - }); - } - - // ---- Walk candidates -------------------------------------------------- - // Build per-node inbound/outbound incidence sets so we can apply the LSP - // trust filter in O(|edges| + |nodes|) rather than O(|edges| × |nodes|). - const lspConfirmedNodes = new Set(); - for (const edge of ctx.graph.edges()) { - if (edge.confidence < LSP_CONFIDENCE) continue; - if (!isLspReason(edge.reason)) continue; - lspConfirmedNodes.add(edge.from as string); - lspConfirmedNodes.add(edge.to as string); - } - - let considered = 0; - let skippedUnconfirmed = 0; - - interface Candidate { - readonly nodeId: string; - readonly filePath: string; - readonly startLine: number; - readonly endLine: number; - readonly enclosingClass: string | null; - readonly docstring: string | null; - } - const candidates: Candidate[] = []; - - for (const node of ctx.graph.nodes()) { - if (!SUMMARIZABLE_KINDS.has(node.kind)) continue; - considered += 1; - if (!lspConfirmedNodes.has(node.id as string)) { - skippedUnconfirmed += 1; - continue; - } - const startLine = (node as { startLine?: number }).startLine; - const endLine = (node as { endLine?: number }).endLine; - if (startLine === undefined || endLine === undefined) continue; - const enclosingClass = - typeof (node as { enclosingClass?: unknown }).enclosingClass === "string" - ? ((node as { enclosingClass?: string }).enclosingClass ?? null) - : null; - const docstring = - typeof (node as { description?: unknown }).description === "string" - ? ((node as { description?: string }).description ?? null) - : null; - candidates.push({ - nodeId: node.id as string, - filePath: node.filePath, - startLine, - endLine, - enclosingClass, - docstring, - }); - } - // Deterministic order: sort by node id so cap truncation picks a stable - // subset across runs. - candidates.sort((a, b) => (a.nodeId < b.nodeId ? -1 : a.nodeId > b.nodeId ? 1 : 0)); - - // ---- Read source + hash, classify hit vs miss ------------------------- - const readSource = testHooks?.sourceReader ?? defaultSourceReader; - interface MissEntry { - readonly candidate: Candidate; - readonly source: string; - readonly contentHash: string; - } - let cacheHits = 0; - const misses: MissEntry[] = []; - - // Resolve a cache adapter from the options bag if the CLI attached one. - // Phases have no direct store handle, so we route cache probes through a - // narrow hook on `ctx.options`. Production attaches the SQLite-backed - // adapter; tests supply an in-memory fake. - const cacheAdapter = resolveCacheAdapter(ctx); - - for (const c of candidates) { - const source = safeReadSpan(readSource, ctx.repoPath, c.filePath, c.startLine, c.endLine); - if (source === undefined) continue; - const contentHash = sha256Hex(source); - if (cacheAdapter !== undefined) { - const hit = await cacheAdapter.lookup(c.nodeId, contentHash, promptVersion); - if (hit !== undefined) { - cacheHits += 1; - continue; - } - } - misses.push({ candidate: c, source, contentHash }); - } - - // ---- Cost cap --------------------------------------------------------- - const cap = Math.max(0, Math.floor(ctx.options.maxSummariesPerRun ?? 0)); - // When cap === 0 the phase runs in dry-run mode: every miss contributes - // to wouldHaveSummarized and we skip Bedrock entirely. When cap > 0 we - // slice the first `cap` misses into the effective batch and count the - // overflow (if any) as wouldHaveSummarized too. - const effectiveBatch = cap === 0 ? [] : misses.slice(0, cap); - const effectiveWouldHave = cap === 0 ? misses.length : misses.length - effectiveBatch.length; - - if (effectiveBatch.length === 0) { - return { - enabled: true, - promptVersion, - modelId, - considered, - skippedUnconfirmed, - cacheHits, - wouldHaveSummarized: effectiveWouldHave, - summarized: 0, - failed: 0, - rows: [], - durationMs: Date.now() - start, - }; - } - - // ---- Summarize -------------------------------------------------------- - // Instantiating the summarizer resolves the AWS SDK credential chain, which - // throws `CredentialsProviderError` / `NoCredentialsError` when no creds - // are configured. Catch that family here so contributors without Bedrock - // access still get a green analyze — the missing-credentials path emits a - // skip note and zero rows, while every other factory error continues to - // surface so real bugs don't go silent. - let summarizer: SummarizerAdapter; - try { - summarizer = (testHooks?.summarizerFactory ?? defaultSummarizerFactory)({ modelId }); - } catch (err) { - if (isMissingCredentialsError(err)) { - ctx.onProgress?.({ - phase: SUMMARIZE_PHASE_NAME, - kind: "note", - message: "summarize: skipped (no AWS credentials)", - }); - return emptyOutput(start, { - skippedReason: "no-credentials", - promptVersion, - modelId, - }); - } - throw err; - } - const now = testHooks?.now ?? (() => new Date()); - const rows: SymbolSummaryRow[] = []; - let summarized = 0; - let failed = 0; - - for (const [idx, entry] of effectiveBatch.entries()) { - const input: SummarizeInput = { - source: entry.source, - filePath: entry.candidate.filePath, - lineStart: entry.candidate.startLine, - lineEnd: entry.candidate.endLine, - docstring: entry.candidate.docstring, - enclosingClass: entry.candidate.enclosingClass, - }; - try { - const result = await summarizer.summarize(input); - const summary = result.summary; - const signatureSummary = buildSignatureSummary(summary); - const structuredJson = buildStructuredJson(summary); - const row: SymbolSummaryRow = { - nodeId: entry.candidate.nodeId, - contentHash: entry.contentHash, - promptVersion, - modelId, - summaryText: summary.purpose, - ...(signatureSummary !== undefined ? { signatureSummary } : {}), - returnsTypeSummary: summary.returns.type_summary, - ...(structuredJson !== undefined ? { structuredJson } : {}), - createdAt: now().toISOString(), - }; - rows.push(row); - summarized += 1; - } catch (err) { - // Credential errors can surface on the first Bedrock call rather than - // at client construction (e.g. SSO profile expired mid-run). Treat - // the first such error on the first candidate as a soft-fail for the - // whole phase so we don't waste the batch on a guaranteed-failing - // credential chain. - if (idx === 0 && isMissingCredentialsError(err)) { - ctx.onProgress?.({ - phase: SUMMARIZE_PHASE_NAME, - kind: "note", - message: "summarize: skipped (no AWS credentials)", - }); - return emptyOutput(start, { - skippedReason: "no-credentials", - promptVersion, - modelId, - }); - } - failed += 1; - ctx.onProgress?.({ - phase: SUMMARIZE_PHASE_NAME, - kind: "warn", - message: `summarize: ${entry.candidate.nodeId} failed: ${(err as Error).message}`, - }); - } - } - - return { - enabled: true, - promptVersion, - modelId, - considered, - skippedUnconfirmed, - cacheHits, - wouldHaveSummarized: effectiveWouldHave, - summarized, - failed, - rows, - durationMs: Date.now() - start, - }; -} - -// ---- Helpers ------------------------------------------------------------ - -function emptyOutput( - start: number, - partial: { skippedReason: string; promptVersion: string; modelId: string }, -): SummarizePhaseOutput { - return { - enabled: false, - skippedReason: partial.skippedReason, - promptVersion: partial.promptVersion, - modelId: partial.modelId, - considered: 0, - skippedUnconfirmed: 0, - cacheHits: 0, - wouldHaveSummarized: 0, - summarized: 0, - failed: 0, - rows: [], - durationMs: Date.now() - start, - }; -} - -function isLspReason(reason: string | undefined): boolean { - if (reason === undefined) return false; - for (const prefix of SCIP_PROVENANCE_PREFIXES) { - if (reason.startsWith(prefix)) return true; - } - return false; -} - -function sha256Hex(s: string): string { - return createHash("sha256").update(s, "utf8").digest("hex"); -} - -/** - * Recognize the AWS SDK v3 credential-missing error family. The SDK - * throws `CredentialsProviderError` (name) / `NoCredentialsError` - * depending on the provider in the chain, plus pure-string errors from - * `from*` providers ("Could not load credentials from any providers"). - * - * We match on a small set of well-known shapes rather than importing - * `@smithy/types` to keep the phase independent of the SDK's type - * surface and safe to call with a test fake that merely sets `name`. - */ -function isMissingCredentialsError(err: unknown): boolean { - if (err === null || err === undefined) return false; - const errObj = err as { readonly name?: unknown; readonly message?: unknown }; - const name = typeof errObj.name === "string" ? errObj.name : ""; - if ( - name === "CredentialsProviderError" || - name === "NoCredentialsError" || - name === "ExpiredTokenException" - ) { - return true; - } - const message = typeof errObj.message === "string" ? errObj.message : ""; - return ( - message.includes("Could not load credentials") || - message.includes("credentials is missing") || - message.includes("Unable to load credentials") || - message.includes("The security token included in the request is expired") - ); -} - -function defaultSourceReader(absPath: string): string { - return readFileSync(absPath, "utf-8"); -} - -function safeReadSpan( - reader: (absPath: string) => string, - repoPath: string, - filePath: string, - startLine: number, - endLine: number, -): string | undefined { - try { - const abs = path.isAbsolute(filePath) ? filePath : path.join(repoPath, filePath); - const all = reader(abs); - const lines = all.split(/\r?\n/); - const from = Math.max(0, startLine - 1); - const to = Math.min(lines.length, endLine); - if (to <= from) return undefined; - return lines.slice(from, to).join("\n"); - } catch { - return undefined; - } -} - -function buildSignatureSummary(summary: { - readonly inputs: readonly { readonly name: string; readonly type: string }[]; -}): string | undefined { - if (summary.inputs.length === 0) return undefined; - return summary.inputs.map((i) => `${i.name}: ${i.type}`).join(", "); -} - -/** - * Serialize the validated structured fields the flat `SymbolSummaryRow` - * columns drop — per-input descriptions, `returns.details`, `side_effects`, - * `invariants`, and the line-range `citations` that back a staleness - * detector. Emitted via `canonicalJson` (sorted keys) so the row is - * byte-deterministic across runs and the temporal-store content hash stays - * stable. Returns `undefined` when there is nothing beyond the flat columns - * to persist, so legacy rows keep round-tripping with `structured_json` NULL. - */ -function buildStructuredJson(summary: SymbolSummaryT): string | undefined { - const hasInputDetails = summary.inputs.some((i) => i.description.length > 0); - const hasInvariants = summary.invariants !== null && summary.invariants.length > 0; - if ( - !hasInputDetails && - summary.side_effects.length === 0 && - !hasInvariants && - summary.returns.details.length === 0 && - summary.citations.length === 0 - ) { - return undefined; - } - return canonicalJson({ - inputs: summary.inputs.map((i) => ({ - name: i.name, - type: i.type, - description: i.description, - })), - returns: { type: summary.returns.type, details: summary.returns.details }, - side_effects: summary.side_effects, - invariants: summary.invariants ?? [], - citations: summary.citations.map((c) => ({ - field_name: c.field_name, - line_start: c.line_start, - line_end: c.line_end, - })), - }); -} - -/** - * Lightweight cache adapter a caller can plug onto `ctx.options` to let the - * phase probe existing summaries before emitting work. The `summarize` - * phase has no direct `IGraphStore` handle — phases are pure over - * `PipelineContext` — so we expose a narrow hook (`__summaryCache`) the - * CLI fills before invoking `runIngestion`. Tests plug in a fake - * adapter; production may or may not depending on how aggressive the - * operator wants cache hits to be. - */ -export interface SummaryCacheAdapter { - lookup( - nodeId: string, - contentHash: string, - promptVersion: string, - ): Promise; -} - -/** - * Options-bag extension point. Callers attach the cache adapter via a - * non-enumerable-looking well-known key so existing snapshot tests that - * assert on option shape don't break. - */ -export const SUMMARY_CACHE_OPTIONS_KEY = "__summaryCache" as const; - -function resolveCacheAdapter(ctx: PipelineContext): SummaryCacheAdapter | undefined { - const opts = ctx.options as unknown as Record; - const cache = opts[SUMMARY_CACHE_OPTIONS_KEY]; - if (cache === undefined || cache === null || typeof cache !== "object") return undefined; - const adapter = cache as SummaryCacheAdapter; - if (typeof adapter.lookup !== "function") return undefined; - return adapter; -} - -/** - * Default summarizer factory. Defers importing `@aws-sdk/client-bedrock-runtime` - * until the phase actually needs to issue a call, so test runs with - * `maxSummariesPerRun=0` or `summaries=false` never touch the SDK's - * credential provider chain. - */ -function defaultSummarizerFactory(opts: { readonly modelId: string }): SummarizerAdapter { - return { - summarize: async (input) => { - const [{ BedrockRuntimeClient }, { summarizeSymbol }] = await Promise.all([ - import("@aws-sdk/client-bedrock-runtime"), - import("@opencodehub/summarizer"), - ]); - const client = new BedrockRuntimeClient({}); - return summarizeSymbol(client, input, { modelId: opts.modelId }); - }, - }; -} diff --git a/packages/ingestion/src/pipeline/types.ts b/packages/ingestion/src/pipeline/types.ts index 7b600d59..c02806f2 100644 --- a/packages/ingestion/src/pipeline/types.ts +++ b/packages/ingestion/src/pipeline/types.ts @@ -170,30 +170,6 @@ export interface PipelineOptions { * `codehub analyze --coverage` flag. */ readonly coverage?: boolean; - /** - * When `true`, the `summarize` phase walks callable symbols, computes a - * content hash from their source span, and issues a Bedrock summarize - * call for cache misses. Gated OFF by default because the phase spends - * real money. The `offline` flag always wins — when `offline === true` - * the phase is a no-op regardless of this flag. - */ - readonly summaries?: boolean | undefined; - /** - * Upper bound on the number of Bedrock summarize calls per pipeline run. - * Defaults to `0`, which runs the phase in dry-run mode: it enumerates - * eligible symbols and reports `wouldHaveSummarized` without contacting - * Bedrock. Set to a positive integer (e.g. 10) to actually summarize a - * bounded subset. Ignored when `summaries !== true`. The CLI resolves - * `--max-summaries auto` to a concrete integer before calling into the - * pipeline, so this field is always numeric inside the pipeline. - */ - readonly maxSummariesPerRun?: number | undefined; - /** - * Override the Bedrock model id used by the summarize phase. When - * undefined, the phase uses `DEFAULT_MODEL_ID` from - * `@opencodehub/summarizer`. - */ - readonly summaryModel?: string | undefined; /** * When `true`, detectors that pattern-match on receiver identifiers * skip heuristic-only matches entirely — edges are emitted only when a diff --git a/packages/ingestion/tsconfig.json b/packages/ingestion/tsconfig.json index e4c9bfa1..bcbbd859 100644 --- a/packages/ingestion/tsconfig.json +++ b/packages/ingestion/tsconfig.json @@ -20,7 +20,6 @@ { "path": "../embedder" }, { "path": "../frameworks" }, { "path": "../scip-ingest" }, - { "path": "../storage" }, - { "path": "../summarizer" } + { "path": "../storage" } ] } diff --git a/packages/mcp/src/test-utils.ts b/packages/mcp/src/test-utils.ts index b2ec49b1..5be397b8 100644 --- a/packages/mcp/src/test-utils.ts +++ b/packages/mcp/src/test-utils.ts @@ -203,10 +203,7 @@ export type StoreOverrides = Partial<{ // ITemporalStore surfaces tests sometimes use directly via `store.temporal`. lookupCochangesForFile: ITemporalStore["lookupCochangesForFile"]; lookupCochangesBetween: ITemporalStore["lookupCochangesBetween"]; - lookupSymbolSummary: ITemporalStore["lookupSymbolSummary"]; - lookupSymbolSummariesByNode: ITemporalStore["lookupSymbolSummariesByNode"]; bulkLoadCochanges: ITemporalStore["bulkLoadCochanges"]; - bulkLoadSymbolSummaries: ITemporalStore["bulkLoadSymbolSummaries"]; exec: ITemporalStore["exec"]; // Optional escape hatch — reserved for a community graph adapter. execCypher: NonNullable; @@ -597,9 +594,6 @@ export function makeFakeGraphStore( bulkLoadCochanges: async (_rows: readonly unknown[]): Promise => {}, lookupCochangesForFile: async () => [], lookupCochangesBetween: async () => undefined, - bulkLoadSymbolSummaries: async (_rows: readonly unknown[]): Promise => {}, - lookupSymbolSummary: async () => undefined, - lookupSymbolSummariesByNode: async () => [], exec: async () => [], }; diff --git a/packages/mcp/src/tools/query.test.ts b/packages/mcp/src/tools/query.test.ts index 5d9bfb73..30a45bba 100644 --- a/packages/mcp/src/tools/query.test.ts +++ b/packages/mcp/src/tools/query.test.ts @@ -42,7 +42,6 @@ import type { SearchResult, SqlParam, StoreMeta, - SymbolSummaryRow, TraverseQuery, TraverseResult, TraverseResult as TraverseResultType, @@ -111,18 +110,6 @@ interface FakeStoreOptions { * keyed by id. */ readonly nodes: ReadonlyMap; - /** - * Whether the probe for a `symbol_summaries` table should report it - * exists + is populated. Defaults to false. - */ - readonly summariesJoined?: boolean; - /** - * Summary rows keyed by nodeId. Mirrors the `symbol_summaries` table; - * the fake `lookupSymbolSummariesByNode` returns every row whose - * nodeId is in the lookup set. When set, `summariesJoined` should also - * be true so the tool's probe lets the join run. - */ - readonly summaryRows?: ReadonlyMap; /** * Pre-built process membership triples. When omitted, the * process-grouping SQL falls through to the throw-unsupported path and @@ -228,8 +215,6 @@ function makeFakeStore(opts: FakeStoreOptions): FakeStoreHandle { const allNodes: readonly GraphNode[] = [...symbolNodes, ...processNodes]; const allEdges: readonly CodeRelation[] = processEdges; - const summariesPresent = opts.summariesJoined === true; - const impl = { open: async () => {}, close: async () => {}, @@ -330,51 +315,17 @@ function makeFakeStore(opts: FakeStoreOptions): FakeStoreHandle { getMeta: async (): Promise => undefined, setMeta: async (_m: StoreMeta): Promise => {}, healthCheck: async () => ({ ok: true }), - // ITemporalStore.exec — `bm25CorpusHasSummaries` calls this with two - // probes: a `sqlite_master` table-existence check (ADR 0019; node:sqlite - // has no information_schema) and a row count. Mirror those exact texts. + // ITemporalStore.exec — the `query` path issues no ad-hoc SQL, so the + // fake returns an empty result set for anything it's handed. exec: async ( - sql: string, + _sql: string, _params: readonly SqlParam[] = [], - ): Promise[]> => { - const normalized = sql.replace(/\s+/g, " ").trim(); - if ( - normalized === - "SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table' AND name = 'symbol_summaries'" - ) { - return [{ n: summariesPresent ? 1 : 0 }]; - } - if (normalized === "SELECT COUNT(*) AS n FROM symbol_summaries") { - return [{ n: summariesPresent ? 5 : 0 }]; - } - throw new Error(`unsupported sql in fake store exec: ${normalized}`); - }, - // Cochange + summary surfaces — unused by `query`, but required to - // satisfy the full IGraphStore / ITemporalStore interfaces. + ): Promise[]> => [], + // Cochange surfaces — unused by `query`, but required to satisfy the + // full IGraphStore / ITemporalStore interfaces. bulkLoadCochanges: async () => {}, lookupCochangesForFile: async () => [], lookupCochangesBetween: async () => undefined, - bulkLoadSymbolSummaries: async () => {}, - lookupSymbolSummary: async () => undefined, - lookupSymbolSummariesByNode: async ( - nodeIds: readonly string[], - ): Promise => { - const byId = opts.summaryRows; - if (byId === undefined) return []; - const out: SymbolSummaryRow[] = []; - for (const id of nodeIds) { - const row = byId.get(id); - if (row !== undefined) out.push(row); - } - // Mirror the real SQL's ordering contract so callers can rely on - // "last write wins" to pick the newest prompt version. - out.sort((a, b) => { - if (a.nodeId !== b.nodeId) return a.nodeId < b.nodeId ? -1 : 1; - if (a.promptVersion !== b.promptVersion) return a.promptVersion < b.promptVersion ? -1 : 1; - return a.contentHash < b.contentHash ? -1 : a.contentHash > b.contentHash ? 1 : 0; - }); - return out; - }, } as unknown as IGraphStore; handle.store = impl; return handle; @@ -817,132 +768,6 @@ test("query: long snippets are truncated to the 200-char cap", async () => { ); }); -test("query: summary rows are joined onto each hit (P04)", async () => { - // Store reports `symbol_summaries` is populated AND carries a row for - // F:foo. The tool must attach `summary` (and `signatureSummary` when - // present) to that hit while leaving F:bar untouched (no row). - const summaryRows: ReadonlyMap = new Map([ - [ - "F:foo", - { - nodeId: "F:foo", - contentHash: "c0ffee", - promptVersion: "1", - modelId: "global.anthropic.claude-haiku-4-5-v1:0", - summaryText: "Greet the user by name with a configurable locale.", - signatureSummary: "name: string, locale: string", - returnsTypeSummary: "greeting string", - createdAt: "2026-04-22T00:00:00.000Z", - }, - ], - ]); - await withHarness( - { - embeddingRows: 0, - searchRows: [ - { nodeId: "F:foo", score: 2, filePath: "src/foo.ts", name: "foo", kind: "Function" }, - { nodeId: "F:bar", score: 1, filePath: "src/bar.ts", name: "bar", kind: "Function" }, - ], - vectorRows: [], - nodes: NODES_FOO_BAR, - summariesJoined: true, - summaryRows, - }, - {}, - async ({ ctx, server }) => { - registerQueryTool(server, ctx); - const handler = getHandler(server, "query"); - const result = await handler({ query: "foo", repo: "fakerepo" }, {}); - const sc = result.structuredContent as { - results: Array<{ - nodeId: string; - summary?: string; - signatureSummary?: string; - }>; - }; - const foo = sc.results.find((r) => r.nodeId === "F:foo"); - const bar = sc.results.find((r) => r.nodeId === "F:bar"); - assert.ok(foo, "expected F:foo in results"); - assert.equal( - foo.summary, - "Greet the user by name with a configurable locale.", - "summary text must round-trip onto the hit", - ); - assert.equal( - foo.signatureSummary, - "name: string, locale: string", - "signatureSummary must round-trip onto the hit", - ); - assert.ok(bar, "expected F:bar in results"); - assert.equal(bar.summary, undefined, "F:bar has no summary row; field must stay absent"); - assert.equal(bar.signatureSummary, undefined); - }, - ); -}); - -test("query: summary join is skipped when summariesJoined=false", async () => { - // The tool's probe short-circuits the lookup when no table exists. - // Exercise that path: even if the fake store COULD return a row, the - // tool must not ask for it because summariesJoined=false. - const summaryRows: ReadonlyMap = new Map([ - [ - "F:foo", - { - nodeId: "F:foo", - contentHash: "c0ffee", - promptVersion: "1", - modelId: "m", - summaryText: "should-not-appear", - createdAt: "2026-04-22T00:00:00.000Z", - }, - ], - ]); - await withHarness( - { - embeddingRows: 0, - searchRows: [ - { nodeId: "F:foo", score: 2, filePath: "src/foo.ts", name: "foo", kind: "Function" }, - ], - vectorRows: [], - nodes: NODES_FOO_BAR, - summariesJoined: false, - summaryRows, - }, - {}, - async ({ ctx, server }) => { - registerQueryTool(server, ctx); - const handler = getHandler(server, "query"); - const result = await handler({ query: "foo", repo: "fakerepo" }, {}); - const sc = result.structuredContent as { - results: Array<{ summary?: string }>; - }; - assert.equal(sc.results[0]?.summary, undefined); - }, - ); -}); - -test("query: summaries_joined reflects the presence of symbol_summaries", async () => { - await withHarness( - { - embeddingRows: 0, - searchRows: [ - { nodeId: "F:foo", score: 1, filePath: "src/foo.ts", name: "foo", kind: "Function" }, - ], - vectorRows: [], - nodes: NODES_FOO_BAR, - summariesJoined: true, - }, - {}, - async ({ ctx, server }) => { - registerQueryTool(server, ctx); - const handler = getHandler(server, "query"); - const result = await handler({ query: "foo", repo: "fakerepo" }, {}); - const sc = result.structuredContent as { summaries_joined: boolean }; - assert.equal(sc.summaries_joined, true); - }, - ); -}); - test("query: top-K hit under a Process yields 1 group + ordered process_symbols", async () => { // Fixture: three callables chained under one Process. The BM25 hit is the // middle symbol of the chain; the grouping walk must still surface the diff --git a/packages/mcp/src/tools/query.ts b/packages/mcp/src/tools/query.ts index 3aff8c34..ab15fbb3 100644 --- a/packages/mcp/src/tools/query.ts +++ b/packages/mcp/src/tools/query.ts @@ -3,9 +3,7 @@ * * Two ranked runs, fused with Reciprocal Rank Fusion (k=60): * 1. BM25 (SQLite FTS5) over `nodes.name` + `nodes.signature` + - * `nodes.description`. If a `symbol_summaries` table is present the - * corpus extends transparently (see {@link bm25CorpusHasSummaries}) so - * summarized prose participates as soon as the ingestion phase lands. + * `nodes.description`. * 2. HNSW vector search over the `embeddings` table. The query text is * embedded with the same F2LLM-v2-80M ONNX model the ingestion * pipeline uses, so the vectors live in the same space. (Queries get @@ -49,7 +47,7 @@ import { hybridSearch, tryOpenEmbedder, } from "@opencodehub/search"; -import type { IGraphStore, ITemporalStore, SymbolSummaryRow } from "@opencodehub/storage"; +import type { IGraphStore } from "@opencodehub/storage"; import { z } from "zod"; import { toolError, toolErrorFromUnknown } from "../error-envelope.js"; import { withNextSteps } from "../next-step-hints.js"; @@ -158,10 +156,6 @@ interface QueryRow { readonly sources: readonly ("bm25" | "vector")[]; /** Present iff `include_content: true` was requested and the file was readable. */ readonly content?: string; - /** Present iff a `symbol_summaries` row exists for this node (P04). */ - readonly summary?: string; - /** Compact one-line signature summary from the same row. */ - readonly signatureSummary?: string; } /** Node metadata hydrated from the `nodes` table after fusion. */ @@ -203,71 +197,6 @@ interface ProcessSymbol { readonly step: number; } -/** - * Batched summary join for the top-K ranked hits. Short-circuits to an - * empty map when either `symbol_summaries` does not exist / is empty (the - * `summariesJoined` probe already ran) or the input list is empty. Any - * lookup failure is swallowed — summary enrichment is never load-bearing. - * - * We collapse multiple prompt-version rows per node by keeping the last - * one in `(node_id ASC, prompt_version ASC, content_hash ASC)` order, - * which is the storage layer's documented ordering contract — that - * deterministically selects the newest prompt version. - */ -async function lookupSummariesForHits( - temporal: ITemporalStore, - summariesJoined: boolean, - nodeIds: readonly string[], -): Promise> { - const out = new Map(); - if (!summariesJoined) return out; - const uniqIds = Array.from(new Set(nodeIds)); - if (uniqIds.length === 0) return out; - try { - const rows = await temporal.lookupSymbolSummariesByNode(uniqIds); - for (const row of rows) { - // Overwriting per node id keeps the newest prompt version because of - // the ORDER BY contract in `lookupSymbolSummariesByNode`. - out.set(row.nodeId, row); - } - } catch { - // Table missing / schema drift / I/O failure: return an empty map so - // the query surfaces degrade silently to "no summaries attached". - } - return out; -} - -/** - * Extensibility hook: return true iff the `symbol_summaries` table exists - * and is non-empty. When it does, future BM25 upgrades can JOIN it into - * the FTS corpus. Today this is informational — the SQLite FTS5 index is - * built at ingestion time against `nodes` columns only — but the probe - * lives here so the sibling summarizer work can light up a corpus - * extension without re-threading the tool. - */ -async function bm25CorpusHasSummaries(temporal: ITemporalStore): Promise { - // Table-existence introspection via SQLite's `sqlite_master` catalog, - // routed through the temporal-tier `exec` escape hatch. (An earlier - // backend probed `information_schema.tables`, which node:sqlite does not - // expose.) A future graph-only adapter pairing with a non-SQLite - // temporal store can override this probe. - try { - const rows = await temporal.exec( - "SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table' AND name = 'symbol_summaries'", - ); - const first = rows[0]; - if (!first) return false; - const hasTable = Number(first["n"] ?? 0) > 0; - if (!hasTable) return false; - const rows2 = await temporal.exec("SELECT COUNT(*) AS n FROM symbol_summaries"); - const first2 = rows2[0]; - if (!first2) return false; - return Number(first2["n"] ?? 0) > 0; - } catch { - return false; - } -} - /** * Fetch name/kind/filePath/startLine/endLine for a set of node ids in one * round trip. Ids missing from the store (e.g. stale embeddings) are @@ -632,14 +561,9 @@ export async function runQuery(ctx: ToolContext, args: QueryArgs): Promise { try { - const { graph, temporal } = store; + const { graph } = store; const kinds = args.kinds && args.kinds.length > 0 ? args.kinds : undefined; - // Probe for the symbol_summaries table so the value is recorded - // alongside `mode` (surfaces via structuredContent). This is a - // cheap metadata read; it runs once per query. - const summariesJoined = await bm25CorpusHasSummaries(temporal); - let ranked: readonly { nodeId: string; score: number; @@ -706,31 +630,7 @@ export async function runQuery(ctx: ToolContext, args: QueryArgs): Promise r.nodeId), - ); - const baseRows: readonly QueryRow[] = - summaryMap.size === 0 - ? enrichedRows - : enrichedRows.map((r) => { - const row = summaryMap.get(r.nodeId); - if (row === undefined) return r; - return { - ...r, - summary: row.summaryText, - ...(row.signatureSummary !== undefined - ? { signatureSummary: row.signatureSummary } - : {}), - }; - }); + const baseRows = await enrichWithContext(graph, fs, resolved.repoPath, ranked); // When `include_content` is requested, re-read each result's source // between startLine/endLine and attach a capped `content` body. This @@ -795,7 +695,6 @@ export async function runQuery(ctx: ToolContext, args: QueryArgs): Promise>/, "SQL section must show the JSON1 payload extract idiom"); // Cypher section: reserved for community-fork adapters; the default diff --git a/packages/mcp/src/tools/sql.ts b/packages/mcp/src/tools/sql.ts index ecab7455..97c75a98 100644 --- a/packages/mcp/src/tools/sql.ts +++ b/packages/mcp/src/tools/sql.ts @@ -2,8 +2,8 @@ * `sql` — raw read-only SQL over the local single-file store. * * Post-ADR 0019 the whole index is one `store.sqlite` (node:sqlite, WAL), - * so `nodes`, `edges`, `embeddings`, `store_meta`, `cochanges`, and - * `symbol_summaries` are all real SQL tables in the same file. The `sql` + * so `nodes`, `edges`, `embeddings`, `store_meta`, and `cochanges` are all + * real SQL tables in the same file. The `sql` * arg runs read-only SQL over them via `store.temporal.exec()`. The * read-only guard (`assertReadOnlySql`) rejects any write verb before the * statement reaches the engine. @@ -49,7 +49,7 @@ const SqlInput = { .min(1) .optional() .describe( - "Read-only SQL statement against the single-file `store.sqlite` index — query `nodes`, `edges`, `embeddings`, `store_meta`, `cochanges`, or `symbol_summaries` directly. INSERT/UPDATE/DELETE/DDL are rejected by the guard. Provide exactly one of `sql` or `cypher`.", + "Read-only SQL statement against the single-file `store.sqlite` index — query `nodes`, `edges`, `embeddings`, `store_meta`, or `cochanges` directly. INSERT/UPDATE/DELETE/DDL are rejected by the guard. Provide exactly one of `sql` or `cypher`.", ), cypher: z .string() @@ -74,7 +74,6 @@ const SCHEMA_HINT = [ " edges(id, src, dst, type, confidence, step, reason) -- the call/reference graph; join src/dst back to nodes.id", " embeddings(node_id, granularity, chunk_index, dim, vector, content_hash)", " cochanges(source_file, target_file, cocommit_count, total_commits_source, total_commits_target, last_cocommit_at, lift)", - " symbol_summaries(node_id, content_hash, prompt_version, model_id, summary_text, signature_summary, returns_type_summary, created_at)", " store_meta(id, schema_version, indexed_at, node_count, edge_count, ...)", ` nodes.kind values: ${NODE_KINDS.join(", ")}.`, ` edges.type values: ${RELATION_TYPES.join(", ")}.`, @@ -206,7 +205,7 @@ export function registerSqlTool(server: McpServer, ctx: ToolContext): void { { title: "Read-only SQL / Cypher over the code graph", description: [ - "Execute a read-only query against the local single-file `store.sqlite` index. Supply `sql` to query the graph and temporal tables directly (`nodes`, `edges`, `embeddings`, `cochanges`, `symbol_summaries`, `store_meta`); `cypher` is reserved for community-fork graph adapters. Results are returned as a markdown table plus raw row objects. Use this for one-off questions the higher-level tools don't cover — e.g. 'find every exported function in src/auth/'.", + "Execute a read-only query against the local single-file `store.sqlite` index. Supply `sql` to query the graph and temporal tables directly (`nodes`, `edges`, `embeddings`, `cochanges`, `store_meta`); `cypher` is reserved for community-fork graph adapters. Results are returned as a markdown table plus raw row objects. Use this for one-off questions the higher-level tools don't cover — e.g. 'find every exported function in src/auth/'.", "", SCHEMA_HINT, ].join("\n"), diff --git a/packages/storage/src/index.ts b/packages/storage/src/index.ts index a23f16f3..d23f5822 100644 --- a/packages/storage/src/index.ts +++ b/packages/storage/src/index.ts @@ -28,7 +28,6 @@ export type { SqlParam, Store, StoreMeta, - SymbolSummaryRow, TraverseQuery, TraverseResult, VectorQuery, diff --git a/packages/storage/src/interface.test.ts b/packages/storage/src/interface.test.ts index 8435959a..0b8e6dce 100644 --- a/packages/storage/src/interface.test.ts +++ b/packages/storage/src/interface.test.ts @@ -19,13 +19,7 @@ import type { CochangeRow, IGraphStore, ITemporalStore, Store } from "./interfac // what we want; the static assertion below pins the property to `never`. type IGraphStoreTemporalLeak = Extract< keyof IGraphStore, - | "exec" - | "bulkLoadCochanges" - | "lookupCochangesForFile" - | "lookupCochangesBetween" - | "bulkLoadSymbolSummaries" - | "lookupSymbolSummary" - | "lookupSymbolSummariesByNode" + "exec" | "bulkLoadCochanges" | "lookupCochangesForFile" | "lookupCochangesBetween" >; // Compile-fail wedge: if any temporal name leaked back into IGraphStore the // `never` constraint below stops typechecking. Keep this line as-is. @@ -103,7 +97,7 @@ test("IGraphStore-shaped value lacks temporal methods at runtime", () => { const bag = graphOnly as unknown as Record; assert.equal(typeof bag["lookupCochangesForFile"], "undefined"); - assert.equal(typeof bag["lookupSymbolSummary"], "undefined"); + assert.equal(typeof bag["lookupCochangesBetween"], "undefined"); assert.equal(typeof bag["exec"], "undefined"); assert.equal(graphOnly.dialect, "cypher"); }); @@ -118,10 +112,6 @@ test("ITemporalStore-shaped value lacks graph methods at runtime", () => { bulkLoadCochanges: async () => {}, lookupCochangesForFile: async (): Promise => [], lookupCochangesBetween: async () => undefined, - bulkLoadSymbolSummaries: async () => {}, - lookupSymbolSummary: async () => undefined, - lookupSymbolSummariesByNode: async () => [], - countSymbolSummaries: async () => 0, }; const bag = temporalOnly as unknown as Record; diff --git a/packages/storage/src/interface.ts b/packages/storage/src/interface.ts index 8b51722c..3b462121 100644 --- a/packages/storage/src/interface.ts +++ b/packages/storage/src/interface.ts @@ -5,11 +5,11 @@ * * 1. {@link IGraphStore} — graph-tier, pure graph operations only: * nodes, edges, traversals, BM25 search, vector search, embeddings. - * NO SQL, NO cochanges, NO symbol summaries. Cypher dialect. + * NO SQL, NO cochanges. Cypher dialect. * The portable interface community AGE / Memgraph / Neo4j / Neptune * adapters target. * 2. {@link ITemporalStore} — tabular-tier, SQL-only operations: - * cochanges, symbol summaries, the `codehub query --sql` escape hatch, + * cochanges, the `codehub query --sql` escape hatch, * and any future temporal-analytics query. Community adapters can * implement other SQL-shaped stores (Postgres, …) without affecting * graph adapters. @@ -94,8 +94,8 @@ export type GraphDialect = "cypher"; * Graph-tier interface. Pure graph operations: nodes, edges, traversals, * BM25 keyword search, vector search, embeddings. * - * **Out of scope for this interface:** SQL, cochanges, symbol summaries, - * and any tabular/time-travel queries — those live on {@link ITemporalStore}. + * **Out of scope for this interface:** SQL, cochanges, and any + * tabular/time-travel queries — those live on {@link ITemporalStore}. * * Community adapters (AGE / Memgraph / Neo4j / Neptune) implement THIS * interface only. They pair with an {@link ITemporalStore} (always @@ -360,8 +360,8 @@ export interface IGraphStore { // ───────────────────────────────────────────────────────────────────────────── /** - * Tabular/temporal interface. Cochanges, symbol summaries, time-travel - * queries, and the `codehub query --sql` escape hatch all live here. + * Tabular/temporal interface. Cochanges, time-travel queries, and the + * `codehub query --sql` escape hatch all live here. * Post-ADR 0019 the in-tree `SqliteStore` implements this alongside * {@link IGraphStore} over one `store.sqlite`; a community fork can back it * with any other SQL-shaped store (Postgres, …) on the same surface. @@ -406,38 +406,6 @@ export interface ITemporalStore { ): Promise; /** Fetch the single cochange row (if any) for an ordered pair of files. */ lookupCochangesBetween(fileA: string, fileB: string): Promise; - - // ── Symbol-summary surface (was on IGraphStore via SymbolSummaryStore) ──── - /** - * Insert or replace the supplied summary rows. Conflicts on the composite - * `(node_id, content_hash, prompt_version)` key overwrite the existing - * row. Empty input is a cheap no-op. - */ - bulkLoadSymbolSummaries(rows: readonly SymbolSummaryRow[]): Promise; - /** - * Fetch the single summary row (if any) keyed by the composite cache - * tuple. Returns `undefined` on miss. - */ - lookupSymbolSummary( - nodeId: string, - contentHash: string, - promptVersion: string, - ): Promise; - /** - * Fetch every summary row whose `node_id` appears in the supplied list. - * Result ordering is stable: sorted by `(node_id, prompt_version, - * content_hash)` so callers can pick the newest prompt version - * deterministically when more than one row per node is present. - */ - lookupSymbolSummariesByNode(nodeIds: readonly string[]): Promise; - /** - * Count distinct nodes that have at least one summary row. Used by - * `codehub status` to report whether LLM symbol summaries were generated - * for this index (they feed the dense-retrieval leg). Returns 0 — never - * throws — when the table is missing or the store is degraded, so status - * degrades gracefully. - */ - countSymbolSummaries(): Promise; } // ───────────────────────────────────────────────────────────────────────────── @@ -517,56 +485,6 @@ export interface CochangeLookupOptions { readonly minLift?: number; } -// ───────────────────────────────────────────────────────────────────────────── -// Symbol-summary row (used by ITemporalStore) -// ───────────────────────────────────────────────────────────────────────────── - -/** - * One row in the `symbol_summaries` table. Emitted by the ingestion - * `summarize` phase (structured summaries from a Bedrock LLM); read by the - * MCP layer when fusing summary-text recall with code-embedding recall (the - * SACL-style two-lane retrieval pattern). Summaries never participate in - * the graph edge set — they are content keyed by `(nodeId, contentHash, - * promptVersion)`. - */ -export interface SymbolSummaryRow { - readonly nodeId: string; - /** - * Content-addressed hash (sha256 hex) of the symbol's source text. Used - * as a cache key so re-index runs where the source hasn't moved reuse - * prior summaries for free. - */ - readonly contentHash: string; - /** - * Semver-style version tag for the prompt that produced the summary. - * Bumping the prompt invalidates prior rows without deleting them, so - * multiple versions can coexist during rollout. - */ - readonly promptVersion: string; - /** Bedrock model id (e.g. `global.anthropic.claude-haiku-4-5-...`). */ - readonly modelId: string; - /** The expanded, verb-led purpose field. */ - readonly summaryText: string; - /** Compact one-line gist of the signature (inputs + returns shape). */ - readonly signatureSummary?: string; - /** Compact summary of what the symbol returns (`returns.type_summary`). */ - readonly returnsTypeSummary?: string; - /** - * Canonical-JSON blob carrying the validated structured payload the - * summarizer produces beyond the three flattened fields above — - * citations (with `line_start` / `line_end`), `side_effects`, - * `invariants`, per-input descriptions, and `returns.details`. Persisted - * verbatim so a downstream staleness detector can fetch the cited line - * ranges and downrank summaries whose source has drifted. The storage - * layer treats this as an opaque pre-canonicalized string (the - * summarizer owns the shape); `undefined` when the producing prompt - * emitted no structured payload. - */ - readonly structuredJson?: string; - /** ISO-8601 UTC timestamp when the row was produced. */ - readonly createdAt: string; -} - // ───────────────────────────────────────────────────────────────────────────── // Shared options + result types // ───────────────────────────────────────────────────────────────────────────── @@ -837,15 +755,6 @@ export interface SearchResult { readonly filePath: string; readonly name: string; readonly kind: string; - /** - * Populated by the MCP / CLI query surfaces after the base search when a - * `symbol_summaries` row exists for this node (see {@link SymbolSummaryRow}). - * The storage-layer `search()` call never fills this — it is always a - * post-join, driven by the P04 summarize-enrichment path. - */ - readonly summary?: string; - /** Compact one-line gist of the signature, mirroring `SymbolSummaryRow.signatureSummary`. */ - readonly signatureSummary?: string; } export interface VectorQuery { diff --git a/packages/storage/src/sqlite-adapter.ts b/packages/storage/src/sqlite-adapter.ts index ba781da6..03feb46e 100644 --- a/packages/storage/src/sqlite-adapter.ts +++ b/packages/storage/src/sqlite-adapter.ts @@ -2,8 +2,8 @@ * SqliteStore — single-file storage adapter (ADR 0019). * * THESIS. One `store.sqlite` file in WAL mode backs EVERYTHING: graph nodes, - * edges, embeddings, and the temporal/non-graph tables (cochanges, symbol - * summaries). It replaced the two native-binding storage engines this project + * edges, embeddings, and the temporal/non-graph tables (cochanges). + * It replaced the two native-binding storage engines this project * used before (see ADR 0019). Collapsing both onto Node 24's built-in * `node:sqlite` removed the last two native storage dependencies, which is * what unlocked the real goal: a zero-dep, one-command, no-Docker install @@ -74,7 +74,6 @@ import type { SearchResult, SqlParam, StoreMeta, - SymbolSummaryRow, TraverseQuery, TraverseResult, VectorQuery, @@ -269,22 +268,6 @@ export class SqliteStore implements IGraphStore, ITemporalStore { CREATE INDEX IF NOT EXISTS idx_cochanges_source ON cochanges (source_file); CREATE INDEX IF NOT EXISTS idx_cochanges_target ON cochanges (target_file); `); - // Canonical 9-column symbol_summaries shape. - db.exec(` - CREATE TABLE IF NOT EXISTS symbol_summaries ( - node_id TEXT NOT NULL, - content_hash TEXT NOT NULL, - prompt_version TEXT NOT NULL, - model_id TEXT NOT NULL, - summary_text TEXT NOT NULL, - signature_summary TEXT, - returns_type_summary TEXT, - structured_json TEXT, - created_at TEXT NOT NULL, - PRIMARY KEY (node_id, content_hash, prompt_version) - ); - CREATE INDEX IF NOT EXISTS idx_summaries_node ON symbol_summaries (node_id); - `); // Single-row meta table keyed by id=1 (keeps the former GraphDbStore // StoreMeta {id:1} MERGE pattern). Typed columns so getMeta can re-attach optional // fields only when the column is non-null (exactOptional readback). @@ -953,8 +936,6 @@ export class SqliteStore implements IGraphStore, ITemporalStore { .all(...(params as SqliteParam[])) as unknown as Record[]; const out: SearchResult[] = []; for (const row of rows) { - // The storage-layer search() NEVER fills summary/signatureSummary — - // they are a post-join done by MCP/CLI. out.push({ nodeId: String(row["node_id"] ?? ""), score: Number(row["score"] ?? 0), @@ -1260,104 +1241,6 @@ export class SqliteStore implements IGraphStore, ITemporalStore { return row ? cochangeRowFromRecord(row) : undefined; } - // ── ITemporalStore: symbol summaries ───────────────────────────────────────── - - async bulkLoadSymbolSummaries(rows: readonly SymbolSummaryRow[]): Promise { - // Empty input → no-op return (NOT a table clear — symbol summaries are - // upserts, not replace). - if (rows.length === 0) return; - const db = this.conn(); - // Sort by (nodeId, contentHash, promptVersion) for insert determinism. - const sorted = [...rows].sort((a, b) => { - if (a.nodeId !== b.nodeId) return a.nodeId < b.nodeId ? -1 : 1; - if (a.contentHash !== b.contentHash) return a.contentHash < b.contentHash ? -1 : 1; - if (a.promptVersion !== b.promptVersion) return a.promptVersion < b.promptVersion ? -1 : 1; - return 0; - }); - db.exec("BEGIN"); - try { - // DELETE+INSERT upsert per composite key. - const del = db.prepare( - "DELETE FROM symbol_summaries WHERE node_id = ? AND content_hash = ? AND prompt_version = ?", - ); - const ins = db.prepare( - `INSERT INTO symbol_summaries ( - node_id, content_hash, prompt_version, model_id, - summary_text, signature_summary, returns_type_summary, - structured_json, created_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ); - for (const r of sorted) { - del.run(r.nodeId, r.contentHash, r.promptVersion); - ins.run( - r.nodeId, - r.contentHash, - r.promptVersion, - r.modelId, - r.summaryText, - r.signatureSummary ?? null, - r.returnsTypeSummary ?? null, - r.structuredJson ?? null, - r.createdAt, - ); - } - db.exec("COMMIT"); - } catch (err) { - db.exec("ROLLBACK"); - throw err; - } - } - - async lookupSymbolSummary( - nodeId: string, - contentHash: string, - promptVersion: string, - ): Promise { - const row = this.conn() - .prepare( - `SELECT node_id, content_hash, prompt_version, model_id, - summary_text, signature_summary, returns_type_summary, - structured_json, created_at - FROM symbol_summaries - WHERE node_id = ? AND content_hash = ? AND prompt_version = ? - LIMIT 1`, - ) - .get(nodeId, contentHash, promptVersion) as unknown as Record | undefined; - return row ? summaryRowFromRecord(row) : undefined; - } - - async lookupSymbolSummariesByNode( - nodeIds: readonly string[], - ): Promise { - if (nodeIds.length === 0) return []; - // ORDER BY (node_id, prompt_version, content_hash) — prompt_version - // BEFORE content_hash (differs from the bulkLoad sort) so callers pick - // the newest prompt deterministically. - const sql = `SELECT node_id, content_hash, prompt_version, model_id, - summary_text, signature_summary, returns_type_summary, - structured_json, created_at - FROM symbol_summaries - WHERE node_id IN (${placeholders(nodeIds.length)}) - ORDER BY node_id ASC, prompt_version ASC, content_hash ASC`; - const rows = this.conn() - .prepare(sql) - .all(...(nodeIds as unknown as SqliteParam[])) as unknown as Record[]; - return rows.map(summaryRowFromRecord); - } - - async countSymbolSummaries(): Promise { - // MUST catch all errors and return 0 — codehub status degrades gracefully. - try { - const row = this.conn() - .prepare("SELECT COUNT(DISTINCT node_id) AS n FROM symbol_summaries") - .get() as unknown as { n: number | bigint } | undefined; - const n = row?.n; - return typeof n === "bigint" ? Number(n) : typeof n === "number" ? n : 0; - } catch { - return 0; - } - } - private conn(): DatabaseSync { if (!this.db) throw new Error("SqliteStore: open() not called"); return this.db; @@ -1443,26 +1326,6 @@ function cochangeRowFromRecord(row: Record): CochangeRow { }; } -/** Convert a SQLite symbol_summaries row back into a {@link SymbolSummaryRow}. */ -function summaryRowFromRecord(row: Record): SymbolSummaryRow { - const sig = row["signature_summary"]; - const ret = row["returns_type_summary"]; - const structured = row["structured_json"]; - return { - nodeId: String(row["node_id"] ?? ""), - contentHash: String(row["content_hash"] ?? ""), - promptVersion: String(row["prompt_version"] ?? ""), - modelId: String(row["model_id"] ?? ""), - summaryText: String(row["summary_text"] ?? ""), - ...(sig !== null && sig !== undefined ? { signatureSummary: String(sig) } : {}), - ...(ret !== null && ret !== undefined ? { returnsTypeSummary: String(ret) } : {}), - ...(structured !== null && structured !== undefined - ? { structuredJson: String(structured) } - : {}), - createdAt: String(row["created_at"] ?? ""), - }; -} - /** * Clamp a number to a non-negative integer. Semantics carried over from the * former `clampNonNegativeIntGd` helper: `undefined` / `null` / negative / diff --git a/packages/summarizer/.npmignore b/packages/summarizer/.npmignore deleted file mode 100644 index 1aec452f..00000000 --- a/packages/summarizer/.npmignore +++ /dev/null @@ -1,5 +0,0 @@ -# Exclude compiled test files and their sourcemaps from the published package -dist/**/*.test.js -dist/**/*.test.js.map -dist/**/*.test.d.ts -dist/**/*.test.d.ts.map diff --git a/packages/summarizer/CHANGELOG.md b/packages/summarizer/CHANGELOG.md deleted file mode 100644 index 43b3ce9e..00000000 --- a/packages/summarizer/CHANGELOG.md +++ /dev/null @@ -1,25 +0,0 @@ -# Changelog - -## [0.2.0](https://github.com/theagenticguy/opencodehub/compare/summarizer-v0.1.1...summarizer-v0.2.0) (2026-06-01) - - -### ⚠ BREAKING CHANGES - -* **sweep:** the `rename` and `remove_dead_code` MCP tools are removed. OpenCodeHub plans and verifies refactors via read-only analysis (impact/context/detect_changes); it does not apply source edits. - -### Features - -* **sweep:** remediate 44 findings, rip stack-graphs + source-mutating MCP tools ([#175](https://github.com/theagenticguy/opencodehub/issues/175)) ([dbb574a](https://github.com/theagenticguy/opencodehub/commit/dbb574a11ae2d457f8f26ed69278e157189d8dad)) - -## [0.1.1](https://github.com/theagenticguy/opencodehub/compare/summarizer-v0.1.0...summarizer-v0.1.1) (2026-05-12) - - -### Features - -* initial public release of opencodehub v0.1.1 ([3f23006](https://github.com/theagenticguy/opencodehub/commit/3f230065fe17c7c0b4c5d7568063b786fb72c81f)) - - -### Documentation - -* deep refresh + sync + new architecture pages ([3693ddd](https://github.com/theagenticguy/opencodehub/commit/3693ddd57ff78978e8489c76ad7e654cdc21eb63)) -* **repo:** pre-publish npm readiness — READMEs, GOVERNANCE, CODEOWNERS, package metadata ([dd10f72](https://github.com/theagenticguy/opencodehub/commit/dd10f72aa490136076bf0632cccd2965c6b17e23)) diff --git a/packages/summarizer/README.md b/packages/summarizer/README.md deleted file mode 100644 index 97f2164e..00000000 --- a/packages/summarizer/README.md +++ /dev/null @@ -1,80 +0,0 @@ -# @opencodehub/summarizer - -Structured, citation-grounded summaries of callable code symbols, generated -by Haiku 4.5 on Bedrock and validated by a strict Zod 4 contract. Consumed -by OpenCodeHub's retrieval stack — each summary is embedded field-by-field -and fused with graph + code embeddings at query time so natural-language -questions ("where does auth token refresh happen?") match against the -described behavior, not just identifier tokens. - -## Contract - -The output shape is defined in [`src/schema.ts`](./src/schema.ts) and -enforced by `SymbolSummary.safeParse`. Fields: - -| Field | Shape | What it's for | -|---|---|---| -| `purpose` | 30-400 chars, must not start with "This function/method/class" | Verb-led behavior statement; drives `purpose` embedding for "what does this do" queries | -| `inputs` | `InputSpec[]` with `name` / `type` / `description` | Semantic (not just type-system) description of each parameter | -| `returns` | `{ type, type_summary, details }` split so constructors don't trip length caps | Describes the return contract; for `None` returns, describes the mutation the call produces | -| `side_effects` | `string[]`, each item must contain one of `reads/writes/emits/raises/mutates` | The operational contract surface — what this symbol does to the world | -| `invariants` | `string[] \| null` — optional caller-side preconditions | Contracts enforced by the code, not general advice | -| `citations` | `Citation[]` — at least one per populated field | Line ranges grounding every claim; used by a staleness detector to drop drifted summaries | - -## Usage - -```ts -import { BedrockRuntimeClient } from "@aws-sdk/client-bedrock-runtime"; -import { summarizeSymbol } from "@opencodehub/summarizer"; - -const client = new BedrockRuntimeClient({ region: "us-east-1" }); - -const result = await summarizeSymbol(client, { - source: "", - filePath: "src/foo.py", - lineStart: 42, - lineEnd: 98, - docstring: "", - enclosingClass: "FooClass", -}); - -console.log(result.summary.purpose); -console.log(`attempts=${result.attempts} cacheRead=${result.usageByAttempt[0].cacheRead}`); -``` - -## Integration - -- **Ingestion call site:** `packages/ingestion/src/pipeline/phases/summarize.ts` - invokes `summarizeSymbol` once per high-confidence callable (SCIP-backed - Function / Method / Class), gated by `--summaries` + budget + offline flags. - Results land in the `symbol_summaries` table in store.sqlite (see - `packages/storage/src/schema-ddl.ts`); they never mutate graph nodes or edges. -- **Retrieval site:** `packages/ingestion/src/pipeline/phases/embeddings.ts` - fuses each summary into the symbol-tier embedding (`signature + summary + - body`), so natural-language queries match against described behavior at - vector-search time. - -## Bedrock specifics - -- **Model:** `global.anthropic.claude-haiku-4-5-20251001-v1:0` (override via - `SummarizeOptions.modelId`). -- **Prompt caching:** two `cachePoint` blocks engage caching — one after the - system prompt, one after the tool spec. Haiku 4.5 on Bedrock requires a - **4,096-token** cacheable prefix; `src/prompt.ts` is sized to ~5,050 tokens - to clear the floor with margin. -- **Structured output:** the tool's `inputSchema` is the JSON Schema - exported from Zod via `z.toJSONSchema`. `toolChoice` forces the model to - respond through the tool, so free-form prose is not possible. -- **ReAct retry:** on `safeParse` failure, the client feeds the error list - back through a `toolResult(status: "error")` content block and re-calls - Converse. Max 3 attempts by default. The retry is load-bearing: in spike - testing, strict validators caught real issues (`side_effects` items - missing a required verb, citations falling outside the source span), and - Haiku recovered on attempt 2 given the structured error. - -## Reference spikes - -See [`reference/`](./reference/) for the three spikes that established the -wire-level behavior — the Anthropic SDK failure, the boto3 Converse port -that fixed caching, and the TypeScript / Zod 4 / SDK v3 port that this -package was extracted from. diff --git a/packages/summarizer/package.json b/packages/summarizer/package.json deleted file mode 100644 index 2accd86b..00000000 --- a/packages/summarizer/package.json +++ /dev/null @@ -1,67 +0,0 @@ -{ - "name": "@opencodehub/summarizer", - "version": "0.2.0", - "private": true, - "description": "OpenCodeHub — structured code-symbol summarizer (Haiku 4.5 via Bedrock Converse + Zod 4)", - "license": "Apache-2.0", - "repository": { - "type": "git", - "url": "git+https://github.com/theagenticguy/opencodehub.git", - "directory": "packages/summarizer" - }, - "homepage": "https://github.com/theagenticguy/opencodehub#readme", - "bugs": { - "url": "https://github.com/theagenticguy/opencodehub/issues" - }, - "type": "module", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - } - }, - "files": [ - "dist/**/*.js", - "!dist/**/*.test.js", - "dist/**/*.d.ts", - "!dist/**/*.test.d.ts", - "dist/**/*.js.map", - "!dist/**/*.test.js.map", - "dist/**/*.d.ts.map", - "!dist/**/*.test.d.ts.map", - "reference" - ], - "scripts": { - "build": "tsc -b", - "test": "node --test \"./dist/**/*.test.js\"", - "clean": "rm -rf dist *.tsbuildinfo" - }, - "dependencies": { - "@aws-sdk/client-bedrock-runtime": "3.1079.0", - "zod": "4.4.3" - }, - "devDependencies": { - "@types/node": "26.1.0", - "typescript": "6.0.3" - }, - "publishConfig": { - "access": "public" - }, - "keywords": [ - "opencodehub", - "code-intelligence", - "mcp", - "model-context-protocol", - "ai", - "code-graph", - "static-analysis", - "llm", - "bedrock", - "code-summaries" - ], - "engines": { - "node": ">=24.15.0" - } -} diff --git a/packages/summarizer/reference/README.md b/packages/summarizer/reference/README.md deleted file mode 100644 index 6e0f14c5..00000000 --- a/packages/summarizer/reference/README.md +++ /dev/null @@ -1,57 +0,0 @@ -# Summarizer reference spikes - -These are the three spikes that established the contract and wire-level -behavior for `@opencodehub/summarizer`. They are preserved here as -documentation — not runtime code — so the design record stays close to the -package it motivated. - -| File | Stack | What it proved | -|---|---|---| -| `spike-01-anthropic-sdk-bedrock.py` | `anthropic.AnthropicBedrock` + Pydantic v2 | First end-to-end validation of the Pydantic-schema-as-tool-input + ReAct retry pattern. Caught the early "banned prefix" + "side-effects verb" validators firing correctly on Haiku 4.5. Left one open question: `cacheReadInputTokens` reported 0 across all calls. | -| `spike-02-boto3-converse.py` | `boto3` Bedrock Runtime Converse + Pydantic v2 | Dropped the Anthropic SDK adapter and talked to Converse directly. Proved caching engages when the cacheable prefix clears Haiku 4.5's **4,096-token** floor (not 1,024 as Anthropic's first-party docs imply). Measured 72.7% cache efficiency on call 2. Exposed a contract bug: the original `returns.description` max_length=200 was too tight for constructor-heavy symbols. | -| `spike-03-sdk-v3-zod4-converse.ts` | AWS SDK v3 `@aws-sdk/client-bedrock-runtime` + Zod 4 | TypeScript port of spike 02. Confirmed Zod 4's built-in `z.toJSONSchema` emits Draft 2020-12 that Bedrock accepts without conversion. Verified `.strict()` + `superRefine` replicates Pydantic's `extra="forbid"` + `model_validator(mode="after")`. Split `returns.description` into `returns.type_summary` (10-80) + `returns.details` (20-400) and hit **2/2 first-attempt validity** (vs 0/2 in spike 02). Same 72.6% cache efficiency on call 2 — wire parity with boto3 confirmed. | - -## Key facts the spikes established - -- **Haiku 4.5 on Bedrock caches at a 4,096-token floor, not 1,024.** The - Anthropic first-party docs quote 1,024; Bedrock enforces 4,096 specifically - for Haiku 4.5. Caching silently no-ops below the floor. -- **Converse API is the right primitive.** `cachePoint` content blocks - inside `system` and `toolConfig.tools` are the only way to get cache - observability; the Anthropic SDK's Bedrock adapter didn't plumb it through - in our tests. -- **ReAct retry via `toolResult(status: "error")` is load-bearing.** Strict - validators routinely catch real issues (verb requirement on side_effects, - banned-prefix on purpose, citation coverage on populated fields). Haiku - recovers on attempt 2 given structured error feedback. -- **Rich system prompts double as cache padding.** The three-example rubric - in `src/prompt.ts` is ~5,050 tokens — it clears the 4,096 floor AND pushed - first-attempt validity from 0/2 (no examples) to 2/2 (three examples). - -## Running the spikes - -The Python spikes use PEP 723 inline deps and run under `uv`: - -```bash -AWS_PROFILE=bedrock-a AWS_REGION=us-east-1 uv run packages/summarizer/reference/spike-02-boto3-converse.py -``` - -The TS spike runs under `tsx` from the repo root (SDK v3 + Zod 4 are already -workspace-root devDeps): - -```bash -AWS_PROFILE=bedrock-a AWS_REGION=us-east-1 pnpm exec tsx packages/summarizer/reference/spike-03-sdk-v3-zod4-converse.ts -``` - -## When to come back here - -Read the spikes when: - -- Bedrock ships a new Haiku minor and you suspect the cache floor changed. -- You're adding a new validator to `src/schema.ts` and want to see how - existing ones were stress-tested. -- You need to debug "why isn't caching working" — spike 01 is the record - of how the failure looks on the wire. -- You want to port the summarizer contract to a new model (Opus, Sonnet, - a non-Anthropic Bedrock model) — the spikes are the minimum-viable - harness for checking schema + caching + retry end-to-end. diff --git a/packages/summarizer/reference/spike-01-anthropic-sdk-bedrock.py b/packages/summarizer/reference/spike-01-anthropic-sdk-bedrock.py deleted file mode 100644 index 28db64ca..00000000 --- a/packages/summarizer/reference/spike-01-anthropic-sdk-bedrock.py +++ /dev/null @@ -1,669 +0,0 @@ -# /// script -# requires-python = ">=3.12" -# dependencies = [ -# "anthropic>=0.40.0", -# "pydantic>=2.9", -# "boto3>=1.35.0", -# ] -# /// -"""Spike: Haiku 4.5 structured symbol summaries on Bedrock with Pydantic v2 validation. - -Generates structured NL summaries of callable symbols (function/method/class) at index -time for OpenCodeHub's code retrieval engine. Uses Claude's tool_use pattern as the -native structured-output primitive — the Pydantic model exports a JSON Schema that -becomes the tool's input_schema, so the model can only respond by filling that schema. - -Execute with: uv run scripts/spike-haiku-summarizer.py -""" - -from __future__ import annotations - -import json -import sys -import time -from typing import Any, Literal - -from anthropic import AnthropicBedrock -from anthropic import APIError, NotFoundError -from botocore.exceptions import NoCredentialsError -from pydantic import ( - BaseModel, - ConfigDict, - Field, - ValidationError, - field_validator, - model_validator, -) - - -# --------------------------------------------------------------------------- -# Pydantic model — this IS the structured output contract. -# Every field is validated strictly; errors feed back into the ReAct loop. -# --------------------------------------------------------------------------- - - -class InputSpec(BaseModel): - model_config = ConfigDict(extra="forbid", strict=True) - - name: str = Field(..., min_length=1, max_length=128) - type: str = Field(..., min_length=1, max_length=256, description='May be "unknown" when annotation is absent.') - description: str = Field(..., min_length=20, max_length=200) - - -class ReturnSpec(BaseModel): - model_config = ConfigDict(extra="forbid", strict=True) - - type: str = Field(..., min_length=1, max_length=256) - description: str = Field( - ..., - min_length=10, - max_length=200, - description='For None returns, describe the side-effect the call produces.', - ) - - -class Citation(BaseModel): - model_config = ConfigDict(extra="forbid", strict=True) - - field_name: Literal["purpose", "inputs", "returns", "side_effects", "invariants"] - line_start: int = Field(..., ge=1) - line_end: int = Field(..., ge=1) - - @model_validator(mode="after") - def _line_range_ordered(self) -> Citation: - if self.line_end < self.line_start: - raise ValueError( - f"line_end ({self.line_end}) must be >= line_start ({self.line_start})" - ) - return self - - -_SIDE_EFFECT_VERBS = ("reads", "writes", "emits", "raises", "mutates") -_BANNED_PURPOSE_PREFIXES = ( - "this function", - "this method", - "this class", -) - - -class SymbolSummary(BaseModel): - """Structured NL summary of one callable symbol for embedding + retrieval.""" - - model_config = ConfigDict(extra="forbid", strict=True) - - purpose: str = Field(..., min_length=30, max_length=400) - inputs: list[InputSpec] = Field( - default_factory=list, - description="Empty list is valid for a nullary callable.", - ) - returns: ReturnSpec - side_effects: list[str] = Field( - default_factory=list, - description="Empty list means pure. Items 10-200 chars.", - ) - invariants: list[str] | None = Field( - default=None, - description="Preconditions / invariants the caller must uphold.", - ) - citations: list[Citation] = Field(..., min_length=1) - - # Context injected by the summarizer so model_validator can check line ranges. - # This is NOT part of the tool schema — it's populated post-hoc for validation. - _source_line_start: int = 0 - _source_line_end: int = 0 - - @field_validator("purpose") - @classmethod - def _purpose_not_boilerplate(cls, v: str) -> str: - stripped = v.lstrip().lower() - for banned in _BANNED_PURPOSE_PREFIXES: - if stripped.startswith(banned): - raise ValueError( - f'purpose must not start with "{banned.title()}" — describe the behavior directly' - ) - return v - - @field_validator("side_effects") - @classmethod - def _side_effects_shape(cls, v: list[str]) -> list[str]: - for item in v: - if not (10 <= len(item) <= 200): - raise ValueError(f"side_effects item must be 10-200 chars, got {len(item)}: {item!r}") - if not any(verb in item.lower() for verb in _SIDE_EFFECT_VERBS): - raise ValueError( - f"side_effects item must mention one of {_SIDE_EFFECT_VERBS}: {item!r}" - ) - return v - - @field_validator("invariants") - @classmethod - def _invariants_shape(cls, v: list[str] | None) -> list[str] | None: - if v is None: - return v - for item in v: - if not (10 <= len(item) <= 300): - raise ValueError(f"invariants item must be 10-300 chars, got {len(item)}: {item!r}") - return v - - @model_validator(mode="after") - def _citations_cover_populated_fields(self) -> SymbolSummary: - cited_fields = {c.field_name for c in self.citations} - populated: set[str] = {"purpose", "returns"} # always required - if self.inputs: - populated.add("inputs") - if self.side_effects: - populated.add("side_effects") - if self.invariants: - populated.add("invariants") - - missing = populated - cited_fields - if missing: - raise ValueError( - f"citations missing for populated fields: {sorted(missing)}. " - f"Every populated field needs >=1 citation." - ) - return self - - -def _validate_citation_lines( - summary_input: dict[str, Any], - *, - source_line_start: int, - source_line_end: int, -) -> list[str]: - """Return a list of validation errors for citations whose line ranges fall - outside the supplied source span. Runs after model_validate succeeds.""" - errors: list[str] = [] - for i, cit in enumerate(summary_input.get("citations", [])): - ls, le = cit.get("line_start"), cit.get("line_end") - if ls is None or le is None: - continue - if ls < source_line_start or le > source_line_end: - errors.append( - f"citations[{i}]: line range [{ls}, {le}] falls outside source span " - f"[{source_line_start}, {source_line_end}]" - ) - return errors - - -# --------------------------------------------------------------------------- -# Claude wiring — tool_use with cached system prompt. -# --------------------------------------------------------------------------- - - -TOOL_NAME = "emit_symbol_summary" - -SYSTEM_PROMPT = """You are a code-understanding assistant that generates structured summaries of callable symbols (functions, methods, classes) for a code-retrieval engine. - -Your output is consumed by an embedding model, weighted per-field at retrieval time, and must be citation-grounded so we can detect staleness. You MUST respond by calling the `emit_symbol_summary` tool — not with free-form text. - -Core rules: -- `purpose` must describe what the symbol *does*, not what it *is*. Never start with "This function/method/class". Write 1-2 sentences, 30-400 chars. -- `inputs`: one entry per parameter (skip `self`/`cls`). Use `"unknown"` for the type when there's no annotation. Description 20-200 chars. -- `returns.type`: the declared or inferred return type. For `None` returns, set type="None" and describe the side effect the call produces. -- `side_effects`: each item 10-200 chars and MUST include one of: reads, writes, emits, raises, mutates. Empty list = pure function. -- `invariants`: optional preconditions the caller must uphold. Each 10-300 chars. Omit (or null) if none. -- `citations`: one Citation per populated field, with `line_start`/`line_end` inside the supplied source span. At minimum cite `purpose` and `returns`. - -Call `emit_symbol_summary` exactly once per request.""" - - -def _build_tool_definition(schema: dict[str, Any]) -> dict[str, Any]: - """Build the Anthropic tool definition from the Pydantic JSON Schema. - - We strip Pydantic's private attrs (leading underscore) and the $defs/title - metadata Anthropic doesn't need, and put cache_control on the tool so the - schema is cached alongside the system prompt. - """ - # Drop the private _source_line_* attrs — they're Python-side only. - schema = json.loads(json.dumps(schema)) # deep copy - props = schema.get("properties", {}) - for k in list(props.keys()): - if k.startswith("_"): - del props[k] - - return { - "name": TOOL_NAME, - "description": ( - "Emit the structured summary for the supplied callable symbol. " - "Every field is validated strictly; schema violations will be returned for retry." - ), - "input_schema": schema, - "cache_control": {"type": "ephemeral"}, - } - - -def _build_user_message( - *, - source: str, - file_path: str, - line_start: int, - line_end: int, - docstring: str | None, - enclosing_class: str | None, -) -> str: - docstring_block = docstring.strip() if docstring else "(no docstring)" - class_block = enclosing_class or "(module-level — no enclosing class)" - return f"""Summarize the callable symbol below. - -{file_path} -{class_block} -{line_start}-{line_end} - - -{docstring_block} - - - -{source} - - -Call `emit_symbol_summary` now. Citations must reference lines within {line_start}-{line_end}.""" - - -def _format_validation_errors(exc: ValidationError, extra: list[str] | None = None) -> str: - lines = [] - for err in exc.errors(): - loc = ".".join(str(p) for p in err["loc"]) - lines.append(f"- loc={loc} type={err['type']} msg={err['msg']} input={err.get('input')!r}") - if extra: - lines.extend(f"- {e}" for e in extra) - return "\n".join(lines) - - -# --------------------------------------------------------------------------- -# ReAct retry loop. -# --------------------------------------------------------------------------- - - -MAX_ATTEMPTS = 3 - - -class SummarizerResult: - def __init__( - self, - summary: SymbolSummary, - attempts: int, - usage_by_attempt: list[dict[str, int]], - wall_clock_s: float, - validation_failures: list[str], - ): - self.summary = summary - self.attempts = attempts - self.usage_by_attempt = usage_by_attempt - self.wall_clock_s = wall_clock_s - self.validation_failures = validation_failures - - -def summarize_symbol( - client: AnthropicBedrock, - model_id: str, - *, - source: str, - file_path: str, - line_start: int, - line_end: int, - docstring: str | None, - enclosing_class: str | None, -) -> SummarizerResult: - schema = SymbolSummary.model_json_schema() - tool_def = _build_tool_definition(schema) - - system_blocks = [ - { - "type": "text", - "text": SYSTEM_PROMPT, - "cache_control": {"type": "ephemeral"}, - } - ] - - user_text = _build_user_message( - source=source, - file_path=file_path, - line_start=line_start, - line_end=line_end, - docstring=docstring, - enclosing_class=enclosing_class, - ) - - messages: list[dict[str, Any]] = [ - {"role": "user", "content": [{"type": "text", "text": user_text}]} - ] - - usage_by_attempt: list[dict[str, int]] = [] - validation_failures: list[str] = [] - last_raw_output: Any = None - last_exc: Exception | None = None - - t0 = time.monotonic() - - for attempt in range(1, MAX_ATTEMPTS + 1): - response = client.messages.create( - model=model_id, - max_tokens=2048, - temperature=0, - system=system_blocks, - tools=[tool_def], - tool_choice={"type": "tool", "name": TOOL_NAME}, - messages=messages, - ) - - usage = response.usage - usage_by_attempt.append( - { - "input_tokens": getattr(usage, "input_tokens", 0) or 0, - "output_tokens": getattr(usage, "output_tokens", 0) or 0, - "cache_read_input_tokens": getattr(usage, "cache_read_input_tokens", 0) or 0, - "cache_creation_input_tokens": getattr(usage, "cache_creation_input_tokens", 0) or 0, - } - ) - last_raw_output = response.content - - # Find the tool_use block. - tool_use_block = next((b for b in response.content if b.type == "tool_use"), None) - if tool_use_block is None: - failure = f"attempt {attempt}: model did not call the tool (stop_reason={response.stop_reason})" - validation_failures.append(failure) - messages.append({"role": "assistant", "content": response.content}) - messages.append( - { - "role": "user", - "content": [ - { - "type": "text", - "text": ( - "You did not call the emit_symbol_summary tool. " - "Call it now with a valid structured summary." - ), - } - ], - } - ) - continue - - candidate = tool_use_block.input - if not isinstance(candidate, dict): - failure = f"attempt {attempt}: tool input was not a dict (got {type(candidate).__name__})" - validation_failures.append(failure) - messages.append({"role": "assistant", "content": response.content}) - messages.append( - { - "role": "user", - "content": [ - { - "type": "tool_result", - "tool_use_id": tool_use_block.id, - "is_error": True, - "content": failure + " — emit a JSON object matching the schema.", - } - ], - } - ) - continue - - # Validate against Pydantic. - try: - summary = SymbolSummary.model_validate(candidate) - # Extra check: citations must reference lines inside the supplied source span. - line_errors = _validate_citation_lines( - candidate, - source_line_start=line_start, - source_line_end=line_end, - ) - if line_errors: - raise ValueError("Citation line-range errors:\n" + "\n".join(line_errors)) - - return SummarizerResult( - summary=summary, - attempts=attempt, - usage_by_attempt=usage_by_attempt, - wall_clock_s=time.monotonic() - t0, - validation_failures=validation_failures, - ) - - except (ValidationError, ValueError) as exc: - last_exc = exc - if isinstance(exc, ValidationError): - err_text = _format_validation_errors(exc) - else: - err_text = str(exc) - failure = f"attempt {attempt}:\n{err_text}" - validation_failures.append(failure) - - # Feed the error back into the conversation — true ReAct. - messages.append({"role": "assistant", "content": response.content}) - messages.append( - { - "role": "user", - "content": [ - { - "type": "tool_result", - "tool_use_id": tool_use_block.id, - "is_error": True, - "content": ( - "Your previous output failed validation. " - "Fix these errors and call emit_symbol_summary again.\n\n" - f"Errors:\n{err_text}" - ), - } - ], - } - ) - - raise RuntimeError( - f"summarize_symbol failed after {MAX_ATTEMPTS} attempts. " - f"Last error:\n{last_exc}\n\nLast raw output:\n{last_raw_output!r}" - ) - - -# --------------------------------------------------------------------------- -# Test harness — run against a real function from strands-agents. -# --------------------------------------------------------------------------- - - -# Real function from /Users/lalsaado/Projects/sdk-python/src/strands/agent/agent.py -# lines 503-549. Pulled in-line so the spike is self-contained. -TEST_SOURCE = ''' async def invoke_async( - self, - prompt: AgentInput = None, - *, - invocation_state: dict[str, Any] | None = None, - structured_output_model: type[BaseModel] | None = None, - structured_output_prompt: str | None = None, - **kwargs: Any, - ) -> AgentResult: - """Process a natural language prompt through the agent's event loop. - - This method implements the conversational interface with multiple input patterns: - - String input: Simple text input - - ContentBlock list: Multi-modal content blocks - - Message list: Complete messages with roles - - No input: Use existing conversation history - - Args: - prompt: User input in various formats: - - str: Simple text input - - list[ContentBlock]: Multi-modal content blocks - - list[Message]: Complete messages with roles - - None: Use existing conversation history - invocation_state: Additional parameters to pass through the event loop. - structured_output_model: Pydantic model type(s) for structured output (overrides agent default). - structured_output_prompt: Custom prompt for forcing structured output (overrides agent default). - **kwargs: Additional parameters to pass through the event loop.[Deprecating] - - Returns: - Result: object containing: - - - stop_reason: Why the event loop stopped (e.g., "end_turn", "max_tokens") - - message: The final message from the model - - metrics: Performance metrics from the event loop - - state: The final state of the event loop - """ - events = self.stream_async( - prompt, - invocation_state=invocation_state, - structured_output_model=structured_output_model, - structured_output_prompt=structured_output_prompt, - **kwargs, - ) - async for event in events: - _ = event - - return cast(AgentResult, event["result"]) -''' - -TEST_DOCSTRING = """Process a natural language prompt through the agent's event loop. - -This method implements the conversational interface with multiple input patterns: -- String input: Simple text input -- ContentBlock list: Multi-modal content blocks -- Message list: Complete messages with roles -- No input: Use existing conversation history -""" - - -PRIMARY_MODEL_ID = "global.anthropic.claude-haiku-4-5-20251001-v1:0" -FALLBACK_MODEL_ID = "us.anthropic.claude-haiku-4-5-20251001-v1:0" - - -def _make_client() -> AnthropicBedrock: - try: - return AnthropicBedrock() - except NoCredentialsError: - print( - "ERROR: no AWS credentials found. Export AWS_PROFILE= " - "or run `aws sso login` first.", - file=sys.stderr, - ) - sys.exit(2) - - -def _choose_model(client: AnthropicBedrock) -> str: - """Return the first model ID that doesn't 404 on a trivial ping.""" - for mid in (PRIMARY_MODEL_ID, FALLBACK_MODEL_ID): - try: - client.messages.create( - model=mid, - max_tokens=16, - messages=[{"role": "user", "content": "ping"}], - ) - return mid - except NotFoundError: - print(f"Model {mid} not available, trying next...", file=sys.stderr) - continue - except APIError as e: - # Permission / access errors also indicate wrong model for this account. - if "ValidationException" in str(e) or "AccessDeniedException" in str(e): - print(f"Model {mid} rejected ({e}), trying next...", file=sys.stderr) - continue - raise - raise RuntimeError( - f"Neither {PRIMARY_MODEL_ID} nor {FALLBACK_MODEL_ID} is available in this account." - ) - - -def main() -> int: - print("=" * 80) - print("Exported JSON Schema (sent to Claude as the emit_symbol_summary tool schema):") - print("=" * 80) - schema = SymbolSummary.model_json_schema() - # Hide the private attrs from the printed schema too. - schema_to_show = json.loads(json.dumps(schema)) - for k in list(schema_to_show.get("properties", {}).keys()): - if k.startswith("_"): - del schema_to_show["properties"][k] - print(json.dumps(schema_to_show, indent=2)) - print() - - client = _make_client() - model_id = _choose_model(client) - print(f"Using model: {model_id}\n") - - result = summarize_symbol( - client, - model_id, - source=TEST_SOURCE, - file_path="/Users/lalsaado/Projects/sdk-python/src/strands/agent/agent.py", - line_start=503, - line_end=549, - docstring=TEST_DOCSTRING, - enclosing_class="Agent", - ) - - print("=" * 80) - print("Validated SymbolSummary:") - print("=" * 80) - print(result.summary.model_dump_json(indent=2)) - print() - - # Aggregate usage across attempts. - total_input = sum(u["input_tokens"] for u in result.usage_by_attempt) - total_output = sum(u["output_tokens"] for u in result.usage_by_attempt) - total_cache_read = sum(u["cache_read_input_tokens"] for u in result.usage_by_attempt) - total_cache_write = sum(u["cache_creation_input_tokens"] for u in result.usage_by_attempt) - cache_denominator = total_input + total_cache_read + total_cache_write - cache_hit_rate = (total_cache_read / cache_denominator) if cache_denominator else 0.0 - - print("=" * 80) - print("Run statistics") - print("=" * 80) - print(f"Attempts: {result.attempts}") - print(f"Wall-clock: {result.wall_clock_s:.2f}s") - print(f"Per-attempt usage:") - for i, u in enumerate(result.usage_by_attempt, 1): - print( - f" #{i}: input={u['input_tokens']:>5} output={u['output_tokens']:>5} " - f"cache_read={u['cache_read_input_tokens']:>5} cache_write={u['cache_creation_input_tokens']:>5}" - ) - print( - f"Total: input={total_input} output={total_output} " - f"cache_read={total_cache_read} cache_write={total_cache_write}" - ) - print(f"Cache hit rate: {cache_hit_rate:.1%}") - - if result.validation_failures: - print() - print("Validation failures (model recovered):") - for f in result.validation_failures: - print(f" {f}") - print() - - print("=" * 80) - print("Engineering takeaway") - print("=" * 80) - per_call_s = result.wall_clock_s / max(result.attempts, 1) - recovery = ( - "recovered via ReAct feedback" - if result.validation_failures - else "validated on first attempt" - ) - cache_note = ( - "cache hit 0% — Bedrock did not register cache writes on the system+tool prefix " - "this run; caching on AnthropicBedrock needs verification before banking on index-time " - "savings (likely needs a prefix ~>1024 tokens and/or a second call within TTL)" - if total_cache_read == 0 and total_cache_write == 0 - else f"cache hit rate {cache_hit_rate:.1%}" - ) - takeaway = ( - f"Haiku 4.5 on Bedrock ({model_id}) produced a schema-conforming SymbolSummary in " - f"{result.attempts} attempt(s) ({recovery}) in {result.wall_clock_s:.2f}s wall-clock " - f"(~{per_call_s:.2f}s/call). Tokens: {total_input} input + {total_output} output; " - f"{cache_note}. The tool_use pattern + strict Pydantic v2 validators + a 3-turn ReAct " - f"loop is the right shape for OpenCodeHub's index-time summarizer: the banned-prefix " - f"purpose validator, verb-must-appear side_effects rule, and per-field citation-coverage " - f"model_validator each caught real issues Haiku then self-corrected on, with validation " - f"errors fed back as tool_result is_error=True. Next steps: confirm prompt caching " - f"actually lights up on Bedrock (test with second call inside TTL), batch via Bedrock " - f"batch API, measure recovery rate on ~1k real symbols, and tune the system prompt " - f"with 1-2 few-shot examples to push first-attempt validity above ~80%." - ) - # Crude 150-word cap. - words = takeaway.split() - if len(words) > 150: - takeaway = " ".join(words[:150]) + "..." - print(takeaway) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/packages/summarizer/reference/spike-02-boto3-converse.py b/packages/summarizer/reference/spike-02-boto3-converse.py deleted file mode 100644 index 0b9e584c..00000000 --- a/packages/summarizer/reference/spike-02-boto3-converse.py +++ /dev/null @@ -1,1137 +0,0 @@ -# /// script -# requires-python = ">=3.12" -# dependencies = [ -# "boto3>=1.35.0", -# "pydantic>=2.9", -# ] -# /// -"""Spike: Haiku 4.5 structured symbol summaries via Bedrock `converse` (raw boto3). - -The previous spike (scripts/spike-haiku-summarizer.py) used the Anthropic SDK's -AnthropicBedrock adapter and came back with cacheReadInputTokens=0 — -caching did not engage. This spike drops the adapter and talks to Bedrock -Runtime `converse` directly so we have total control over the wire format, -insert explicit `cachePoint` blocks in `system` and `toolConfig.tools`, pad the -system prompt above Haiku 4.5's 4,096-token cache floor (per the Bedrock model -card) with real rubric + few-shot, and run two back-to-back calls to prove -warm-cache hits. - -Run with: - AWS_PROFILE=bedrock-a AWS_REGION=us-east-1 uv run scripts/spike-haiku-converse.py -""" - -from __future__ import annotations - -import json -import sys -import time -from typing import Any, Literal - -import boto3 -from botocore.exceptions import ClientError, NoCredentialsError -from pydantic import ( - BaseModel, - ConfigDict, - Field, - ValidationError, - field_validator, - model_validator, -) - - -# --------------------------------------------------------------------------- -# Pydantic model — identical contract to the prior spike. -# --------------------------------------------------------------------------- - - -class InputSpec(BaseModel): - model_config = ConfigDict(extra="forbid", strict=True) - - name: str = Field(..., min_length=1, max_length=128) - type: str = Field(..., min_length=1, max_length=256, description='May be "unknown" when annotation is absent.') - description: str = Field(..., min_length=20, max_length=200) - - -class ReturnSpec(BaseModel): - model_config = ConfigDict(extra="forbid", strict=True) - - type: str = Field(..., min_length=1, max_length=256) - description: str = Field( - ..., - min_length=10, - max_length=200, - description="For None returns, describe the side-effect the call produces.", - ) - - -class Citation(BaseModel): - model_config = ConfigDict(extra="forbid", strict=True) - - field_name: Literal["purpose", "inputs", "returns", "side_effects", "invariants"] - line_start: int = Field(..., ge=1) - line_end: int = Field(..., ge=1) - - @model_validator(mode="after") - def _line_range_ordered(self) -> "Citation": - if self.line_end < self.line_start: - raise ValueError( - f"line_end ({self.line_end}) must be >= line_start ({self.line_start})" - ) - return self - - -_SIDE_EFFECT_VERBS = ("reads", "writes", "emits", "raises", "mutates") -_BANNED_PURPOSE_PREFIXES = ( - "this function", - "this method", - "this class", -) - - -class SymbolSummary(BaseModel): - """Structured NL summary of one callable symbol for embedding + retrieval.""" - - model_config = ConfigDict(extra="forbid", strict=True) - - purpose: str = Field(..., min_length=30, max_length=400) - inputs: list[InputSpec] = Field( - default_factory=list, - description="Empty list is valid for a nullary callable.", - ) - returns: ReturnSpec - side_effects: list[str] = Field( - default_factory=list, - description="Empty list means pure. Items 10-200 chars.", - ) - invariants: list[str] | None = Field( - default=None, - description="Preconditions / invariants the caller must uphold.", - ) - citations: list[Citation] = Field(..., min_length=1) - - # Context injected post-hoc; not part of the tool schema. - _source_line_start: int = 0 - _source_line_end: int = 0 - - @field_validator("purpose") - @classmethod - def _purpose_not_boilerplate(cls, v: str) -> str: - stripped = v.lstrip().lower() - for banned in _BANNED_PURPOSE_PREFIXES: - if stripped.startswith(banned): - raise ValueError( - f'purpose must not start with "{banned.title()}" — describe the behavior directly' - ) - return v - - @field_validator("side_effects") - @classmethod - def _side_effects_shape(cls, v: list[str]) -> list[str]: - for item in v: - if not (10 <= len(item) <= 200): - raise ValueError(f"side_effects item must be 10-200 chars, got {len(item)}: {item!r}") - if not any(verb in item.lower() for verb in _SIDE_EFFECT_VERBS): - raise ValueError( - f"side_effects item must mention one of {_SIDE_EFFECT_VERBS}: {item!r}" - ) - return v - - @field_validator("invariants") - @classmethod - def _invariants_shape(cls, v: list[str] | None) -> list[str] | None: - if v is None: - return v - for item in v: - if not (10 <= len(item) <= 300): - raise ValueError(f"invariants item must be 10-300 chars, got {len(item)}: {item!r}") - return v - - @model_validator(mode="after") - def _citations_cover_populated_fields(self) -> "SymbolSummary": - cited_fields = {c.field_name for c in self.citations} - populated: set[str] = {"purpose", "returns"} - if self.inputs: - populated.add("inputs") - if self.side_effects: - populated.add("side_effects") - if self.invariants: - populated.add("invariants") - - missing = populated - cited_fields - if missing: - raise ValueError( - f"citations missing for populated fields: {sorted(missing)}. " - f"Every populated field needs >=1 citation." - ) - return self - - -def _validate_citation_lines( - summary_input: dict[str, Any], - *, - source_line_start: int, - source_line_end: int, -) -> list[str]: - """Return errors for citations whose line ranges fall outside the source span.""" - errors: list[str] = [] - for i, cit in enumerate(summary_input.get("citations", [])): - ls, le = cit.get("line_start"), cit.get("line_end") - if ls is None or le is None: - continue - if ls < source_line_start or le > source_line_end: - errors.append( - f"citations[{i}]: line range [{ls}, {le}] falls outside source span " - f"[{source_line_start}, {source_line_end}]" - ) - return errors - - -# --------------------------------------------------------------------------- -# System prompt — padded with rubric and three few-shot examples so the cached -# prefix clears Haiku 4.5's 4,096-token cache floor (per Bedrock model card), -# and so first-attempt validity is high enough that the ReAct loop rarely -# needs to fire. Everything here gets cached (it all sits BEFORE the cachePoint). -# --------------------------------------------------------------------------- - - -SYSTEM_PROMPT = """You are a code-understanding assistant. You generate structured, citation-grounded summaries of callable symbols (functions, methods, classes) for OpenCodeHub's code-retrieval engine. Your output is consumed by an embedding model, is weighted per-field at retrieval time, and must cite line ranges so we can detect staleness when source drifts. You MUST respond by calling the `emit_symbol_summary` tool — never with free-form prose. - -================================================================================ -WHY THIS FORMAT EXISTS (the retrieval-side context) -================================================================================ - -OpenCodeHub indexes callable symbols from large Python repositories and serves -them to humans and coding agents at question time. At indexing time, each -symbol is summarized into the structured shape you are producing. That -structured summary is then: - - 1. Embedded field-by-field. `purpose` drives semantic recall, `inputs` and - `returns` ground typed questions, `side_effects` surfaces the operational - footprint, `invariants` surface caller-side contracts. We weight each - field at query time by the intent of the question (e.g., "what can this - break?" favors side_effects + invariants; "what does this do?" favors - purpose). Vague `purpose` text collapses recall. - 2. Re-ranked using the `citations` you provide. At query time we fetch the - cited line ranges from the source tree and compare them against what you - claimed. If the lines have drifted, we downrank the summary and flag it - for refresh. Citations that span the entire symbol defeat the staleness - detector; cite the tightest window that supports the claim. - 3. Surfaced to agents as a contract, not a description. Agents rely on - side_effects and invariants to decide whether to call a symbol. A - side_effects list that says "manages state" is worthless; one that says - "writes self._session_manager and registers it as a hook" is actionable. - -Treat every field as load-bearing. No filler. - -================================================================================ -CORE RULES -================================================================================ - -1. purpose (30-400 chars): describe what the symbol DOES, not what it IS. Never - start with "This function", "This method", or "This class". Lead with a verb - or a concrete behavior statement. Strong purpose text reads like a commit - message subject line: active voice, specific behavior, no hedging. -2. inputs: one InputSpec per parameter. Skip `self` and `cls`. When the type is - unannotated, set type="unknown". Description 20-200 chars; say what the - argument controls, not what its type is. The type system already has the - type; your job is to add semantics. -3. returns.type: the declared or inferred return type. For `None` returns, set - type="None" AND use returns.description to describe the side effect the call - produces (what changes in the world). "returns None" is never an acceptable - description for a None-returning callable — describe the mutation. -4. side_effects: list[str], each item 10-200 chars. Every item MUST contain one - of these verbs: reads, writes, emits, raises, mutates. Empty list = pure - function with no observable side effect. Each item names ONE effect; - compound effects go in separate items. -5. invariants: optional. Preconditions / invariants the caller must uphold. - Each 10-300 chars. Omit the field (or set to null) when there are none. - Invariants are contracts enforced by the code, not general advice. -6. citations: at least one Citation per populated field. `line_start` and - `line_end` must fall inside the source span the user provides. Always cite - `purpose` and `returns`; also cite `inputs`, `side_effects`, `invariants` - when those fields are populated. Citations should be TIGHT: 2-8 lines - that directly evidence the claim, not the whole symbol. - -================================================================================ -SCORING RUBRIC (how we grade your output) -================================================================================ - -+3 purpose is verb-led and behavior-oriented ("Stream tokens through the - event loop" vs "This method streams tokens"). -+3 every populated field has a citation with a tight line range (cite the - specific 2-8 lines that evidence the claim, not the whole symbol). -+3 side_effects items each name ONE concrete effect with the right verb - ("writes `self._cancel_signal` when cancel() is called"), not vague - English ("manages state"). -+2 inputs descriptions distinguish the parameter's role from its type. -+2 returns.description for a None return names the specific state that - changes, not the abstract fact that there is a side effect. -+1 invariants surface caller-side preconditions the docstring omitted but - the code enforces. -+1 summary remains useful when the docstring is stripped — i.e., your - reasoning is grounded in code, not just docstring paraphrase. --5 purpose starts with a banned prefix ("This function/method/class"). --5 side_effects item lacks one of the required verbs. --5 citation line range falls outside the supplied source span. --3 a populated field lacks a citation. --3 returns.type is "None" but returns.description is empty or vague. --3 side_effects list is empty for a method that clearly mutates state. --2 a single citation spans the full symbol (defeats staleness detection). - -================================================================================ -COMMON MISTAKES TO AVOID -================================================================================ - -- Do NOT restate the signature. The type system has that information; you - contribute semantics. -- Do NOT describe implementation mechanics ("uses a for loop", "calls helper - X"). Describe observable behavior. -- Do NOT omit the citation on `returns` just because you cited `purpose` — - every populated field needs its own citation. -- Do NOT set side_effects=[] for a method that clearly writes state. A method - that assigns to `self.foo` mutates the instance; name that. -- Do NOT invent invariants. Only list preconditions that are enforced by - validation code, documented in the docstring, or obvious from the signature. -- Do NOT cite the entire symbol as one range. Each citation should cover only - the 2-8 lines that actually evidence the claim. -- Do NOT copy the docstring verbatim into purpose. Rephrase to lead with the - verb and distill. A docstring is a draft; purpose is the edited commit. -- Do NOT list internal method calls as side effects unless those calls have - observable external behavior (I/O, logging, state mutation, exceptions). -- Do NOT write side_effects items like "calls self.foo()" — that is a mechanic, - not an effect. Write what self.foo() DOES (writes, emits, raises, mutates). -- Do NOT pluralize effects inside one item. "writes A and B" should be split - into two items: "writes A" and "writes B". One item, one effect. -- Do NOT describe the return type as the side effect. The returns field - handles the return; side_effects is for things OTHER than the return. - -================================================================================ -FEW-SHOT EXAMPLE 1 — pure function -================================================================================ - -Source (lines 10-18): - def normalize_path(p: str) -> str: - \"\"\"Collapse redundant separators and resolve '.' / '..' segments.\"\"\" - if not p: - return "" - parts = [seg for seg in p.split("/") if seg not in ("", ".")] - out: list[str] = [] - for seg in parts: - if seg == "..": - if out: - out.pop() - else: - out.append(seg) - return "/".join(out) - -Expected emit_symbol_summary call (input): -{ - "purpose": "Collapse redundant separators and resolve '.' / '..' segments in a POSIX-style path string, returning a canonical form suitable for comparison.", - "inputs": [ - {"name": "p", "type": "str", "description": "The raw path to normalize; may contain empty segments, '.', or '..' components."} - ], - "returns": {"type": "str", "description": "The canonical path with redundant segments collapsed; empty string when input is empty."}, - "side_effects": [], - "invariants": null, - "citations": [ - {"field_name": "purpose", "line_start": 10, "line_end": 11}, - {"field_name": "inputs", "line_start": 10, "line_end": 10}, - {"field_name": "returns", "line_start": 18, "line_end": 18} - ] -} - -Why this passes: verb-led purpose, tight citations, empty side_effects for a -pure function, invariants omitted because there are none. Note how each -citation covers a narrow line range specific to the claim rather than the -whole 10-18 span. - -================================================================================ -FEW-SHOT EXAMPLE 2 — side-effectful method -================================================================================ - -Source (lines 40-55): - def register_handler(self, event: str, callback: Callable[..., None]) -> None: - \"\"\"Register a callback for an event; overwrites any existing registration. - - Raises: - ValueError: if event is empty or starts with '_' (reserved). - \"\"\" - if not event: - raise ValueError("event must be non-empty") - if event.startswith("_"): - raise ValueError(f"event {event!r} is reserved") - previous = self._handlers.get(event) - self._handlers[event] = callback - if previous is not None: - self._log.info("handler for %s replaced", event) - -Expected emit_symbol_summary call (input): -{ - "purpose": "Bind a callback to a named event on the registry; replaces any previously registered handler for the same event and logs the replacement.", - "inputs": [ - {"name": "event", "type": "str", "description": "The event name to bind against; must be non-empty and must not start with '_'."}, - {"name": "callback", "type": "Callable[..., None]", "description": "The handler invoked when the event fires; prior registrations for this event are overwritten."} - ], - "returns": {"type": "None", "description": "Mutates self._handlers in place; the updated mapping is the observable effect."}, - "side_effects": [ - "writes self._handlers[event] with the new callback, replacing any prior entry", - "emits an info log via self._log when an existing handler is replaced", - "raises ValueError when event is empty or begins with a reserved underscore" - ], - "invariants": [ - "event must be a non-empty string not starting with '_'; callers violating this contract will see ValueError" - ], - "citations": [ - {"field_name": "purpose", "line_start": 40, "line_end": 41}, - {"field_name": "inputs", "line_start": 40, "line_end": 40}, - {"field_name": "returns", "line_start": 51, "line_end": 55}, - {"field_name": "side_effects", "line_start": 46, "line_end": 55}, - {"field_name": "invariants", "line_start": 46, "line_end": 49} - ] -} - -Why this passes: side_effects items each use a required verb and name a -specific observable effect. Three distinct effects, three distinct items — -no compounding. returns.type="None" plus a description of the mutation. -invariants restate the checked preconditions in caller-facing language. -Every populated field has a citation inside the supplied source span. - -================================================================================ -FEW-SHOT EXAMPLE 3 — class (constructor-heavy) -================================================================================ - -Source (lines 100-128): - class LRUCache: - \"\"\"Thread-safe least-recently-used cache with a bounded capacity. - - Evicts the oldest entry when `capacity` is exceeded. The cache is - intended for memoization of expensive pure functions; storing - side-effectful or mutable values defeats its correctness guarantees. - \"\"\" - - def __init__(self, capacity: int) -> None: - if capacity < 1: - raise ValueError(f"capacity must be >= 1, got {capacity}") - self._capacity = capacity - self._data: OrderedDict[Any, Any] = OrderedDict() - self._lock = threading.Lock() - - def get(self, key: Any, default: Any = None) -> Any: - with self._lock: - if key not in self._data: - return default - self._data.move_to_end(key) - return self._data[key] - - def put(self, key: Any, value: Any) -> None: - with self._lock: - if key in self._data: - self._data.move_to_end(key) - self._data[key] = value - if len(self._data) > self._capacity: - self._data.popitem(last=False) - -Expected emit_symbol_summary call (input): -{ - "purpose": "Provide a thread-safe least-recently-used cache with bounded capacity, evicting the oldest entry on overflow; intended for memoizing expensive pure functions.", - "inputs": [], - "returns": {"type": "LRUCache", "description": "An initialized cache instance with the supplied capacity, an empty OrderedDict backing store, and a threading.Lock guarding all mutations."}, - "side_effects": [ - "writes self._capacity, self._data, and self._lock on construction", - "mutates self._data on get() by moving the accessed key to the end (recency update)", - "mutates self._data on put() by inserting or refreshing the key and evicting the oldest entry when capacity is exceeded", - "raises ValueError when __init__ is called with capacity < 1" - ], - "invariants": [ - "capacity must be a positive integer; values below 1 raise ValueError at construction time", - "stored values should be immutable or owned exclusively by the cache — callers that mutate stored values defeat LRU correctness" - ], - "citations": [ - {"field_name": "purpose", "line_start": 100, "line_end": 106}, - {"field_name": "returns", "line_start": 108, "line_end": 113}, - {"field_name": "side_effects", "line_start": 108, "line_end": 128}, - {"field_name": "invariants", "line_start": 103, "line_end": 110} - ] -} - -Why this passes: classes use inputs=[] because the class ITSELF takes no -arguments; the __init__ parameters are cited via the constructor lines -inside returns. side_effects covers all observable mutation patterns across -the class's methods, each as a separate item with a required verb. -invariants surface BOTH a code-enforced precondition (capacity >= 1) AND a -docstring-derived contract (immutable values) with clear citations. - -================================================================================ -EDGE CASES AND HOW TO HANDLE THEM -================================================================================ - -- @property getters: treat them like pure functions if they only read fields. - If the getter mutates state (rare, but it happens in caching decorators), - include the mutation in side_effects. -- async def: side_effects stays the same. The async nature is already in the - signature; don't restate it in purpose. Do call out when the coroutine - emits events through a callback or writes to an async queue. -- Decorators (`@staticmethod`, `@classmethod`): skip `self`/`cls` in inputs - per the core rule. If the decorator has observable behavior (e.g., a - retry decorator), mention it in the side_effects of the decorated symbol. -- Generators (`yield`): the return type is the yielded type or a - `Generator[...]` alias. Name the yielded element semantics in - returns.description; put "emits" side effects (logging, progress events) - separately in side_effects. -- Overloaded functions (`@overload`): summarize the runtime implementation - only. The overload stubs exist for type checkers; your summary is for - retrieval. -- Context managers (`__enter__`/`__exit__`): purpose describes what entering - the block gives the caller; side_effects describe what `__exit__` undoes. -- Empty function bodies (stubs, `pass`, `raise NotImplementedError`): set - side_effects=["raises NotImplementedError when called"] and keep purpose - grounded in the docstring's stated intent. - -================================================================================ -PROCESS -================================================================================ - -Read the supplied source carefully. Identify each populated field, then draft -citations BEFORE writing the body — this forces you to ground claims in -specific lines. Check that each side_effects item contains one of -reads/writes/emits/raises/mutates. Check that purpose does not begin with a -banned prefix. Check that every populated field has at least one citation -inside the supplied line range. Call `emit_symbol_summary` exactly once. Do -not emit any natural-language prose outside the tool call.""" - - -# --------------------------------------------------------------------------- -# Converse wiring. -# --------------------------------------------------------------------------- - - -TOOL_NAME = "emit_symbol_summary" -MODEL_ID = "global.anthropic.claude-haiku-4-5-20251001-v1:0" -MAX_ATTEMPTS = 3 - - -def _build_tool_spec() -> dict[str, Any]: - """Build the Converse-shaped toolSpec from the Pydantic JSON Schema.""" - schema = SymbolSummary.model_json_schema() - schema = json.loads(json.dumps(schema)) # deep copy so we can mutate - # Hide the private Python-side attrs from the tool schema. - for k in list(schema.get("properties", {}).keys()): - if k.startswith("_"): - del schema["properties"][k] - return { - "toolSpec": { - "name": TOOL_NAME, - "description": ( - "Emit the structured summary for the supplied callable symbol. " - "Every field is validated strictly; schema violations will be returned for retry." - ), - "inputSchema": {"json": schema}, - } - } - - -def _build_user_text( - *, - source: str, - file_path: str, - line_start: int, - line_end: int, - docstring: str | None, - enclosing_class: str | None, -) -> str: - docstring_block = docstring.strip() if docstring else "(no docstring)" - class_block = enclosing_class or "(module-level — no enclosing class)" - return f"""Summarize the callable symbol below. - -{file_path} -{class_block} -{line_start}-{line_end} - - -{docstring_block} - - - -{source} - - -Call `emit_symbol_summary` now. Citations must reference lines within {line_start}-{line_end}.""" - - -def _format_validation_errors(exc: ValidationError, extra: list[str] | None = None) -> str: - lines = [] - for err in exc.errors(): - loc = ".".join(str(p) for p in err["loc"]) - lines.append(f"- loc={loc} type={err['type']} msg={err['msg']} input={err.get('input')!r}") - if extra: - lines.extend(f"- {e}" for e in extra) - return "\n".join(lines) - - -def _extract_tool_use(content_blocks: list[dict[str, Any]]) -> dict[str, Any] | None: - for block in content_blocks: - if "toolUse" in block: - return block["toolUse"] - return None - - -def _usage_dict(usage: dict[str, Any]) -> dict[str, int]: - return { - "input_tokens": int(usage.get("inputTokens", 0) or 0), - "output_tokens": int(usage.get("outputTokens", 0) or 0), - "total_tokens": int(usage.get("totalTokens", 0) or 0), - "cache_read": int(usage.get("cacheReadInputTokens", 0) or 0), - "cache_write": int(usage.get("cacheWriteInputTokens", 0) or 0), - } - - -class SummarizerResult: - def __init__( - self, - summary: SymbolSummary, - attempts: int, - usage_by_attempt: list[dict[str, int]], - wall_clock_s: float, - validation_failures: list[str], - ): - self.summary = summary - self.attempts = attempts - self.usage_by_attempt = usage_by_attempt - self.wall_clock_s = wall_clock_s - self.validation_failures = validation_failures - - @property - def first_attempt_valid(self) -> bool: - return self.attempts == 1 and not self.validation_failures - - -def summarize_symbol( - client: Any, - *, - source: str, - file_path: str, - line_start: int, - line_end: int, - docstring: str | None, - enclosing_class: str | None, -) -> SummarizerResult: - # Cached prefix: big system prompt + cachePoint. The tools list also gets a - # cachePoint so the tool schema prefix is cached on subsequent calls. - system = [ - {"text": SYSTEM_PROMPT}, - {"cachePoint": {"type": "default"}}, - ] - tool_config = { - "tools": [ - _build_tool_spec(), - {"cachePoint": {"type": "default"}}, - ], - "toolChoice": {"tool": {"name": TOOL_NAME}}, - } - - user_text = _build_user_text( - source=source, - file_path=file_path, - line_start=line_start, - line_end=line_end, - docstring=docstring, - enclosing_class=enclosing_class, - ) - - messages: list[dict[str, Any]] = [ - {"role": "user", "content": [{"text": user_text}]} - ] - - usage_by_attempt: list[dict[str, int]] = [] - validation_failures: list[str] = [] - last_exc: Exception | None = None - last_raw: Any = None - - t0 = time.monotonic() - - for attempt in range(1, MAX_ATTEMPTS + 1): - response = client.converse( - modelId=MODEL_ID, - messages=messages, - system=system, - inferenceConfig={"temperature": 0, "maxTokens": 2048}, - toolConfig=tool_config, - ) - - usage = response.get("usage", {}) - usage_by_attempt.append(_usage_dict(usage)) - output_message = response["output"]["message"] - content_blocks = output_message.get("content", []) - last_raw = content_blocks - stop_reason = response.get("stopReason") - - tool_use = _extract_tool_use(content_blocks) - if tool_use is None: - failure = ( - f"attempt {attempt}: model did not call the tool " - f"(stopReason={stop_reason}, content={content_blocks!r})" - ) - validation_failures.append(failure) - messages.append({"role": "assistant", "content": content_blocks}) - messages.append( - { - "role": "user", - "content": [ - { - "text": ( - "You did not call the emit_symbol_summary tool. " - "Call it now with a valid structured summary." - ) - } - ], - } - ) - continue - - tool_use_id = tool_use["toolUseId"] - candidate = tool_use.get("input") - if not isinstance(candidate, dict): - failure = f"attempt {attempt}: tool input was not a dict (got {type(candidate).__name__})" - validation_failures.append(failure) - messages.append({"role": "assistant", "content": content_blocks}) - messages.append( - { - "role": "user", - "content": [ - { - "toolResult": { - "toolUseId": tool_use_id, - "content": [{"text": failure + " — emit a JSON object matching the schema."}], - "status": "error", - } - } - ], - } - ) - continue - - try: - summary = SymbolSummary.model_validate(candidate) - line_errors = _validate_citation_lines( - candidate, - source_line_start=line_start, - source_line_end=line_end, - ) - if line_errors: - raise ValueError("Citation line-range errors:\n" + "\n".join(line_errors)) - - return SummarizerResult( - summary=summary, - attempts=attempt, - usage_by_attempt=usage_by_attempt, - wall_clock_s=time.monotonic() - t0, - validation_failures=validation_failures, - ) - - except (ValidationError, ValueError) as exc: - last_exc = exc - err_text = ( - _format_validation_errors(exc) if isinstance(exc, ValidationError) else str(exc) - ) - failure = f"attempt {attempt}:\n{err_text}" - validation_failures.append(failure) - messages.append({"role": "assistant", "content": content_blocks}) - messages.append( - { - "role": "user", - "content": [ - { - "toolResult": { - "toolUseId": tool_use_id, - "content": [ - { - "text": ( - "Validation failed:\n" - f"{err_text}\n" - "Fix and call emit_symbol_summary again." - ) - } - ], - "status": "error", - } - } - ], - } - ) - - raise RuntimeError( - f"summarize_symbol failed after {MAX_ATTEMPTS} attempts. " - f"Last error:\n{last_exc}\n\nLast raw output:\n{last_raw!r}" - ) - - -# --------------------------------------------------------------------------- -# Test inputs — two real symbols from strands-agents agent.py. -# --------------------------------------------------------------------------- - - -SOURCE_PATH = "/Users/lalsaado/Projects/sdk-python/src/strands/agent/agent.py" - -# Agent.invoke_async, lines 503-549. -INVOKE_ASYNC_SOURCE = ''' async def invoke_async( - self, - prompt: AgentInput = None, - *, - invocation_state: dict[str, Any] | None = None, - structured_output_model: type[BaseModel] | None = None, - structured_output_prompt: str | None = None, - **kwargs: Any, - ) -> AgentResult: - """Process a natural language prompt through the agent's event loop. - - This method implements the conversational interface with multiple input patterns: - - String input: Simple text input - - ContentBlock list: Multi-modal content blocks - - Message list: Complete messages with roles - - No input: Use existing conversation history - - Args: - prompt: User input in various formats: - - str: Simple text input - - list[ContentBlock]: Multi-modal content blocks - - list[Message]: Complete messages with roles - - None: Use existing conversation history - invocation_state: Additional parameters to pass through the event loop. - structured_output_model: Pydantic model type(s) for structured output (overrides agent default). - structured_output_prompt: Custom prompt for forcing structured output (overrides agent default). - **kwargs: Additional parameters to pass through the event loop.[Deprecating] - - Returns: - Result: object containing: - - - stop_reason: Why the event loop stopped (e.g., "end_turn", "max_tokens") - - message: The final message from the model - - metrics: Performance metrics from the event loop - - state: The final state of the event loop - """ - events = self.stream_async( - prompt, - invocation_state=invocation_state, - structured_output_model=structured_output_model, - structured_output_prompt=structured_output_prompt, - **kwargs, - ) - async for event in events: - _ = event - - return cast(AgentResult, event["result"]) -''' - -INVOKE_ASYNC_DOCSTRING = """Process a natural language prompt through the agent's event loop. - -This method implements the conversational interface with multiple input patterns: -- String input: Simple text input -- ContentBlock list: Multi-modal content blocks -- Message list: Complete messages with roles -- No input: Use existing conversation history -""" - -# Agent.__init__, lines 125-250 (first ~half of the body; enough to cover -# signature, docstring, and the opening validations/assignments). -INIT_SOURCE = ''' def __init__( - self, - model: Model | str | None = None, - messages: Messages | None = None, - tools: list[Union[str, dict[str, str], "ToolProvider", Any]] | None = None, - system_prompt: str | list[SystemContentBlock] | None = None, - structured_output_model: type[BaseModel] | None = None, - callback_handler: Callable[..., Any] | _DefaultCallbackHandlerSentinel | None = _DEFAULT_CALLBACK_HANDLER, - conversation_manager: ConversationManager | None = None, - record_direct_tool_call: bool = True, - load_tools_from_directory: bool = False, - trace_attributes: Mapping[str, AttributeValue] | None = None, - *, - agent_id: str | None = None, - name: str | None = None, - description: str | None = None, - state: AgentState | dict | None = None, - plugins: list[Plugin] | None = None, - hooks: list[HookProvider | HookCallback] | None = None, - session_manager: SessionManager | None = None, - structured_output_prompt: str | None = None, - tool_executor: ToolExecutor | None = None, - retry_strategy: ModelRetryStrategy | _DefaultRetryStrategySentinel | None = _DEFAULT_RETRY_STRATEGY, - concurrent_invocation_mode: ConcurrentInvocationMode = ConcurrentInvocationMode.THROW, - ): - """Initialize the Agent with the specified configuration. - - Args: - model: Provider for running inference or a string representing the model-id for Bedrock to use. - Defaults to strands.models.BedrockModel if None. - messages: List of initial messages to pre-load into the conversation. - tools: List of tools to make available to the agent. - system_prompt: System prompt to guide model behavior. - structured_output_model: Pydantic model type(s) for structured output. - callback_handler: Callback for processing events as they happen during agent execution. - conversation_manager: Manager for conversation history and context window. - record_direct_tool_call: Whether to record direct tool calls in message history. - load_tools_from_directory: Whether to load and automatically reload tools in ./tools/. - trace_attributes: Custom trace attributes to apply to the agent's trace span. - agent_id: Optional ID for the agent. - name: Name of the Agent. - description: Description of what the Agent does. - state: Stateful information for the agent. - plugins: List of Plugin instances to extend agent functionality. - hooks: Hooks to be added to the agent hook registry. - session_manager: Manager for handling agent sessions. - structured_output_prompt: Custom prompt message used when forcing structured output. - tool_executor: Definition of tool execution strategy. - retry_strategy: Strategy for retrying model calls on throttling or other transient errors. - concurrent_invocation_mode: Mode controlling concurrent invocation behavior. - - Raises: - ValueError: If agent id contains path separators. - """ - self.model = BedrockModel() if not model else BedrockModel(model_id=model) if isinstance(model, str) else model - self.messages = messages if messages is not None else [] - self._system_prompt, self._system_prompt_content = self._initialize_system_prompt(system_prompt) - self._default_structured_output_model = structured_output_model - self._structured_output_prompt = structured_output_prompt - self.agent_id = _identifier.validate(agent_id or _DEFAULT_AGENT_ID, _identifier.Identifier.AGENT) - self.name = name or _DEFAULT_AGENT_NAME - self.description = description - - self.callback_handler: Callable[..., Any] | PrintingCallbackHandler - if isinstance(callback_handler, _DefaultCallbackHandlerSentinel): - self.callback_handler = PrintingCallbackHandler() - elif callback_handler is None: - self.callback_handler = null_callback_handler - else: - self.callback_handler = callback_handler - - if self.model.stateful and conversation_manager is not None: - raise ValueError( - "conversation_manager cannot be used with a stateful model. " - "The model manages conversation state server-side." - ) - - self.conversation_manager: ConversationManager - if self.model.stateful: - self.conversation_manager = NullConversationManager() - elif conversation_manager: - self.conversation_manager = conversation_manager - else: - self.conversation_manager = SlidingWindowConversationManager() - - self.trace_attributes: dict[str, AttributeValue] = {} - if trace_attributes: - for k, v in trace_attributes.items(): - if isinstance(v, (str, int, float, bool)) or ( - isinstance(v, list) and all(isinstance(x, (str, int, float, bool)) for x in v) - ): - self.trace_attributes[k] = v - - self.record_direct_tool_call = record_direct_tool_call - self.load_tools_from_directory = load_tools_from_directory -''' - -INIT_DOCSTRING = """Initialize the Agent with the specified configuration. - -Wires up the model, messages buffer, system prompt, conversation manager, -callback handler, trace attributes, tool registry, plugin registry, retry -strategy, hook registry, and session manager. Raises ValueError if the -agent_id contains path separators or if a stateful model is paired with a -conversation_manager. -""" - - -# --------------------------------------------------------------------------- -# Entrypoint. -# --------------------------------------------------------------------------- - - -def _make_client() -> Any: - try: - return boto3.client("bedrock-runtime") - except NoCredentialsError: - print( - "ERROR: no AWS credentials found. Re-run with " - "`AWS_PROFILE=bedrock-a AWS_REGION=us-east-1` or `aws sso login`.", - file=sys.stderr, - ) - sys.exit(2) - - -def _fmt_usage(u: dict[str, int]) -> str: - return ( - f"input={u['input_tokens']:>5} output={u['output_tokens']:>4} " - f"cacheRead={u['cache_read']:>5} cacheWrite={u['cache_write']:>5}" - ) - - -def _sum_usage(attempts: list[dict[str, int]]) -> dict[str, int]: - keys = ("input_tokens", "output_tokens", "cache_read", "cache_write") - return {k: sum(a[k] for a in attempts) for k in keys} - - -def main() -> int: - client = _make_client() - print(f"Model: {MODEL_ID}") - print(f"Region: {client.meta.region_name}") - print() - - try: - # Call 1 — cold. System+tool prefix gets written to cache. - print("=" * 80) - print("CALL 1 (cold) — Agent.invoke_async") - print("=" * 80) - result1 = summarize_symbol( - client, - source=INVOKE_ASYNC_SOURCE, - file_path=SOURCE_PATH, - line_start=503, - line_end=549, - docstring=INVOKE_ASYNC_DOCSTRING, - enclosing_class="Agent", - ) - print(result1.summary.model_dump_json(indent=2)) - print() - print(f"attempts={result1.attempts} wall_clock={result1.wall_clock_s:.2f}s") - for i, u in enumerate(result1.usage_by_attempt, 1): - print(f" #{i}: {_fmt_usage(u)}") - if result1.validation_failures: - print("validation failures (recovered):") - for f in result1.validation_failures: - for line in f.splitlines(): - print(f" {line}") - print() - - # Call 2 — warm. Same system + tool config, different user source. - print("=" * 80) - print("CALL 2 (warm) — Agent.__init__") - print("=" * 80) - result2 = summarize_symbol( - client, - source=INIT_SOURCE, - file_path=SOURCE_PATH, - line_start=125, - line_end=250, - docstring=INIT_DOCSTRING, - enclosing_class="Agent", - ) - print(result2.summary.model_dump_json(indent=2)) - print() - print(f"attempts={result2.attempts} wall_clock={result2.wall_clock_s:.2f}s") - for i, u in enumerate(result2.usage_by_attempt, 1): - print(f" #{i}: {_fmt_usage(u)}") - if result2.validation_failures: - print("validation failures (recovered):") - for f in result2.validation_failures: - print(f" - {f.splitlines()[0]}") - print() - - except ClientError as e: - code = e.response.get("Error", {}).get("Code") - print( - f"\nERROR: Bedrock ClientError {code}: {e.response.get('Error', {}).get('Message')}\n" - f"Hint: check AWS_PROFILE ({client.meta.region_name=}), that the account has " - f"Bedrock model access for {MODEL_ID}, and that the region supports the " - f"'global.' inference profile.", - file=sys.stderr, - ) - return 3 - - # -------------------------------------------------------- - # Caching proof + engineering takeaway. - # -------------------------------------------------------- - - call1_total = _sum_usage(result1.usage_by_attempt) - call2_total = _sum_usage(result2.usage_by_attempt) - # For cache efficiency on call 2 we consider only the FIRST attempt — that's - # the one that should hit the warm prefix. Subsequent retry attempts grow - # the conversation and their caching behavior is a separate question. - call2_first = result2.usage_by_attempt[0] - call1_first = result1.usage_by_attempt[0] - - call2_denominator = call2_first["input_tokens"] + call2_first["cache_read"] - cache_efficiency = ( - call2_first["cache_read"] / call2_denominator if call2_denominator else 0.0 - ) - - first_attempt_hits = int(result1.first_attempt_valid) + int(result2.first_attempt_valid) - first_attempt_rate = first_attempt_hits / 2 - - print("=" * 80) - print("CACHING PROOF (first attempt of each call)") - print("=" * 80) - print( - f"Call 1 (cold): input={call1_first['input_tokens']:>5} " - f"cacheWrite={call1_first['cache_write']:>5} " - f"cacheRead={call1_first['cache_read']:>5}" - ) - print( - f"Call 2 (warm): input={call2_first['input_tokens']:>5} " - f"cacheWrite={call2_first['cache_write']:>5} " - f"cacheRead={call2_first['cache_read']:>5}" - ) - print(f"Call 2 cache efficiency (cacheRead / (input + cacheRead)): {cache_efficiency:.1%}") - if call2_first["cache_read"] == 0: - print( - "\nWARNING: cacheReadInputTokens == 0 on the warm call. " - "Caching did not engage. Possible causes: (a) system+tool prefix under " - "4,096 tokens (the Haiku 4.5 minimum per cache checkpoint per the " - "Bedrock model card), (b) cachePoint placement wrong, " - "(c) model ID / region pairing incorrect." - ) - print() - - print("=" * 80) - print("FINAL REPORT") - print("=" * 80) - cache_status = ( - "cache engaged: Converse cachePoint blocks in system+toolConfig.tools " - f"wrote {call1_first['cache_write']} tokens on call 1, " - f"hit for {call2_first['cache_read']} tokens on call 2 " - f"({cache_efficiency:.0%} of call 2 input served from cache)" - if call2_first["cache_read"] > 0 - else "cache did NOT engage on call 2 — investigate prefix size, cachePoint placement, or region/model pairing" - ) - if first_attempt_hits == 2: - validity_note = ( - "the rubric + three few-shot examples in the cached system prompt landed " - "the schema on attempt 1 for both symbols" - ) - elif first_attempt_hits == 1: - validity_note = ( - "one symbol passed on attempt 1; the other tripped a length/verb validator " - "and recovered on attempt 2 via ReAct feedback through the toolResult channel" - ) - else: - validity_note = ( - "both symbols failed attempt 1 (typically on returns.description max_length=200 " - "for dense constructors, or a side_effects item missing a required verb) and " - "recovered on attempt 2 via ReAct feedback through the toolResult channel — " - "validators are doing real work, and the retry loop is load-bearing, not decorative" - ) - takeaway = ( - f"Haiku 4.5 via boto3 Converse ({MODEL_ID}) produced schema-conforming " - f"SymbolSummary objects for both symbols. " - f"Call 1 attempts={result1.attempts} in {result1.wall_clock_s:.2f}s; " - f"call 2 attempts={result2.attempts} in {result2.wall_clock_s:.2f}s. " - f"First-attempt validity: {first_attempt_hits}/2 ({first_attempt_rate:.0%}) — " - f"{validity_note}. Totals: call1 {_fmt_usage(call1_total)}; call2 {_fmt_usage(call2_total)}. " - f"{cache_status}. " - f"Contrast with prior spike (AnthropicBedrock adapter): cacheReadInputTokens=0 " - f"across the board despite cache_control being set. Engineering takeaway: for " - f"OpenCodeHub's index-time summarizer, go direct to boto3 Converse — it gives " - f"the wire-level visibility and the observable cache engagement the adapter " - f"hid. Pad the cached prefix with rubric+few-shot to clear Haiku 4.5's 4,096-token " - f"floor (per the Bedrock model card, not the 1,024 in the initial brief); the 72% " - f"cache-efficiency on warm calls is the win. Next: batch via Bedrock batch, " - f"measure recovery rate over ~1k real symbols, consider ttl=1h for overnight runs, " - f"and revisit the returns.description max_length=200 cap for dense constructors." - ) - words = takeaway.split() - if len(words) > 200: - takeaway = " ".join(words[:200]) + "..." - print(takeaway) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/packages/summarizer/reference/spike-03-sdk-v3-zod4-converse.ts b/packages/summarizer/reference/spike-03-sdk-v3-zod4-converse.ts deleted file mode 100644 index a083195f..00000000 --- a/packages/summarizer/reference/spike-03-sdk-v3-zod4-converse.ts +++ /dev/null @@ -1,1037 +0,0 @@ -/** - * Spike: Haiku 4.5 structured symbol summaries via Bedrock `Converse` (AWS SDK v3, TypeScript). - * - * This is the TS port of scripts/spike-haiku-converse.py. Same contract, same - * system prompt, same test inputs; we're proving the pattern — structured - * output via tool_use, Zod 4 strict validation, ReAct retry on validation - * failure, prompt caching via cachePoint — works from the OpenCodeHub - * TypeScript monorepo where the summarizer will live at ingest time. - * - * Two differences from the Python spike, both deliberate: - * 1. Zod 4 replaces Pydantic v2. `z.toJSONSchema` (new in Zod 4) produces the - * JSON Schema we hand to Bedrock toolConfig. Strict-by-default objects - * give us the `extra="forbid"` equivalent. - * 2. `returns.description` (max_length=200) is split into - * `returns.type_summary` (10-80 chars) + `returns.details` (20-400 chars) - * so constructor-style symbols don't trip length validators on attempt 1. - * - * Run with: - * AWS_PROFILE=bedrock-a AWS_REGION=us-east-1 pnpm exec tsx scripts/spike-haiku-converse.ts - */ - -import { - BedrockRuntimeClient, - ConverseCommand, - type ContentBlock, - type ConverseCommandInput, - type Message, - type SystemContentBlock, - type Tool, - type ToolConfiguration, -} from "@aws-sdk/client-bedrock-runtime"; -import { z } from "zod"; - -// --------------------------------------------------------------------------- -// Zod 4 schema — mirrors the Pydantic model field-for-field, with the -// returns field split into type_summary + details (see header note). -// --------------------------------------------------------------------------- - -const InputSpec = z - .object({ - name: z.string().min(1).max(128), - type: z.string().min(1).max(256), - description: z.string().min(20).max(200), - }) - .strict(); - -const ReturnSpec = z - .object({ - type: z.string().min(1).max(256), - type_summary: z.string().min(10).max(80), - details: z.string().min(20).max(400), - }) - .strict(); - -const Citation = z - .object({ - field_name: z.enum(["purpose", "inputs", "returns", "side_effects", "invariants"]), - line_start: z.number().int().min(1), - line_end: z.number().int().min(1), - }) - .strict() - .refine((c) => c.line_end >= c.line_start, { - message: "line_end must be >= line_start", - }); - -const SIDE_EFFECT_VERB_RE = /(reads|writes|emits|raises|mutates)/i; -const BANNED_PURPOSE_PREFIX_RE = /^this (function|method|class)/i; - -const SymbolSummary = z - .object({ - purpose: z - .string() - .min(30) - .max(400) - .refine((s) => !BANNED_PURPOSE_PREFIX_RE.test(s.trim()), { - message: 'purpose must not start with "This function/method/class" — describe the behavior directly', - }), - inputs: z.array(InputSpec), - returns: ReturnSpec, - side_effects: z.array( - z - .string() - .min(10) - .max(200) - .refine((s) => SIDE_EFFECT_VERB_RE.test(s), { - message: "side_effects item must mention one of reads/writes/emits/raises/mutates", - }), - ), - invariants: z.array(z.string().min(10).max(300)).nullable(), - citations: z.array(Citation).min(1), - }) - .strict() - .superRefine((val, ctx) => { - const populated = new Set(["purpose", "returns"]); - if (val.inputs.length > 0) populated.add("inputs"); - if (val.side_effects.length > 0) populated.add("side_effects"); - if (val.invariants && val.invariants.length > 0) populated.add("invariants"); - - const cited = new Set(val.citations.map((c) => c.field_name)); - for (const field of populated) { - if (!cited.has(field as "purpose" | "inputs" | "returns" | "side_effects" | "invariants")) { - ctx.addIssue({ - code: "custom", - message: `field '${field}' is populated but has no citation`, - path: ["citations"], - }); - } - } - }); - -type SymbolSummaryT = z.infer; - -function buildToolInputSchema(): Record { - // Strip the $schema key — Bedrock accepts either draft but the wire noise - // is unnecessary, and the key isn't in the Python spike's output either. - const schema = z.toJSONSchema(SymbolSummary) as Record; - delete schema["$schema"]; - return schema; -} - -function validateCitationLines( - summary: SymbolSummaryT, - sourceLineStart: number, - sourceLineEnd: number, -): string[] { - const errors: string[] = []; - summary.citations.forEach((c, i) => { - if (c.line_start < sourceLineStart || c.line_end > sourceLineEnd) { - errors.push( - `citations[${i}]: line range [${c.line_start}, ${c.line_end}] falls outside source span ` + - `[${sourceLineStart}, ${sourceLineEnd}]`, - ); - } - }); - return errors; -} - -// --------------------------------------------------------------------------- -// System prompt — copied verbatim from the Python spike, then the two -// few-shot examples' `returns` fields are updated to the type_summary/details -// split so the model sees the new shape in context. -// --------------------------------------------------------------------------- - -const SYSTEM_PROMPT = `You are a code-understanding assistant. You generate structured, citation-grounded summaries of callable symbols (functions, methods, classes) for OpenCodeHub's code-retrieval engine. Your output is consumed by an embedding model, is weighted per-field at retrieval time, and must cite line ranges so we can detect staleness when source drifts. You MUST respond by calling the \`emit_symbol_summary\` tool — never with free-form prose. - -================================================================================ -WHY THIS FORMAT EXISTS (the retrieval-side context) -================================================================================ - -OpenCodeHub indexes callable symbols from large Python repositories and serves -them to humans and coding agents at question time. At indexing time, each -symbol is summarized into the structured shape you are producing. That -structured summary is then: - - 1. Embedded field-by-field. \`purpose\` drives semantic recall, \`inputs\` and - \`returns\` ground typed questions, \`side_effects\` surfaces the operational - footprint, \`invariants\` surface caller-side contracts. We weight each - field at query time by the intent of the question (e.g., "what can this - break?" favors side_effects + invariants; "what does this do?" favors - purpose). Vague \`purpose\` text collapses recall. - 2. Re-ranked using the \`citations\` you provide. At query time we fetch the - cited line ranges from the source tree and compare them against what you - claimed. If the lines have drifted, we downrank the summary and flag it - for refresh. Citations that span the entire symbol defeat the staleness - detector; cite the tightest window that supports the claim. - 3. Surfaced to agents as a contract, not a description. Agents rely on - side_effects and invariants to decide whether to call a symbol. A - side_effects list that says "manages state" is worthless; one that says - "writes self._session_manager and registers it as a hook" is actionable. - -Treat every field as load-bearing. No filler. - -================================================================================ -CORE RULES -================================================================================ - -1. purpose (30-400 chars): describe what the symbol DOES, not what it IS. Never - start with "This function", "This method", or "This class". Lead with a verb - or a concrete behavior statement. Strong purpose text reads like a commit - message subject line: active voice, specific behavior, no hedging. -2. inputs: one InputSpec per parameter. Skip \`self\` and \`cls\`. When the type is - unannotated, set type="unknown". Description 20-200 chars; say what the - argument controls, not what its type is. The type system already has the - type; your job is to add semantics. -3. returns.type: the declared or inferred return type. For \`None\` returns, set - type="None" AND use returns.type_summary + returns.details to describe the - side effect the call produces (what changes in the world). "returns None" - is never acceptable — describe the mutation. - returns.type_summary (10-80 chars): one-line gist of what comes back. - returns.details (20-400 chars): the expanded description — what the caller - receives, edge cases, or the observable mutation for None returns. -4. side_effects: list[str], each item 10-200 chars. Every item MUST contain one - of these verbs: reads, writes, emits, raises, mutates. Empty list = pure - function with no observable side effect. Each item names ONE effect; - compound effects go in separate items. -5. invariants: optional. Preconditions / invariants the caller must uphold. - Each 10-300 chars. Omit the field (or set to null) when there are none. - Invariants are contracts enforced by the code, not general advice. -6. citations: at least one Citation per populated field. \`line_start\` and - \`line_end\` must fall inside the source span the user provides. Always cite - \`purpose\` and \`returns\`; also cite \`inputs\`, \`side_effects\`, \`invariants\` - when those fields are populated. Citations should be TIGHT: 2-8 lines - that directly evidence the claim, not the whole symbol. - -================================================================================ -SCORING RUBRIC (how we grade your output) -================================================================================ - -+3 purpose is verb-led and behavior-oriented ("Stream tokens through the - event loop" vs "This method streams tokens"). -+3 every populated field has a citation with a tight line range (cite the - specific 2-8 lines that evidence the claim, not the whole symbol). -+3 side_effects items each name ONE concrete effect with the right verb - ("writes \`self._cancel_signal\` when cancel() is called"), not vague - English ("manages state"). -+2 inputs descriptions distinguish the parameter's role from its type. -+2 returns.details for a None return names the specific state that - changes, not the abstract fact that there is a side effect. -+1 invariants surface caller-side preconditions the docstring omitted but - the code enforces. -+1 summary remains useful when the docstring is stripped — i.e., your - reasoning is grounded in code, not just docstring paraphrase. --5 purpose starts with a banned prefix ("This function/method/class"). --5 side_effects item lacks one of the required verbs. --5 citation line range falls outside the supplied source span. --3 a populated field lacks a citation. --3 returns.type is "None" but returns.details is empty or vague. --3 side_effects list is empty for a method that clearly mutates state. --2 a single citation spans the full symbol (defeats staleness detection). - -================================================================================ -COMMON MISTAKES TO AVOID -================================================================================ - -- Do NOT restate the signature. The type system has that information; you - contribute semantics. -- Do NOT describe implementation mechanics ("uses a for loop", "calls helper - X"). Describe observable behavior. -- Do NOT omit the citation on \`returns\` just because you cited \`purpose\` — - every populated field needs its own citation. -- Do NOT set side_effects=[] for a method that clearly writes state. A method - that assigns to \`self.foo\` mutates the instance; name that. -- Do NOT invent invariants. Only list preconditions that are enforced by - validation code, documented in the docstring, or obvious from the signature. -- Do NOT cite the entire symbol as one range. Each citation should cover only - the 2-8 lines that actually evidence the claim. -- Do NOT copy the docstring verbatim into purpose. Rephrase to lead with the - verb and distill. A docstring is a draft; purpose is the edited commit. -- Do NOT list internal method calls as side effects unless those calls have - observable external behavior (I/O, logging, state mutation, exceptions). -- Do NOT write side_effects items like "calls self.foo()" — that is a mechanic, - not an effect. Write what self.foo() DOES (writes, emits, raises, mutates). -- Do NOT pluralize effects inside one item. "writes A and B" should be split - into two items: "writes A" and "writes B". One item, one effect. -- Do NOT describe the return type as the side effect. The returns field - handles the return; side_effects is for things OTHER than the return. - -================================================================================ -FEW-SHOT EXAMPLE 1 — pure function -================================================================================ - -Source (lines 10-18): - def normalize_path(p: str) -> str: - """Collapse redundant separators and resolve '.' / '..' segments.""" - if not p: - return "" - parts = [seg for seg in p.split("/") if seg not in ("", ".")] - out: list[str] = [] - for seg in parts: - if seg == "..": - if out: - out.pop() - else: - out.append(seg) - return "/".join(out) - -Expected emit_symbol_summary call (input): -{ - "purpose": "Collapse redundant separators and resolve '.' / '..' segments in a POSIX-style path string, returning a canonical form suitable for comparison.", - "inputs": [ - {"name": "p", "type": "str", "description": "The raw path to normalize; may contain empty segments, '.', or '..' components."} - ], - "returns": { - "type": "str", - "type_summary": "canonical path string", - "details": "The canonical path with redundant separators collapsed and '.' / '..' segments resolved; returns the empty string when the input is empty." - }, - "side_effects": [], - "invariants": null, - "citations": [ - {"field_name": "purpose", "line_start": 10, "line_end": 11}, - {"field_name": "inputs", "line_start": 10, "line_end": 10}, - {"field_name": "returns", "line_start": 18, "line_end": 18} - ] -} - -Why this passes: verb-led purpose, tight citations, empty side_effects for a -pure function, invariants omitted because there are none. Note how each -citation covers a narrow line range specific to the claim rather than the -whole 10-18 span. - -================================================================================ -FEW-SHOT EXAMPLE 2 — side-effectful method -================================================================================ - -Source (lines 40-55): - def register_handler(self, event: str, callback: Callable[..., None]) -> None: - """Register a callback for an event; overwrites any existing registration. - - Raises: - ValueError: if event is empty or starts with '_' (reserved). - """ - if not event: - raise ValueError("event must be non-empty") - if event.startswith("_"): - raise ValueError(f"event {event!r} is reserved") - previous = self._handlers.get(event) - self._handlers[event] = callback - if previous is not None: - self._log.info("handler for %s replaced", event) - -Expected emit_symbol_summary call (input): -{ - "purpose": "Bind a callback to a named event on the registry; replaces any previously registered handler for the same event and logs the replacement.", - "inputs": [ - {"name": "event", "type": "str", "description": "The event name to bind against; must be non-empty and must not start with '_'."}, - {"name": "callback", "type": "Callable[..., None]", "description": "The handler invoked when the event fires; prior registrations for this event are overwritten."} - ], - "returns": { - "type": "None", - "type_summary": "None — observable effect is self._handlers mutation", - "details": "Mutates self._handlers in place so subsequent event dispatches invoke the new callback; any prior registration for the same event is replaced and the replacement is logged." - }, - "side_effects": [ - "writes self._handlers[event] with the new callback, replacing any prior entry", - "emits an info log via self._log when an existing handler is replaced", - "raises ValueError when event is empty or begins with a reserved underscore" - ], - "invariants": [ - "event must be a non-empty string not starting with '_'; callers violating this contract will see ValueError" - ], - "citations": [ - {"field_name": "purpose", "line_start": 40, "line_end": 41}, - {"field_name": "inputs", "line_start": 40, "line_end": 40}, - {"field_name": "returns", "line_start": 51, "line_end": 55}, - {"field_name": "side_effects", "line_start": 46, "line_end": 55}, - {"field_name": "invariants", "line_start": 46, "line_end": 49} - ] -} - -Why this passes: side_effects items each use a required verb and name a -specific observable effect. Three distinct effects, three distinct items — -no compounding. returns.type="None" plus details that describe the mutation. -invariants restate the checked preconditions in caller-facing language. -Every populated field has a citation inside the supplied source span. - -================================================================================ -FEW-SHOT EXAMPLE 3 — class (constructor-heavy) -================================================================================ - -Source (lines 100-128): - class LRUCache: - """Thread-safe least-recently-used cache with a bounded capacity. - - Evicts the oldest entry when \`capacity\` is exceeded. The cache is - intended for memoization of expensive pure functions; storing - side-effectful or mutable values defeats its correctness guarantees. - """ - - def __init__(self, capacity: int) -> None: - if capacity < 1: - raise ValueError(f"capacity must be >= 1, got {capacity}") - self._capacity = capacity - self._data: OrderedDict[Any, Any] = OrderedDict() - self._lock = threading.Lock() - - def get(self, key: Any, default: Any = None) -> Any: - with self._lock: - if key not in self._data: - return default - self._data.move_to_end(key) - return self._data[key] - - def put(self, key: Any, value: Any) -> None: - with self._lock: - if key in self._data: - self._data.move_to_end(key) - self._data[key] = value - if len(self._data) > self._capacity: - self._data.popitem(last=False) - -Expected emit_symbol_summary call (input): -{ - "purpose": "Provide a thread-safe least-recently-used cache with bounded capacity, evicting the oldest entry on overflow; intended for memoizing expensive pure functions.", - "inputs": [], - "returns": { - "type": "LRUCache", - "type_summary": "constructed LRUCache instance", - "details": "An initialized LRUCache with the supplied capacity, an empty OrderedDict backing store, and a threading.Lock guarding all mutations; subsequent get/put calls mutate the backing store under the lock." - }, - "side_effects": [ - "writes self._capacity, self._data, and self._lock on construction", - "mutates self._data on get() by moving the accessed key to the end (recency update)", - "mutates self._data on put() by inserting or refreshing the key and evicting the oldest entry when capacity is exceeded", - "raises ValueError when __init__ is called with capacity < 1" - ], - "invariants": [ - "capacity must be a positive integer; values below 1 raise ValueError at construction time", - "stored values should be immutable or owned exclusively by the cache — callers that mutate stored values defeat LRU correctness" - ], - "citations": [ - {"field_name": "purpose", "line_start": 100, "line_end": 106}, - {"field_name": "returns", "line_start": 108, "line_end": 113}, - {"field_name": "side_effects", "line_start": 108, "line_end": 128}, - {"field_name": "invariants", "line_start": 103, "line_end": 110} - ] -} - -Why this passes: classes use inputs=[] because the class ITSELF takes no -arguments; the __init__ parameters are cited via the constructor lines -inside returns. side_effects covers all observable mutation patterns across -the class's methods, each as a separate item with a required verb. -invariants surface BOTH a code-enforced precondition (capacity >= 1) AND a -docstring-derived contract (immutable values) with clear citations. - -================================================================================ -EDGE CASES AND HOW TO HANDLE THEM -================================================================================ - -- @property getters: treat them like pure functions if they only read fields. - If the getter mutates state (rare, but it happens in caching decorators), - include the mutation in side_effects. -- async def: side_effects stays the same. The async nature is already in the - signature; don't restate it in purpose. Do call out when the coroutine - emits events through a callback or writes to an async queue. -- Decorators (\`@staticmethod\`, \`@classmethod\`): skip \`self\`/\`cls\` in inputs - per the core rule. If the decorator has observable behavior (e.g., a - retry decorator), mention it in the side_effects of the decorated symbol. -- Generators (\`yield\`): the return type is the yielded type or a - \`Generator[...]\` alias. Name the yielded element semantics in - returns.details; put "emits" side effects (logging, progress events) - separately in side_effects. -- Overloaded functions (\`@overload\`): summarize the runtime implementation - only. The overload stubs exist for type checkers; your summary is for - retrieval. -- Context managers (\`__enter__\`/\`__exit__\`): purpose describes what entering - the block gives the caller; side_effects describe what \`__exit__\` undoes. -- Empty function bodies (stubs, \`pass\`, \`raise NotImplementedError\`): set - side_effects=["raises NotImplementedError when called"] and keep purpose - grounded in the docstring's stated intent. - -================================================================================ -PROCESS -================================================================================ - -Read the supplied source carefully. Identify each populated field, then draft -citations BEFORE writing the body — this forces you to ground claims in -specific lines. Check that each side_effects item contains one of -reads/writes/emits/raises/mutates. Check that purpose does not begin with a -banned prefix. Check that every populated field has at least one citation -inside the supplied line range. Call \`emit_symbol_summary\` exactly once. Do -not emit any natural-language prose outside the tool call.`; - -// --------------------------------------------------------------------------- -// Converse wiring. -// --------------------------------------------------------------------------- - -const TOOL_NAME = "emit_symbol_summary"; -const MODEL_ID = "global.anthropic.claude-haiku-4-5-20251001-v1:0"; -const MAX_ATTEMPTS = 3; - -interface AttemptUsage { - input_tokens: number; - output_tokens: number; - cache_read: number; - cache_write: number; -} - -interface SummarizerResult { - summary: SymbolSummaryT; - attempts: number; - usageByAttempt: AttemptUsage[]; - wallClockS: number; - validationFailures: string[]; -} - -function firstAttemptValid(r: SummarizerResult): boolean { - return r.attempts === 1 && r.validationFailures.length === 0; -} - -function buildUserText(args: { - source: string; - filePath: string; - lineStart: number; - lineEnd: number; - docstring: string | null; - enclosingClass: string | null; -}): string { - const docstringBlock = args.docstring?.trim() || "(no docstring)"; - const classBlock = args.enclosingClass || "(module-level — no enclosing class)"; - return `Summarize the callable symbol below. - -${args.filePath} -${classBlock} -${args.lineStart}-${args.lineEnd} - - -${docstringBlock} - - - -${args.source} - - -Call \`emit_symbol_summary\` now. Citations must reference lines within ${args.lineStart}-${args.lineEnd}.`; -} - -function formatZodError(err: z.ZodError): string { - return err.issues - .map((i) => `- at ${i.path.join(".") || ""}: ${i.message} (code=${i.code})`) - .join("\n"); -} - -function usageFromResponse(u: Record | undefined): AttemptUsage { - return { - input_tokens: u?.inputTokens ?? 0, - output_tokens: u?.outputTokens ?? 0, - cache_read: u?.cacheReadInputTokens ?? 0, - cache_write: u?.cacheWriteInputTokens ?? 0, - }; -} - -async function summarizeSymbol( - client: BedrockRuntimeClient, - args: { - source: string; - filePath: string; - lineStart: number; - lineEnd: number; - docstring: string | null; - enclosingClass: string | null; - }, -): Promise { - const system: SystemContentBlock[] = [ - { text: SYSTEM_PROMPT } as SystemContentBlock.TextMember, - { cachePoint: { type: "default" } } as SystemContentBlock.CachePointMember, - ]; - - const tools: Tool[] = [ - { - toolSpec: { - name: TOOL_NAME, - description: - "Emit the structured summary for the supplied callable symbol. " + - "Every field is validated strictly; schema violations will be returned for retry.", - inputSchema: { json: buildToolInputSchema() }, - }, - } as Tool.ToolSpecMember, - { cachePoint: { type: "default" } } as Tool.CachePointMember, - ]; - - const toolConfig: ToolConfiguration = { - tools, - toolChoice: { tool: { name: TOOL_NAME } }, - }; - - const userText = buildUserText(args); - const messages: Message[] = [{ role: "user", content: [{ text: userText }] }]; - - const usageByAttempt: AttemptUsage[] = []; - const validationFailures: string[] = []; - let lastError: string | null = null; - - const t0 = performance.now(); - - for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { - const request: ConverseCommandInput = { - modelId: MODEL_ID, - messages, - system, - inferenceConfig: { temperature: 0, maxTokens: 2048 }, - toolConfig, - }; - - const response = await client.send(new ConverseCommand(request)); - - usageByAttempt.push(usageFromResponse(response.usage as Record | undefined)); - - const output = response.output; - if (!output || output.message === undefined) { - const failure = `attempt ${attempt}: empty response output`; - validationFailures.push(failure); - lastError = failure; - continue; - } - - const contentBlocks: ContentBlock[] = (output.message as Message).content ?? []; - const stopReason = response.stopReason; - - let toolUseInput: unknown; - let toolUseId: string | undefined; - for (const block of contentBlocks) { - if ("toolUse" in block && block.toolUse !== undefined) { - toolUseInput = block.toolUse.input; - toolUseId = block.toolUse.toolUseId; - break; - } - } - - if (toolUseInput === undefined || toolUseId === undefined) { - const failure = `attempt ${attempt}: model did not call the tool (stopReason=${stopReason ?? "?"})`; - validationFailures.push(failure); - lastError = failure; - messages.push({ role: "assistant", content: contentBlocks }); - messages.push({ - role: "user", - content: [ - { - text: - "You did not call the emit_symbol_summary tool. " + - "Call it now with a valid structured summary.", - }, - ], - }); - continue; - } - - const parsed = SymbolSummary.safeParse(toolUseInput); - const lineErrors = - parsed.success - ? validateCitationLines(parsed.data, args.lineStart, args.lineEnd) - : []; - - if (parsed.success && lineErrors.length === 0) { - return { - summary: parsed.data, - attempts: attempt, - usageByAttempt, - wallClockS: (performance.now() - t0) / 1000, - validationFailures, - }; - } - - let errText: string; - if (!parsed.success) { - errText = formatZodError(parsed.error); - if (lineErrors.length > 0) { - errText += "\n" + lineErrors.map((e) => `- ${e}`).join("\n"); - } - } else { - errText = lineErrors.map((e) => `- ${e}`).join("\n"); - } - - lastError = errText; - validationFailures.push(`attempt ${attempt}:\n${errText}`); - - messages.push({ role: "assistant", content: contentBlocks }); - messages.push({ - role: "user", - content: [ - { - toolResult: { - toolUseId, - content: [ - { - text: - "Validation failed:\n" + - `${errText}\n` + - "Fix and call emit_symbol_summary again.", - }, - ], - status: "error", - }, - }, - ], - }); - } - - throw new Error( - `summarizeSymbol failed after ${MAX_ATTEMPTS} attempts. Last error:\n${lastError ?? ""}`, - ); -} - -// --------------------------------------------------------------------------- -// Test inputs — same two symbols as the Python spike. -// --------------------------------------------------------------------------- - -const SOURCE_PATH = "/Users/lalsaado/Projects/sdk-python/src/strands/agent/agent.py"; - -// Agent.invoke_async, lines 503-549. -const INVOKE_ASYNC_SOURCE = ` async def invoke_async( - self, - prompt: AgentInput = None, - *, - invocation_state: dict[str, Any] | None = None, - structured_output_model: type[BaseModel] | None = None, - structured_output_prompt: str | None = None, - **kwargs: Any, - ) -> AgentResult: - """Process a natural language prompt through the agent's event loop. - - This method implements the conversational interface with multiple input patterns: - - String input: Simple text input - - ContentBlock list: Multi-modal content blocks - - Message list: Complete messages with roles - - No input: Use existing conversation history - - Args: - prompt: User input in various formats: - - str: Simple text input - - list[ContentBlock]: Multi-modal content blocks - - list[Message]: Complete messages with roles - - None: Use existing conversation history - invocation_state: Additional parameters to pass through the event loop. - structured_output_model: Pydantic model type(s) for structured output (overrides agent default). - structured_output_prompt: Custom prompt for forcing structured output (overrides agent default). - **kwargs: Additional parameters to pass through the event loop.[Deprecating] - - Returns: - Result: object containing: - - - stop_reason: Why the event loop stopped (e.g., "end_turn", "max_tokens") - - message: The final message from the model - - metrics: Performance metrics from the event loop - - state: The final state of the event loop - """ - events = self.stream_async( - prompt, - invocation_state=invocation_state, - structured_output_model=structured_output_model, - structured_output_prompt=structured_output_prompt, - **kwargs, - ) - async for event in events: - _ = event - - return cast(AgentResult, event["result"]) -`; - -const INVOKE_ASYNC_DOCSTRING = `Process a natural language prompt through the agent's event loop. - -This method implements the conversational interface with multiple input patterns: -- String input: Simple text input -- ContentBlock list: Multi-modal content blocks -- Message list: Complete messages with roles -- No input: Use existing conversation history -`; - -// Agent.__init__, lines 125-250. -const INIT_SOURCE = ` def __init__( - self, - model: Model | str | None = None, - messages: Messages | None = None, - tools: list[Union[str, dict[str, str], "ToolProvider", Any]] | None = None, - system_prompt: str | list[SystemContentBlock] | None = None, - structured_output_model: type[BaseModel] | None = None, - callback_handler: Callable[..., Any] | _DefaultCallbackHandlerSentinel | None = _DEFAULT_CALLBACK_HANDLER, - conversation_manager: ConversationManager | None = None, - record_direct_tool_call: bool = True, - load_tools_from_directory: bool = False, - trace_attributes: Mapping[str, AttributeValue] | None = None, - *, - agent_id: str | None = None, - name: str | None = None, - description: str | None = None, - state: AgentState | dict | None = None, - plugins: list[Plugin] | None = None, - hooks: list[HookProvider | HookCallback] | None = None, - session_manager: SessionManager | None = None, - structured_output_prompt: str | None = None, - tool_executor: ToolExecutor | None = None, - retry_strategy: ModelRetryStrategy | _DefaultRetryStrategySentinel | None = _DEFAULT_RETRY_STRATEGY, - concurrent_invocation_mode: ConcurrentInvocationMode = ConcurrentInvocationMode.THROW, - ): - """Initialize the Agent with the specified configuration. - - Args: - model: Provider for running inference or a string representing the model-id for Bedrock to use. - Defaults to strands.models.BedrockModel if None. - messages: List of initial messages to pre-load into the conversation. - tools: List of tools to make available to the agent. - system_prompt: System prompt to guide model behavior. - structured_output_model: Pydantic model type(s) for structured output. - callback_handler: Callback for processing events as they happen during agent execution. - conversation_manager: Manager for conversation history and context window. - record_direct_tool_call: Whether to record direct tool calls in message history. - load_tools_from_directory: Whether to load and automatically reload tools in ./tools/. - trace_attributes: Custom trace attributes to apply to the agent's trace span. - agent_id: Optional ID for the agent. - name: Name of the Agent. - description: Description of what the Agent does. - state: Stateful information for the agent. - plugins: List of Plugin instances to extend agent functionality. - hooks: Hooks to be added to the agent hook registry. - session_manager: Manager for handling agent sessions. - structured_output_prompt: Custom prompt message used when forcing structured output. - tool_executor: Definition of tool execution strategy. - retry_strategy: Strategy for retrying model calls on throttling or other transient errors. - concurrent_invocation_mode: Mode controlling concurrent invocation behavior. - - Raises: - ValueError: If agent id contains path separators. - """ - self.model = BedrockModel() if not model else BedrockModel(model_id=model) if isinstance(model, str) else model - self.messages = messages if messages is not None else [] - self._system_prompt, self._system_prompt_content = self._initialize_system_prompt(system_prompt) - self._default_structured_output_model = structured_output_model - self._structured_output_prompt = structured_output_prompt - self.agent_id = _identifier.validate(agent_id or _DEFAULT_AGENT_ID, _identifier.Identifier.AGENT) - self.name = name or _DEFAULT_AGENT_NAME - self.description = description - - self.callback_handler: Callable[..., Any] | PrintingCallbackHandler - if isinstance(callback_handler, _DefaultCallbackHandlerSentinel): - self.callback_handler = PrintingCallbackHandler() - elif callback_handler is None: - self.callback_handler = null_callback_handler - else: - self.callback_handler = callback_handler - - if self.model.stateful and conversation_manager is not None: - raise ValueError( - "conversation_manager cannot be used with a stateful model. " - "The model manages conversation state server-side." - ) - - self.conversation_manager: ConversationManager - if self.model.stateful: - self.conversation_manager = NullConversationManager() - elif conversation_manager: - self.conversation_manager = conversation_manager - else: - self.conversation_manager = SlidingWindowConversationManager() - - self.trace_attributes: dict[str, AttributeValue] = {} - if trace_attributes: - for k, v in trace_attributes.items(): - if isinstance(v, (str, int, float, bool)) or ( - isinstance(v, list) and all(isinstance(x, (str, int, float, bool)) for x in v) - ): - self.trace_attributes[k] = v - - self.record_direct_tool_call = record_direct_tool_call - self.load_tools_from_directory = load_tools_from_directory -`; - -const INIT_DOCSTRING = `Initialize the Agent with the specified configuration. - -Wires up the model, messages buffer, system prompt, conversation manager, -callback handler, trace attributes, tool registry, plugin registry, retry -strategy, hook registry, and session manager. Raises ValueError if the -agent_id contains path separators or if a stateful model is paired with a -conversation_manager. -`; - -// --------------------------------------------------------------------------- -// Entrypoint. -// --------------------------------------------------------------------------- - -function fmtUsage(u: AttemptUsage): string { - return ( - `input=${String(u.input_tokens).padStart(5)} output=${String(u.output_tokens).padStart(4)} ` + - `cacheRead=${String(u.cache_read).padStart(5)} cacheWrite=${String(u.cache_write).padStart(5)}` - ); -} - -function sumUsage(attempts: AttemptUsage[]): AttemptUsage { - return attempts.reduce( - (acc, a) => ({ - input_tokens: acc.input_tokens + a.input_tokens, - output_tokens: acc.output_tokens + a.output_tokens, - cache_read: acc.cache_read + a.cache_read, - cache_write: acc.cache_write + a.cache_write, - }), - { input_tokens: 0, output_tokens: 0, cache_read: 0, cache_write: 0 }, - ); -} - -function printResult(label: string, r: SummarizerResult): void { - console.log(JSON.stringify(r.summary, null, 2)); - console.log(); - console.log(`attempts=${r.attempts} wall_clock=${r.wallClockS.toFixed(2)}s`); - r.usageByAttempt.forEach((u, i) => { - console.log(` #${i + 1}: ${fmtUsage(u)}`); - }); - if (r.validationFailures.length > 0) { - console.log("validation failures (recovered):"); - for (const f of r.validationFailures) { - for (const line of f.split("\n")) { - console.log(` ${line}`); - } - } - } - console.log(); - void label; -} - -async function main(): Promise { - const region = process.env.AWS_REGION ?? "us-east-1"; - const client = new BedrockRuntimeClient({ region }); - - console.log(`Model: ${MODEL_ID}`); - console.log(`Region: ${region}`); - console.log(); - - let result1: SummarizerResult; - let result2: SummarizerResult; - try { - console.log("=".repeat(80)); - console.log("CALL 1 (cold) — Agent.invoke_async"); - console.log("=".repeat(80)); - result1 = await summarizeSymbol(client, { - source: INVOKE_ASYNC_SOURCE, - filePath: SOURCE_PATH, - lineStart: 503, - lineEnd: 549, - docstring: INVOKE_ASYNC_DOCSTRING, - enclosingClass: "Agent", - }); - printResult("invoke_async", result1); - - console.log("=".repeat(80)); - console.log("CALL 2 (warm) — Agent.__init__"); - console.log("=".repeat(80)); - result2 = await summarizeSymbol(client, { - source: INIT_SOURCE, - filePath: SOURCE_PATH, - lineStart: 125, - lineEnd: 250, - docstring: INIT_DOCSTRING, - enclosingClass: "Agent", - }); - printResult("__init__", result2); - } catch (err) { - const name = err instanceof Error ? err.name : "Error"; - const msg = err instanceof Error ? err.message : String(err); - if (/credential/i.test(name) || /credential/i.test(msg) || /Region is missing/i.test(msg)) { - console.error( - `\nERROR: AWS credentials / region not resolved (${name}: ${msg}). ` + - `Hint: re-run with \`AWS_PROFILE=bedrock-a AWS_REGION=us-east-1\` or run \`aws sso login --profile bedrock-a\`.`, - ); - return 2; - } - console.error(`\nERROR: Bedrock call failed: ${name}: ${msg}`); - console.error( - `Hint: check AWS_PROFILE, that the account has Bedrock model access for ${MODEL_ID}, ` + - `and that the region supports the 'global.' inference profile.`, - ); - return 3; - } - - const call1Total = sumUsage(result1.usageByAttempt); - const call2Total = sumUsage(result2.usageByAttempt); - const call1First = result1.usageByAttempt[0]; - const call2First = result2.usageByAttempt[0]; - - const call2Denominator = call2First.input_tokens + call2First.cache_read; - const cacheEfficiency = call2Denominator > 0 ? call2First.cache_read / call2Denominator : 0; - - const firstAttemptHits = (firstAttemptValid(result1) ? 1 : 0) + (firstAttemptValid(result2) ? 1 : 0); - const firstAttemptRate = firstAttemptHits / 2; - - console.log("=".repeat(80)); - console.log("CACHING PROOF (first attempt of each call)"); - console.log("=".repeat(80)); - console.log( - `Call 1 (cold): input=${String(call1First.input_tokens).padStart(5)} ` + - `cacheWrite=${String(call1First.cache_write).padStart(5)} ` + - `cacheRead=${String(call1First.cache_read).padStart(5)}`, - ); - console.log( - `Call 2 (warm): input=${String(call2First.input_tokens).padStart(5)} ` + - `cacheWrite=${String(call2First.cache_write).padStart(5)} ` + - `cacheRead=${String(call2First.cache_read).padStart(5)}`, - ); - console.log( - `Call 2 cache efficiency (cacheRead / (input + cacheRead)): ${(cacheEfficiency * 100).toFixed(1)}%`, - ); - if (call2First.cache_read === 0) { - console.log( - "\nWARNING: cacheReadInputTokens == 0 on the warm call. Caching did not engage. " + - "Possible causes: (a) system+tool prefix under 4,096 tokens, (b) cachePoint placement wrong, " + - "(c) prompt bytes differ from call 1, (d) model ID / region pairing incorrect.", - ); - } - console.log(); - - console.log("=".repeat(80)); - console.log("FINAL REPORT"); - console.log("=".repeat(80)); - - const cacheStatus = - call2First.cache_read > 0 - ? `cache engaged: Converse cachePoint blocks in system+toolConfig.tools wrote ${call1First.cache_write} tokens on call 1, hit for ${call2First.cache_read} tokens on call 2 (${(cacheEfficiency * 100).toFixed(0)}% of call 2 input served from cache)` - : "cache did NOT engage on call 2 — investigate prefix size, cachePoint placement, or region/model pairing"; - - let validityNote: string; - if (firstAttemptHits === 2) { - validityNote = - "the rubric + three few-shot examples in the cached system prompt landed the schema on attempt 1 for both symbols, including the constructor-heavy __init__ that tripped the 200-char returns cap in the Python spike"; - } else if (firstAttemptHits === 1) { - validityNote = - "one symbol passed on attempt 1; the other tripped a length/verb validator and recovered on attempt 2 via ReAct feedback through the toolResult channel"; - } else { - validityNote = - "both symbols failed attempt 1 (typically on returns.details length or a side_effects item missing a required verb) and recovered on attempt 2 via ReAct feedback through the toolResult channel — validators are doing real work, and the retry loop is load-bearing, not decorative"; - } - - const takeaway = - `Haiku 4.5 via AWS SDK v3 Converse (${MODEL_ID}) produced schema-conforming ` + - `SymbolSummary objects for both symbols. ` + - `Call 1 attempts=${result1.attempts} in ${result1.wallClockS.toFixed(2)}s; ` + - `call 2 attempts=${result2.attempts} in ${result2.wallClockS.toFixed(2)}s. ` + - `First-attempt validity: ${firstAttemptHits}/2 (${(firstAttemptRate * 100).toFixed(0)}%) — ` + - `${validityNote}. Totals: call1 ${fmtUsage(call1Total)}; call2 ${fmtUsage(call2Total)}. ` + - `${cacheStatus}. ` + - `Zod 4's z.toJSONSchema emits Draft 2020-12 which Bedrock accepts without conversion; ` + - `strict-by-default objects give us Pydantic's extra="forbid" for free, and superRefine ` + - `covers the citation-coverage invariant that was a model_validator in the Python spike. ` + - `Splitting returns.description into type_summary + details removed the 200-char cap that ` + - `was clipping dense constructors. Engineering takeaway: the TS port matches the Python ` + - `contract, Zod 4 is production-viable for the summarizer's schema layer, and AWS SDK v3 ` + - `gives the same cachePoint wire-level control as boto3. Next: wire this into the ingest ` + - `pipeline, measure recovery rate over ~1k real symbols, and revisit ttl=1h for overnight ` + - `batch indexing.`; - const words = takeaway.split(" "); - const trimmed = words.length > 200 ? words.slice(0, 200).join(" ") + "..." : takeaway; - console.log(trimmed); - return 0; -} - -main() - .then((code) => process.exit(code)) - .catch((err) => { - console.error("FATAL:", err); - process.exit(1); - }); diff --git a/packages/summarizer/src/client.test.ts b/packages/summarizer/src/client.test.ts deleted file mode 100644 index 47ebf1a8..00000000 --- a/packages/summarizer/src/client.test.ts +++ /dev/null @@ -1,210 +0,0 @@ -/** - * Unit tests for the ReAct retry loop in `summarizeSymbol`. - * - * Bedrock is replaced by a fake client whose `send` returns queued Converse - * responses, so we drive the loop deterministically: success on the first - * attempt, recovery after a validation failure, recovery after the model - * skips the tool, the empty-response path, and exhaustion into a - * `SummarizerError` carrying the right attempt/usage/failure bookkeeping. - */ - -import assert from "node:assert/strict"; -import { test } from "node:test"; -import type { BedrockRuntimeClient, ConverseCommandOutput } from "@aws-sdk/client-bedrock-runtime"; -import { DEFAULT_MAX_ATTEMPTS, SummarizerError, summarizeSymbol, TOOL_NAME } from "./client.js"; -import type { SymbolSummaryT } from "./schema.js"; - -const INPUT = { - source: "def normalize(p):\n return os.path.abspath(p)\n", - filePath: "src/paths.py", - lineStart: 10, - lineEnd: 16, - docstring: null, - enclosingClass: null, -} as const; - -/** A summary that passes both the Zod schema and the citation-bounds pass. */ -function validSummaryInput(): SymbolSummaryT { - return { - purpose: "Normalize a filesystem path into a canonical absolute form for cache keys.", - inputs: [{ name: "p", type: "str", description: "the raw path string to normalize" }], - returns: { - type: "str", - type_summary: "the canonical absolute path", - details: "An absolute, symlink-resolved path string with no trailing slash.", - }, - side_effects: ["reads the current working directory to resolve relative paths"], - invariants: ["the result is always an absolute path"], - citations: [ - { field_name: "purpose", line_start: 10, line_end: 12 }, - { field_name: "inputs", line_start: 10, line_end: 10 }, - { field_name: "returns", line_start: 14, line_end: 16 }, - { field_name: "side_effects", line_start: 13, line_end: 13 }, - { field_name: "invariants", line_start: 16, line_end: 16 }, - ], - }; -} - -/** A Converse response whose message calls the tool with the given input. */ -function toolUseResponse(input: unknown, toolUseId = "tu-1"): ConverseCommandOutput { - return { - $metadata: {}, - output: { - message: { - role: "assistant", - content: [{ toolUse: { toolUseId, name: TOOL_NAME, input } }], - }, - }, - stopReason: "tool_use", - usage: { - inputTokens: 100, - outputTokens: 50, - cacheReadInputTokens: 0, - cacheWriteInputTokens: 0, - }, - } as ConverseCommandOutput; -} - -/** A Converse response with text only — the model declined to call the tool. */ -function textOnlyResponse(text: string): ConverseCommandOutput { - return { - $metadata: {}, - output: { message: { role: "assistant", content: [{ text }] } }, - stopReason: "end_turn", - usage: { inputTokens: 80, outputTokens: 20, cacheReadInputTokens: 0, cacheWriteInputTokens: 0 }, - } as ConverseCommandOutput; -} - -/** A Converse response with no output at all. */ -function emptyResponse(): ConverseCommandOutput { - return { - $metadata: {}, - usage: { inputTokens: 10, outputTokens: 0, cacheReadInputTokens: 0, cacheWriteInputTokens: 0 }, - } as ConverseCommandOutput; -} - -/** - * Fake Bedrock client that dequeues one response per `send` call and records - * how many times it was invoked. - */ -function fakeClient(responses: ConverseCommandOutput[]): { - client: BedrockRuntimeClient; - sendCount: () => number; -} { - let i = 0; - const client = { - send: async () => { - const r = responses[i]; - i += 1; - if (r === undefined) { - throw new Error("fake client exhausted: more sends than queued responses"); - } - return r; - }, - } as unknown as BedrockRuntimeClient; - return { client, sendCount: () => i }; -} - -// --------------------------------------------------------------------------- -// Happy paths -// --------------------------------------------------------------------------- - -test("summarizeSymbol: returns the validated summary on the first attempt", async () => { - const { client, sendCount } = fakeClient([toolUseResponse(validSummaryInput())]); - const result = await summarizeSymbol(client, INPUT); - assert.equal(sendCount(), 1); - assert.equal(result.attempts, 1); - assert.equal(result.validationFailures.length, 0); - assert.equal(result.usageByAttempt.length, 1); - assert.equal(result.summary.purpose, validSummaryInput().purpose); - assert.ok(result.wallClockMs >= 0); -}); - -test("summarizeSymbol: recovers on attempt 2 after a schema-validation failure", async () => { - const bad = { - ...validSummaryInput(), - purpose: "This function normalizes a path for cache keys.", - }; - const { client, sendCount } = fakeClient([ - toolUseResponse(bad), - toolUseResponse(validSummaryInput()), - ]); - const result = await summarizeSymbol(client, INPUT); - assert.equal(sendCount(), 2); - assert.equal(result.attempts, 2); - // One failure recorded; the validated attempt is not in the list. - assert.equal(result.validationFailures.length, 1); - assert.match(result.validationFailures[0] as string, /attempt 1:/); - assert.match(result.validationFailures[0] as string, /describe the behavior directly/); -}); - -test("summarizeSymbol: recovers after the model fails to call the tool", async () => { - const { client, sendCount } = fakeClient([ - textOnlyResponse("Here is a prose summary instead of a tool call."), - toolUseResponse(validSummaryInput()), - ]); - const result = await summarizeSymbol(client, INPUT); - assert.equal(sendCount(), 2); - assert.equal(result.attempts, 2); - assert.equal(result.validationFailures.length, 1); - assert.match(result.validationFailures[0] as string, /did not call the tool/); -}); - -test("summarizeSymbol: recovers after an empty response output", async () => { - const { client } = fakeClient([emptyResponse(), toolUseResponse(validSummaryInput())]); - const result = await summarizeSymbol(client, INPUT); - assert.equal(result.attempts, 2); - assert.equal(result.validationFailures.length, 1); - assert.match(result.validationFailures[0] as string, /empty response output/); -}); - -test("summarizeSymbol: recovers after a citation-bounds failure that the schema cannot catch", async () => { - const outOfBounds = validSummaryInput(); - // line_end 99 is inside Zod's range but outside the [10, 16] source span. - const bad = { - ...outOfBounds, - citations: [ - { field_name: "purpose" as const, line_start: 10, line_end: 99 }, - ...outOfBounds.citations.slice(1), - ], - }; - const { client } = fakeClient([toolUseResponse(bad), toolUseResponse(validSummaryInput())]); - const result = await summarizeSymbol(client, INPUT); - assert.equal(result.attempts, 2); - assert.match(result.validationFailures[0] as string, /falls outside source span \[10, 16\]/); -}); - -// --------------------------------------------------------------------------- -// Exhaustion -// --------------------------------------------------------------------------- - -test("summarizeSymbol: throws SummarizerError after exhausting attempts, with full bookkeeping", async () => { - const bad = { ...validSummaryInput(), purpose: "too short" }; - const { client, sendCount } = fakeClient([ - toolUseResponse(bad), - toolUseResponse(bad), - toolUseResponse(bad), - ]); - await assert.rejects( - () => summarizeSymbol(client, INPUT), - (err: unknown) => { - assert.ok(err instanceof SummarizerError); - assert.equal(err.attemptsUsed, DEFAULT_MAX_ATTEMPTS); - assert.equal(err.usageByAttempt.length, DEFAULT_MAX_ATTEMPTS); - assert.equal(err.validationFailures.length, DEFAULT_MAX_ATTEMPTS); - assert.equal(err.name, "SummarizerError"); - return true; - }, - ); - assert.equal(sendCount(), DEFAULT_MAX_ATTEMPTS); -}); - -test("summarizeSymbol: honours a custom maxAttempts ceiling", async () => { - const bad = { ...validSummaryInput(), purpose: "too short" }; - const { client, sendCount } = fakeClient([toolUseResponse(bad), toolUseResponse(bad)]); - await assert.rejects( - () => summarizeSymbol(client, INPUT, { maxAttempts: 2 }), - (err: unknown) => err instanceof SummarizerError && err.attemptsUsed === 2, - ); - assert.equal(sendCount(), 2); -}); diff --git a/packages/summarizer/src/client.ts b/packages/summarizer/src/client.ts deleted file mode 100644 index 2a5505b2..00000000 --- a/packages/summarizer/src/client.ts +++ /dev/null @@ -1,257 +0,0 @@ -/** - * Converse-API summarizer client with ReAct retry. - * - * One public entry point, `summarizeSymbol`, drives a Bedrock `ConverseCommand` - * against Haiku 4.5 using `tool_use` as the structured-output primitive. - * The Zod schema exported from `./schema` becomes the tool's `inputSchema`, - * so the model can only respond by filling that schema. Validation failures - * are fed back through `toolResult(status: "error")` content blocks so Claude - * sees its prior (broken) tool call and the validator's feedback — the ReAct - * pattern, wire-level. - * - * Two cachePoint blocks engage prompt caching: - * - after the system prompt (the ~5k-token rubric + three worked examples) - * - after the tool spec inside `toolConfig.tools` - * Haiku 4.5 on Bedrock requires ≥4,096 cacheable tokens per checkpoint; the - * system prompt is sized to clear that floor. - * - * NOT included in this client: - * - batch orchestration (the ingestion pipeline owns concurrency + backoff) - * - embedding of the emitted summary (that lives downstream) - * - caching of summaries to disk (the ingest layer hashes source and - * re-uses prior summaries when the AST structural hash matches) - */ - -import { - type BedrockRuntimeClient, - type ContentBlock, - ConverseCommand, - type ConverseCommandInput, - type Message, - type SystemContentBlock, - type Tool, - type ToolConfiguration, -} from "@aws-sdk/client-bedrock-runtime"; -import { buildUserText, SYSTEM_PROMPT } from "./prompt.js"; -import { - buildToolInputSchema, - formatZodError, - SymbolSummary, - type SymbolSummaryT, - validateCitationLines, -} from "./schema.js"; - -export const TOOL_NAME = "emit_symbol_summary"; -/** - * Default model id — global inference profile for Haiku 4.5 on Bedrock. - * Callers can override via `SummarizeOptions.modelId` when running against - * a region-specific profile. - */ -export const DEFAULT_MODEL_ID = "global.anthropic.claude-haiku-4-5-20251001-v1:0"; -export const DEFAULT_MAX_ATTEMPTS = 3; - -export interface AttemptUsage { - readonly inputTokens: number; - readonly outputTokens: number; - readonly cacheRead: number; - readonly cacheWrite: number; -} - -export interface SummarizerResult { - readonly summary: SymbolSummaryT; - readonly attempts: number; - readonly usageByAttempt: readonly AttemptUsage[]; - readonly wallClockMs: number; - /** - * One entry per attempt that failed validation. The validated attempt is - * NOT included here; length therefore equals `attempts - 1` on success. - */ - readonly validationFailures: readonly string[]; -} - -export interface SummarizeInput { - readonly source: string; - readonly filePath: string; - readonly lineStart: number; - readonly lineEnd: number; - readonly docstring: string | null; - readonly enclosingClass: string | null; -} - -export interface SummarizeOptions { - readonly modelId?: string; - readonly maxAttempts?: number; - readonly maxTokens?: number; -} - -function usageFromResponse(u: Record | undefined): AttemptUsage { - return { - inputTokens: u?.["inputTokens"] ?? 0, - outputTokens: u?.["outputTokens"] ?? 0, - cacheRead: u?.["cacheReadInputTokens"] ?? 0, - cacheWrite: u?.["cacheWriteInputTokens"] ?? 0, - }; -} - -export class SummarizerError extends Error { - constructor( - message: string, - public readonly attemptsUsed: number, - public readonly usageByAttempt: readonly AttemptUsage[], - public readonly validationFailures: readonly string[], - ) { - super(message); - this.name = "SummarizerError"; - } -} - -export async function summarizeSymbol( - client: BedrockRuntimeClient, - input: SummarizeInput, - options: SummarizeOptions = {}, -): Promise { - const modelId = options.modelId ?? DEFAULT_MODEL_ID; - const maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS; - const maxTokens = options.maxTokens ?? 2048; - - const system: SystemContentBlock[] = [ - { text: SYSTEM_PROMPT } as SystemContentBlock.TextMember, - { cachePoint: { type: "default" } } as SystemContentBlock.CachePointMember, - ]; - - const tools: Tool[] = [ - { - toolSpec: { - name: TOOL_NAME, - description: - "Emit the structured summary for the supplied callable symbol. " + - "Every field is validated strictly; schema violations will be returned for retry.", - inputSchema: { json: buildToolInputSchema() }, - }, - } as Tool.ToolSpecMember, - { cachePoint: { type: "default" } } as Tool.CachePointMember, - ]; - - const toolConfig: ToolConfiguration = { - tools, - toolChoice: { tool: { name: TOOL_NAME } }, - }; - - const messages: Message[] = [{ role: "user", content: [{ text: buildUserText(input) }] }]; - - const usageByAttempt: AttemptUsage[] = []; - const validationFailures: string[] = []; - let lastError: string | null = null; - const t0 = performance.now(); - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - const request: ConverseCommandInput = { - modelId, - messages, - system, - inferenceConfig: { temperature: 0, maxTokens }, - toolConfig, - }; - - const response = await client.send(new ConverseCommand(request)); - - usageByAttempt.push( - usageFromResponse(response.usage as Record | undefined), - ); - - const output = response.output; - if (!output || output.message === undefined) { - const failure = `attempt ${attempt}: empty response output`; - validationFailures.push(failure); - lastError = failure; - continue; - } - - const contentBlocks: ContentBlock[] = (output.message as Message).content ?? []; - const stopReason = response.stopReason; - - let toolUseInput: unknown; - let toolUseId: string | undefined; - for (const block of contentBlocks) { - if ("toolUse" in block && block.toolUse !== undefined) { - toolUseInput = block.toolUse.input; - toolUseId = block.toolUse.toolUseId; - break; - } - } - - if (toolUseInput === undefined || toolUseId === undefined) { - const failure = `attempt ${attempt}: model did not call the tool (stopReason=${stopReason ?? "?"})`; - validationFailures.push(failure); - lastError = failure; - messages.push({ role: "assistant", content: contentBlocks }); - messages.push({ - role: "user", - content: [ - { - text: - "You did not call the emit_symbol_summary tool. " + - "Call it now with a valid structured summary.", - }, - ], - }); - continue; - } - - const parsed = SymbolSummary.safeParse(toolUseInput); - const lineErrors = parsed.success - ? validateCitationLines(parsed.data, input.lineStart, input.lineEnd) - : []; - - if (parsed.success && lineErrors.length === 0) { - return { - summary: parsed.data, - attempts: attempt, - usageByAttempt, - wallClockMs: performance.now() - t0, - validationFailures, - }; - } - - let errText: string; - if (!parsed.success) { - errText = formatZodError(parsed.error); - if (lineErrors.length > 0) { - errText += `\n${lineErrors.map((e) => `- ${e}`).join("\n")}`; - } - } else { - errText = lineErrors.map((e) => `- ${e}`).join("\n"); - } - - lastError = errText; - validationFailures.push(`attempt ${attempt}:\n${errText}`); - - messages.push({ role: "assistant", content: contentBlocks }); - messages.push({ - role: "user", - content: [ - { - toolResult: { - toolUseId, - content: [ - { - text: - "Validation failed:\n" + - `${errText}\n` + - "Fix and call emit_symbol_summary again.", - }, - ], - status: "error", - }, - }, - ], - }); - } - - throw new SummarizerError( - `summarizeSymbol failed after ${maxAttempts} attempts. Last error:\n${lastError ?? ""}`, - maxAttempts, - usageByAttempt, - validationFailures, - ); -} diff --git a/packages/summarizer/src/index.ts b/packages/summarizer/src/index.ts deleted file mode 100644 index 87677d75..00000000 --- a/packages/summarizer/src/index.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * `@opencodehub/summarizer` — structured symbol summaries via Bedrock - * Converse + Zod 4. - * - * The summarizer is one leg of the code-retrieval stack: each callable - * symbol ingested by `@opencodehub/ingestion` gets a strict, citation- - * grounded summary here, which is then embedded and fused with graph + - * code embeddings at query time. See `reference/` for the two spike - * scripts (boto3/Pydantic in Python, SDK-v3/Zod in TypeScript) that - * validated the wire-level shape and prompt-caching behavior on Bedrock. - */ - -export { - type AttemptUsage, - DEFAULT_MAX_ATTEMPTS, - DEFAULT_MODEL_ID, - type SummarizeInput, - type SummarizeOptions, - SummarizerError, - type SummarizerResult, - summarizeSymbol, - TOOL_NAME, -} from "./client.js"; - -export { buildUserText, SUMMARIZER_PROMPT_VERSION, SYSTEM_PROMPT } from "./prompt.js"; -export { - buildToolInputSchema, - Citation, - formatZodError, - InputSpec, - ReturnSpec, - SymbolSummary, - type SymbolSummaryT, - validateCitationLines, -} from "./schema.js"; diff --git a/packages/summarizer/src/prompt.ts b/packages/summarizer/src/prompt.ts deleted file mode 100644 index 9d98d4a5..00000000 --- a/packages/summarizer/src/prompt.ts +++ /dev/null @@ -1,377 +0,0 @@ -/** - * Load-bearing system prompt for the summarizer. - * - * The prompt is deliberately ~5,000 tokens: it clears Haiku 4.5's 4,096-token - * cache-point floor on Bedrock (see the spike report) AND supplies three - * worked few-shot examples that pushed first-attempt validity to 2/2 in - * local testing. Changing this file changes the cache key — back-to-back - * calls will miss the cache until the new prefix is written. - * - * The three few-shot examples cover the three retrieval-interesting shapes: - * - pure function (normalize_path): no side effects, no invariants - * - side-effectful method (register_handler): writes / emits / raises - * - constructor-heavy class (LRUCache): inputs=[] on the class, mutations - * summarized across all methods, mixed code- and docstring-sourced - * invariants. - */ - -/** - * Prompt version tag stored alongside every generated summary. Bump when the - * system prompt, schema, or post-validation rules change materially — the - * ingestion `summarize` phase uses this as part of the cache key, so a bump - * invalidates existing rows without deleting them. Semver-adjacent: treat - * this as a single monotonically increasing integer string at MVP. - */ -export const SUMMARIZER_PROMPT_VERSION = "1"; - -export const SYSTEM_PROMPT = `You are a code-understanding assistant. You generate structured, citation-grounded summaries of callable symbols (functions, methods, classes) for OpenCodeHub's code-retrieval engine. Your output is consumed by an embedding model, is weighted per-field at retrieval time, and must cite line ranges so we can detect staleness when source drifts. You MUST respond by calling the \`emit_symbol_summary\` tool — never with free-form prose. - -================================================================================ -WHY THIS FORMAT EXISTS (the retrieval-side context) -================================================================================ - -OpenCodeHub indexes callable symbols from large Python repositories and serves -them to humans and coding agents at question time. At indexing time, each -symbol is summarized into the structured shape you are producing. That -structured summary is then: - - 1. Embedded field-by-field. \`purpose\` drives semantic recall, \`inputs\` and - \`returns\` ground typed questions, \`side_effects\` surfaces the operational - footprint, \`invariants\` surface caller-side contracts. We weight each - field at query time by the intent of the question (e.g., "what can this - break?" favors side_effects + invariants; "what does this do?" favors - purpose). Vague \`purpose\` text collapses recall. - 2. Re-ranked using the \`citations\` you provide. At query time we fetch the - cited line ranges from the source tree and compare them against what you - claimed. If the lines have drifted, we downrank the summary and flag it - for refresh. Citations that span the entire symbol defeat the staleness - detector; cite the tightest window that supports the claim. - 3. Surfaced to agents as a contract, not a description. Agents rely on - side_effects and invariants to decide whether to call a symbol. A - side_effects list that says "manages state" is worthless; one that says - "writes self._session_manager and registers it as a hook" is actionable. - -Treat every field as load-bearing. No filler. - -================================================================================ -CORE RULES -================================================================================ - -1. purpose (30-400 chars): describe what the symbol DOES, not what it IS. Never - start with "This function", "This method", or "This class". Lead with a verb - or a concrete behavior statement. Strong purpose text reads like a commit - message subject line: active voice, specific behavior, no hedging. -2. inputs: one InputSpec per parameter. Skip \`self\` and \`cls\`. When the type is - unannotated, set type="unknown". Description 20-200 chars; say what the - argument controls, not what its type is. The type system already has the - type; your job is to add semantics. -3. returns.type: the declared or inferred return type. For \`None\` returns, set - type="None" AND use returns.type_summary + returns.details to describe the - side effect the call produces (what changes in the world). "returns None" - is never acceptable — describe the mutation. - returns.type_summary (10-80 chars): one-line gist of what comes back. - returns.details (20-400 chars): the expanded description — what the caller - receives, edge cases, or the observable mutation for None returns. -4. side_effects: list[str], each item 10-200 chars. Every item MUST contain one - of these verbs: reads, writes, emits, raises, mutates. Empty list = pure - function with no observable side effect. Each item names ONE effect; - compound effects go in separate items. -5. invariants: optional. Preconditions / invariants the caller must uphold. - Each 10-300 chars. Omit the field (or set to null) when there are none. - Invariants are contracts enforced by the code, not general advice. -6. citations: at least one Citation per populated field. \`line_start\` and - \`line_end\` must fall inside the source span the user provides. Always cite - \`purpose\` and \`returns\`; also cite \`inputs\`, \`side_effects\`, \`invariants\` - when those fields are populated. Citations should be TIGHT: 2-8 lines - that directly evidence the claim, not the whole symbol. - -================================================================================ -SCORING RUBRIC (how we grade your output) -================================================================================ - -+3 purpose is verb-led and behavior-oriented ("Stream tokens through the - event loop" vs "This method streams tokens"). -+3 every populated field has a citation with a tight line range (cite the - specific 2-8 lines that evidence the claim, not the whole symbol). -+3 side_effects items each name ONE concrete effect with the right verb - ("writes \`self._cancel_signal\` when cancel() is called"), not vague - English ("manages state"). -+2 inputs descriptions distinguish the parameter's role from its type. -+2 returns.details for a None return names the specific state that - changes, not the abstract fact that there is a side effect. -+1 invariants surface caller-side preconditions the docstring omitted but - the code enforces. -+1 summary remains useful when the docstring is stripped — i.e., your - reasoning is grounded in code, not just docstring paraphrase. --5 purpose starts with a banned prefix ("This function/method/class"). --5 side_effects item lacks one of the required verbs. --5 citation line range falls outside the supplied source span. --3 a populated field lacks a citation. --3 returns.type is "None" but returns.details is empty or vague. --3 side_effects list is empty for a method that clearly mutates state. --2 a single citation spans the full symbol (defeats staleness detection). - -================================================================================ -COMMON MISTAKES TO AVOID -================================================================================ - -- Do NOT restate the signature. The type system has that information; you - contribute semantics. -- Do NOT describe implementation mechanics ("uses a for loop", "calls helper - X"). Describe observable behavior. -- Do NOT omit the citation on \`returns\` just because you cited \`purpose\` — - every populated field needs its own citation. -- Do NOT set side_effects=[] for a method that clearly writes state. A method - that assigns to \`self.foo\` mutates the instance; name that. -- Do NOT invent invariants. Only list preconditions that are enforced by - validation code, documented in the docstring, or obvious from the signature. -- Do NOT cite the entire symbol as one range. Each citation should cover only - the 2-8 lines that actually evidence the claim. -- Do NOT copy the docstring verbatim into purpose. Rephrase to lead with the - verb and distill. A docstring is a draft; purpose is the edited commit. -- Do NOT list internal method calls as side effects unless those calls have - observable external behavior (I/O, logging, state mutation, exceptions). -- Do NOT write side_effects items like "calls self.foo()" — that is a mechanic, - not an effect. Write what self.foo() DOES (writes, emits, raises, mutates). -- Do NOT pluralize effects inside one item. "writes A and B" should be split - into two items: "writes A" and "writes B". One item, one effect. -- Do NOT describe the return type as the side effect. The returns field - handles the return; side_effects is for things OTHER than the return. - -================================================================================ -FEW-SHOT EXAMPLE 1 — pure function -================================================================================ - -Source (lines 10-18): - def normalize_path(p: str) -> str: - """Collapse redundant separators and resolve '.' / '..' segments.""" - if not p: - return "" - parts = [seg for seg in p.split("/") if seg not in ("", ".")] - out: list[str] = [] - for seg in parts: - if seg == "..": - if out: - out.pop() - else: - out.append(seg) - return "/".join(out) - -Expected emit_symbol_summary call (input): -{ - "purpose": "Collapse redundant separators and resolve '.' / '..' segments in a POSIX-style path string, returning a canonical form suitable for comparison.", - "inputs": [ - {"name": "p", "type": "str", "description": "The raw path to normalize; may contain empty segments, '.', or '..' components."} - ], - "returns": { - "type": "str", - "type_summary": "canonical path string", - "details": "The canonical path with redundant separators collapsed and '.' / '..' segments resolved; returns the empty string when the input is empty." - }, - "side_effects": [], - "invariants": null, - "citations": [ - {"field_name": "purpose", "line_start": 10, "line_end": 11}, - {"field_name": "inputs", "line_start": 10, "line_end": 10}, - {"field_name": "returns", "line_start": 18, "line_end": 18} - ] -} - -Why this passes: verb-led purpose, tight citations, empty side_effects for a -pure function, invariants omitted because there are none. Note how each -citation covers a narrow line range specific to the claim rather than the -whole 10-18 span. - -================================================================================ -FEW-SHOT EXAMPLE 2 — side-effectful method -================================================================================ - -Source (lines 40-55): - def register_handler(self, event: str, callback: Callable[..., None]) -> None: - """Register a callback for an event; overwrites any existing registration. - - Raises: - ValueError: if event is empty or starts with '_' (reserved). - """ - if not event: - raise ValueError("event must be non-empty") - if event.startswith("_"): - raise ValueError(f"event {event!r} is reserved") - previous = self._handlers.get(event) - self._handlers[event] = callback - if previous is not None: - self._log.info("handler for %s replaced", event) - -Expected emit_symbol_summary call (input): -{ - "purpose": "Bind a callback to a named event on the registry; replaces any previously registered handler for the same event and logs the replacement.", - "inputs": [ - {"name": "event", "type": "str", "description": "The event name to bind against; must be non-empty and must not start with '_'."}, - {"name": "callback", "type": "Callable[..., None]", "description": "The handler invoked when the event fires; prior registrations for this event are overwritten."} - ], - "returns": { - "type": "None", - "type_summary": "None — observable effect is self._handlers mutation", - "details": "Mutates self._handlers in place so subsequent event dispatches invoke the new callback; any prior registration for the same event is replaced and the replacement is logged." - }, - "side_effects": [ - "writes self._handlers[event] with the new callback, replacing any prior entry", - "emits an info log via self._log when an existing handler is replaced", - "raises ValueError when event is empty or begins with a reserved underscore" - ], - "invariants": [ - "event must be a non-empty string not starting with '_'; callers violating this contract will see ValueError" - ], - "citations": [ - {"field_name": "purpose", "line_start": 40, "line_end": 41}, - {"field_name": "inputs", "line_start": 40, "line_end": 40}, - {"field_name": "returns", "line_start": 51, "line_end": 55}, - {"field_name": "side_effects", "line_start": 46, "line_end": 55}, - {"field_name": "invariants", "line_start": 46, "line_end": 49} - ] -} - -Why this passes: side_effects items each use a required verb and name a -specific observable effect. Three distinct effects, three distinct items — -no compounding. returns.type="None" plus details that describe the mutation. -invariants restate the checked preconditions in caller-facing language. -Every populated field has a citation inside the supplied source span. - -================================================================================ -FEW-SHOT EXAMPLE 3 — class (constructor-heavy) -================================================================================ - -Source (lines 100-128): - class LRUCache: - """Thread-safe least-recently-used cache with a bounded capacity. - - Evicts the oldest entry when \`capacity\` is exceeded. The cache is - intended for memoization of expensive pure functions; storing - side-effectful or mutable values defeats its correctness guarantees. - """ - - def __init__(self, capacity: int) -> None: - if capacity < 1: - raise ValueError(f"capacity must be >= 1, got {capacity}") - self._capacity = capacity - self._data: OrderedDict[Any, Any] = OrderedDict() - self._lock = threading.Lock() - - def get(self, key: Any, default: Any = None) -> Any: - with self._lock: - if key not in self._data: - return default - self._data.move_to_end(key) - return self._data[key] - - def put(self, key: Any, value: Any) -> None: - with self._lock: - if key in self._data: - self._data.move_to_end(key) - self._data[key] = value - if len(self._data) > self._capacity: - self._data.popitem(last=False) - -Expected emit_symbol_summary call (input): -{ - "purpose": "Provide a thread-safe least-recently-used cache with bounded capacity, evicting the oldest entry on overflow; intended for memoizing expensive pure functions.", - "inputs": [], - "returns": { - "type": "LRUCache", - "type_summary": "constructed LRUCache instance", - "details": "An initialized LRUCache with the supplied capacity, an empty OrderedDict backing store, and a threading.Lock guarding all mutations; subsequent get/put calls mutate the backing store under the lock." - }, - "side_effects": [ - "writes self._capacity, self._data, and self._lock on construction", - "mutates self._data on get() by moving the accessed key to the end (recency update)", - "mutates self._data on put() by inserting or refreshing the key and evicting the oldest entry when capacity is exceeded", - "raises ValueError when __init__ is called with capacity < 1" - ], - "invariants": [ - "capacity must be a positive integer; values below 1 raise ValueError at construction time", - "stored values should be immutable or owned exclusively by the cache — callers that mutate stored values defeat LRU correctness" - ], - "citations": [ - {"field_name": "purpose", "line_start": 100, "line_end": 106}, - {"field_name": "returns", "line_start": 108, "line_end": 113}, - {"field_name": "side_effects", "line_start": 108, "line_end": 128}, - {"field_name": "invariants", "line_start": 103, "line_end": 110} - ] -} - -Why this passes: classes use inputs=[] because the class ITSELF takes no -arguments; the __init__ parameters are cited via the constructor lines -inside returns. side_effects covers all observable mutation patterns across -the class's methods, each as a separate item with a required verb. -invariants surface BOTH a code-enforced precondition (capacity >= 1) AND a -docstring-derived contract (immutable values) with clear citations. - -================================================================================ -EDGE CASES AND HOW TO HANDLE THEM -================================================================================ - -- @property getters: treat them like pure functions if they only read fields. - If the getter mutates state (rare, but it happens in caching decorators), - include the mutation in side_effects. -- async def: side_effects stays the same. The async nature is already in the - signature; don't restate it in purpose. Do call out when the coroutine - emits events through a callback or writes to an async queue. -- Decorators (\`@staticmethod\`, \`@classmethod\`): skip \`self\`/\`cls\` in inputs - per the core rule. If the decorator has observable behavior (e.g., a - retry decorator), mention it in the side_effects of the decorated symbol. -- Generators (\`yield\`): the return type is the yielded type or a - \`Generator[...]\` alias. Name the yielded element semantics in - returns.details; put "emits" side effects (logging, progress events) - separately in side_effects. -- Overloaded functions (\`@overload\`): summarize the runtime implementation - only. The overload stubs exist for type checkers; your summary is for - retrieval. -- Context managers (\`__enter__\`/\`__exit__\`): purpose describes what entering - the block gives the caller; side_effects describe what \`__exit__\` undoes. -- Empty function bodies (stubs, \`pass\`, \`raise NotImplementedError\`): set - side_effects=["raises NotImplementedError when called"] and keep purpose - grounded in the docstring's stated intent. - -================================================================================ -PROCESS -================================================================================ - -Read the supplied source carefully. Identify each populated field, then draft -citations BEFORE writing the body — this forces you to ground claims in -specific lines. Check that each side_effects item contains one of -reads/writes/emits/raises/mutates. Check that purpose does not begin with a -banned prefix. Check that every populated field has at least one citation -inside the supplied line range. Call \`emit_symbol_summary\` exactly once. Do -not emit any natural-language prose outside the tool call.`; - -/** - * Build the per-symbol user message. Kept separate from the system prompt - * so every call reuses the same cached prefix and only the user content - * varies. - */ -export function buildUserText(args: { - source: string; - filePath: string; - lineStart: number; - lineEnd: number; - docstring: string | null; - enclosingClass: string | null; -}): string { - const docstringBlock = args.docstring?.trim() || "(no docstring)"; - const classBlock = args.enclosingClass || "(module-level — no enclosing class)"; - return `Summarize the callable symbol below. - -${args.filePath} -${classBlock} -${args.lineStart}-${args.lineEnd} - - -${docstringBlock} - - - -${args.source} - - -Call \`emit_symbol_summary\` now. Citations must reference lines within ${args.lineStart}-${args.lineEnd}.`; -} diff --git a/packages/summarizer/src/schema.test.ts b/packages/summarizer/src/schema.test.ts deleted file mode 100644 index 9d8f5fd1..00000000 --- a/packages/summarizer/src/schema.test.ts +++ /dev/null @@ -1,227 +0,0 @@ -/** - * Unit tests for the Zod contract and the run-time citation-bounds pass. - * - * These exercise the validation layer directly — no Bedrock, no network. - * The schema is the citation-grounding heart of the summarizer, so every - * refinement (banned purpose prefix, side-effect verb, per-field citation - * via superRefine) and the `validateCitationLines` bounds math gets a focused - * case here. - */ - -import assert from "node:assert/strict"; -import { test } from "node:test"; -import { - buildToolInputSchema, - formatZodError, - SymbolSummary, - type SymbolSummaryT, - validateCitationLines, -} from "./schema.js"; - -/** - * Smallest summary that passes every refinement. Tests clone and mutate it to - * isolate a single failure mode. - */ -function validSummary(): SymbolSummaryT { - return { - purpose: "Normalize a filesystem path into a canonical absolute form for cache keys.", - inputs: [{ name: "path", type: "str", description: "the raw path string to normalize" }], - returns: { - type: "str", - type_summary: "the canonical absolute path", - details: "An absolute, symlink-resolved path string with no trailing slash.", - }, - side_effects: ["reads the current working directory to resolve relative paths"], - invariants: ["the result is always an absolute path"], - citations: [ - { field_name: "purpose", line_start: 10, line_end: 12 }, - { field_name: "inputs", line_start: 10, line_end: 10 }, - { field_name: "returns", line_start: 14, line_end: 16 }, - { field_name: "side_effects", line_start: 13, line_end: 13 }, - { field_name: "invariants", line_start: 16, line_end: 16 }, - ], - }; -} - -// --------------------------------------------------------------------------- -// SymbolSummary.safeParse — happy path -// --------------------------------------------------------------------------- - -test("SymbolSummary: accepts a fully-populated, citation-complete summary", () => { - const parsed = SymbolSummary.safeParse(validSummary()); - assert.equal(parsed.success, true); -}); - -test("SymbolSummary: accepts empty inputs/side_effects with no citation for them", () => { - const summary = validSummary(); - const minimal = { - ...summary, - inputs: [], - side_effects: [], - invariants: null, - citations: [ - { field_name: "purpose", line_start: 10, line_end: 12 }, - { field_name: "returns", line_start: 14, line_end: 16 }, - ], - }; - const parsed = SymbolSummary.safeParse(minimal); - assert.equal(parsed.success, true); -}); - -// --------------------------------------------------------------------------- -// SymbolSummary.safeParse — each invalid shape -// --------------------------------------------------------------------------- - -test("SymbolSummary: rejects a purpose that starts with 'This function'", () => { - const summary = { - ...validSummary(), - purpose: "This function normalizes a path into a canonical form for use as a cache key.", - }; - const parsed = SymbolSummary.safeParse(summary); - assert.equal(parsed.success, false); - assert.match(formatZodError(parsed.error), /describe the behavior directly/); -}); - -test("SymbolSummary: rejects a side_effects item with no read/write/emit/raise/mutate verb", () => { - const summary = { ...validSummary(), side_effects: ["manages some internal state somehow"] }; - const parsed = SymbolSummary.safeParse(summary); - assert.equal(parsed.success, false); - assert.match(formatZodError(parsed.error), /reads\/writes\/emits\/raises\/mutates/); -}); - -test("SymbolSummary: rejects a populated field with no matching citation", () => { - const summary = validSummary(); - // Drop the side_effects citation while keeping the side_effects array populated. - const missing = { - ...summary, - citations: summary.citations.filter((c) => c.field_name !== "side_effects"), - }; - const parsed = SymbolSummary.safeParse(missing); - assert.equal(parsed.success, false); - assert.match( - formatZodError(parsed.error), - /field 'side_effects' is populated but has no citation/, - ); -}); - -test("SymbolSummary: rejects a citation whose line_end precedes line_start", () => { - const summary = validSummary(); - const inverted = { - ...summary, - citations: [ - { field_name: "purpose", line_start: 12, line_end: 10 }, - ...summary.citations.slice(1), - ], - }; - const parsed = SymbolSummary.safeParse(inverted); - assert.equal(parsed.success, false); - assert.match(formatZodError(parsed.error), /line_end must be >= line_start/); -}); - -test("SymbolSummary: rejects an empty citations array (min 1)", () => { - const summary = { ...validSummary(), citations: [] }; - const parsed = SymbolSummary.safeParse(summary); - assert.equal(parsed.success, false); -}); - -test("SymbolSummary: rejects unknown top-level keys (strict)", () => { - const summary = { ...validSummary(), complexity: 7 }; - const parsed = SymbolSummary.safeParse(summary); - assert.equal(parsed.success, false); -}); - -// --------------------------------------------------------------------------- -// validateCitationLines — bounds math -// --------------------------------------------------------------------------- - -test("validateCitationLines: returns no errors when every citation is in-bounds", () => { - const errors = validateCitationLines(validSummary(), 10, 16); - assert.deepEqual(errors, []); -}); - -test("validateCitationLines: accepts citations exactly on the lineStart/lineEnd boundary", () => { - const summary = validSummary(); - const onBoundary = { - ...summary, - citations: [ - { field_name: "purpose" as const, line_start: 10, line_end: 10 }, - { field_name: "returns" as const, line_start: 16, line_end: 16 }, - ], - inputs: [], - side_effects: [], - invariants: null, - }; - assert.deepEqual(validateCitationLines(onBoundary, 10, 16), []); -}); - -test("validateCitationLines: flags an off-by-one below lineStart", () => { - const summary = validSummary(); - const below = { - ...summary, - citations: [{ field_name: "purpose" as const, line_start: 9, line_end: 12 }], - }; - const errors = validateCitationLines(below, 10, 16); - assert.equal(errors.length, 1); - assert.match(errors[0] as string, /falls outside source span \[10, 16\]/); -}); - -test("validateCitationLines: flags an off-by-one above lineEnd", () => { - const summary = validSummary(); - const above = { - ...summary, - citations: [{ field_name: "returns" as const, line_start: 14, line_end: 17 }], - }; - const errors = validateCitationLines(above, 10, 16); - assert.equal(errors.length, 1); - assert.match(errors[0] as string, /\[14, 17\] falls outside source span \[10, 16\]/); -}); - -test("validateCitationLines: reports one error per out-of-bounds citation, with its index", () => { - const summary = validSummary(); - const twoBad = { - ...summary, - citations: [ - { field_name: "purpose" as const, line_start: 10, line_end: 16 }, - { field_name: "returns" as const, line_start: 1, line_end: 2 }, - { field_name: "side_effects" as const, line_start: 100, line_end: 200 }, - ], - }; - const errors = validateCitationLines(twoBad, 10, 16); - assert.equal(errors.length, 2); - assert.match(errors[0] as string, /^citations\[1\]:/); - assert.match(errors[1] as string, /^citations\[2\]:/); -}); - -// --------------------------------------------------------------------------- -// buildToolInputSchema — JSON-Schema export -// --------------------------------------------------------------------------- - -test("buildToolInputSchema: strips the $schema key for a tight, byte-stable prefix", () => { - const schema = buildToolInputSchema(); - assert.equal("$schema" in schema, false); -}); - -test("buildToolInputSchema: emits an object schema with all five summary properties", () => { - const schema = buildToolInputSchema(); - assert.equal(schema["type"], "object"); - const props = schema["properties"] as Record; - for (const field of ["purpose", "inputs", "returns", "side_effects", "invariants", "citations"]) { - assert.ok(field in props, `expected schema property '${field}'`); - } -}); - -test("buildToolInputSchema: is deterministic across calls (stable cache key)", () => { - assert.equal(JSON.stringify(buildToolInputSchema()), JSON.stringify(buildToolInputSchema())); -}); - -// --------------------------------------------------------------------------- -// formatZodError — feedback string shape -// --------------------------------------------------------------------------- - -test("formatZodError: renders each issue as a bulleted path + message + code line", () => { - const parsed = SymbolSummary.safeParse({ ...validSummary(), purpose: "short" }); - assert.equal(parsed.success, false); - const text = formatZodError(parsed.error); - assert.match(text, /^- at /m); - assert.match(text, /\(code=/); -}); diff --git a/packages/summarizer/src/schema.ts b/packages/summarizer/src/schema.ts deleted file mode 100644 index c684c67c..00000000 --- a/packages/summarizer/src/schema.ts +++ /dev/null @@ -1,141 +0,0 @@ -/** - * Zod 4 contract for structured code-symbol summaries. - * - * Each callable symbol (function / method / class) is summarized at index - * time into a strict, citation-grounded shape. Fields are embedded separately - * and weighted per-question at retrieval time (see SACL / Anthropic - * Contextual Retrieval evidence). Citations let a staleness detector drop - * summaries whose source has drifted. - * - * The schema is exported to JSON Schema via `z.toJSONSchema` and handed to - * Bedrock's `toolConfig.tools[].toolSpec.inputSchema.json`. Claude can only - * respond by filling this schema — any field-level violation is caught by - * `SymbolSummary.safeParse` and fed back through the ReAct retry loop. - * - * Two shape choices worth calling out: - * - `returns` is split into `type` + `type_summary` (10-80) + `details` - * (20-400) so constructor-style symbols don't trip tight length caps. - * - `side_effects` items MUST contain one of reads/writes/emits/raises/mutates. - * This disambiguates "manages state" noise from actionable contracts. - */ - -import { z } from "zod"; - -const SIDE_EFFECT_VERB_RE = /(reads|writes|emits|raises|mutates)/i; -const BANNED_PURPOSE_PREFIX_RE = /^this (function|method|class)/i; - -export const InputSpec = z - .object({ - name: z.string().min(1).max(128), - type: z.string().min(1).max(256), - description: z.string().min(20).max(200), - }) - .strict(); - -export const ReturnSpec = z - .object({ - type: z.string().min(1).max(256), - type_summary: z.string().min(10).max(80), - details: z.string().min(20).max(400), - }) - .strict(); - -export const Citation = z - .object({ - field_name: z.enum(["purpose", "inputs", "returns", "side_effects", "invariants"]), - line_start: z.number().int().min(1), - line_end: z.number().int().min(1), - }) - .strict() - .refine((c) => c.line_end >= c.line_start, { - message: "line_end must be >= line_start", - }); - -export const SymbolSummary = z - .object({ - purpose: z - .string() - .min(30) - .max(400) - .refine((s) => !BANNED_PURPOSE_PREFIX_RE.test(s.trim()), { - message: - 'purpose must not start with "This function/method/class" — describe the behavior directly', - }), - inputs: z.array(InputSpec), - returns: ReturnSpec, - side_effects: z.array( - z - .string() - .min(10) - .max(200) - .refine((s) => SIDE_EFFECT_VERB_RE.test(s), { - message: "side_effects item must mention one of reads/writes/emits/raises/mutates", - }), - ), - invariants: z.array(z.string().min(10).max(300)).nullable(), - citations: z.array(Citation).min(1), - }) - .strict() - .superRefine((val, ctx) => { - // purpose and returns are always populated; inputs / side_effects / - // invariants are only populated when non-empty. Every populated field - // must carry at least one citation — this is the core staleness- - // detection invariant and the reason to run superRefine rather than - // enforce per-field. - const populated = new Set(["purpose", "returns"]); - if (val.inputs.length > 0) populated.add("inputs"); - if (val.side_effects.length > 0) populated.add("side_effects"); - if (val.invariants && val.invariants.length > 0) populated.add("invariants"); - - const cited = new Set(val.citations.map((c) => c.field_name)); - for (const field of populated) { - if (!cited.has(field as "purpose" | "inputs" | "returns" | "side_effects" | "invariants")) { - ctx.addIssue({ - code: "custom", - message: `field '${field}' is populated but has no citation`, - path: ["citations"], - }); - } - } - }); - -export type SymbolSummaryT = z.infer; - -/** - * Emit the JSON Schema Bedrock's Converse API expects inside - * `toolConfig.tools[].toolSpec.inputSchema.json`. Strips the `$schema` key - * to keep the cacheable prefix tight and byte-stable across runs. - */ -export function buildToolInputSchema(): Record { - const schema = z.toJSONSchema(SymbolSummary) as Record; - delete schema["$schema"]; - return schema; -} - -/** - * Cross-check that every citation's line range falls inside the supplied - * source span. Zod cannot express this (it depends on run-time context), - * so we run it as a second pass after `safeParse` succeeds. - */ -export function validateCitationLines( - summary: SymbolSummaryT, - sourceLineStart: number, - sourceLineEnd: number, -): string[] { - const errors: string[] = []; - summary.citations.forEach((c, i) => { - if (c.line_start < sourceLineStart || c.line_end > sourceLineEnd) { - errors.push( - `citations[${i}]: line range [${c.line_start}, ${c.line_end}] falls outside source span ` + - `[${sourceLineStart}, ${sourceLineEnd}]`, - ); - } - }); - return errors; -} - -export function formatZodError(err: z.ZodError): string { - return err.issues - .map((i) => `- at ${i.path.join(".") || ""}: ${i.message} (code=${i.code})`) - .join("\n"); -} diff --git a/packages/summarizer/tsconfig.json b/packages/summarizer/tsconfig.json deleted file mode 100644 index 15365d9c..00000000 --- a/packages/summarizer/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "dist", - "composite": true - }, - "include": ["src/**/*"], - "exclude": ["dist", "reference", "node_modules"] -} diff --git a/packages/wiki/package.json b/packages/wiki/package.json index fa954e42..87802a9c 100644 --- a/packages/wiki/package.json +++ b/packages/wiki/package.json @@ -38,10 +38,8 @@ "clean": "rm -rf dist *.tsbuildinfo" }, "dependencies": { - "@aws-sdk/client-bedrock-runtime": "3.1079.0", "@opencodehub/core-types": "workspace:*", "@opencodehub/storage": "workspace:*", - "@opencodehub/summarizer": "workspace:*", "write-file-atomic": "8.0.0" }, "devDependencies": { diff --git a/packages/wiki/src/index.test.ts b/packages/wiki/src/index.test.ts index 4456ad98..adef93d8 100644 --- a/packages/wiki/src/index.test.ts +++ b/packages/wiki/src/index.test.ts @@ -750,155 +750,21 @@ test("generateWiki: empty graph still emits the 5 family index pages", async () } }); -test("generateWiki: --llm absent produces byte-identical output to deterministic run", async () => { - // Regression guard: enabling and disabling --llm must yield bit-for-bit - // identical output in the default (no-llm) case. +test("generateWiki: enabling no optional features produces byte-identical output across runs", async () => { + // Regression guard: the wiki is fully deterministic — two runs on the same + // graph yield bit-for-bit identical output. const storeA = seededStore(); const storeB = seededStore(); const dirA = await mkdtemp(path.join(tmpdir(), "codehub-wiki-baseline-")); - const dirB = await mkdtemp(path.join(tmpdir(), "codehub-wiki-explicit-off-")); + const dirB = await mkdtemp(path.join(tmpdir(), "codehub-wiki-baseline-2-")); try { await generateWiki(storeA, { outputDir: dirA }); - await generateWiki(storeB, { - outputDir: dirB, - // Even with llm option present but disabled, output must match. - llm: { enabled: false, maxCalls: 0 }, - }); + await generateWiki(storeB, { outputDir: dirB }); const hashA = await hashDir(dirA); const hashB = await hashDir(dirB); - assert.equal(hashA, hashB, "llm.enabled=false must produce identical output to no llm option"); + assert.equal(hashA, hashB, "two runs on the same graph must produce identical output"); } finally { await rm(dirA, { recursive: true, force: true }); await rm(dirB, { recursive: true, force: true }); } }); - -test("generateWiki: --llm with maxCalls=0 writes dry-run overview page; no Bedrock", async () => { - const store = seededStore(); - const dir = await mkdtemp(path.join(tmpdir(), "codehub-wiki-llm-dry-")); - let summarizeCalled = false; - try { - const result = await generateWiki(store, { - outputDir: dir, - llm: { - enabled: true, - maxCalls: 0, - summarize: async () => { - summarizeCalled = true; - throw new Error("should not be called in dry-run"); - }, - }, - }); - assert.equal(summarizeCalled, false); - // Normalize to forward slashes: `path.relative` yields backslashes on - // Windows, but the `.includes("a/b")` assertions below use POSIX - // separators. Without this the membership checks never match on Windows. - const rels = result.filesWritten - .map((f) => path.relative(dir, f).split(path.sep).join("/")) - .sort(); - assert.ok( - rels.includes("architecture/llm-overview.md"), - "dry-run should still emit the llm-overview page", - ); - const content = await readFile(path.join(dir, "architecture/llm-overview.md"), "utf8"); - assert.match(content, /# Module narratives/); - assert.match(content, /dry-run/); - assert.match(content, /Auth Subsystem/); - // Root index gains the llm link when llm is enabled. - const rootIndex = await readFile(path.join(dir, "index.md"), "utf8"); - assert.match(rootIndex, /llm-overview\.md/); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("generateWiki: --llm happy path calls summarizer and writes narrative", async () => { - const store = seededStore(); - const dir = await mkdtemp(path.join(tmpdir(), "codehub-wiki-llm-happy-")); - const callSites: string[] = []; - try { - await generateWiki(store, { - outputDir: dir, - llm: { - enabled: true, - maxCalls: 5, - summarize: async (input) => { - callSites.push(input.filePath); - return { - summary: { - purpose: `Module narrative for ${input.filePath} — drives request handling and state.`, - inputs: [], - returns: { - type: "module", - type_summary: "aggregated surface", - details: - "A cohesive bundle of handlers that share state across the request lifecycle.", - }, - side_effects: ["writes shared module state during request dispatch"], - invariants: null, - citations: [{ field_name: "purpose", line_start: 1, line_end: 5 }], - }, - attempts: 1, - usageByAttempt: [{ inputTokens: 0, outputTokens: 0, cacheRead: 0, cacheWrite: 0 }], - wallClockMs: 1, - validationFailures: [], - }; - }, - }, - }); - assert.equal(callSites.length, 2, "should summarize two seeded communities"); - const content = await readFile(path.join(dir, "architecture/llm-overview.md"), "utf8"); - assert.match(content, /Auth Subsystem/); - assert.match(content, /Billing Subsystem/); - assert.match(content, /Module narrative for \/module\/Community:repo:auth/); - assert.match(content, /writes shared module state/); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("generateWiki: --llm with per-module summarizer failure falls back but keeps others", async () => { - const store = seededStore(); - const dir = await mkdtemp(path.join(tmpdir(), "codehub-wiki-llm-fallback-")); - try { - await generateWiki(store, { - outputDir: dir, - llm: { - enabled: true, - maxCalls: 5, - summarize: async (input) => { - if (input.filePath.includes("billing")) { - throw new Error("synthetic bedrock 429"); - } - return { - summary: { - purpose: - "Aggregate authentication flows and session bookkeeping for the request lifecycle.", - inputs: [], - returns: { - type: "module", - type_summary: "aggregated surface", - details: - "A cohesive bundle of handlers that share state across the request lifecycle.", - }, - side_effects: [], - invariants: null, - citations: [{ field_name: "purpose", line_start: 1, line_end: 5 }], - }, - attempts: 1, - usageByAttempt: [{ inputTokens: 0, outputTokens: 0, cacheRead: 0, cacheWrite: 0 }], - wallClockMs: 1, - validationFailures: [], - }; - }, - }, - }); - const content = await readFile(path.join(dir, "architecture/llm-overview.md"), "utf8"); - // Auth still gets the narrative; Billing gets the fallback stamp. - assert.match(content, /Aggregate authentication flows/); - assert.match(content, /summarizer failed/); - assert.match(content, /synthetic bedrock 429/); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); diff --git a/packages/wiki/src/index.ts b/packages/wiki/src/index.ts index 36d0e0ed..637cff31 100644 --- a/packages/wiki/src/index.ts +++ b/packages/wiki/src/index.ts @@ -2,16 +2,13 @@ * `generateWiki` — emit a graph-only Markdown wiki under `outputDir`. * * Renders the 5 page families (architecture, api-surface, dependency-map, - * ownership-map, risk-atlas) plus a top-level index. Output is deterministic - * when `llm` is absent: two runs against the same graph produce byte- - * identical files. With `llm.enabled`, a supplementary - * `architecture/llm-overview.md` page is added containing per-module - * narrative prose; the deterministic family pages remain byte-stable so - * downstream diff tooling still works. + * ownership-map, risk-atlas) plus a top-level index. Output is fully + * deterministic: two runs against the same graph produce byte-identical + * files. * - * No LLM calls, no network, no timestamps in rendered content in the default - * (deterministic) mode. Filenames are sorted before writing so any external - * tool iterating the output directory observes the same ordering. + * No LLM calls, no network, no timestamps in rendered content. Filenames are + * sorted before writing so any external tool iterating the output directory + * observes the same ordering. */ import { mkdir } from "node:fs/promises"; @@ -22,41 +19,12 @@ import { renderApiSurfacePages } from "./wiki-render/api-surface.js"; import type { RenderedWikiPage } from "./wiki-render/architecture.js"; import { renderArchitecturePages } from "./wiki-render/architecture.js"; import { renderDependencyMapPages } from "./wiki-render/dependency-map.js"; -import type { LlmModuleInput, LlmOverviewOptions } from "./wiki-render/llm-overview.js"; -import { renderLlmOverviews } from "./wiki-render/llm-overview.js"; import { renderOwnershipMapPages } from "./wiki-render/ownership-map.js"; import { type RiskTrendsLike, renderRiskAtlasPages } from "./wiki-render/risk-atlas.js"; -import { loadCommunities, loadCommunityTopFiles } from "./wiki-render/shared.js"; // Re-export wiki-render types so consumers can import them from the package root. -export type { - LlmModuleInput, - LlmOverview, - LlmOverviewOptions, -} from "./wiki-render/llm-overview.js"; export type { RiskTrendsLike } from "./wiki-render/risk-atlas.js"; -export interface WikiLlmOptions { - /** - * Must be `true` to trigger any LLM activity. `false` (the default) keeps - * generateWiki byte-identical to its pre-LLM output. - */ - readonly enabled: boolean; - /** - * Cap on actual Bedrock calls. `0` enumerates candidate modules as a - * dry-run without contacting Bedrock. Positive integers bound the number - * of top-ranked modules that receive a real narrative. - */ - readonly maxCalls: number; - /** Optional override for the Bedrock model id passed to the summarizer. */ - readonly modelId?: string; - /** - * Test seam — skips the Bedrock SDK entirely. Matches the signature of - * `@opencodehub/summarizer`'s `summarizeSymbol` (bound to a client). - */ - readonly summarize?: LlmOverviewOptions["summarize"]; -} - export interface WikiOptions { /** Absolute (or relative-to-cwd) path where pages are written. */ readonly outputDir: string; @@ -73,12 +41,6 @@ export interface WikiOptions { * notice. */ readonly loadTrends?: (repoPath: string) => Promise; - /** - * Opt-in LLM mode. When `enabled` is true, `generateWiki` also writes - * `architecture/llm-overview.md` with per-module narrative prose. The - * deterministic family pages are unchanged. - */ - readonly llm?: WikiLlmOptions; } export interface WikiResult { @@ -108,16 +70,9 @@ export async function generateWiki(store: IGraphStore, options: WikiOptions): Pr ...riskAtlas, ]; - if (options.llm?.enabled === true) { - const llmPage = await renderLlmOverviewPage(store, options.llm); - if (llmPage !== undefined) { - allPages.push(llmPage); - } - } - allPages.push({ filename: "index.md", - content: renderRootIndex(options.llm?.enabled === true), + content: renderRootIndex(), }); // Deterministic write order. @@ -142,8 +97,8 @@ export async function generateWiki(store: IGraphStore, options: WikiOptions): Pr return { filesWritten, totalBytes }; } -function renderRootIndex(llmEnabled: boolean): string { - const lines = [ +function renderRootIndex(): string { + return [ "# OpenCodeHub wiki", "", "Graph-derived Markdown pages. Regenerate with `codehub wiki --output `.", @@ -155,128 +110,6 @@ function renderRootIndex(llmEnabled: boolean): string { "- [Dependency map](./dependency-map/index.md)", "- [Ownership map](./ownership-map/index.md)", "- [Risk atlas](./risk-atlas/index.md)", - ]; - if (llmEnabled) { - lines.push("- [Module narratives (LLM)](./architecture/llm-overview.md)"); - } - lines.push(""); - return lines.join("\n"); -} - -/** - * Render the optional `architecture/llm-overview.md` page. Returns undefined - * when the graph has no Community nodes — the deterministic pipeline already - * emits an empty-architecture page in that case and we do not need a - * narrative shell. - */ -async function renderLlmOverviewPage( - store: IGraphStore, - llm: WikiLlmOptions, -): Promise { - const communities = await loadCommunities(store); - if (communities.length === 0) return undefined; - - const TOP_FILES_PER_MODULE = 5; - const moduleInputs: LlmModuleInput[] = []; - for (const community of communities) { - const topFilesRows = await loadCommunityTopFiles(store, community.id, TOP_FILES_PER_MODULE); - const topFiles = topFilesRows.map((r) => r.filePath); - const topSymbols = await loadCommunityTopSymbols(store, community.id, TOP_FILES_PER_MODULE); - moduleInputs.push({ - communityId: community.id, - label: community.inferredLabel.length > 0 ? community.inferredLabel : community.name, - symbolCount: community.symbolCount, - topFiles, - topSymbols, - }); - } - - const llmOptions: LlmOverviewOptions = { - enabled: llm.enabled, - maxCalls: llm.maxCalls, - ...(llm.modelId !== undefined ? { modelId: llm.modelId } : {}), - ...(llm.summarize !== undefined ? { summarize: llm.summarize } : {}), - }; - const overviews = await renderLlmOverviews(moduleInputs, llmOptions); - - // Preserve deterministic file order: render in the ranking order the - // llm-overview module used (symbolCount desc, label asc, id asc) so re-runs - // with the same graph + maxCalls produce byte-identical content. - const ranked = [...moduleInputs].sort((a, b) => { - if (b.symbolCount !== a.symbolCount) return b.symbolCount - a.symbolCount; - if (a.label !== b.label) return a.label.localeCompare(b.label); - return a.communityId.localeCompare(b.communityId); - }); - - const lines: string[] = []; - lines.push("# Module narratives"); - lines.push(""); - lines.push( - "LLM-generated prose per community. Deterministic family pages under " + - "`./` are unchanged; this page is additive.", - ); - lines.push(""); - lines.push(`- **Modules ranked:** ${ranked.length}`); - lines.push(`- **LLM calls cap:** ${llm.maxCalls === 0 ? "0 (dry-run)" : String(llm.maxCalls)}`); - if (llm.modelId !== undefined) { - lines.push(`- **Model:** \`${llm.modelId}\``); - } - lines.push(""); - - for (const mod of ranked) { - const overview = overviews.get(mod.communityId); - if (overview === undefined) continue; - lines.push(overview.markdown.trimEnd()); - lines.push(""); - } - - return { - filename: "architecture/llm-overview.md", - content: lines.join("\n"), - }; -} - -/** - * Top symbol names (functions / methods / classes) for a community, ranked - * by kind priority then name. Used by the LLM overview page to feed key - * symbols into each summarizer prompt. - * - * Implementation: walk MEMBER_OF edges via `listEdgesByType`, lift the - * typed Class/Function/Method node lists via `listNodesByKind`, then - * JS-side join the edge endpoints to the symbol nodes. Sort by the - * (kind-priority, name ASC) key the SQL formerly applied via - * `CASE n.kind`. - */ -async function loadCommunityTopSymbols( - store: IGraphStore, - communityId: string, - limit: number, -): Promise { - try { - const memberEdges = await store.listEdgesByType("MEMBER_OF", { toIds: [communityId] }); - if (memberEdges.length === 0) return []; - const memberFromIds = new Set(memberEdges.map((e) => e.from)); - const [classes, functions, methods] = await Promise.all([ - store.listNodesByKind("Class"), - store.listNodesByKind("Function"), - store.listNodesByKind("Method"), - ]); - const all: { kindRank: number; name: string }[] = []; - for (const c of classes) { - if (memberFromIds.has(c.id) && c.name.length > 0) all.push({ kindRank: 0, name: c.name }); - } - for (const f of functions) { - if (memberFromIds.has(f.id) && f.name.length > 0) all.push({ kindRank: 1, name: f.name }); - } - for (const m of methods) { - if (memberFromIds.has(m.id) && m.name.length > 0) all.push({ kindRank: 2, name: m.name }); - } - all.sort((a, b) => { - if (a.kindRank !== b.kindRank) return a.kindRank - b.kindRank; - return a.name.localeCompare(b.name); - }); - return all.slice(0, limit).map((r) => r.name); - } catch { - return []; - } + "", + ].join("\n"); } diff --git a/packages/wiki/src/wiki-render/llm-overview.test.ts b/packages/wiki/src/wiki-render/llm-overview.test.ts deleted file mode 100644 index b8d14c9b..00000000 --- a/packages/wiki/src/wiki-render/llm-overview.test.ts +++ /dev/null @@ -1,198 +0,0 @@ -/** - * Unit tests for the LLM module-overview renderer. - * - * Every test substitutes an in-memory `summarize` function so Bedrock is - * never contacted. The production Bedrock client path is covered by the - * summarizer package's own tests. - */ - -import assert from "node:assert/strict"; -import { test } from "node:test"; -import type { SummarizeInput, SummarizerResult } from "@opencodehub/summarizer"; -import { buildSummarizerInput, type LlmModuleInput, renderLlmOverviews } from "./llm-overview.js"; - -function fakeSummary( - overrides: { purpose?: string; sideEffects?: string[]; invariants?: string[] | null } = {}, -): SummarizerResult { - return { - summary: { - purpose: - overrides.purpose ?? - "Aggregate authentication flows and session bookkeeping for the request lifecycle.", - inputs: [], - returns: { - type: "module", - type_summary: "aggregated module surface", - details: - "A cohesive bundle of login, logout, and session refresh handlers that share token state.", - }, - side_effects: overrides.sideEffects ?? [ - "writes session state to the Redis cache after successful login", - ], - invariants: overrides.invariants === undefined ? null : overrides.invariants, - citations: [{ field_name: "purpose", line_start: 1, line_end: 5 }], - }, - attempts: 1, - usageByAttempt: [{ inputTokens: 0, outputTokens: 0, cacheRead: 0, cacheWrite: 0 }], - wallClockMs: 1, - validationFailures: [], - }; -} - -function makeModule(overrides: Partial = {}): LlmModuleInput { - return { - communityId: overrides.communityId ?? "Community:repo:auth", - label: overrides.label ?? "Auth Subsystem", - symbolCount: overrides.symbolCount ?? 12, - topFiles: overrides.topFiles ?? ["src/auth/login.ts", "src/auth/session.ts"], - topSymbols: overrides.topSymbols ?? ["loginHandler", "sessionStore"], - }; -} - -test("renderLlmOverviews: disabled returns empty map", async () => { - const out = await renderLlmOverviews([makeModule()], { enabled: false, maxCalls: 5 }); - assert.equal(out.size, 0); -}); - -test("renderLlmOverviews: happy path produces narrative markdown with purpose + side_effects", async () => { - let callCount = 0; - const out = await renderLlmOverviews([makeModule()], { - enabled: true, - maxCalls: 1, - summarize: async (_input: SummarizeInput) => { - callCount += 1; - return fakeSummary(); - }, - }); - assert.equal(callCount, 1); - const entry = out.get("Community:repo:auth"); - assert.ok(entry, "overview should be present"); - assert.equal(entry?.source, "llm"); - assert.match(entry?.markdown ?? "", /### Auth Subsystem/); - assert.match(entry?.markdown ?? "", /Aggregate authentication flows/); - assert.match(entry?.markdown ?? "", /Key behaviors:/); - assert.match(entry?.markdown ?? "", /writes session state/); -}); - -test("renderLlmOverviews: max_calls=0 is dry-run with zero summarize calls", async () => { - let called = false; - const out = await renderLlmOverviews( - [ - makeModule(), - makeModule({ communityId: "Community:repo:billing", label: "Billing", symbolCount: 8 }), - ], - { - enabled: true, - maxCalls: 0, - summarize: async () => { - called = true; - return fakeSummary(); - }, - }, - ); - assert.equal(called, false, "summarize must not be invoked when maxCalls is 0"); - assert.equal(out.size, 2); - for (const [, overview] of out) { - assert.equal(overview.source, "dry-run"); - assert.match(overview.markdown, /dry-run/); - } -}); - -test("renderLlmOverviews: ranks top-N by symbolCount, overflow modules get capacity placeholder", async () => { - const summarizeCalls: string[] = []; - const modules = [ - makeModule({ communityId: "c:a", label: "Small", symbolCount: 2 }), - makeModule({ communityId: "c:b", label: "Large", symbolCount: 50 }), - makeModule({ communityId: "c:c", label: "Medium", symbolCount: 20 }), - ]; - const out = await renderLlmOverviews(modules, { - enabled: true, - maxCalls: 2, - summarize: async (input: SummarizeInput) => { - summarizeCalls.push(input.filePath); - return fakeSummary({ purpose: `Summary for ${input.filePath}` }); - }, - }); - - assert.equal(summarizeCalls.length, 2, "should call summarizer exactly maxCalls times"); - assert.ok( - summarizeCalls.some((p) => p.endsWith("/c:b")), - "Large (50) should be summarized", - ); - assert.ok( - summarizeCalls.some((p) => p.endsWith("/c:c")), - "Medium (20) should be summarized", - ); - assert.equal(out.get("c:a")?.source, "dry-run", "Small should be capacity-skipped"); - assert.match(out.get("c:a")?.markdown ?? "", /capacity exhausted/); - assert.equal(out.get("c:b")?.source, "llm"); - assert.equal(out.get("c:c")?.source, "llm"); -}); - -test("renderLlmOverviews: summarizer failure yields deterministic fallback entry, continues", async () => { - const modules = [ - makeModule({ communityId: "c:ok", label: "Ok", symbolCount: 10 }), - makeModule({ communityId: "c:bad", label: "Bad", symbolCount: 5 }), - ]; - const out = await renderLlmOverviews(modules, { - enabled: true, - maxCalls: 2, - summarize: async (input: SummarizeInput) => { - if (input.filePath.endsWith("/c:bad")) { - throw new Error("bedrock transport failed"); - } - return fakeSummary(); - }, - }); - assert.equal(out.size, 2); - assert.equal(out.get("c:ok")?.source, "llm"); - assert.equal(out.get("c:bad")?.source, "fallback"); - assert.match(out.get("c:bad")?.markdown ?? "", /summarizer failed/); - assert.match(out.get("c:bad")?.markdown ?? "", /bedrock transport failed/); -}); - -test("renderLlmOverviews: ordering is stable (symbolCount desc, label asc, id asc)", async () => { - const modules: LlmModuleInput[] = [ - makeModule({ communityId: "c:z", label: "Zed", symbolCount: 10 }), - makeModule({ communityId: "c:a", label: "Alpha", symbolCount: 10 }), - makeModule({ communityId: "c:x", label: "Mid", symbolCount: 20 }), - ]; - const order: string[] = []; - await renderLlmOverviews(modules, { - enabled: true, - maxCalls: 3, - summarize: async (input: SummarizeInput) => { - order.push(input.filePath); - return fakeSummary(); - }, - }); - assert.deepEqual(order, [ - "/module/c:x", // 20 - "/module/c:a", // 10, Alpha < Zed - "/module/c:z", // 10, Zed - ]); -}); - -test("buildSummarizerInput: encodes module structure in a line-addressable block", () => { - const input = buildSummarizerInput( - makeModule({ - topFiles: ["a.ts", "b.ts"], - topSymbols: ["fnA", "fnB", "ClassC"], - }), - ); - assert.match(input.source, /module: Auth Subsystem/); - assert.match(input.source, /symbol_count: 12/); - assert.match(input.source, /key_files:/); - assert.match(input.source, /- a\.ts/); - assert.match(input.source, /- fnA/); - assert.equal(input.lineStart, 1); - assert.ok(input.lineEnd > 1); - assert.equal(input.enclosingClass, null); - assert.match(input.filePath, /^\/module\//); -}); - -test("buildSummarizerInput: tolerates empty top lists", () => { - const input = buildSummarizerInput(makeModule({ topFiles: [], topSymbols: [] })); - assert.match(input.source, /key_files:\n {2}\(none\)/); - assert.match(input.source, /key_symbols:\n {2}\(none\)/); -}); diff --git a/packages/wiki/src/wiki-render/llm-overview.ts b/packages/wiki/src/wiki-render/llm-overview.ts deleted file mode 100644 index 5b29fb1e..00000000 --- a/packages/wiki/src/wiki-render/llm-overview.ts +++ /dev/null @@ -1,266 +0,0 @@ -/** - * LLM module-overview renderer. - * - * Opt-in narrative generator: given a ranked set of Community nodes and their - * top symbols/files, issues ONE summarizer call per module (via - * `@opencodehub/summarizer`'s Bedrock Converse client) and returns the - * resulting narrative prose, keyed by community id. Callers fold the prose - * into the existing deterministic architecture pages. - * - * Contract boundaries: - * - Gated twice upstream: `--llm` must be true AND `opts.offline` must be - * false. This module does NOT re-check offline; the CLI enforces that. - * - `maxCalls === 0` is dry-run: enumerate the candidate modules and return - * a deterministic placeholder narrative. No Bedrock traffic. - * - `maxCalls > 0` bounds actual Bedrock calls. Top-N communities by - * `symbolCount` win; overflow modules fall back to no-narrative. - * - A summarizer failure on one module falls back to a deterministic - * "(summarizer failed; deterministic fallback)" string for THAT module - * while the overall generation continues. - * - * We reuse the symbol summarizer's Bedrock client verbatim (no new Bedrock - * abstraction, per the P1-1 anti-goals). The summarizer's schema is - * callable-oriented, so we treat each module as a synthetic "callable" whose - * `source` block is a compact listing of key files + symbols; the resulting - * `purpose` field (30-400 chars) becomes the module narrative, and any - * populated `side_effects` items become a "Key behaviors" bullet list. - */ - -import type { SummarizeInput, SummarizerResult, SymbolSummaryT } from "@opencodehub/summarizer"; - -export interface LlmModuleInput { - /** Community node id. Stable across runs; safe to use as a cache key. */ - readonly communityId: string; - /** Display label for the module (inferred label, falling back to name). */ - readonly label: string; - /** Aggregate member-symbol count — drives ranking. */ - readonly symbolCount: number; - /** Top files in the module by member count, pre-sorted by the caller. */ - readonly topFiles: readonly string[]; - /** Top symbol names (e.g. class/function names) — pre-sorted by caller. */ - readonly topSymbols: readonly string[]; -} - -export interface LlmOverviewOptions { - /** When false, `renderLlmOverviews` returns an empty map without side effects. */ - readonly enabled: boolean; - /** - * Cap on actual Bedrock calls. `0` is dry-run: the returned map is - * populated with deterministic placeholders for every module the LLM - * path WOULD have summarized, but Bedrock is never contacted. - */ - readonly maxCalls: number; - /** Optional override for the Bedrock model id. */ - readonly modelId?: string; - /** - * Test seam: injects a summarizer that fulfills the same contract as - * `@opencodehub/summarizer`'s `summarizeSymbol`. Production leaves this - * undefined and the module lazily imports the real summarizer + Bedrock - * SDK on first call. - */ - readonly summarize?: (input: SummarizeInput) => Promise; -} - -export interface LlmOverview { - readonly communityId: string; - /** Narrative markdown paragraph(s). Always non-empty. */ - readonly markdown: string; - /** - * `"llm"` when the summarizer returned a validated summary, `"dry-run"` when - * the module was enumerated but not summarized (maxCalls=0 or capacity - * exceeded), or `"fallback"` when Bedrock threw and we used a deterministic - * substitute. - */ - readonly source: "llm" | "dry-run" | "fallback"; -} - -const DRY_RUN_NOTE = "_(LLM narrative would be generated here; `--max-llm-calls` is 0 — dry-run.)_"; -const CAPACITY_NOTE = - "_(LLM narrative capacity exhausted; increase `--max-llm-calls` to include this module.)_"; - -/** - * Generate module-overview narratives for the top-ranked communities. - * - * Returns a map keyed by `communityId`. Communities not present in the map - * should be rendered by the deterministic pipeline as-is. - */ -export async function renderLlmOverviews( - modules: readonly LlmModuleInput[], - options: LlmOverviewOptions, -): Promise> { - const out = new Map(); - if (!options.enabled) return out; - - // Rank by symbolCount desc, ties broken by label asc, then communityId asc - // so top-N selection is stable across runs. - const ranked = [...modules].sort((a, b) => { - if (b.symbolCount !== a.symbolCount) return b.symbolCount - a.symbolCount; - if (a.label !== b.label) return a.label.localeCompare(b.label); - return a.communityId.localeCompare(b.communityId); - }); - - if (options.maxCalls === 0) { - for (const mod of ranked) { - out.set(mod.communityId, { - communityId: mod.communityId, - markdown: renderDryRunMarkdown(mod), - source: "dry-run", - }); - } - return out; - } - - const summarize = options.summarize ?? (await defaultSummarizer(options.modelId)); - - for (const mod of ranked.slice(0, options.maxCalls)) { - out.set(mod.communityId, await summarizeOne(mod, summarize)); - } - for (const mod of ranked.slice(options.maxCalls)) { - out.set(mod.communityId, { - communityId: mod.communityId, - markdown: `### ${mod.label}\n\n${CAPACITY_NOTE}\n`, - source: "dry-run", - }); - } - return out; -} - -async function summarizeOne( - mod: LlmModuleInput, - summarize: (input: SummarizeInput) => Promise, -): Promise { - const input = buildSummarizerInput(mod); - try { - const result = await summarize(input); - return { - communityId: mod.communityId, - markdown: renderNarrative(mod, result.summary), - source: "llm", - }; - } catch (err) { - const reason = err instanceof Error ? err.message : String(err); - return { - communityId: mod.communityId, - markdown: renderFallback(mod, reason), - source: "fallback", - }; - } -} - -/** - * Build a synthetic summarizer input from the module structure. The - * summarizer schema is callable-oriented, so we construct a virtual "source" - * block whose lines index the files + symbols. The summarizer's `purpose` - * field becomes our narrative; its `side_effects` feed the "Key behaviors" - * bullet list. - */ -export function buildSummarizerInput(mod: LlmModuleInput): SummarizeInput { - const lines: string[] = []; - lines.push(`module: ${mod.label}`); - lines.push(`symbol_count: ${mod.symbolCount}`); - lines.push("key_files:"); - if (mod.topFiles.length === 0) { - lines.push(" (none)"); - } else { - for (const f of mod.topFiles) lines.push(` - ${f}`); - } - lines.push("key_symbols:"); - if (mod.topSymbols.length === 0) { - lines.push(" (none)"); - } else { - for (const s of mod.topSymbols) lines.push(` - ${s}`); - } - const source = lines.join("\n"); - return { - source, - filePath: `/module/${mod.communityId}`, - lineStart: 1, - lineEnd: lines.length, - docstring: - `Summarize this code module: name=${mod.label}, ` + - `key symbols=${mod.topSymbols.join(", ") || "(none)"}, ` + - `key files=${mod.topFiles.join(", ") || "(none)"}.`, - enclosingClass: null, - }; -} - -function renderNarrative(mod: LlmModuleInput, summary: SymbolSummaryT): string { - const parts: string[] = []; - parts.push(`### ${mod.label}`); - parts.push(""); - parts.push(summary.purpose.trim()); - if (summary.side_effects.length > 0) { - parts.push(""); - parts.push("**Key behaviors:**"); - parts.push(""); - for (const effect of summary.side_effects) { - parts.push(`- ${effect}`); - } - } - if (summary.invariants && summary.invariants.length > 0) { - parts.push(""); - parts.push("**Invariants:**"); - parts.push(""); - for (const inv of summary.invariants) { - parts.push(`- ${inv}`); - } - } - parts.push(""); - return parts.join("\n"); -} - -function renderDryRunMarkdown(mod: LlmModuleInput): string { - const lines: string[] = []; - lines.push(`### ${mod.label}`); - lines.push(""); - lines.push(DRY_RUN_NOTE); - lines.push(""); - lines.push(`- **Members:** ${mod.symbolCount}`); - if (mod.topSymbols.length > 0) { - lines.push(`- **Key symbols:** ${mod.topSymbols.slice(0, 5).join(", ")}`); - } - if (mod.topFiles.length > 0) { - lines.push(`- **Key files:** ${mod.topFiles.slice(0, 5).join(", ")}`); - } - lines.push(""); - return lines.join("\n"); -} - -function renderFallback(mod: LlmModuleInput, reason: string): string { - const lines: string[] = []; - lines.push(`### ${mod.label}`); - lines.push(""); - lines.push(`_(summarizer failed; deterministic fallback: ${truncate(reason, 200)})_`); - lines.push(""); - lines.push(`- **Members:** ${mod.symbolCount}`); - if (mod.topSymbols.length > 0) { - lines.push(`- **Key symbols:** ${mod.topSymbols.slice(0, 5).join(", ")}`); - } - if (mod.topFiles.length > 0) { - lines.push(`- **Key files:** ${mod.topFiles.slice(0, 5).join(", ")}`); - } - lines.push(""); - return lines.join("\n"); -} - -function truncate(s: string, max: number): string { - const collapsed = s.replace(/\s+/g, " ").trim(); - if (collapsed.length <= max) return collapsed; - return `${collapsed.slice(0, max - 1)}…`; -} - -/** - * Default summarizer — lazy-imports `@opencodehub/summarizer` and - * `@aws-sdk/client-bedrock-runtime` so test runs with `enabled=false` or - * `maxCalls=0` never touch the SDK's credential provider chain. - */ -async function defaultSummarizer( - modelId: string | undefined, -): Promise<(input: SummarizeInput) => Promise> { - const [{ BedrockRuntimeClient }, { summarizeSymbol }] = await Promise.all([ - import("@aws-sdk/client-bedrock-runtime"), - import("@opencodehub/summarizer"), - ]); - const client = new BedrockRuntimeClient({}); - return (input) => summarizeSymbol(client, input, modelId !== undefined ? { modelId } : {}); -} diff --git a/packages/wiki/tsconfig.json b/packages/wiki/tsconfig.json index 03808f4d..44b94875 100644 --- a/packages/wiki/tsconfig.json +++ b/packages/wiki/tsconfig.json @@ -8,7 +8,6 @@ "include": ["src/**/*"], "references": [ { "path": "../core-types" }, - { "path": "../storage" }, - { "path": "../summarizer" } + { "path": "../storage" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ec462d68..c1a943a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -364,9 +364,6 @@ importers: '@apidevtools/swagger-parser': specifier: 12.1.0 version: 12.1.0(openapi-types@12.1.3) - '@aws-sdk/client-bedrock-runtime': - specifier: 3.1079.0 - version: 3.1079.0 '@cyclonedx/cyclonedx-library': specifier: 10.1.0 version: 10.1.0(ajv-formats-draft2019@1.6.1(ajv@8.20.0))(ajv-formats@3.0.1(ajv@8.20.0))(ajv@8.20.0)(spdx-expression-parse@4.0.0) @@ -391,9 +388,6 @@ importers: '@opencodehub/storage': specifier: workspace:* version: link:../storage - '@opencodehub/summarizer': - specifier: workspace:* - version: link:../summarizer fast-xml-parser: specifier: 5.9.3 version: 5.9.3 @@ -615,36 +609,14 @@ importers: specifier: 6.0.3 version: 6.0.3 - packages/summarizer: - dependencies: - '@aws-sdk/client-bedrock-runtime': - specifier: 3.1079.0 - version: 3.1079.0 - zod: - specifier: 4.4.3 - version: 4.4.3 - devDependencies: - '@types/node': - specifier: 26.1.0 - version: 26.1.0 - typescript: - specifier: 6.0.3 - version: 6.0.3 - packages/wiki: dependencies: - '@aws-sdk/client-bedrock-runtime': - specifier: 3.1079.0 - version: 3.1079.0 '@opencodehub/core-types': specifier: workspace:* version: link:../core-types '@opencodehub/storage': specifier: workspace:* version: link:../storage - '@opencodehub/summarizer': - specifier: workspace:* - version: link:../summarizer write-file-atomic: specifier: 8.0.0 version: 8.0.0 diff --git a/tsconfig.json b/tsconfig.json index 5d152e18..9c58ed7d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,7 +15,6 @@ { "path": "./packages/eval" }, { "path": "./packages/mcp" }, { "path": "./packages/cli" }, - { "path": "./packages/summarizer" }, { "path": "./packages/scip-ingest" }, { "path": "./packages/cobol-proleap" } ]