From d26c3d00023002768b34be75167ad7a65f383b04 Mon Sep 17 00:00:00 2001 From: Harsh Singh Date: Mon, 9 Nov 2026 13:30:02 +0530 Subject: [PATCH] refactor(retrieval): Unify file-to-memory resolution Why: W26-1. Pre-flight anchored recall and PR context implemented the same question twice with different matching, scoping and ranking, so the two surfaces could disagree about which memories govern a file. What: - src/retrieval/code_context.ts: canonical resolver with normalized path matching, anchor-over-mention precedence, project/trust/freshness gates, type ranking and reason tokens. - src/retrieval/anchors.ts: anchor-only adapter. - src/orchestrator/bidirectional.ts: PR context delegates to the canonical resolver and keeps its constraint/negative/associated buckets. - test/code_context.test.ts: project scoping, mention opt-in, adapter agreement and ranking. Verification: bun test 1498 pass / 0 fail; tsc clean; build 434 modules. Co-authored-by: CommandCodeBot --- .agents/artifacts/TECHNICAL_DEBT-wave26.md | 2 +- .agents/context/current.md | 1 + CHANGELOG.md | 1 + src/orchestrator/bidirectional.ts | 65 +++++---------- src/retrieval/anchors.ts | 70 +++------------- src/retrieval/code_context.ts | 95 ++++++++++++++++++++++ test/code_context.test.ts | 40 +++++++++ 7 files changed, 171 insertions(+), 103 deletions(-) create mode 100644 src/retrieval/code_context.ts create mode 100644 test/code_context.test.ts diff --git a/.agents/artifacts/TECHNICAL_DEBT-wave26.md b/.agents/artifacts/TECHNICAL_DEBT-wave26.md index 457933b..88d85f4 100644 --- a/.agents/artifacts/TECHNICAL_DEBT-wave26.md +++ b/.agents/artifacts/TECHNICAL_DEBT-wave26.md @@ -2,7 +2,7 @@ | # | Finding | Location | Fix | |---|---|---|---| -| W26-1 | Two file→memory resolvers (`recallAnchoredMemories` vs `resolveMemoryForCode`) with different matching | `src/retrieval/anchors.ts`, `src/orchestrator/bidirectional.ts` | Pick one owner; extend it with project + freshness | +| W26-1 | ~~Two file→memory resolvers~~ **done**: canonical `resolveMemoriesForCode`; pre-flight and PR context are adapters | `src/retrieval/code_context.ts` | — | | W26-2 | Pre-flight token budget covers entries only; constraints/CORE blocks are appended uncounted | `src/hooks/preflight.ts`, `src/retrieval.ts` | Count/trim the final markdown or rename the claim to "entry budget" | | W26-3 | ~~Pre-flight is CLI-only~~ **done**: PreToolUse hook + read-only `muse_preflight` MCP tool | `src/hooks/pack.ts`, `src/mcp.ts` | — | | W26-4 | `associated` is ids-only and not fused into ranking/token accounting | `src/retrieval.ts` | Fuse with `why_retrieved` vocabulary or document the consumer contract | diff --git a/.agents/context/current.md b/.agents/context/current.md index 823d8ad..d2f79f9 100644 --- a/.agents/context/current.md +++ b/.agents/context/current.md @@ -180,6 +180,7 @@ on any blocking failure, so CI runs it. Report: --- ## 🏁 Recently Landed Waves (Verified on `dev`) +- [x] **Unified code resolver** (2026-11-09) — `resolveMemoriesForCode` is the single matcher; pre-flight and PR context adapt it. - [x] **Pre-flight surfaces** (2026-11-09) — PreToolUse hook + read-only `muse_preflight` MCP tool; 119 tools. - [x] **Wave 26 (`T-432`–`T-435`)** (2026-11-09) — recursive-CTE associative expansion (`T-432`, direct `dev`), pre-flight context (`T-433`, #370), blast-radius risk (`T-434`, #371), anchor-matched auto-recall (`T-435`, #372). Tests 1495/0. - [x] **Site build + scaffolding wiring** (2026-11-08) — PR #367 (Node Astro launcher; site:qa 17/17 SHIP) and #368 (briefing episodes, explicit `asserted_at` belief clock in `as_of`, duplicate decay removed). diff --git a/CHANGELOG.md b/CHANGELOG.md index ca3c1c1..8d41ea5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Unified file→memory resolver**: pre-flight anchored invariants and PR context now share one project/trust/freshness-aware matcher, ranking and reason tokens. - **Pre-flight surfaces**: a matcher-scoped `PreToolUse` hook and read-only `muse_preflight` MCP tool expose anchored invariants, blast-radius risk and a token-budgeted pack before edits. - **Wave 26 associative retrieval**: recursive-CTE associative expansion (`memory recall --associative`), speculative pre-flight context (`memory preflight`) with blast-radius risk scoring, and anchor-matched invariant auto-recall. - **Episode fidelity and `muse_episodes`**: observation provenance no longer classifies failed test commands as verifications; explicit metadata phases win; recent causal episodes are available over MCP. diff --git a/src/orchestrator/bidirectional.ts b/src/orchestrator/bidirectional.ts index e0e665b..4a39c83 100644 --- a/src/orchestrator/bidirectional.ts +++ b/src/orchestrator/bidirectional.ts @@ -2,6 +2,7 @@ import type { Store } from "../store.ts"; import { get, list } from "../store.ts"; import type { MemoryEntry } from "../types.ts"; import type { CodeForMemoryResult, MemoryForCodeResult } from "./types.ts"; +import { resolveMemoriesForCode } from "../retrieval/code_context.ts"; /** * Given a memory ID, returns all anchored code references, files, and symbols. @@ -51,59 +52,35 @@ export function resolveCodeForMemory( */ export function resolveMemoryForCode( store: Store, - options: { filePath: string; symbolName?: string } + options: { filePath: string; symbolName?: string; project?: string } ): MemoryForCodeResult { - const allEntries = list(store); - const normalizedFile = options.filePath.toLowerCase(); - const normalizedSymbol = options.symbolName ? options.symbolName.toLowerCase() : undefined; + // W26-1: shared resolver. PR context keeps prose mentions and untrusted + // entries for a superset view, but matching/ranking lives in one place. + const matches = resolveMemoriesForCode(store, { + files: [options.filePath], + symbols: options.symbolName ? [options.symbolName] : undefined, + project: options.project, + includeMentions: true, + includeUntrusted: true, + enforceFreshness: false, + limit: 50, + }); const associated: MemoryEntry[] = []; const negative: MemoryEntry[] = []; const constraints: MemoryEntry[] = []; - for (const entry of allEntries) { - if (entry.status === "archived" || entry.status === "superseded") continue; - - let isMatch = false; - - // 1. Check native code anchors - if (entry.anchors && entry.anchors.length > 0) { - for (const anc of entry.anchors) { - const ancFile = anc.file_path.toLowerCase(); - const fileMatch = ancFile.includes(normalizedFile) || normalizedFile.includes(ancFile); - const symbolMatch = - normalizedSymbol && anc.symbol_name - ? anc.symbol_name.toLowerCase() === normalizedSymbol - : false; - - if (fileMatch || symbolMatch) { - isMatch = true; - break; - } - } - } - - // 2. Check content or title references if not already matched - if (!isMatch) { - const lowerContent = `${entry.title} ${entry.content}`.toLowerCase(); - if (lowerContent.includes(normalizedFile)) { - isMatch = true; - } else if (normalizedSymbol && lowerContent.includes(normalizedSymbol)) { - isMatch = true; - } - } - - if (isMatch) { - if (entry.negative != null || entry.tags?.some((t) => t.includes("negative") || t.includes("anti-pattern"))) { - negative.push(entry); - } else if (entry.type === "constraint" || entry.temporal_mode === "timeless") { - constraints.push(entry); - } else { - associated.push(entry); - } + for (const { entry } of matches) { + if (entry.negative != null || entry.tags?.some((t) => t.includes("negative") || t.includes("anti-pattern"))) { + negative.push(entry); + } else if (entry.type === "constraint" || entry.temporal_mode === "timeless") { + constraints.push(entry); + } else { + associated.push(entry); } } + return { file_path: options.filePath, symbol_name: options.symbolName, diff --git a/src/retrieval/anchors.ts b/src/retrieval/anchors.ts index 5de8822..79add0a 100644 --- a/src/retrieval/anchors.ts +++ b/src/retrieval/anchors.ts @@ -1,6 +1,6 @@ import type { Store } from "../store.ts"; -import { list } from "../store.ts"; import type { ScoredEntry } from "../retrieval.ts"; +import { resolveMemoriesForCode } from "./code_context.ts"; export interface AnchorRecallInput { files?: string[]; @@ -10,64 +10,18 @@ export interface AnchorRecallInput { limit?: number; } -const PRIORITY: Record = { - constraint: 1.0, - adr: 0.95, - architecture: 0.9, - failure: 0.85, - negative: 0.85, -}; - -function normalizePath(value: string): string { - return value.replace(/\\/g, "/").replace(/^\.?\//, ""); -} - -function fileMatches(anchorPath: string, queryPath: string): boolean { - const a = normalizePath(anchorPath); - const b = normalizePath(queryPath); - return a === b || a.endsWith(`/${b}`) || b.endsWith(`/${a}`); -} - /** - * T-435: AST-anchored invariant auto-recall. Given the files/symbols being - * edited, surface the constraints, ADRs and architecture memories anchored to - * them before the agent changes anything. + * T-435: anchored invariants for pre-flight. Adapter over the canonical + * resolver (W26-1) with anchor-only matching and freshness enforced. */ export function recallAnchoredMemories(store: Store, input: AnchorRecallInput): ScoredEntry[] { - const files = input.files ?? []; - const symbols = input.symbols ?? []; - if (files.length === 0 && symbols.length === 0) return []; - - const matches: ScoredEntry[] = []; - for (const entry of list(store)) { - if (entry.status !== "active" && entry.status !== "confirmed") continue; - if (input.project && entry.project !== input.project) continue; - if (!input.includeUntrusted && entry.trust === "untrusted") continue; - if (entry.valid_to && Date.parse(entry.valid_to) <= Date.now()) continue; - if (entry.expires_at && Date.parse(entry.expires_at) <= Date.now()) continue; - const hit = entry.anchors?.find((anchor) => { - if (anchor.status === "orphaned" || anchor.status === "drifted") return false; - if (anchor.file_path && files.some((file) => fileMatches(anchor.file_path, file))) return true; - const name = anchor.symbol_name ?? anchor.qualified_name; - return Boolean(name && symbols.some((symbol) => symbol.toLowerCase() === name.toLowerCase())); - }); - if (!hit) continue; - const base = entry.type ? PRIORITY[entry.type] ?? 0.7 : 0.7; - const verified = entry.verification?.level && entry.verification.level !== "unverified" ? 0.05 : 0; - matches.push({ - entry, - score: Math.min(1, base + verified), - why_retrieved: [`anchor:${hit.kind}:${hit.file_path || hit.symbol_name || hit.qualified_name || hit.id}`], - }); - } - - return matches - .sort((a, b) => { - const byScore = b.score - a.score; - if (byScore !== 0) return byScore; - const at = Date.parse(a.entry.updated_at); - const bt = Date.parse(b.entry.updated_at); - return (Number.isNaN(bt) ? 0 : bt) - (Number.isNaN(at) ? 0 : at); - }) - .slice(0, Math.min(Math.max(input.limit ?? 10, 1), 50)); + return resolveMemoriesForCode(store, { + files: input.files, + symbols: input.symbols, + project: input.project, + includeUntrusted: input.includeUntrusted, + enforceFreshness: true, + includeMentions: false, + limit: input.limit, + }); } diff --git a/src/retrieval/code_context.ts b/src/retrieval/code_context.ts new file mode 100644 index 0000000..d38e864 --- /dev/null +++ b/src/retrieval/code_context.ts @@ -0,0 +1,95 @@ +import type { Store } from "../store.ts"; +import { list } from "../store.ts"; +import type { ScoredEntry } from "../retrieval.ts"; +import type { MemoryEntry } from "../types.ts"; + +export interface CodeContextInput { + files?: string[]; + symbols?: string[]; + project?: string; + includeMentions?: boolean; + includeUntrusted?: boolean; + /** Apply anchor freshness, validity and expiry gates (pre-flight default). */ + enforceFreshness?: boolean; + limit?: number; +} + +const PRIORITY: Record = { + constraint: 1.0, + adr: 0.95, + architecture: 0.9, + failure: 0.85, + negative: 0.85, +}; + +const HIDDEN_STATUSES = new Set(["superseded", "rejected", "conflicted"]); + +export function normalizeCodePath(value: string): string { + return value.replace(/\\/g, "/").replace(/^\.?\//, "").toLowerCase(); +} + +function fileMatches(anchorPath: string, queryPath: string): boolean { + const a = normalizeCodePath(anchorPath); + const b = normalizeCodePath(queryPath); + return a === b || a.endsWith(`/${b}`) || b.endsWith(`/${a}`); +} + +function isUnfresh(entry: MemoryEntry, now: number): boolean { + if (entry.valid_to && Date.parse(entry.valid_to) <= now) return true; + if (entry.expires_at && Date.parse(entry.expires_at) <= now) return true; + return (entry.anchors ?? []).some((anchor) => anchor.status === "orphaned" || anchor.status === "drifted"); +} + +/** + * W26-1: the single file/symbol → memory resolver. Both the pre-flight + * anchored-invariant surface and the PR-context resolver are adapters over + * this function, so matching, ranking and reason tokens cannot drift apart. + */ +export function resolveMemoriesForCode(store: Store, input: CodeContextInput): ScoredEntry[] { + const files = input.files ?? []; + const symbols = input.symbols ?? []; + if (files.length === 0 && symbols.length === 0) return []; + const now = Date.now(); + const matched: ScoredEntry[] = []; + + for (const entry of list(store)) { + if (entry.status === "archived") continue; + if (HIDDEN_STATUSES.has(entry.status)) continue; + if (input.project && entry.project !== input.project) continue; + if (!input.includeUntrusted && entry.trust === "untrusted") continue; + if (input.enforceFreshness !== false && isUnfresh(entry, now)) continue; + + let reason: string | undefined; + const anchor = (entry.anchors ?? []).find((candidate) => { + if (candidate.status === "orphaned" || candidate.status === "drifted") return false; + if (candidate.file_path && files.some((file) => fileMatches(candidate.file_path, file))) return true; + const name = candidate.symbol_name ?? candidate.qualified_name; + return Boolean(name && symbols.some((symbol) => symbol.toLowerCase() === name.toLowerCase())); + }); + if (anchor) { + reason = `anchor:${anchor.kind}:${anchor.file_path || anchor.symbol_name || anchor.qualified_name || anchor.id}`; + } else if (input.includeMentions) { + const haystack = `${entry.title} ${entry.content}`.toLowerCase(); + const file = files.find((candidate) => haystack.includes(normalizeCodePath(candidate))); + const symbol = symbols.find((candidate) => haystack.includes(candidate.toLowerCase())); + if (file) reason = `mention:file:${file}`; + else if (symbol) reason = `mention:symbol:${symbol}`; + } + if (!reason) continue; + + const base = entry.type ? PRIORITY[entry.type] ?? 0.7 : 0.7; + const anchorBoost = reason.startsWith("anchor:") ? 0.1 : 0; + const verified = entry.verification?.level && entry.verification.level !== "unverified" ? 0.05 : 0; + matched.push({ entry, score: Math.min(1, base + anchorBoost + verified), why_retrieved: [reason] }); + } + + return matched + .sort((a, b) => { + const byScore = b.score - a.score; + if (byScore !== 0) return byScore; + const at = Date.parse(a.entry.updated_at); + const bt = Date.parse(b.entry.updated_at); + return (Number.isNaN(bt) ? 0 : bt) - (Number.isNaN(at) ? 0 : at); + }) + .slice(0, Math.min(Math.max(input.limit ?? 10, 1), 50)); +} diff --git a/test/code_context.test.ts b/test/code_context.test.ts new file mode 100644 index 0000000..ce9e3bc --- /dev/null +++ b/test/code_context.test.ts @@ -0,0 +1,40 @@ +import { describe, test, expect } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { openStore, propose, save, get } from "../src/store.ts"; +import { resolveMemoriesForCode } from "../src/retrieval/code_context.ts"; +import { recallAnchoredMemories } from "../src/retrieval/anchors.ts"; +import { resolveMemoryForCode } from "../src/orchestrator/bidirectional.ts"; + +describe("W26-1: one file→memory resolver", () => { + test("anchor-only, mention and project scoping agree across adapters", () => { + const memoryDir = mkdtempSync(join(tmpdir(), "muse-code-context-")); + try { + const store = openStore(memoryDir); + const constraint = propose(store, { content: "auth tokens must rotate", project: "p", type: "constraint", confirmed: true }); + save(store, { ...get(store, constraint.id)!, anchors: [{ id: "a1", kind: "file", file_path: "src/auth.ts" }] }); + + const prose = propose(store, { content: "see src/auth.ts before editing the session", project: "p", type: "discovery", confirmed: true }); + const foreign = propose(store, { content: "foreign auth rule", project: "other", type: "constraint", confirmed: true }); + save(store, { ...get(store, foreign.id)!, anchors: [{ id: "a2", kind: "file", file_path: "src/auth.ts" }] }); + + const anchored = resolveMemoriesForCode(store, { files: ["src/auth.ts"], project: "p" }); + expect(anchored.map((item) => item.entry.id)).toEqual([constraint.id]); + expect(anchored[0].why_retrieved?.[0]).toContain("anchor:file"); + + const withMentions = resolveMemoriesForCode(store, { files: ["src/auth.ts"], project: "p", includeMentions: true }); + expect(withMentions.map((item) => item.entry.id)).toContain(prose.id); + expect(withMentions[0].entry.id).toBe(constraint.id); + + const preflight = recallAnchoredMemories(store, { files: ["src/auth.ts"], project: "p" }); + const prContext = resolveMemoryForCode(store, { filePath: "src/auth.ts", project: "p" }); + expect(preflight.map((item) => item.entry.id)).toEqual([constraint.id]); + expect(prContext.constraints.map((entry) => entry.id)).toContain(constraint.id); + expect(prContext.constraints.map((entry) => entry.id)).not.toContain(foreign.id); + expect(prContext.associated_memories.map((entry) => entry.id)).toContain(prose.id); + } finally { + rmSync(memoryDir, { recursive: true, force: true }); + } + }); +});