From b18a52b4e39120061277f759599a20acb0730c40 Mon Sep 17 00:00:00 2001 From: rebel Date: Wed, 15 Jul 2026 21:32:23 +0530 Subject: [PATCH 001/276] Fix release regressions and optimize token saver --- ASSIGN_TO_AGENT.md | 2 +- CHANGELOG.md | 15 +- HANDOFF_VERIFICATION.json | 16 +- PROJECT_STATUS.json | 10 +- README.md | 18 +- REPOSITORY_MANIFEST.json | 59 ++++--- apps/cli/oaf.mjs | 83 +++++++++- docs/release/1.0-COMPATIBILITY-MATRIX.md | 2 +- docs/release/1.0-MARKETPLACE-MANIFEST.json | 4 +- docs/release/1.0-PROVENANCE.json | 8 +- docs/release/1.0-READINESS-REPORT.md | 6 +- docs/release/1.0-REPRODUCIBILITY.md | 2 +- docs/release/1.0-SBOM.json | 4 +- package-lock.json | 4 +- package.json | 2 +- scripts/consumer-smoke.mjs | 2 +- tests/release-regressions.test.mjs | 181 +++++++++++++++++++++ tests/usage-docs.test.mjs | 12 +- 18 files changed, 347 insertions(+), 83 deletions(-) create mode 100644 tests/release-regressions.test.mjs diff --git a/ASSIGN_TO_AGENT.md b/ASSIGN_TO_AGENT.md index 7bca3463..5682d0e9 100644 --- a/ASSIGN_TO_AGENT.md +++ b/ASSIGN_TO_AGENT.md @@ -1,6 +1,6 @@ # Assignment Brief for the Development Agent -You are working on **Memory Recall 1.1.0**, currently an unreleased release candidate. Evolve it through small verified changes. Do not rebuild the architecture from scratch and never claim that specified capabilities already exist. +You are working on **Memory Recall 1.1.1**, currently a patch candidate. Evolve it through small verified changes. Do not rebuild the architecture from scratch and never claim that specified capabilities already exist. ## Mission diff --git a/CHANGELOG.md b/CHANGELOG.md index a01a0c83..befca18c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,17 @@ # Changelog -## [Unreleased] — 1.1.0 release candidate +## [Unreleased] — 1.1.1 patch candidate + +### Fixed + +- Default packaged benchmarks now use bundled fixtures unless the caller explicitly supplies `--dataset`, preventing same-named repository files from changing the package benchmark. +- `recall serve` now forwards interrupt and termination signals to the Control API child process. + +### Changed + +- Token Saver and handoff measurement reuse the already-built context pack for their real MCP stdio readback, avoiding a second repository scan while preserving fingerprint verification. + +## [1.1.0] — 2026-07-15 No breaking CLI migration is required. Local-only defaults, proposal-gated memory, and read-only MCP remain unchanged. @@ -30,7 +41,7 @@ No breaking CLI migration is required. Local-only defaults, proposal-gated memor ### Internal -- Release gates cover 659 Node tests, 182 protocol fixtures, 144 evaluation assertions, installed-package smoke, browser smoke, package-content verification, and release-evidence drift checks. +- Release gates cover the full Node suite, 182 protocol fixtures, 144 evaluation assertions, installed-package smoke, browser smoke, package-content verification, and release-evidence drift checks. ## [1.0.5] — 2026-07-09 diff --git a/HANDOFF_VERIFICATION.json b/HANDOFF_VERIFICATION.json index 48840450..3b5c1f2d 100644 --- a/HANDOFF_VERIFICATION.json +++ b/HANDOFF_VERIFICATION.json @@ -1,8 +1,8 @@ { "schemaVersion": "1.0.0", - "project": "open-agent-fabric", - "version": "0.2.0-dev", - "verifiedAt": "2026-07-08T15:33:32.001Z", + "project": "memory-recall", + "version": "1.1.1", + "verifiedAt": "2026-07-15T16:01:28.188Z", "environment": { "platform": "darwin", "release": "25.5.0", @@ -18,7 +18,7 @@ { "command": "npm run check", "exitCode": 0, - "durationMs": 284 + "durationMs": 289 }, { "command": "npm run protocol:validate", @@ -28,22 +28,22 @@ { "command": "npm test", "exitCode": 0, - "durationMs": 47096 + "durationMs": 141473 }, { "command": "npm run eval", "exitCode": 0, - "durationMs": 282 + "durationMs": 328 }, { "command": "npm run demo", "exitCode": 0, - "durationMs": 163 + "durationMs": 170 }, { "command": "npm run native:smoke", "exitCode": 0, - "durationMs": 223 + "durationMs": 243 } ], "result": "passed" diff --git a/PROJECT_STATUS.json b/PROJECT_STATUS.json index 3bb09671..b967aacd 100644 --- a/PROJECT_STATUS.json +++ b/PROJECT_STATUS.json @@ -1,8 +1,8 @@ { "schemaVersion": "1.0.0", "project": "memory-recall", - "release": "1.1.0", - "phase": "1.1-release-candidate-readiness", + "release": "1.1.1", + "phase": "1.1.1-patch-readiness", "nextTask": null, "defaults": { "network": "deny", @@ -785,8 +785,8 @@ "docs/release/1.0-RELEASE-CHECKLIST.md" ], "limitations": [ - "memory-recall 1.1.0 is the package release candidate; verify `npm view memory-recall version` returns 1.1.0 before using the global Recall Map quickstart", - "Publishing, tagging, or signing the 1.1.0 release candidate remains a maintainer action", + "memory-recall 1.1.1 is the source patch candidate; use `npm view memory-recall version` to verify the current registry release before global installation", + "Publishing, tagging, or signing the 1.1.1 patch remains a maintainer action", "No signing keys, release credentials, hosted publishing automation, external writes, or external adapter activation are added" ] }, @@ -1048,7 +1048,7 @@ "npm run consumer:smoke", "npm run consumer:browser-smoke" ], - "testsExpected": 660, + "testsExpected": 664, "protocolFixturesExpected": 182, "evaluationsExpected": 144, "note": "Counts are a dated snapshot; command results and HANDOFF_VERIFICATION.json are authoritative." diff --git a/README.md b/README.md index 77d9a1ee..60eccf1e 100644 --- a/README.md +++ b/README.md @@ -22,18 +22,18 @@ Memory Recall turns a repository into a governed context source. New agent sessions get reviewed repo facts, changed-file impact, required local reads, and proof of what was sent instead of a giant pasted transcript. -The source and package release candidate are 1.1.0. Check the registry before -using the global install path: +The source checkout is the 1.1.1 patch candidate. The registry remains the +installation authority: ```bash npm view memory-recall version -npm install -g memory-recall@1.1.0 +npm install -g memory-recall@latest recall setup recall map --root . --sqlite .local/memory.sqlite --format summary recall handoff ``` -If the registry still reports an older version, use the source checkout: +Use the source checkout when testing changes that are not yet on the registry: ```bash git clone https://github.com/rebel0789/Memory-Recall.git @@ -68,9 +68,9 @@ for preserved legacy names and URIs. ## Five-Minute Path ```bash -# Confirm and install the 1.1.0 package. +# Confirm and install the current registry release. npm view memory-recall version -npm install -g memory-recall@1.1.0 +npm install -g memory-recall@latest # Create local state only. This does not scan the repository. recall setup @@ -264,12 +264,12 @@ boundaries. ## Status -Release candidate: **1.1.0**. Registry version: verify with `npm view memory-recall version`. +Source patch candidate: **1.1.1**. Registry version: verify with `npm view memory-recall version`. | Surface | Status | | --- | --- | -| Source checkout | 1.1.0 local-ready release candidate | -| npm package | 1.1.0 publish-ready; use only after the registry reports 1.1.0 | +| Source checkout | 1.1.1 local-ready patch candidate | +| npm package | Install the current registry release with `memory-recall@latest` | | CLI | `recall` | | Marketplace / plugin registry | Manifest prepared; not submitted | | Client hooks | Opt-in Codex/Claude connect writer; dry-run/manual fallback | diff --git a/REPOSITORY_MANIFEST.json b/REPOSITORY_MANIFEST.json index 011a3af1..82ee0ca0 100644 --- a/REPOSITORY_MANIFEST.json +++ b/REPOSITORY_MANIFEST.json @@ -1,9 +1,9 @@ { "schemaVersion": "1.0.0", "project": "memory-recall", - "generatedAt": "2026-07-15T14:04:23.410Z", - "fileCount": 923, - "totalBytes": 6437920, + "generatedAt": "2026-07-15T16:01:28.404Z", + "fileCount": 924, + "totalBytes": 6447558, "exclusions": [ ".git/**", ".local/**", @@ -457,8 +457,8 @@ }, { "path": "apps/cli/oaf.mjs", - "bytes": 460424, - "sha256": "62512b730c7146f2ed243d22a9071290bd00301d28cce64d69b34461b1774787" + "bytes": 463181, + "sha256": "45c46ca2b9da9c9d1f9eb34b56ca51a8da6d325bbb6c34d6068d749b1d15bd48" }, { "path": "apps/web/AGENTS.md", @@ -517,8 +517,8 @@ }, { "path": "ASSIGN_TO_AGENT.md", - "bytes": 3313, - "sha256": "d200fc428f0f6b53ee9fd5a22e248564da09217e43e3662349504c7bd55ca578" + "bytes": 3299, + "sha256": "a624e8b7b523c6664d30e61cc675ecd2cb48e89a9329f3d8dc7d6120f73256ba" }, { "path": "BOOTSTRAP_REPORT.md", @@ -532,8 +532,8 @@ }, { "path": "CHANGELOG.md", - "bytes": 24010, - "sha256": "0f0377bf2901ab2cc29d38fb80f858f4928c207f94b1d80941690be294fae1d6" + "bytes": 24533, + "sha256": "eecb3751e2cde2cf4032cae191a448e1baeda04d1ce83eab594b498939cf3bc5" }, { "path": "CITATION.cff", @@ -1078,12 +1078,12 @@ { "path": "docs/release/1.0-COMPATIBILITY-MATRIX.md", "bytes": 5427, - "sha256": "5b5b502956a17a26abe69785faf0c11cc36f1d1d13f218fc1eaf9d67b79b69df" + "sha256": "1d494bfb2ed31dfdf8db2962df23a8836a3c09187c87d16abfbc5e32c39ff193" }, { "path": "docs/release/1.0-MARKETPLACE-MANIFEST.json", "bytes": 911, - "sha256": "b24c59297530d79be9ae7b1c5b81f48c654fd72156c688a56bbd02191ef788a1" + "sha256": "19d49e9fa0887540b98920c9b974dd6fd88d18120ca3add8fbc29e8861f4d695" }, { "path": "docs/release/1.0-NORTH-STAR-GAP-AUDIT.md", @@ -1093,12 +1093,12 @@ { "path": "docs/release/1.0-PROVENANCE.json", "bytes": 2414, - "sha256": "559f17ed84af28b5896f6bd346c3fd37fc8d4e1cd3b7c30886a907c3585c858b" + "sha256": "de1da2e3a2d45a5042d72ead1e348f0b9fc657ab36ce18a43ab276c68af06cdd" }, { "path": "docs/release/1.0-READINESS-REPORT.md", - "bytes": 3391, - "sha256": "c143026eca72465202cc3f5a713e2c1efe98c03dac8e0423a34301a6760667fc" + "bytes": 3381, + "sha256": "65d15d1d57bf05a07d9d25f46eeb110c45d9347e8384bcba1f7f2edd8eb1775c" }, { "path": "docs/release/1.0-RELEASE-CHECKLIST.md", @@ -1108,12 +1108,12 @@ { "path": "docs/release/1.0-REPRODUCIBILITY.md", "bytes": 1723, - "sha256": "ffaa143ba56be41c18e99ddee9826b518d2e609eaead617743a9ad7e02db0648" + "sha256": "e9daf1a1196a09c62474d968e83e00ae14de6e822f543f7c9d401ec3848508d7" }, { "path": "docs/release/1.0-SBOM.json", "bytes": 17159, - "sha256": "59e9ba25e87acc81c2b6cc02b6d803fee49bc5a7f7644a8590c8dd6562926e76" + "sha256": "e6d2b39f8a3d57ee29458eb094a273f837e5c08978f3c485e5273094e26e0430" }, { "path": "docs/release/1.0-SECURITY-REVIEW.md", @@ -2372,8 +2372,8 @@ }, { "path": "HANDOFF_VERIFICATION.json", - "bytes": 975, - "sha256": "fc3e3c8dcf101d72e70e1a1e062802fce1b7b70cab9c48aacfb3efabc12be7d2" + "bytes": 968, + "sha256": "eb2d810e244a03d9df572f3eea7fc1c15142ad4ccc7cb509a25264d746ccfe9f" }, { "path": "LICENSE", @@ -2403,12 +2403,12 @@ { "path": "package-lock.json", "bytes": 11169, - "sha256": "66bac759b6c28f95c29eca52c0b42e9bb24237a4a926fb67a42093f9ee425ed7" + "sha256": "e7af858186750dbd7386fce41dd5e37303dca712915d31bc41fd22f7ecb0a368" }, { "path": "package.json", "bytes": 4476, - "sha256": "0b4ad199b2c9bcb0d4832f3da1747633cee59deb65ec94d3a33f83292bef5dff" + "sha256": "69062661e8988c9d18d38201caf0e4d62547c5e2b1d86de4a57d9582e2ffaf19" }, { "path": "packages/adapter-contracts/package.json", @@ -3447,8 +3447,8 @@ }, { "path": "PROJECT_STATUS.json", - "bytes": 60894, - "sha256": "66a4cea825b1893e0f234f0f8ae71906dae322756cdde7cccfb967a4d44e2a83" + "bytes": 60872, + "sha256": "c45a2069a55fe9d917ba7f1d463726670f5c91fe00aa6dbc48b9f60e7b75b151" }, { "path": "providers/AGENTS.md", @@ -3662,8 +3662,8 @@ }, { "path": "README.md", - "bytes": 14946, - "sha256": "032ed4f09e75d866df80450c60f17f83dd772ea4a4d09ead0481156e407cc4e4" + "bytes": 14960, + "sha256": "e594862598e86418574f909056059e3c30014f14d56bbbbcd6ede477c9c48a6b" }, { "path": "REPOSITORY_MAP.md", @@ -3763,7 +3763,7 @@ { "path": "scripts/consumer-smoke.mjs", "bytes": 15345, - "sha256": "c1a2e0bdd7b67707249d2dc8b62680c5c879e502b3716eb598cd782e12b1c134" + "sha256": "0934591890fdda7dea862d6d3655ccd0c05ce649c983ea516275bf9be575f762" }, { "path": "scripts/context-recall-eval.mjs", @@ -4465,6 +4465,11 @@ "bytes": 21729, "sha256": "56124e792cccb107c8528e50342dd81106235944828e17f37aecc4b270559af7" }, + { + "path": "tests/release-regressions.test.mjs", + "bytes": 6373, + "sha256": "e16538d23248aefdb543e4e7711aa285a365c74ae32dd685be753ce5dbf2b84e" + }, { "path": "tests/replay.test.mjs", "bytes": 2758, @@ -4527,8 +4532,8 @@ }, { "path": "tests/usage-docs.test.mjs", - "bytes": 17606, - "sha256": "885efd11e5c90f3c41e2c45d452e802176f4b7523eb71756f8e05a674a7c6f1f" + "bytes": 17630, + "sha256": "071dca81f164e734308b88186c37b721350896fa2fbfddeda9288c4ecd814e0f" }, { "path": "tests/web-shell.test.mjs", diff --git a/apps/cli/oaf.mjs b/apps/cli/oaf.mjs index d39eb0e5..bda4f0c4 100755 --- a/apps/cli/oaf.mjs +++ b/apps/cli/oaf.mjs @@ -57,6 +57,7 @@ import { } from '../../packages/semantic-setup/src/index.mjs'; import { assertSafeContextPackUsePlanForResource, buildOafReadOnlyResourceCatalog, createMcpBridge } from '../../packages/protocol-bridges/src/index.mjs'; import contextPackUsePlanSchema from '../../packages/protocol/schemas/context-pack-use-plan.schema.json' with { type: 'json' }; +import contextPackSchema from '../../packages/protocol/schemas/context-pack.schema.json' with { type: 'json' }; import contextPackHandoffReportSchema from '../../packages/protocol/schemas/context-pack-handoff-report.schema.json' with { type: 'json' }; import contextPackMeasurementReportSchema from '../../packages/protocol/schemas/context-pack-measurement-report.schema.json' with { type: 'json' }; import mcpContextPackSmokeSchema from '../../packages/protocol/schemas/mcp-context-pack-smoke.schema.json' with { type: 'json' }; @@ -86,6 +87,7 @@ const MCP_STDIO_MAX_MESSAGES = boundedEnvInteger('OAF_MCP_STDIO_MAX_MESSAGES', 1 const MCP_STDIO_CHILD_TIMEOUT_MS = boundedEnvInteger('OAF_MCP_STDIO_CHILD_TIMEOUT_MS', 30_000, { min: 1, max: 60_000 }); const MCP_STDIO_CHILD_MAX_STDOUT_BYTES = boundedEnvInteger('OAF_MCP_STDIO_CHILD_MAX_STDOUT_BYTES', 512 * 1024, { min: 1, max: 2_000_000 }); const MCP_STDIO_CHILD_MAX_STDERR_BYTES = boundedEnvInteger('OAF_MCP_STDIO_CHILD_MAX_STDERR_BYTES', 64 * 1024, { min: 1, max: 512 * 1024 }); +const MCP_CONTEXT_PACK_AUX_MAX_BYTES = 2_000_000; const MEMORY_PATH_MAX_BYTES = 8 * 1024 * 1024; const SEMANTIC_HARNESSES = new Set(['codex', 'claude-code', 'cursor', 'generic']); const SEMANTIC_PROVIDERS = new Set(['gemini', 'openai-compatible']); @@ -4626,7 +4628,12 @@ async function loadTemporalDataset(datasetPath) { } async function resolveBenchmarkDataset(requestedPath, bundledPath) { - const selectedPath = requestedPath ?? bundledPath; + if (requestedPath === null || requestedPath === undefined) { + const packagePath = path.join(PACKAGE_ROOT, ...bundledPath.split('/')); + return { path: packagePath, ref: `package://${bundledPath}` }; + } + + const selectedPath = requestedPath; const workspacePath = path.resolve(selectedPath); const workspaceFile = await stat(workspacePath).catch(() => null); if (workspaceFile?.isFile()) { @@ -8414,6 +8421,21 @@ async function loadMcpContextPackRegistryStatus(values, { root, workspaceId }) { async function buildMcpContextPackResource(values, { root, workspaceId }) { if (!values.includes('--context-pack')) return null; + const contextPackFd = option(values, '--context-pack-fd'); + if (contextPackFd !== null) { + if (!values.includes('--stdio') || contextPackFd !== '3') { + throw new Error('prebuilt context pack input is available only to internal MCP stdio verification'); + } + const serialized = await readBoundedFileDescriptor(3, MCP_CONTEXT_PACK_AUX_MAX_BYTES, 'prebuilt context pack'); + let pack; + try { + pack = JSON.parse(serialized); + } catch { + throw new Error('prebuilt context pack is not valid JSON'); + } + assertJsonSchema(contextPackSchema, pack, 'prebuilt context pack'); + return { pack, markdown: renderContextPackMarkdown(pack) }; + } const objective = option(values, '--objective'); const step = option(values, '--step'); if (!objective || !step) { @@ -8443,7 +8465,7 @@ async function buildMcpContextPackResource(values, { root, workspaceId }) { }; } -async function buildMcpContextPackSmokeReport(values, { objective, step, fixedTimestamp = null }) { +async function buildMcpContextPackSmokeReport(values, { objective, step, fixedTimestamp = null, contextPack = null }) { const root = option(values, '--root') ?? process.cwd(); const workspaceId = option(values, '--workspace') ?? 'ws_local'; const targetHarness = option(values, '--target') ?? option(values, '--target-harness') ?? 'generic'; @@ -8480,6 +8502,7 @@ async function buildMcpContextPackSmokeReport(values, { objective, step, fixedTi for (const value of options(values, '--changed-locator')) childArgs.push('--changed-locator', value); if (gitChangedLocatorsRequested(values)) childArgs.push('--changed-from-git'); if (option(values, '--changed-shard')) childArgs.push('--changed-shard', String(changedShard(values))); + if (contextPack) childArgs.push('--context-pack-fd', '3'); const messages = [ { jsonrpc: '2.0', id: 1, method: 'initialize' }, @@ -8490,7 +8513,8 @@ async function buildMcpContextPackSmokeReport(values, { objective, step, fixedTi ]; const started = process.hrtime.bigint(); const child = await runCliStdio(childArgs, messages.map((message) => JSON.stringify(message)).join('\n'), { - env: fixedTimestamp ? { ...process.env, OAF_FIXED_NOW: fixedTimestamp } : process.env + env: fixedTimestamp ? { ...process.env, OAF_FIXED_NOW: fixedTimestamp } : process.env, + auxiliaryInput: contextPack ? JSON.stringify(contextPack) : null }); const durationMs = Math.max(0, Math.round(Number(process.hrtime.bigint() - started) / 1_000_000)); if (child.code !== 0) { @@ -8620,7 +8644,7 @@ async function buildContextHandoffReport(values, { objective, step }) { }); const usePlan = buildContextPackUsePlan(pack); assertJsonSchema(contextPackUsePlanSchema, usePlan, 'context-pack handoff use plan'); - const smoke = await buildMcpContextPackSmokeReport(values, { objective, step, fixedTimestamp: generatedAt }); + const smoke = await buildMcpContextPackSmokeReport(values, { objective, step, fixedTimestamp: generatedAt, contextPack: pack }); const memoryProposalPreflight = await buildMemoryProposalPreflight(values, { root, workspaceId, generatedAt }); const skillCatalog = await buildSkillCatalogPreflight({ root, workspaceId, generatedAt }); const setupClient = contextHandoffSetupClient(targetHarness); @@ -9175,7 +9199,7 @@ async function buildContextPackMeasurementReport(values, { objective, step }) { clock: () => generatedAt }); const buildDurationMs = Math.max(0, Math.round(Number(process.hrtime.bigint() - started) / 1_000_000)); - const smoke = await buildMcpContextPackSmokeReport(values, { objective, step, fixedTimestamp: generatedAt }); + const smoke = await buildMcpContextPackSmokeReport(values, { objective, step, fixedTimestamp: generatedAt, contextPack: pack }); const usePlan = buildContextPackUsePlan(pack, { generatedAt }); const impactBrief = buildContextPackImpactBrief(pack, { generatedAt, @@ -9425,6 +9449,7 @@ function runCliStdio( input, { env = process.env, + auxiliaryInput = null, timeoutMs = MCP_STDIO_CHILD_TIMEOUT_MS, maxStdoutBytes = MCP_STDIO_CHILD_MAX_STDOUT_BYTES, maxStderrBytes = MCP_STDIO_CHILD_MAX_STDERR_BYTES @@ -9434,11 +9459,15 @@ function runCliStdio( if (inputBytes > MCP_STDIO_MAX_STDIN_BYTES) { return Promise.reject(new Error(`mcp stdio child input exceeded ${MCP_STDIO_MAX_STDIN_BYTES} bytes`)); } + const auxiliaryBytes = auxiliaryInput === null ? 0 : Buffer.byteLength(auxiliaryInput, 'utf8'); + if (auxiliaryBytes > MCP_CONTEXT_PACK_AUX_MAX_BYTES) { + return Promise.reject(new Error(`mcp stdio auxiliary input exceeded ${MCP_CONTEXT_PACK_AUX_MAX_BYTES} bytes`)); + } return new Promise((resolve, reject) => { const child = spawn(process.execPath, nodeArgs, { cwd: process.cwd(), env, - stdio: ['pipe', 'pipe', 'pipe'] + stdio: auxiliaryInput === null ? ['pipe', 'pipe', 'pipe'] : ['pipe', 'pipe', 'pipe', 'pipe'] }); const stdout = []; const stderr = []; @@ -9482,6 +9511,12 @@ function runCliStdio( child.stdin.on('error', (error) => { if (!settled) fail(error); }); + if (auxiliaryInput !== null) { + child.stdio[3].on('error', (error) => { + if (!settled) fail(error); + }); + child.stdio[3].end(auxiliaryInput); + } child.on('error', fail); child.on('close', (code) => { if (settled) return; @@ -10738,11 +10773,43 @@ function runNode(nodeArgs, { cwd = PACKAGE_ROOT, env = process.env } = {}) { const [script, ...rest] = nodeArgs; const resolvedScript = path.isAbsolute(script) ? script : path.join(PACKAGE_ROOT, script); const child = spawn(process.execPath, [resolvedScript, ...rest], { stdio: 'inherit', env, cwd }); - child.on('error', reject); - child.on('exit', (code) => resolve(code ?? 1)); + const forwardSignal = (signal) => { + if (!child.killed) child.kill(signal); + }; + const onSigint = () => forwardSignal('SIGINT'); + const onSigterm = () => forwardSignal('SIGTERM'); + const cleanup = () => { + process.removeListener('SIGINT', onSigint); + process.removeListener('SIGTERM', onSigterm); + }; + process.once('SIGINT', onSigint); + process.once('SIGTERM', onSigterm); + child.once('error', (error) => { + cleanup(); + reject(error); + }); + child.once('exit', (code, signal) => { + cleanup(); + resolve(code ?? (signal === 'SIGINT' ? 130 : signal === 'SIGTERM' ? 143 : 1)); + }); }); } +async function readBoundedFileDescriptor(fd, maxBytes, label) { + const stream = createReadStream(null, { fd, autoClose: false }); + const chunks = []; + let total = 0; + for await (const chunk of stream) { + total += chunk.length; + if (total > maxBytes) { + stream.destroy(); + throw new Error(`${label} exceeded ${maxBytes} bytes`); + } + chunks.push(chunk); + } + return Buffer.concat(chunks).toString('utf8'); +} + function isHelpCommand(value) { return ['help', '--help', '-h'].includes(value); } diff --git a/docs/release/1.0-COMPATIBILITY-MATRIX.md b/docs/release/1.0-COMPATIBILITY-MATRIX.md index 86a67fc7..a8eb28dc 100644 --- a/docs/release/1.0-COMPATIBILITY-MATRIX.md +++ b/docs/release/1.0-COMPATIBILITY-MATRIX.md @@ -54,7 +54,7 @@ External adapters enabled by default: 0. | Package | Version | License | Runtime deps | | --- | --- | --- | --- | -| memory-recall | 1.1.0 | Apache-2.0 | 0 | +| memory-recall | 1.1.1 | Apache-2.0 | 0 | | @open-agent-fabric/adapter-contracts | 0.2.0-dev | Apache-2.0 | 0 | | @open-agent-fabric/agentpack | 0.2.0-dev | Apache-2.0 | 0 | | @open-agent-fabric/content-intelligence | 0.2.0-dev | Apache-2.0 | 0 | diff --git a/docs/release/1.0-MARKETPLACE-MANIFEST.json b/docs/release/1.0-MARKETPLACE-MANIFEST.json index 74cbb7be..ded278ff 100644 --- a/docs/release/1.0-MARKETPLACE-MANIFEST.json +++ b/docs/release/1.0-MARKETPLACE-MANIFEST.json @@ -6,9 +6,9 @@ "package": { "registry": "npm", "name": "memory-recall", - "version": "1.1.0", + "version": "1.1.1", "install": "npm install -g memory-recall", - "url": "https://www.npmjs.com/package/memory-recall/v/1.1.0", + "url": "https://www.npmjs.com/package/memory-recall/v/1.1.1", "bins": [ "recall", "oaf" diff --git a/docs/release/1.0-PROVENANCE.json b/docs/release/1.0-PROVENANCE.json index 9a7c6bda..1c2b6222 100644 --- a/docs/release/1.0-PROVENANCE.json +++ b/docs/release/1.0-PROVENANCE.json @@ -3,7 +3,7 @@ "predicateType": "https://github.com/rebel0789/Memory-Recall/provenance/release-readiness/v1", "subject": { "name": "memory-recall", - "version": "1.1.0", + "version": "1.1.1", "releaseCandidate": "1.0-readiness" }, "buildType": "local-deterministic-source-readiness", @@ -21,9 +21,9 @@ "mergePerformedByThisTask": false }, "sourceHashes": { - "package.json": "sha256:0b4ad199b2c9bcb0d4832f3da1747633cee59deb65ec94d3a33f83292bef5dff", - "package-lock.json": "sha256:66bac759b6c28f95c29eca52c0b42e9bb24237a4a926fb67a42093f9ee425ed7", - "PROJECT_STATUS.json": "sha256:66a4cea825b1893e0f234f0f8ae71906dae322756cdde7cccfb967a4d44e2a83", + "package.json": "sha256:69062661e8988c9d18d38201caf0e4d62547c5e2b1d86de4a57d9582e2ffaf19", + "package-lock.json": "sha256:e7af858186750dbd7386fce41dd5e37303dca712915d31bc41fd22f7ecb0a368", + "PROJECT_STATUS.json": "sha256:c45a2069a55fe9d917ba7f1d463726670f5c91fe00aa6dbc48b9f60e7b75b151", "planning/backlog.json": "sha256:a404e5704ba3bf3be635a655d2d73d84eff7eba3ce43ab511b83967cceab6a5f", "adapters/catalog.json": "sha256:11c22a150f847a560be3e64c8280c7757368d813b5f70304938ab7915694c283", "docs/usage/support-matrix.md": "sha256:dd4108828b742e8d06112e6abdfa4119f90a8b034b417b9e7e41f6924dcf26a6", diff --git a/docs/release/1.0-READINESS-REPORT.md b/docs/release/1.0-READINESS-REPORT.md index e9609b0b..75f5cc1c 100644 --- a/docs/release/1.0-READINESS-REPORT.md +++ b/docs/release/1.0-READINESS-REPORT.md @@ -18,13 +18,13 @@ Publication state: npm-published. This evidence is prepared for a maintainer mer | Area | Evidence | | --- | --- | -| Release | 1.1.0 | -| Phase | 1.1-release-candidate-readiness | +| Release | 1.1.1 | +| Phase | 1.1.1-patch-readiness | | Network default | deny | | External writes | false | | Model mode | deterministic | | Quality snapshot date | 2026-07-15 | -| Recorded tests | 660 | +| Recorded tests | 664 | | Recorded protocol fixtures | 182 | | Recorded evaluation assertions | 144 | | Quality snapshot note | Counts are a dated snapshot; command results and HANDOFF_VERIFICATION.json are authoritative. | diff --git a/docs/release/1.0-REPRODUCIBILITY.md b/docs/release/1.0-REPRODUCIBILITY.md index ffa2a422..77461203 100644 --- a/docs/release/1.0-REPRODUCIBILITY.md +++ b/docs/release/1.0-REPRODUCIBILITY.md @@ -24,7 +24,7 @@ Counts are a dated snapshot; command results and HANDOFF_VERIFICATION.json are a | Gate | Expected Count | | --- | --- | -| Tests | 660 | +| Tests | 664 | | Protocol fixtures | 182 | | Evaluation assertions | 144 | diff --git a/docs/release/1.0-SBOM.json b/docs/release/1.0-SBOM.json index 80200d08..04faea47 100644 --- a/docs/release/1.0-SBOM.json +++ b/docs/release/1.0-SBOM.json @@ -3,13 +3,13 @@ "format": "memory-recall-release-readiness-sbom", "releaseCandidate": "1.0-readiness", "project": "memory-recall", - "packageVersion": "1.1.0", + "packageVersion": "1.1.1", "dependencyPolicy": "bootstrap dependency-free; optional integrations remain disabled adapters", "packages": [ { "path": "package.json", "name": "memory-recall", - "version": "1.1.0", + "version": "1.1.1", "license": "Apache-2.0", "private": false, "dependencyCount": 0, diff --git a/package-lock.json b/package-lock.json index 05e41363..b43feafe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "memory-recall", - "version": "1.1.0", + "version": "1.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "memory-recall", - "version": "1.1.0", + "version": "1.1.1", "license": "Apache-2.0", "workspaces": [ "packages/*", diff --git a/package.json b/package.json index 09d2dfd6..63d20dc4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "memory-recall", - "version": "1.1.0", + "version": "1.1.1", "type": "module", "description": "Local-first Recall Map, repo memory, and context for coding agents.", "keywords": [ diff --git a/scripts/consumer-smoke.mjs b/scripts/consumer-smoke.mjs index 9b131d0c..15de543c 100644 --- a/scripts/consumer-smoke.mjs +++ b/scripts/consumer-smoke.mjs @@ -35,7 +35,7 @@ try { const env = { ...process.env, HOME: home, PATH: `${path.join(prefix, 'bin')}${path.delimiter}${process.env.PATH}` }; const version = run(recall, ['--version'], { cwd: workspace, env }); - must(version.stdout.trim() === '1.1.0', 'installed recall reports the release version'); + must(version.stdout.trim() === '1.1.1', 'installed recall reports the release version'); const setupOutput = run(recall, ['setup'], { cwd: workspace, env }); must(setupOutput.stdout.includes('Setup created only local state'), 'installed setup creates explicit local state'); diff --git a/tests/release-regressions.test.mjs b/tests/release-regressions.test.mjs new file mode 100644 index 00000000..3e21dd3c --- /dev/null +++ b/tests/release-regressions.test.mjs @@ -0,0 +1,181 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn, spawnSync } from 'node:child_process'; +import { once } from 'node:events'; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { createServer } from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; + +const CLI_PATH = path.resolve('apps/cli/oaf.mjs'); +const TEMPORAL_FIXTURE = path.resolve('evals/temporal/gold.v1.json'); + +test('default packaged benchmark ignores a same-named workspace fixture', () => { + const root = temporalShadowWorkspace('workspace-shadow-default'); + const result = spawnSync(process.execPath, [ + CLI_PATH, + 'bench', + 'temporal', + '--read-only', + '--root', + '.', + '--format', + 'json' + ], { cwd: root, encoding: 'utf8' }); + + assert.equal(result.status, 0, result.stderr); + const report = JSON.parse(result.stdout); + assert.equal(report.dataset.id, 'oaf-temporal-gold-v1'); + assert.equal(report.dataset.ref, 'package://evals/temporal/gold.v1.json'); +}); + +test('explicit benchmark dataset still resolves from the workspace', () => { + const root = temporalShadowWorkspace('workspace-shadow-explicit'); + const result = spawnSync(process.execPath, [ + CLI_PATH, + 'bench', + 'temporal', + '--read-only', + '--root', + '.', + '--dataset', + 'evals/temporal/gold.v1.json', + '--format', + 'json' + ], { cwd: root, encoding: 'utf8' }); + + assert.equal(result.status, 0, result.stderr); + const report = JSON.parse(result.stdout); + assert.equal(report.dataset.id, 'workspace-shadow-explicit'); + assert.equal(report.dataset.ref, 'workspace://evals/temporal/gold.v1.json'); +}); + +test('recall serve forwards SIGTERM to the control API child', async () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'recall-serve-signal-')); + const port = await freePort(); + mkdirSync(path.join(root, 'data'), { recursive: true }); + writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'signal-target' })); + + const wrapper = spawn(process.execPath, [CLI_PATH, 'serve'], { + cwd: root, + detached: true, + stdio: 'ignore', + env: { + ...process.env, + HOME: root, + OAF_PORT: String(port), + OAF_DATA_DIR: path.join(root, 'data'), + OAF_WORKSPACE_ROOT: root + } + }); + + try { + await waitForHealth(port); + wrapper.kill('SIGTERM'); + await Promise.race([ + once(wrapper, 'exit'), + new Promise((_, reject) => setTimeout(() => reject(new Error('recall wrapper did not exit after SIGTERM')), 5000)) + ]); + assert.equal(await waitForStop(port), true, 'control API child remained alive after wrapper SIGTERM'); + } finally { + try { + process.kill(-wrapper.pid, 'SIGKILL'); + } catch (error) { + if (error.code !== 'ESRCH') throw error; + } + } +}); + +test('token-saver reuses its context pack for MCP readback', () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'recall-token-saver-single-scan-')); + const bin = path.join(root, 'bin'); + const counterRoot = mkdtempSync(path.join(os.tmpdir(), 'recall-token-saver-counter-')); + const counter = path.join(counterRoot, 'git-status-count.txt'); + mkdirSync(bin, { recursive: true }); + writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'single-scan-target' })); + writeFileSync(path.join(root, 'AGENTS.md'), 'Keep the measured handoff local and compact.'); + + const realGit = spawnSync('which', ['git'], { encoding: 'utf8' }).stdout.trim(); + assert(realGit); + const gitWrapper = path.join(bin, 'git'); + writeFileSync(gitWrapper, [ + '#!/usr/bin/env node', + "const { appendFileSync } = require('node:fs');", + "const { spawnSync } = require('node:child_process');", + 'const args = process.argv.slice(2);', + `if (args.includes('status') && args.includes('--porcelain=v1')) appendFileSync(${JSON.stringify(counter)}, 'status\\n');`, + `const result = spawnSync(${JSON.stringify(realGit)}, args, { stdio: 'inherit' });`, + 'process.exit(result.status ?? 1);' + ].join('\n')); + chmodSync(gitWrapper, 0o755); + + runGit(root, ['init']); + runGit(root, ['add', '.']); + runGit(root, ['-c', 'user.name=Memory Recall Test', '-c', 'user.email=recall@example.invalid', 'commit', '-m', 'fixture']); + + const result = spawnSync(process.execPath, [ + CLI_PATH, + 'token-saver', + '--root', + root, + '--include-file', + 'AGENTS.md', + '--format', + 'json' + ], { + encoding: 'utf8', + env: { ...process.env, PATH: `${bin}${path.delimiter}${process.env.PATH}` } + }); + + assert.equal(result.status, 0, result.stderr); + const report = JSON.parse(result.stdout); + assert.equal(report.checks.contextPackFingerprintMatchesMcp, true); + const statusCalls = readFileSync(counter, 'utf8').trim().split(/\n/u).filter(Boolean); + assert.equal(statusCalls.length, 2, `expected one context-pack scan pair, received ${statusCalls.length} git status calls`); +}); + +function temporalShadowWorkspace(id) { + const root = mkdtempSync(path.join(os.tmpdir(), 'recall-benchmark-shadow-')); + const fixture = JSON.parse(readFileSync(TEMPORAL_FIXTURE, 'utf8')); + fixture.id = id; + mkdirSync(path.join(root, 'evals', 'temporal'), { recursive: true }); + writeFileSync(path.join(root, 'evals', 'temporal', 'gold.v1.json'), `${JSON.stringify(fixture, null, 2)}\n`); + return root; +} + +function runGit(root, args) { + const result = spawnSync('git', args, { cwd: root, encoding: 'utf8' }); + assert.equal(result.status, 0, result.stderr); +} + +async function freePort() { + const server = createServer(); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const { port } = server.address(); + await new Promise((resolve) => server.close(resolve)); + return port; +} + +async function waitForHealth(port) { + for (let attempt = 0; attempt < 50; attempt += 1) { + try { + const response = await fetch(`http://127.0.0.1:${port}/api/health`); + if (response.ok) return; + } catch {} + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error('control API did not become healthy'); +} + +async function waitForStop(port) { + for (let attempt = 0; attempt < 30; attempt += 1) { + try { + await fetch(`http://127.0.0.1:${port}/api/health`); + } catch { + return true; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + return false; +} diff --git a/tests/usage-docs.test.mjs b/tests/usage-docs.test.mjs index dfd6e024..612f853c 100644 --- a/tests/usage-docs.test.mjs +++ b/tests/usage-docs.test.mjs @@ -6,17 +6,17 @@ function read(path) { return readFileSync(path, 'utf8'); } -test('README and status keep the release candidate honest across the registry handoff', () => { +test('README and status keep the patch candidate honest across the registry handoff', () => { const readme = read('README.md'); const status = JSON.parse(read('PROJECT_STATUS.json')); const release = status.capabilities.find((capability) => capability.id === 'release.readiness'); const web = status.capabilities.find((capability) => capability.id === 'bootstrap.web'); assert.match(readme, /npm view memory-recall version/); - assert.match(readme, /npm install -g memory-recall@1\.1\.0\nrecall setup\nrecall map --root \. --sqlite \.local\/memory\.sqlite --format summary\nrecall handoff/); - assert.match(readme, /If the registry still reports an older version, use the source checkout/); + assert.match(readme, /npm install -g memory-recall@latest\nrecall setup\nrecall map --root \. --sqlite \.local\/memory\.sqlite --format summary\nrecall handoff/); + assert.match(readme, /Use the source checkout when testing changes that are not yet on the registry/); assert.doesNotMatch(readme, /npm still serves\s+1\.0\.5/); - assert.match(release.limitations.join('\n'), /1\.1\.0 is the package release candidate; verify `npm view memory-recall version` returns 1\.1\.0/); + assert.match(release.limitations.join('\n'), /1\.1\.1 is the source patch candidate; use `npm view memory-recall version` to verify the current registry release/); assert.match(web.limitations.join('\n'), /confirm-gated proposal approval through the authenticated loopback API/); }); @@ -110,8 +110,8 @@ test('public usage docs avoid stale task and missing context-file examples while assert.match(contract, /Implemented: local JS\/TS static graph, reviewed SQLite memory, read-only MCP\./); assert.match(contract, /Experimental: Rust acceleration paths require a local build before explicit invocation\./); assert.match(contract, /Unsupported: automatic transcript capture, write-capable MCP, hosted sync, and non-JS\/TS source graph analysis\./); - assert.match(readme, /npm install -g memory-recall@1\.1\.0\nrecall setup\nrecall map --root \. --sqlite \.local\/memory\.sqlite --format summary\nrecall handoff/); - assert.match(readme, /Release candidate: \*\*1\.1\.0\*\*\. Registry version: verify with `npm view memory-recall version`\./); + assert.match(readme, /npm install -g memory-recall@latest\nrecall setup\nrecall map --root \. --sqlite \.local\/memory\.sqlite --format summary\nrecall handoff/); + assert.match(readme, /Source patch candidate: \*\*1\.1\.1\*\*\. Registry version: verify with `npm view memory-recall version`\./); assert.match(readme, /`recall setup` creates only local state\. `recall map` is the explicit first\s+read-only repository scan; it does not run silently during setup\./); assert.match(normalHandoff, /recall setup\nrecall map --root \. --sqlite \.local\/memory\.sqlite --format summary\nrecall handoff/); assert.match(normalHandoff, /`recall setup` creates only local Recall state in the current repository; it\s+does not scan source files\. `recall map` is the explicit first read-only scan\./); From ffde3a6bb4c5701c12345dd7c4dfce3c2e46ffa5 Mon Sep 17 00:00:00 2001 From: rebel Date: Wed, 15 Jul 2026 21:37:41 +0530 Subject: [PATCH 002/276] Align Rust MCP server version --- REPOSITORY_MANIFEST.json | 4 ++-- rust/oaf/src/main.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/REPOSITORY_MANIFEST.json b/REPOSITORY_MANIFEST.json index 82ee0ca0..f7c77305 100644 --- a/REPOSITORY_MANIFEST.json +++ b/REPOSITORY_MANIFEST.json @@ -1,7 +1,7 @@ { "schemaVersion": "1.0.0", "project": "memory-recall", - "generatedAt": "2026-07-15T16:01:28.404Z", + "generatedAt": "2026-07-15T16:07:36.139Z", "fileCount": 924, "totalBytes": 6447558, "exclusions": [ @@ -3728,7 +3728,7 @@ { "path": "rust/oaf/src/main.rs", "bytes": 179738, - "sha256": "0857b482770209875102cfb78510eb23af0540db020cab384ad591f6e6fb7379" + "sha256": "6bdcbfcf3b8d2ebab774df9ffcab2cbd1a9d9cea448fc466ca3fc22aa4ce494e" }, { "path": "rust/README.md", diff --git a/rust/oaf/src/main.rs b/rust/oaf/src/main.rs index 58c4f015..fb7defc6 100644 --- a/rust/oaf/src/main.rs +++ b/rust/oaf/src/main.rs @@ -24,7 +24,7 @@ use std::time::Instant; const PROVIDER: &str = "provider:native:memory:sqlite"; const PROTOCOL_VERSION: &str = "2025-06-18"; -const SERVER_VERSION: &str = "1.1.0"; +const SERVER_VERSION: &str = "1.1.1"; const SERVER_NAME: &str = "memory-recall"; fn main() { From 941b1c8e5c5a8b75bc12700d23fa85ddef0c71e6 Mon Sep 17 00:00:00 2001 From: rebel Date: Wed, 15 Jul 2026 22:24:47 +0530 Subject: [PATCH 003/276] docs: specify orientation-first Memory Recall workbench --- ...ory-recall-orientation-workbench-design.md | 390 ++++++++++++++++++ 1 file changed, 390 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-15-memory-recall-orientation-workbench-design.md diff --git a/docs/superpowers/specs/2026-07-15-memory-recall-orientation-workbench-design.md b/docs/superpowers/specs/2026-07-15-memory-recall-orientation-workbench-design.md new file mode 100644 index 00000000..d8aa73b1 --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-memory-recall-orientation-workbench-design.md @@ -0,0 +1,390 @@ +# Memory Recall Orientation Workbench and Large-Repository Graph Design + +Date: 2026-07-15 + +Status: Approved direction, pending written-spec review + +## Decision + +Memory Recall opens as an orientation-first repository workbench. + +The first screen must tell a developer how the repository is organized, where to start, what changed, and whether the available context is trustworthy. It must not open with a raw node graph, a wall of status cards, or a long operational report. + +This specification supersedes the `Overview screen`, `Map screen`, and graph-presentation sections of `2026-07-11-memory-recall-product-ui-redesign.md`. It retains that specification's five-destination shell, setup flow, visual system, Memory review queue, Handoffs flow, local-only boundaries, and accessibility requirements. + +The work has two required slices: + +1. Make the source graph return truthful, bounded results on large repositories. +2. Replace the rejected Overview, Map, and memory-graph presentation with the orientation-first experience defined here. + +Neither slice is complete without the other. A polished interface over an invalid graph is misleading. A valid graph hidden behind the current interface does not deliver the approved first impression. + +## Evidence behind the decision + +A current local audit used a 1,627-file monorepo with 572 directly supported JavaScript and TypeScript files. The source scan reached its 1,000-file cap, created 17,018 nodes and 89,012 edges, then failed strict validation because the public graph schema permits 50,000 edges. A legitimate repository-relative path containing `/users/` also failed a display-label safety rule intended to reject absolute user paths. + +The failed preview returned zero files, zero symbols, and zero relations. The web interface announced that the preview was ready instead of showing the validation failure. The submitted query was replaced by the default query. The initial Recall Map request took 13.6 seconds and a second Map request took 11.4 seconds because the graph was rebuilt. + +The same audit found that discovery spent work on nested worktrees, agent tooling, virtual environments, and generated output. It reported more than 78,000 unsupported files before reaching the supported-file cap. + +Competitor products informed the interaction direction but are not acceptance evidence: + +- [codebase-mcp](https://pypi.org/project/codebase-mcp/) starts with one bounded orientation response containing project status, important files, decisions, recent notes, and index freshness. +- [codebase-memory-mcp](https://github.com/DeusData/codebase-memory-mcp) exposes a compact architecture overview before its optional graph UI. +- [GitNexus](https://github.com/nxpatterns/gitnexus) precomputes clusters, flows, and impact instead of asking the client to interpret a raw graph. +- [Graphify](https://github.com/Graphify-Labs/graphify) pairs its graph with a report, path queries, search, and evidence labels. + +Memory Recall keeps its own product boundary: reviewed temporal memory, source evidence, explicit lifecycle state, deterministic authority, and inspectable handoffs. + +## First-ten-seconds contract + +After a valid cached scan, a developer must be able to identify these facts without scrolling: + +1. Repository name, branch, scan freshness, and coverage state. +2. The repository's main modules or services. +3. Three recommended starting points with plain reasons. +4. Whether local changes affect known modules or symbols. +5. Whether governed memory and the current handoff are current, pending, stale, or unavailable. + +The screen may contain counts only when they support one of these decisions. It does not show vanity metrics. + +The copy above the work area is one sentence at most: + +> Repository structure, current impact, and trusted context. + +The product shell contains no slogan. + +## Information architecture + +The five existing destinations remain: + +- `Overview`: orientation-first repository workbench. +- `Map`: focused structure, trace, search, and impact exploration. +- `Memory`: proposal review, current facts, stale facts, history, and the governed memory graph. +- `Handoffs`: prepare, verify, save, and consume context packages. +- `Settings`: local storage, connections, clients, models, security, and advanced operations. + +`Overview` is not a second Map page. It presents a bounded architecture summary and the next useful action. `Map` owns detailed relationships and evidence. `Memory` owns fact lifecycle and temporal relationships. + +## Overview layout + +### Repository truth bar + +The existing compact repository bar remains at the top. It shows: + +- repository and branch; +- scan state and age; +- coverage as `complete`, `partial`, `stale`, `failed`, or `not scanned`; +- local-only and external-write state; +- the global search field. + +Coverage is a text state, not a colored dot without a label. + +### Orientation workspace + +Desktop uses a 70/30 split beneath the truth bar. + +The primary region contains the architecture map. The secondary region contains three compact sections: `Start here`, `Current impact`, and `Trusted context`. These sections use dividers and aligned rows, not three equal cards. + +The layout uses the current graphite and cobalt token system. It reduces vertical padding, avoids unused full-width sections, and keeps all primary information within the first desktop viewport at 1440 px. + +### Architecture map + +The first-screen map shows up to 12 deterministic repository groups. It shows at least 6 when the repository exposes 6 useful groups; smaller repositories show every useful group instead of inventing filler. Groups come from package boundaries, manifests, and stable repository-relative directory prefixes. No model names the groups. + +Each group shows: + +- name and repository-relative prefix; +- supported file and symbol counts; +- changed-file count; +- up to two ranked entry points; +- partial-coverage state when applicable. + +The map does not render every file or symbol. It uses a layered module-dependency layout, not a force-directed node cloud. Strong dependency direction runs left to right. Cyclic groups share a layer. Within a layer, groups sort by repository-relative prefix, so the same snapshot produces the same positions. + +Group nodes are keyboard-focusable semantic controls. A generated relationship layer may connect them visually, but the grouped outline is the canonical accessible representation. Selecting a group from either representation updates the secondary region and provides a direct link to its detailed Map view. + +Relationships on the Overview are limited to the 20 strongest inter-group imports or calls. Ties resolve by source and target repository-relative prefix. Omitted relationship counts remain visible. Dense relationship detail belongs in Map. + +The semantic fallback is a grouped outline containing the same groups, counts, entry points, and selected state. The outline is always present in the accessibility tree. + +### Start here + +`Start here` contains exactly three ranked entry points when three are available. Every item includes a reason such as: + +- package entry point; +- application route; +- executable command; +- changed central module; +- high-confidence inbound dependency hub. + +Generic test helpers, generated files, vendored code, and low-signal utility symbols cannot outrank source entry points. + +### Current impact + +With a clean working tree, the section says `No local changes detected` and does not reserve a large empty area. + +With changes, it shows: + +- changed files represented by the graph; +- changed files omitted by language or scan bounds; +- affected groups and top affected symbols; +- the current impact depth; +- a link to the detailed impact view. + +An unrepresented changed file is never silently treated as safe. + +### Trusted context + +This section combines only decision-relevant state: + +- active, pending, stale, and conflicting memory counts; +- current handoff state and age; +- source verification state; +- measured context-delivery reduction when a current measurement exists. + +It does not claim provider billing-token savings. It links to Memory or Handoffs for detail. + +### Command bar + +One command bar accepts a file, symbol, concept, or path. It offers four deterministic intents: + +- explain a module; +- trace a symbol; +- inspect changed-file impact; +- prepare a handoff. + +The bar routes to existing read-only operations. It is not a chat interface and does not require a model. + +## Map workspace + +Map is the detailed repository explorer. Desktop uses three regions: + +1. Compact query and scope controls. +2. The relationship view or equivalent outline. +3. A selected-item inspector with source evidence and coverage. + +The current form is simplified. `Query` is always visible. `Trace symbol`, `Changed locator`, depth, and limit move behind an `Advanced scope` disclosure unless the current deep link requires them. + +Submitting a query preserves the query in the URL and form through success, partial results, recoverable failure, reload, back, and forward navigation. + +The default Map result shows grouped modules and ranked entry points. File and symbol nodes appear progressively after the developer selects a group, searches, traces, or opens impact detail. + +The detailed graph supports pan, zoom, fit-to-selection, reset, and focus. It never attempts to render the full repository graph at once. The visible payload is bounded to a focused neighborhood, and omitted counts are explicit. + +Every canvas or visual graph state has an equivalent keyboard-reachable outline. Selecting an outline row and selecting its visual node produce the same inspector state. + +## Governed memory graph + +The memory graph remains a secondary view within Memory. + +When there are no governed nodes, the interface renders a compact empty state with the checked workspace, provider state, and next available action. It does not render metric cards or an empty 640 px canvas. + +When nodes exist, the graph: + +- fits visible nodes into the available viewport; +- groups or filters before drawing dense histories; +- distinguishes current and superseded relationships without relying only on color; +- keeps search and history controls compact; +- places the selected fact and provenance in a drawer or inspector; +- provides a complete outline for keyboard and assistive-technology use. + +The graph is read-only. Proposal approval remains in the Memory review flow. + +## Large-repository graph foundation + +### Discovery + +Discovery applies exclusions before supported-file limits. Default exclusions include: + +- `.git` and nested Git worktrees; +- `.worktrees`; +- `node_modules`; +- `.venv`, `venv`, and Python site packages; +- build, coverage, test-result, cache, and generated-output directories; +- agent-skill and assistant-configuration directories such as `.agents` and `.claude`. + +The scanner honors root and descendant `.gitignore` rules. A repository-local `.recallignore` may add product-specific exclusions using gitignore syntax. Explicit CLI includes may override non-security exclusions. + +Only supported source files count toward `maxFiles`. Unsupported extensions remain summarized by bounded counts and a bounded extension list. The scanner does not retain tens of thousands of unsupported locators for a public response. + +### Locator and label safety + +Repository-relative locators and display labels use separate validation rules. + +The locator validator rejects absolute paths, traversal, encoded traversal, and known temporary absolute-path prefixes. The display-label validator accepts ordinary repository directories named `users`, `private`, or similar when the complete value is repository-relative. + +No response exposes an absolute root, home directory, credential, source body, or remote URL. + +### Bounded graph construction + +Graph size limits must produce partial results, not an unavailable graph. + +The builder applies deterministic budgets during construction: + +- preserve file, contains, defined-in, import, export, and call structure first; +- retain high-confidence references next; +- omit low-signal references when the edge budget is reached; +- report represented and omitted node and edge counts by kind; +- mark coverage `partial` with stable reason codes. + +The public preview validates only the bounded public envelope. An internal graph exceeding a public preview limit cannot make the entire preview appear empty. + +### Reuse and invalidation + +Recall Map, Map search, trace, and impact requests share one explicit source-graph snapshot service per server. The service is injected into callers and does not use hidden global state. + +A snapshot is identified by the canonical root, graph-builder version, relevant ignore-rule hash, and a deterministic source fingerprint. The fingerprint covers supported repository-relative locators and content hashes. + +After the first build, the service watches included source directories and relevant ignore files. A relevant create, change, delete, rename, or watcher overflow marks the root dirty. Requests against a clean root reuse the snapshot without rediscovering or rehashing the repository. A dirty root rebuilds and produces a new fingerprint. Explicit refresh always marks the root dirty. + +When recursive watching is unavailable, the service may validate freshness with a bounded metadata manifest before reuse. The interface labels this fallback check as a scan. Map queries against a snapshot already accepted for the current request do not repeat that validation. + +The first release implementation may use an in-process cache. Persistent incremental indexes require a separate storage decision and are not implied by this specification. + +Concurrent requests for the same canonical root and dirty generation share one in-flight build. Cancellation or failure clears the in-flight entry without poisoning the last valid snapshot. + +## UI module boundaries + +The current `apps/web/app.js` is too large to remain the owner of routing, API state, view models, renderers, and graph layout. + +The redesign introduces these focused modules while preserving static ESM and zero runtime dependencies: + +- `apps/web/app.js`: boot, route coordination, and shared shell state. +- `apps/web/api.js`: loopback API requests and bounded error mapping. +- `apps/web/orientation-model.js`: pure Overview view-model construction and action selection. +- `apps/web/orientation-view.js`: Overview rendering and interactions. +- `apps/web/source-map-view.js`: Map rendering, URL state, outline parity, and inspector selection. +- `apps/web/memory-graph-view.js`: governed-memory graph rendering and outline parity. +- `apps/web/graph-layout-worker.js`: bounded layout work for detailed graphs. +- `apps/web/ui-primitives.js`: shared state panels, diagnostics, formatting, and safe escaping. + +Modules communicate through explicit data objects and events. Route modules do not read storage, mutate canonical state, or own authentication. + +The refactor is limited to code touched by Overview, Map, memory graph, and shared primitives. Other routes remain in place until their existing ownership plan runs. + +## Loading, partial, empty, and failure states + +### Loading + +The repository truth bar and layout skeleton render immediately. The work area states which local operation is running. Repeated requests for one snapshot reuse the in-flight build. + +### Partial + +Partial coverage shows represented files, omitted files or relationships, reason codes translated into plain language, and the safest next action. Available results remain usable. + +### Empty + +`No supported source files` and `No governed memory yet` are distinct states. Neither produces a large blank canvas. + +### Failure + +A graph failure states: + +1. what failed; +2. whether the last valid snapshot is still shown; +3. whether local or canonical state changed; +4. the safest retry; +5. the correlation identifier or diagnostic command when available. + +The interface cannot announce `ready` after the graph facade returns an unavailable diagnostic. Form values survive recoverable failures. + +## Performance requirements + +Performance measurements are local implementation gates, not universal hardware claims. + +- An unchanged in-process snapshot must avoid a second full graph build. +- Map queries against an unchanged snapshot must complete without walking the repository again. +- The large local audit repository must return a non-empty success or truthful partial result. It must not return a false zero-result success. +- Cold and cached timings must be recorded by the large-repository smoke script. The cached path must be at least 80% faster than the cold path on the same machine and fixture. +- The browser must not run an unbounded quadratic layout loop on the main thread. +- The first-screen architecture map renders at most 12 groups and the detailed graph renders a bounded focused neighborhood. +- No frontend framework, graph library, model call, network service, or runtime dependency is added. + +## Security and side effects + +- Source scanning and all graph views remain read-only. +- Canonical memory, policy, approvals, and handoffs do not change during orientation or graph exploration. +- No new network access or external writes are enabled. +- Ignore rules cannot broaden filesystem access outside the canonical repository root. +- Retrieved source metadata cannot modify policy, permissions, or permanent memory. +- The UI receives bounded locators and summaries, not raw source bodies. + +## Verification + +### Source graph tests + +Add focused coverage for: + +- nested worktrees, agent directories, virtual environments, and generated output excluded before file accounting; +- `.gitignore` and `.recallignore` behavior; +- more than 1,000 supported files returning truthful partial coverage; +- a legitimate repository-relative `/users/` path remaining valid; +- graph construction exceeding 50,000 candidate edges returning bounded partial output; +- structural edges outranking low-signal references when budgets apply; +- concurrent callers sharing one graph build; +- cache reuse and fingerprint invalidation; +- unavailable diagnostics never becoming a zero-result ready state. + +### UI tests + +Add focused coverage for: + +- the first-ten-seconds fields appearing in the first desktop viewport; +- every useful group for fixtures with fewer than 6 groups, and 6 to 12 groups for larger populated fixtures; +- exactly three `Start here` items when available; +- clean, changed, partial, stale, empty, loading, and failure states; +- query and scope state surviving submit, failure, reload, back, and forward; +- the memory graph omitting its canvas when empty; +- visual and outline selection producing the same inspector model; +- keyboard access, visible focus, reduced motion, and screen-reader status; +- no horizontal overflow at 320, 375, 414, 768, and 1440 px; +- light and dark modes. + +### End-to-end verification + +Run: + +- focused source-graph, Recall Map, Control API, and web-shell tests; +- protocol fixture validation; +- a generated large-repository smoke fixture; +- the current 1,627-file local audit repository as a non-CI validation target; +- consumer smoke and consumer browser smoke; +- full `npm run ci`; +- release-readiness checks; +- npm package smoke from a packed artifact installed into a fresh repository. + +The browser pass captures Overview, Map, populated memory graph, and empty memory graph at desktop and mobile widths. Every accepted screenshot is inspected before handoff. + +## Acceptance criteria + +The work is complete when: + +- a large repository returns useful bounded graph data or an explicit partial/failure state, never a false empty success; +- repeated Overview and Map operations reuse the same unchanged source snapshot; +- ordinary repository-relative directory names do not trigger absolute-path safety failures; +- Overview presents repository truth, every useful group for small repositories or 6 to 12 groups for larger repositories, three starting points when available, current impact, and trusted context within the first desktop viewport; +- Map progressively reveals group, file, and symbol detail without rendering the full graph; +- empty memory does not render an empty graph canvas or zero-value metric strip; +- all visual graphs have equivalent keyboard-reachable outlines; +- submitted queries and scope survive navigation and recoverable failures; +- the touched frontend code is split into the explicit modules above without adding runtime dependencies; +- security, local-only, proposal-review, and external-write boundaries remain unchanged; +- all focused, protocol, browser, full CI, release-readiness, and packed-package checks pass; +- before-and-after large-repository screenshots and cold/cached timing evidence are attached to the implementation handoff. + +## Non-goals + +This specification does not add: + +- a hosted service or cloud graph database; +- embeddings, model reranking, or a chat interface; +- automatic permanent memory; +- write-capable MCP tools; +- non-JavaScript/TypeScript graph coverage; +- a frontend framework or graph visualization dependency; +- persistent incremental graph storage; +- redesigns of the Memory review queue, Handoffs workflow, Settings, or setup beyond shared-shell compatibility; +- npm publication or GitHub merge as part of implementation. + +Publication remains a separate maintainer action after all release gates pass. From 29741c722aff54cfe672a1434f71487850435baa Mon Sep 17 00:00:00 2001 From: rebel Date: Wed, 15 Jul 2026 22:32:30 +0530 Subject: [PATCH 004/276] docs: enforce minimalist anti-slop UI contract --- DESIGN.md | 6 ++- ...ory-recall-orientation-workbench-design.md | 38 +++++++++++++++++-- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 5ef06d9d..33f89299 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -11,7 +11,10 @@ This is the canonical UI contract. Code, prototypes, screenshots, and design-age - Typography: native system sans for interface text; native monospace only for code, paths, hashes, commands, and identifiers. - Shape: 6 px controls and 8 px bounded panels. Lists and dividers take priority over nested cards. - Motion: state transitions only. No ambient or decorative animation. -- Copy: object, state, and action labels only. No product slogan inside the application shell. +- Density: minimal means fewer elements, not oversized gaps. Use the spacing scale to group related work and do not add empty height for visual drama. +- Decoration: no gradients, glass effects, glowing accents, decorative illustrations, heavy shadows, or walls of equal cards. Color and containers must communicate structure or state. +- Copy: object, state, and action labels only. No product slogan, manifesto, inspirational subtitle, or capability hype inside the application shell. +- AI language: use `AI`, `intelligent`, or similar terms only when naming a real model, provider, setting, or technical boundary. Never use them as decoration or a product claim. ## Principles @@ -49,6 +52,7 @@ Executable tokens live in `packages/ui/tokens.json` and `apps/web/tokens.css`. - Spacing: `4, 8, 12, 16, 24, 32, 48, 64`. - Radius: controls 6 px and bounded panels 8 px. - Prefer borders and surface contrast over shadows. +- Avoid nested panels when a divider, row, or disclosure communicates the same hierarchy. ## AI-native components diff --git a/docs/superpowers/specs/2026-07-15-memory-recall-orientation-workbench-design.md b/docs/superpowers/specs/2026-07-15-memory-recall-orientation-workbench-design.md index d8aa73b1..57bfb1f8 100644 --- a/docs/superpowers/specs/2026-07-15-memory-recall-orientation-workbench-design.md +++ b/docs/superpowers/specs/2026-07-15-memory-recall-orientation-workbench-design.md @@ -48,11 +48,36 @@ After a valid cached scan, a developer must be able to identify these facts with The screen may contain counts only when they support one of these decisions. It does not show vanity metrics. -The copy above the work area is one sentence at most: +The work area starts immediately beneath the repository truth bar. It has no hero, tagline, descriptive banner, or product slogan. -> Repository structure, current impact, and trusted context. +## Minimalist interface and copy contract -The product shell contains no slogan. +The implementation follows `DESIGN.md` and the existing warm off-white, graphite, and restrained cobalt tokens. It refines the current product instead of introducing a new visual identity. + +Minimal means fewer elements and clearer hierarchy. It does not mean large empty sections. Within the workbench, related items use 8 to 16 px gaps, primary regions use only the 24 or 32 px spacing tokens, and no section receives a minimum height only to create visual weight. + +The interface uses: + +- native system sans for interface text and native monospace only for code, paths, hashes, commands, and identifiers; +- flat surfaces, thin rules, aligned rows, and compact disclosures; +- 6 px controls and 8 px bounded panels; +- restrained cobalt only for focus, selection, links, and the primary action; +- state-only motion that honors reduced motion. + +The touched screens do not use: + +- marketing heroes, oversized promotional headings, or decorative empty space; +- gradients, glass effects, neon, glow, ambient animation, or decorative background art; +- heavy shadows, floating panels, or a grid of equal statistic cards; +- rounded containers nested inside rounded containers when a divider or row is sufficient; +- decorative badges, icons, or charts that do not change a decision; +- fake chat, typing indicators, sparkle marks, robot imagery, or other intelligence theater. + +Visible copy names an object, state, reason, or action. Headings use concrete nouns such as `Architecture`, `Start here`, `Current impact`, and `Trusted context`. Buttons use direct verbs such as `Open`, `Trace`, `Inspect`, and `Prepare handoff`. + +The words `AI`, `intelligent`, `smart`, `magical`, `autonomous`, `seamless`, `unlock`, `supercharge`, `revolutionary`, and `next-generation` do not appear as promotional UI copy. `AI`, model, and provider terms may appear only when identifying a real configured provider, model-backed operation, setting, or technical boundary. + +Implementation includes a bounded copy review across `apps/web`. Obvious slogan, hype, and intelligence-theater strings are replaced with factual object, state, reason, or action labels. This copy review does not redesign unrelated routes or change their behavior. ## Information architecture @@ -86,7 +111,7 @@ Desktop uses a 70/30 split beneath the truth bar. The primary region contains the architecture map. The secondary region contains three compact sections: `Start here`, `Current impact`, and `Trusted context`. These sections use dividers and aligned rows, not three equal cards. -The layout uses the current graphite and cobalt token system. It reduces vertical padding, avoids unused full-width sections, and keeps all primary information within the first desktop viewport at 1440 px. +The layout uses the current graphite and cobalt token system. It reduces vertical padding, avoids unused full-width sections and nested card grids, and keeps all primary information within the first desktop viewport at 1440 px. ### Architecture map @@ -333,6 +358,9 @@ Add focused coverage for: - the first-ten-seconds fields appearing in the first desktop viewport; - every useful group for fixtures with fewer than 6 groups, and 6 to 12 groups for larger populated fixtures; - exactly three `Start here` items when available; +- no hero, slogan, promotional subtitle, intelligence-theater copy, or decorative metric strip on Overview; +- the approved AI-language allowlist: real provider, model, setting, operation, or technical-boundary labels only; +- no content-free minimum height or nonsemantic gap larger than the 32 px spacing token in the primary workbench; - clean, changed, partial, stale, empty, loading, and failure states; - query and scope state surviving submit, failure, reload, back, and forward; - the memory graph omitting its canvas when empty; @@ -364,6 +392,8 @@ The work is complete when: - repeated Overview and Map operations reuse the same unchanged source snapshot; - ordinary repository-relative directory names do not trigger absolute-path safety failures; - Overview presents repository truth, every useful group for small repositories or 6 to 12 groups for larger repositories, three starting points when available, current impact, and trusted context within the first desktop viewport; +- the touched UI contains no slogan, capability hype, intelligence theater, decorative card wall, gradient, glow, or content-free oversized gap; +- all visible copy in `apps/web` passes the factual object, state, reason, or action review, with model and provider terms retained only where technically necessary; - Map progressively reveals group, file, and symbol detail without rendering the full graph; - empty memory does not render an empty graph canvas or zero-value metric strip; - all visual graphs have equivalent keyboard-reachable outlines; From f0002e7ce8aa781b59f94a7139f513d42d0915c5 Mon Sep 17 00:00:00 2001 From: rebel Date: Wed, 15 Jul 2026 23:09:14 +0530 Subject: [PATCH 005/276] docs: plan Memory Recall orientation workbench --- ...ecall-large-repository-graph-foundation.md | 1060 +++++++++++++++ ...-memory-recall-orientation-workbench-ui.md | 1191 +++++++++++++++++ ...ory-recall-orientation-workbench-design.md | 2 +- 3 files changed, 2252 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-07-15-memory-recall-large-repository-graph-foundation.md create mode 100644 docs/superpowers/plans/2026-07-15-memory-recall-orientation-workbench-ui.md diff --git a/docs/superpowers/plans/2026-07-15-memory-recall-large-repository-graph-foundation.md b/docs/superpowers/plans/2026-07-15-memory-recall-large-repository-graph-foundation.md new file mode 100644 index 00000000..a194aeca --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-memory-recall-large-repository-graph-foundation.md @@ -0,0 +1,1060 @@ +# Memory Recall Large-Repository Graph Foundation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make Recall Map and Map return truthful, bounded, reusable source-graph results for large JavaScript and TypeScript repositories. + +**Architecture:** Keep scanning and graph construction in the native AST provider, but split ignore matching into a focused module and put graph reuse behind an injected snapshot service. Bound nodes and edges before protocol validation, expose coverage and cache truth additively in the v1 schemas, and inject one snapshot service into the local Control API so Overview and Map share the same graph. + +**Tech Stack:** Node.js 22 ESM, dependency-free JavaScript, JSON Schema, Node test runner, loopback Control API. + +## Global Constraints + +- Preserve local-only, read-only behavior; no network calls, model calls, external writes, or raw source bodies. +- Add no runtime dependency, graph database, persistent index, frontend framework, or graph library. +- Support JavaScript and TypeScript only: `.js`, `.jsx`, `.mjs`, `.cjs`, `.ts`, and `.tsx`. +- Keep `maxFiles` at 1,000 supported files; excluded and unsupported files do not consume that budget. +- Keep the public graph within 20,000 nodes and 50,000 edges; exceeding candidates produce partial coverage instead of an unavailable graph. +- Preserve structural edges before low-signal references in this order: `contains`, `defined_in`, `imports`, `exports`, `calls`, `references`. +- Accept ordinary repository-relative directories named `users`, `Users`, or `private`; continue rejecting absolute paths, traversal, URI schemes, and encoded traversal. +- Honor root and descendant `.gitignore` files plus root `.recallignore`; explicit includes may override non-security exclusions only. +- Repeated Overview and Map requests against an unchanged root must reuse one in-process snapshot and one in-flight build. +- Protocol changes are additive within v1 and require RFC, OpenAPI, examples, and compatibility-fixture updates. +- Preserve the last valid snapshot when a refresh fails; surface it as stale rather than reporting a false empty success. + +--- + +## File Map + +### Create + +- `providers/native/context-candidate-ast-code/src/ignore-rules.mjs`: parse and match dependency-free gitignore-style rules. +- `packages/source-graph/src/orientation.mjs`: build deterministic repository groups, inter-group relations, and bounded focus neighborhoods. +- `packages/source-graph/src/snapshot-service.mjs`: own cached graph snapshots, dirty generations, watchers, in-flight deduplication, and stale fallback. +- `tests/source-graph-discovery.test.mjs`: focused discovery, ignore, and supported-file budget tests. +- `tests/source-graph-snapshot-service.test.mjs`: focused cache, invalidation, concurrency, and stale fallback tests. +- `scripts/source-graph-large-repo-smoke.mjs`: generated and optional real-repository cold/cached smoke measurement. + +### Modify + +- `providers/native/context-candidate-ast-code/src/index.mjs`: apply discovery policy before accounting and enforce graph budgets. +- `packages/protocol/src/source-graph-locator.mjs`: separate relative locator safety from display-label safety. +- `packages/protocol/schemas/source-graph.schema.json`: add bounded omission and coverage fields. +- `packages/protocol/schemas/source-graph-preview.schema.json`: add snapshot state and bounded omission fields. +- `packages/protocol/schemas/recall-map.schema.json`: expose cache state and truthful coverage on Overview. +- `packages/source-graph/src/index.mjs`: query an injected snapshot and return fresh, cached, in-flight, stale, or unavailable state. +- `packages/recall-map/src/index.mjs`: consume the injected source-graph snapshot service. +- `services/control-api/src/server.mjs`: construct and close one snapshot service per server. +- `services/control-api/src/route-contracts.mjs`: keep route response validation aligned with additive schemas. +- `tests/source-graph-preview.test.mjs`: cover safe labels, bounded graphs, and stale diagnostics. +- `tests/recall-map.test.mjs`: cover cache and coverage projection. +- `tests/control-api-boundary.test.mjs`: prove Overview and Map share one graph build through the injectable server harness. +- `examples/protocol/source-graph.json`: include partial graph coverage fields. +- `examples/protocol/source-graph-preview.json`: include snapshot state. +- `examples/protocol/recall-map.json`: include source snapshot state. +- `examples/protocol/compatibility/invalid/source-graph-local-path.json`: retain an actually absolute invalid case. +- `examples/protocol/compatibility/invalid/source-graph-preview-local-path.json`: retain an actually absolute invalid case. +- `docs/api/openapi.yaml`: document additive snapshot and coverage fields. +- `docs/usage/recall-map.md`: document ignore rules, partial coverage, cache semantics, and refresh. +- `rfcs/0001-protocol-contracts.md`: record the additive v1 source-graph fields. +- `package.json`: add the large-repository smoke command. + +--- + +### Task 1: Fix locator and display-label safety without weakening path containment + +**Files:** +- Modify: `packages/protocol/src/source-graph-locator.mjs` +- Modify: `packages/protocol/schemas/source-graph.schema.json` +- Modify: `packages/protocol/schemas/source-graph-preview.schema.json` +- Modify: `examples/protocol/compatibility/invalid/source-graph-local-path.json` +- Modify: `examples/protocol/compatibility/invalid/source-graph-preview-local-path.json` +- Test: `tests/source-graph-preview.test.mjs` + +**Interfaces:** +- Consumes: `normalizeSourceGraphWorkspaceLocator(value, { stripFragment })`. +- Produces: unchanged locator-normalization signature plus `isSafeSourceGraphDisplayLabel(value): boolean`. + +- [ ] **Step 1: Write the failing relative-directory regression tests** + +Add these cases to `tests/source-graph-preview.test.mjs`: + +```js +// Extend the existing node:fs/promises import with `rm` and import +// normalizeSourceGraphWorkspaceLocator from packages/protocol/src/source-graph-locator.mjs. +test('source graph accepts ordinary relative users and private directories', async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), 'recall-relative-users-')); + t.after(() => rm(root, { recursive: true, force: true })); + await mkdir(path.join(root, 'apps', 'api', 'users', '[userRef]'), { recursive: true }); + await mkdir(path.join(root, 'src', 'private'), { recursive: true }); + await writeFile(path.join(root, 'apps', 'api', 'users', '[userRef]', 'route.ts'), 'export function GET(){ return "ok"; }\n'); + await writeFile(path.join(root, 'src', 'private', 'state.ts'), 'export const state = "local";\n'); + + const preview = await buildSourceGraphPreview({ root, workspaceId: 'ws_local' }); + + assert.equal(preview.snapshot.status, 'fresh'); + assert.equal(preview.graph.summary.fileCount, 2); + assert.equal(preview.graph.diagnostics.some(({ code }) => code.startsWith('source_graph_unavailable')), false); + assert(preview.graph.sampleNodes.some(({ locator }) => locator?.includes('/users/'))); + assert(preview.graph.sampleNodes.some(({ locator }) => locator?.includes('/private/'))); +}); + +test('source graph still rejects absolute and encoded traversal locators', () => { + for (const locator of [ + '/Users/rebel/project/src/app.js', + 'C:\\Users\\rebel\\project\\src\\app.js', + 'workspace:///Users/rebel/project/src/app.js', + 'workspace://src/%252e%252e/secret.js', + 'https://example.com/source.js' + ]) { + assert.throws( + () => normalizeSourceGraphWorkspaceLocator(locator), + /source_graph_workspace_locator_invalid/ + ); + } +}); +``` + +- [ ] **Step 2: Run the focused test and verify the current false rejection** + +Run: + +```bash +node --test --test-name-pattern="ordinary relative users|absolute and encoded" tests/source-graph-preview.test.mjs +``` + +Expected: the relative `users` or `private` test fails with an unavailable graph or invalid locator while absolute and traversal cases remain rejected. + +- [ ] **Step 3: Separate locator and label rules** + +In `packages/protocol/src/source-graph-locator.mjs`, remove the blanket `Users`, `private`, and `var/folders` segment exclusions from `SOURCE_GRAPH_WORKSPACE_LOCATOR_PATTERN`. Keep the leading slash, drive, URI-scheme, traversal, percent-encoding, length, and fragment constraints. Replace the absolute-directory exclusions in `SOURCE_GRAPH_SAFE_LABEL_PATTERN` with structural checks only, then export a named predicate: + +```js +export function isSafeSourceGraphDisplayLabel(value) { + return typeof value === 'string' && SOURCE_GRAPH_SAFE_LABEL_RE.test(value); +} +``` + +The locator prefix must remain: + +```js +String.raw`^workspace://(?!/)(?![^/]*%)(?![A-Za-z][A-Za-z0-9+.-]*:)(?!\.\.(?:/|$))(?!.*\/\.\.(?:/|$))` +``` + +Update the identical schema patterns in both source-graph schemas. Change the two invalid compatibility examples so they use `workspace:///Users/rebel/project/src/app.js`; do not keep a repository-relative `workspace://Users/...` example marked invalid. + +- [ ] **Step 4: Run focused protocol and source-graph verification** + +Run: + +```bash +node --test --test-name-pattern="ordinary relative users|absolute and encoded|locator" tests/source-graph-preview.test.mjs +npm run protocol:validate +``` + +Expected: focused tests pass and all protocol fixtures validate. + +- [ ] **Step 5: Commit the safety correction** + +```bash +git add packages/protocol/src/source-graph-locator.mjs packages/protocol/schemas/source-graph.schema.json packages/protocol/schemas/source-graph-preview.schema.json examples/protocol/compatibility/invalid/source-graph-local-path.json examples/protocol/compatibility/invalid/source-graph-preview-local-path.json tests/source-graph-preview.test.mjs +git commit -m "fix: accept safe repository-relative graph paths" +``` + +### Task 2: Apply ignore rules before supported-file accounting + +**Files:** +- Create: `providers/native/context-candidate-ast-code/src/ignore-rules.mjs` +- Create: `tests/source-graph-discovery.test.mjs` +- Modify: `providers/native/context-candidate-ast-code/src/index.mjs` + +**Interfaces:** +- Produces: `parseIgnoreFile(text, { base = '' }): IgnoreRule[]`. +- Produces: `isIgnoredPath(relativePath, { isDirectory, rules, explicitIncludes }): boolean`. +- Produces: `loadRootRecallIgnore(root): Promise`. +- `scanAstCodeWorkspace()` adds optional `explicitIncludes = []` and returns bounded ignore coverage. + +- [ ] **Step 1: Write failing ignore and accounting tests** + +Create `tests/source-graph-discovery.test.mjs` with fixtures that prove exclusions happen before `maxFiles`: + +```js +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { scanAstCodeWorkspace } from '../providers/native/context-candidate-ast-code/src/index.mjs'; + +test('discovery honors nested gitignore and recallignore before maxFiles', async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), 'recall-discovery-')); + t.after(() => rm(root, { recursive: true, force: true })); + await mkdir(path.join(root, 'src', 'generated'), { recursive: true }); + await mkdir(path.join(root, '.worktrees', 'old', 'src'), { recursive: true }); + await mkdir(path.join(root, '.venv', 'lib'), { recursive: true }); + await writeFile(path.join(root, '.gitignore'), 'src/generated/\n'); + await writeFile(path.join(root, '.recallignore'), 'src/ignored.ts\n'); + await writeFile(path.join(root, 'src', '.gitignore'), 'nested.ts\n'); + await writeFile(path.join(root, 'src', 'entry.ts'), 'export function entry(){ return 1; }\n'); + await writeFile(path.join(root, 'src', 'nested.ts'), 'export const nested = 1;\n'); + await writeFile(path.join(root, 'src', 'ignored.ts'), 'export const ignored = 1;\n'); + await writeFile(path.join(root, 'src', 'generated', 'output.ts'), 'export const generated = 1;\n'); + await writeFile(path.join(root, '.worktrees', 'old', 'src', 'copy.ts'), 'export const copy = 1;\n'); + await writeFile(path.join(root, '.venv', 'lib', 'tool.js'), 'export const tool = 1;\n'); + + const scan = await scanAstCodeWorkspace({ root, maxFiles: 1 }); + + assert.equal(scan.fileCount, 1); + assert.deepEqual(scan.coverage.representedJsTsLocators, ['workspace://src/entry.ts']); + assert.equal(scan.coverage.maxFilesReached, false); + assert(scan.coverage.ignoredFileCount >= 3); + assert(scan.coverage.excludedDirectoryCount >= 3); +}); + +test('explicit include overrides non-security ignore but not root containment', async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), 'recall-include-')); + t.after(() => rm(root, { recursive: true, force: true })); + await mkdir(path.join(root, 'src'), { recursive: true }); + await writeFile(path.join(root, '.recallignore'), 'src/kept.ts\n'); + await writeFile(path.join(root, 'src', 'kept.ts'), 'export const kept = true;\n'); + const scan = await scanAstCodeWorkspace({ root, explicitIncludes: ['src/kept.ts'] }); + assert.deepEqual(scan.coverage.representedJsTsLocators, ['workspace://src/kept.ts']); + await assert.rejects( + scanAstCodeWorkspace({ root, explicitIncludes: ['../outside.ts'] }), + /source_graph_explicit_include_invalid/ + ); +}); +``` + +- [ ] **Step 2: Run the tests and verify missing ignore behavior** + +Run: + +```bash +node --test tests/source-graph-discovery.test.mjs +``` + +Expected: FAIL because `.worktrees`, `.venv`, nested `.gitignore`, `.recallignore`, and `explicitIncludes` are not implemented. + +- [ ] **Step 3: Implement dependency-free ignore parsing** + +Create `ignore-rules.mjs`. Parse blank lines, comments, escaped `#`/`!`, negation, root anchoring, directory-only suffixes, `*`, `?`, and `**`. Store the ignore-file base directory so descendant `.gitignore` rules are relative to their own directory: + +```js +export function parseIgnoreFile(text, { base = '' } = {}) { + return String(text ?? '').split(/\r?\n/u).flatMap((raw, index) => { + const line = raw.trim(); + if (!line || line.startsWith('#')) return []; + const negated = line.startsWith('!'); + const pattern = (negated ? line.slice(1) : line).replace(/\\([#!])/gu, '$1'); + if (!pattern) return []; + return [{ base, pattern, negated, directoryOnly: pattern.endsWith('/'), index }]; + }); +} + +export function isIgnoredPath(relativePath, { isDirectory = false, rules = [], explicitIncludes = [] } = {}) { + const normalized = normalizeRelativePath(relativePath); + if (explicitIncludes.some((item) => item === normalized || item.startsWith(`${normalized}/`))) return false; + let ignored = false; + for (const rule of rules) { + if (rule.directoryOnly && !isDirectory) continue; + if (matchesIgnoreRule(normalized, rule)) ignored = !rule.negated; + } + return ignored; +} + +export async function loadRootRecallIgnore(root) { + try { + return parseIgnoreFile(await readFile(path.join(root, '.recallignore'), 'utf8'), { base: '' }); + } catch (error) { + if (error?.code === 'ENOENT') return []; + throw error; + } +} + +function normalizeRelativePath(value) { + const raw = String(value ?? ''); + const normalized = raw.replace(/^\.\//u, ''); + if (!normalized || raw.includes('\\') || normalized.startsWith('/') || /(?:^|\/)\.\.(?:\/|$)/u.test(normalized) || /^[A-Za-z][A-Za-z0-9+.-]*:/u.test(normalized)) { + throw new Error('source_graph_explicit_include_invalid'); + } + return normalized; +} + +function matchesIgnoreRule(relativePath, rule) { + const raw = rule.pattern.replace(/\/$/u, ''); + const anchored = raw.startsWith('/'); + const pattern = anchored ? raw.slice(1) : raw; + const base = String(rule.base ?? '').replace(/\/$/u, ''); + const scoped = base ? `${base}/${pattern}` : pattern; + const glob = anchored || pattern.includes('/') + ? scoped + : base ? `${base}/**/${pattern}` : `**/${pattern}`; + return path.matchesGlob(relativePath, glob) + || (!pattern.includes('/') && path.basename(relativePath) === pattern) + || (rule.directoryOnly && (relativePath === scoped || relativePath.startsWith(`${scoped}/`))); +} +``` + +Import `readFile` from `node:fs/promises` and `path` from `node:path`. Use `path.matchesGlob()` only after normalization. Validate every explicit include with `normalizeRelativePath()` before scanning; security exclusions and root containment checks still run after a non-security ignore override. + +- [ ] **Step 4: Integrate discovery policy into the scanner** + +In `scanAstCodeWorkspace()`: + +1. Expand default exclusions to `.worktrees`, `.venv`, `venv`, `site-packages`, `.cache`, `.pytest_cache`, `.turbo`, `.parcel-cache`, `.agents`, `.claude`, `test-results`, and generated build directories. +2. Load root `.recallignore` once. +3. When entering a directory, load its `.gitignore` and append rules for descendants. +4. Evaluate default exclusions and ignore rules before `lstat()` recursion and before unsupported-file counting. +5. Increment `visitedFiles` only for supported files that are actually read. +6. Delete `discoverExcludedDirectoriesAfterCap()`; the scan must not traverse excluded trees after reaching the supported-file cap. +7. Add bounded `ignoredFileCount`, `ignoredDirectoryCount`, `ignoredSamples` (maximum 100), and `unsupportedExtensionCounts` (maximum 32 entries) to coverage. +8. Keep an internal `index.discoveryIdentity` with the normalized relevant ignore-file locators (maximum 100) and a SHA-256 fingerprint of their normalized rules plus explicit includes. Do not project these new fields into the public graph until Task 3 extends the schema. + +Keep coverage samples bounded with the existing `addBoundedLocator()` helper. + +- [ ] **Step 5: Verify discovery and existing AST behavior** + +Run: + +```bash +node --test tests/source-graph-discovery.test.mjs tests/ast-code-candidate-source.test.mjs tests/source-graph-preview.test.mjs +``` + +Expected: all focused tests pass and supported AST/source-graph behavior remains unchanged. + +- [ ] **Step 6: Commit discovery policy** + +```bash +git add providers/native/context-candidate-ast-code/src/ignore-rules.mjs providers/native/context-candidate-ast-code/src/index.mjs tests/source-graph-discovery.test.mjs +git commit -m "feat: bound source discovery before file accounting" +``` + +### Task 3: Bound graph construction and report omitted candidates + +**Files:** +- Modify: `providers/native/context-candidate-ast-code/src/index.mjs` +- Modify: `packages/protocol/schemas/source-graph.schema.json` +- Modify: `packages/protocol/schemas/source-graph-preview.schema.json` +- Modify: `examples/protocol/source-graph.json` +- Modify: `examples/protocol/source-graph-preview.json` +- Modify: `rfcs/0001-protocol-contracts.md` +- Modify: `docs/api/openapi.yaml` +- Test: `tests/source-graph-preview.test.mjs` + +**Interfaces:** +- `buildSourceGraphFromIndex(index, options)` adds `maxNodes = 20_000` and `maxEdges = 50_000`. +- `graph.summary.coverage` adds represented, candidate, and omitted node/edge counts by kind. + +- [ ] **Step 1: Write a graph-budget regression test** + +Add this generated dense-source test to `tests/source-graph-preview.test.mjs`. Extend the existing provider import with `buildJsTsSourceIndex` and `buildSourceGraphFromIndex`: + +```js +test('graph budget preserves structural and call edges before references', async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), 'recall-dense-graph-')); + t.after(() => rm(root, { recursive: true, force: true })); + await mkdir(path.join(root, 'src'), { recursive: true }); + const targets = Array.from({ length: 30 }, (_, index) => `export function target${index}(){ return ${index}; }`); + const calls = Array.from({ length: 30 }, (_, caller) => ( + `export function caller${caller}(){ return ${Array.from({ length: 30 }, (_, target) => `target${target}()`).join(' + ')}; }` + )); + await writeFile(path.join(root, 'src', 'dense.js'), `${targets.join('\n')}\n${calls.join('\n')}\n`); + + const index = await buildJsTsSourceIndex({ root, workspaceId: 'ws_local' }); + const graph = buildSourceGraphFromIndex(index, { + builtAt: '2026-07-15T10:00:00.000Z', + maxNodes: 200, + maxEdges: 300 + }); + + assert(graph.nodes.length <= 200); + assert(graph.edges.length <= 300); + assert.equal(graph.summary.coverage.status, 'partial'); + assert(graph.summary.coverage.omittedEdgeCount > 0); + assert(graph.summary.coverage.omittedEdgeKindCounts.references > 0); + assert(graph.summary.edgeKindCounts.contains > 0); + assert(graph.summary.edgeKindCounts.defined_in > 0); + assert(graph.summary.edgeKindCounts.calls > 0); + assert.equal(validateJsonSchema(sourceGraphSchema, graph).valid, true); +}); +``` + +- [ ] **Step 2: Run the budget test and verify schema overflow** + +Run: + +```bash +node --test --test-name-pattern="graph budget" tests/source-graph-preview.test.mjs +``` + +Expected: FAIL because `buildSourceGraphFromIndex` has no budgets and the schema has no omission fields. + +- [ ] **Step 3: Implement deterministic node and edge budgets** + +Add constants and normalize options: + +```js +export const DEFAULT_SOURCE_GRAPH_MAX_NODES = 20_000; +export const DEFAULT_SOURCE_GRAPH_MAX_EDGES = 50_000; +const EDGE_BUDGET_ORDER = Object.freeze(['contains', 'defined_in', 'imports', 'exports', 'calls', 'references']); +``` + +Build nodes in deterministic file, chunk, symbol, and module order. When the node budget is exhausted, count omitted nodes by kind and do not create edges to omitted nodes. Stage edge candidates by kind, sort each kind by `id`, then take them in `EDGE_BUDGET_ORDER` until `maxEdges`. Produce: + +```js +coverage: { + ...index.coverage, + ignoreRuleFingerprint: index.discoveryIdentity.ignoreRuleFingerprint, + ignoreFileLocators: index.discoveryIdentity.ignoreFileLocators, + status: omittedNodeCount || omittedEdgeCount || index.coverage.status === 'partial' ? 'partial' : 'complete', + candidateNodeCount, + representedNodeCount: nodes.length, + omittedNodeCount, + omittedNodeKindCounts, + candidateEdgeCount, + representedEdgeCount: edges.length, + omittedEdgeCount, + omittedEdgeKindCounts, + reasonCodes: uniqueSortedStrings([ + ...index.coverage.reasonCodes, + ...(omittedNodeCount ? ['node_budget_reached'] : []), + ...(omittedEdgeCount ? ['edge_budget_reached'] : []) + ]) +} +``` + +Do not truncate after graph construction. The returned graph itself must satisfy the public schema. + +- [ ] **Step 4: Extend additive schemas, examples, OpenAPI, and RFC** + +Add optional bounded integer/count-map properties to the shared coverage definitions in both graph schemas. Also allow `ignoreRuleFingerprint` as a `sha256:` string and up to 100 safe workspace-relative `ignoreFileLocators`; these fields let the later snapshot service identify the discovery policy without exposing file bodies or absolute roots. Keep existing required fields and schema versions unchanged. Update `examples/protocol/source-graph.json` and `source-graph-preview.json` with a partial example. Add one paragraph to RFC 0001 explaining that additive v1 coverage fields report omitted candidates and never expand authority. Mirror the fields in `docs/api/openapi.yaml`. + +- [ ] **Step 5: Run graph and protocol verification** + +Run: + +```bash +node --test tests/source-graph-preview.test.mjs tests/ast-code-candidate-source.test.mjs +npm run protocol:validate +``` + +Expected: graph-budget tests, existing graph tests, and compatibility fixtures pass. + +- [ ] **Step 6: Commit bounded construction** + +```bash +git add providers/native/context-candidate-ast-code/src/index.mjs packages/protocol/schemas/source-graph.schema.json packages/protocol/schemas/source-graph-preview.schema.json examples/protocol/source-graph.json examples/protocol/source-graph-preview.json docs/api/openapi.yaml rfcs/0001-protocol-contracts.md tests/source-graph-preview.test.mjs +git commit -m "feat: return bounded partial source graphs" +``` + +### Task 4: Add deterministic orientation and focused-neighborhood projections + +**Files:** +- Create: `packages/source-graph/src/orientation.mjs` +- Modify: `packages/source-graph/src/index.mjs` +- Modify: `packages/recall-map/src/index.mjs` +- Modify: `packages/protocol/schemas/source-graph-preview.schema.json` +- Modify: `packages/protocol/schemas/recall-map.schema.json` +- Modify: `examples/protocol/source-graph-preview.json` +- Modify: `examples/protocol/recall-map.json` +- Test: `tests/source-graph-preview.test.mjs` +- Test: `tests/recall-map.test.mjs` + +**Interfaces:** +- Produces: `buildSourceGraphOrientation(graph, options): { groups, relations }`. +- Produces: `buildSourceGraphFocus(graph, options): { nodes, edges, omittedNodes, omittedEdges }`. +- `buildSourceGraphPreview()` adds top-level `orientation` and `focus` projections. +- `buildRecallMap()` projects `orientation.groups` and `orientation.relations` under `architecture`. + +- [ ] **Step 1: Write failing deterministic orientation tests** + +Extend the provider import with `buildJsTsSourceGraph` and `rankArchitectureNodes`, and import both new projection functions from `orientation.mjs`. Add this test and helper: + +```js +test('source graph orientation is deterministic and bounded', async (t) => { + const root = await writeOrientationRepository(t); + const graph = await buildJsTsSourceGraph({ root, workspaceId: 'ws_local' }); + const ranking = rankArchitectureNodes(graph, { changedLocators: ['workspace://apps/web/app.js'], limit: 12 }); + const options = { + changedLocators: ['workspace://apps/web/app.js'], + entryPoints: ranking.entryPoints, + maxGroups: 12, + maxRelations: 20 + }; + const orientation = buildSourceGraphOrientation(graph, options); + assert.deepEqual(orientation.groups.map(({ prefix }) => prefix), [ + 'apps/web', + 'packages/source-graph', + 'providers/native/context-candidate-ast-code', + 'scripts', + 'services/control-api', + 'tests' + ]); + assert.equal(orientation.groups.find(({ prefix }) => prefix === 'apps/web').changedFileCount, 1); + assert.equal(orientation.groups.every(({ entryPoints }) => entryPoints.length <= 2), true); + assert.equal(orientation.relations.length <= 20, true); + assert.deepEqual(orientation, buildSourceGraphOrientation(structuredClone(graph), options)); +}); + +async function writeOrientationRepository(t) { + const root = await mkdtemp(path.join(os.tmpdir(), 'recall-orientation-')); + t.after(() => rm(root, { recursive: true, force: true })); + const files = new Map([ + ['apps/web/app.js', 'import { build } from "../../packages/source-graph/index.js"; export function renderOverview(){ return build(); }\n'], + ['packages/source-graph/index.js', 'import { serve } from "../../services/control-api/server.js"; export function build(){ return serve(); }\n'], + ['services/control-api/server.js', 'import { parse } from "../../providers/native/context-candidate-ast-code/index.js"; export function serve(){ return parse(); }\n'], + ['providers/native/context-candidate-ast-code/index.js', 'export function parse(){ return "ready"; }\n'], + ['scripts/smoke.js', 'import { renderOverview } from "../apps/web/app.js"; export const smoke = renderOverview();\n'], + ['tests/web.test.js', 'import { renderOverview } from "../apps/web/app.js"; export const expected = typeof renderOverview;\n'] + ]); + for (const [relative, body] of files) { + await mkdir(path.dirname(path.join(root, relative)), { recursive: true }); + await writeFile(path.join(root, relative), body); + } + return root; +} +``` + +- [ ] **Step 2: Write failing focused-neighborhood tests** + +For a query result inside `apps/web`, reuse `writeOrientationRepository()` and assert the focus projection contains seed nodes and bounded adjacent edges but not the full graph: + +```js +test('source graph focus returns a bounded neighborhood', async (t) => { + const root = await writeOrientationRepository(t); + const graph = await buildJsTsSourceGraph({ root, workspaceId: 'ws_local' }); + const seed = graph.nodes.find(({ label }) => label === 'renderOverview'); + assert(seed); + const focus = buildSourceGraphFocus(graph, { + seedNodeIds: [seed.id], + locatorPrefix: 'workspace://apps/web', + nodeLimit: 50, + edgeLimit: 100 + }); + assert(focus.nodes.some(({ id }) => id === seed.id)); + assert(focus.nodes.every(({ locator }) => !locator || locator.startsWith('workspace://apps/web'))); + assert(focus.nodes.length <= 50); + assert(focus.edges.length <= 100); + assert(focus.omittedNodes > 0 || focus.nodes.length < graph.nodes.length); +}); +``` + +- [ ] **Step 3: Run the projection tests and verify missing exports** + +Run: + +```bash +node --test --test-name-pattern="deterministic orientation|focused neighborhood" tests/source-graph-preview.test.mjs +``` + +Expected: FAIL because `orientation.mjs`, `buildSourceGraphOrientation`, and `buildSourceGraphFocus` do not exist. + +- [ ] **Step 4: Implement deterministic group derivation** + +Create `orientation.mjs`. Convert node locators to repository-relative paths. Use two segments for known workspace roots (`apps`, `packages`, `services`, `plugins`, `examples`, `tests`, and `scripts`), three for `providers//` when available, and one segment otherwise. Never invent a group. + +```js +function groupPrefix(locator) { + const parts = relativeLocator(locator).split('/').filter(Boolean); + if (!parts.length) return null; + if (parts[0] === 'providers') return parts.slice(0, Math.min(3, parts.length - 1 || 1)).join('/'); + if (new Set(['apps', 'packages', 'services', 'plugins', 'examples', 'tests', 'scripts']).has(parts[0])) { + return parts.slice(0, Math.min(2, parts.length - 1 || 1)).join('/'); + } + return parts[0]; +} + +function relativeLocator(locator) { + const value = String(locator ?? '').split('#', 1)[0]; + return value.startsWith('workspace://') ? value.slice('workspace://'.length) : ''; +} +``` + +Count unique files and symbols per group. Assign up to two ranked entry points by locator prefix. Aggregate only inter-group `imports` and `calls` relations, sort by descending count then source/target prefix, and cap at 20. Create stable IDs with the existing hash helper; do not expose package-manager metadata or absolute paths. + +- [ ] **Step 5: Implement a bounded one-hop focus projection** + +Start from query/search/trace/impact seed IDs. If `locatorPrefix` is present, include matching file and symbol nodes first. Add one-hop edges in priority order `calls`, `imports`, `exports`, `defined_in`, `contains`, `references`. Add the opposite endpoint only while under the node limit. Sort returned nodes/edges by ID and report omitted counts from the eligible neighborhood. + +```js +return Object.freeze({ + nodeLimit, + edgeLimit, + nodes: Object.freeze(selectedNodes), + edges: Object.freeze(selectedEdges), + omittedNodes: Math.max(0, eligibleNodeIds.size - selectedNodes.length), + omittedEdges: Math.max(0, eligibleEdges.length - selectedEdges.length) +}); +``` + +- [ ] **Step 6: Wire projections into preview and Recall Map** + +Build `orientation` from the validated public graph. Build focus seeds from search result node IDs, trace path node IDs, and impact node IDs. For an unscoped empty query, return an empty focus so Overview uses groups rather than a raw file graph. Project the same group/relations objects into Recall Map: + +```js +architecture: { + ...existingArchitecture, + groups: preview.orientation.groups, + groupRelations: preview.orientation.relations +} +``` + +Add optional strict bounded definitions to both schemas: 12 groups, 20 relations, 200 focus nodes, and 400 focus edges. The producer always emits them, while the optional schema fields preserve additive v1 reader compatibility. Update both examples. + +- [ ] **Step 7: Verify projection and protocol behavior** + +Run: + +```bash +node --test tests/source-graph-preview.test.mjs tests/recall-map.test.mjs +npm run protocol:validate +``` + +Expected: orientation, focus, Recall Map, and protocol tests pass. + +- [ ] **Step 8: Commit orientation data** + +```bash +git add packages/source-graph/src/orientation.mjs packages/source-graph/src/index.mjs packages/recall-map/src/index.mjs packages/protocol/schemas/source-graph-preview.schema.json packages/protocol/schemas/recall-map.schema.json examples/protocol/source-graph-preview.json examples/protocol/recall-map.json tests/source-graph-preview.test.mjs tests/recall-map.test.mjs +git commit -m "feat: project bounded repository orientation data" +``` + +### Task 5: Add the injected in-process snapshot service + +**Files:** +- Create: `packages/source-graph/src/snapshot-service.mjs` +- Create: `tests/source-graph-snapshot-service.test.mjs` +- Modify: `packages/source-graph/src/index.mjs` +- Modify: `packages/protocol/schemas/source-graph-preview.schema.json` +- Modify: `examples/protocol/source-graph-preview.json` + +**Interfaces:** +- Produces: `createSourceGraphSnapshotService(options): SourceGraphSnapshotService`. +- Produces methods `getSnapshot(request)`, `markDirty(root, reason)`, `inspect(root)`, and `close()`. +- `buildSourceGraphPreview(options)` adds optional `snapshotService` and `refresh = false`. +- Snapshot identity is SHA-256 over canonical root, graph/parser version, ignore-rule fingerprint, and source-index fingerprint. + +- [ ] **Step 1: Write failing cache, concurrency, invalidation, and stale tests** + +Create `tests/source-graph-snapshot-service.test.mjs` using an injected `buildGraph` counter and injected watcher: + +```js +test('snapshot service reuses, deduplicates, invalidates, and preserves last valid graph', async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), 'recall-snapshot-service-')); + t.after(() => rm(root, { recursive: true, force: true })); + let builds = 0; + let fail = false; + let onChange = null; + const graph = fixtureGraph(); + const service = createSourceGraphSnapshotService({ + buildGraph: async () => { + builds += 1; + await Promise.resolve(); + if (fail) throw new Error('fixture_build_failed'); + return graph; + }, + watchRoot: (_root, listener) => { + onChange = listener; + return { close() {} }; + }, + clock: () => 1000 + }); + t.after(() => service.close()); + + const request = { root, workspaceId: 'ws_local', maxFiles: 1000, maxFileBytes: 524288 }; + const [first, concurrent] = await Promise.all([service.getSnapshot(request), service.getSnapshot(request)]); + assert.equal(builds, 1); + assert.equal(first.reuse, 'cold'); + assert.equal(concurrent.reuse, 'inflight'); + + const cached = await service.getSnapshot(request); + assert.equal(cached.reuse, 'cache'); + assert.equal(builds, 1); + assert.match(service.inspect(root).identity, /^sha256:[a-f0-9]{64}$/u); + + onChange('change', 'src/app.js'); + fail = true; + const stale = await service.getSnapshot(request); + assert.equal(stale.status, 'stale'); + assert.equal(stale.graph.graphFingerprint, graph.graphFingerprint); + assert.equal(builds, 2); +}); + +test('snapshot service falls back to a bounded metadata scan when recursive watch is unavailable', async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), 'recall-snapshot-fallback-')); + t.after(() => rm(root, { recursive: true, force: true })); + let builds = 0; + let manifest = 'manifest:a'; + const unavailable = Object.assign(new Error('recursive watch unavailable'), { code: 'ERR_FEATURE_UNAVAILABLE' }); + const service = createSourceGraphSnapshotService({ + buildGraph: async () => { + builds += 1; + return fixtureGraph(); + }, + watchRoot: () => { throw unavailable; }, + freshnessProbe: async () => manifest, + clock: () => 1000 + }); + t.after(() => service.close()); + const request = { root, workspaceId: 'ws_local', maxFiles: 1000, maxFileBytes: 524288 }; + + await service.getSnapshot(request); + const cached = await service.getSnapshot(request); + assert.equal(cached.reuse, 'cache'); + assert.equal(cached.validationMode, 'metadata-scan'); + assert.equal(builds, 1); + + manifest = 'manifest:b'; + const rebuilt = await service.getSnapshot(request); + assert.equal(rebuilt.reuse, 'cold'); + assert.equal(builds, 2); +}); + +function fixtureGraph() { + return Object.freeze({ + schemaVersion: '1.0.0', + workspaceId: 'ws_local', + graphVersion: 'memory-recall-test-1.0.0', + parserVersion: 'memory-recall-test-parser', + builtAt: '2026-07-15T10:00:00.000Z', + sourceIndexFingerprint: `sha256:${'a'.repeat(64)}`, + graphFingerprint: `sha256:${'b'.repeat(64)}`, + summary: { + fileCount: 1, + symbolCount: 0, + moduleCount: 0, + nodeCount: 0, + edgeCount: 0, + nodeKindCounts: {}, + edgeKindCounts: {}, + hotspots: [], + entryPoints: [], + coverage: { + ignoreRuleFingerprint: `sha256:${'c'.repeat(64)}`, + ignoreFileLocators: [] + } + }, + nodes: [], + edges: [], + diagnostics: [] + }); +} +``` + +Import `mkdtemp` and `rm` from `node:fs/promises`, plus `os` and `path`; the request root must be canonicalized by the service. + +- [ ] **Step 2: Run the service test and verify the missing module** + +Run: + +```bash +node --test tests/source-graph-snapshot-service.test.mjs +``` + +Expected: FAIL with module-not-found for `snapshot-service.mjs`. + +- [ ] **Step 3: Implement the snapshot service** + +Create `snapshot-service.mjs` with one entry per canonical root plus graph options. Use `node:fs.watch` recursively by default. Relevant supported-source, `.gitignore`, and `.recallignore` events increment `dirtyGeneration`; ignored/cache/build output events do not. The service result shape is: + +```js +Object.freeze({ + graph, + status: 'fresh', // or 'stale' + reuse: 'cold', // or 'cache' or 'inflight' + reason: null, + generation, + buildDurationMs, + builtAt: graph.builtAt +}) +``` + +Use a `Map` of entries with `{ graph, identity, manifest, dirty, generation, inFlight, watcher, validationMode, lastBuildDurationMs }`. Compute identity with `node:crypto` SHA-256 over canonical root, `graphVersion`, `parserVersion`, `graph.summary.coverage.ignoreRuleFingerprint`, and `sourceIndexFingerprint`. Set `inFlight` before awaiting the build. Clear it in `finally`. Replace `graph`, identity, and manifest only after a successful build. On failure with a last valid graph, return `status: 'stale'`, `reason: safe error code`, and the old graph. On first-build failure, rethrow. `close()` closes every watcher and clears the map. + +The default watcher uses `fs.watch(root, { recursive: true })`. If recursive watching reports `ERR_FEATURE_UNAVAILABLE` or `ERR_INVALID_ARG_VALUE`, set `validationMode` to `metadata-scan`. The default bounded freshness probe stats the canonical root, safe file-node locators, every directory prefix derived from those locators, and `ignoreFileLocators`, capped by the existing 1,000-file/100-ignore-file bounds; sort `{ locator, size, mtimeMs }` tuples and hash them. Before returning a cached result in this mode, compare the manifest and mark dirty on change. This fallback may scan metadata but must not read source bodies or walk outside the canonical root. + +- [ ] **Step 4: Query the snapshot from the preview facade** + +In `buildSourceGraphPreview()`: + +```js +const snapshot = snapshotService + ? await snapshotService.getSnapshot({ root, workspaceId: safeWorkspaceId, maxFiles: boundedMaxFiles, maxFileBytes: boundedMaxFileBytes, refresh }) + : { graph: await buildJsTsSourceGraph({ root, workspaceId: safeWorkspaceId, maxFiles: boundedMaxFiles, maxFileBytes: boundedMaxFileBytes, clock: () => generatedAt }), status: 'fresh', reuse: 'cold', reason: null, generation: 0, buildDurationMs: null }; +graph = snapshot.graph; +``` + +Add a `snapshot` object to every preview response with `status`, `reuse`, `reason`, `generation`, `validationMode`, `buildDurationMs`, and `builtAt`. Define it as an optional additive field in the v1 schema so older valid producers remain readable. The unavailable response uses `{ status: 'unavailable', reuse: 'none', reason: code, generation: 0, validationMode: 'none', buildDurationMs: null, builtAt: null }`. The UI may label `metadata-scan` as a scan; it must not describe it as watcher-backed reuse. + +- [ ] **Step 5: Verify snapshot and facade behavior** + +Run: + +```bash +node --test tests/source-graph-snapshot-service.test.mjs tests/source-graph-preview.test.mjs +npm run protocol:validate +``` + +Expected: service, facade, and protocol tests pass. + +- [ ] **Step 6: Commit snapshot service** + +```bash +git add packages/source-graph/src/snapshot-service.mjs packages/source-graph/src/index.mjs packages/protocol/schemas/source-graph-preview.schema.json examples/protocol/source-graph-preview.json tests/source-graph-snapshot-service.test.mjs tests/source-graph-preview.test.mjs +git commit -m "feat: reuse source graph snapshots" +``` + +### Task 6: Share one snapshot across Recall Map and Map API operations + +**Files:** +- Modify: `packages/recall-map/src/index.mjs` +- Modify: `packages/protocol/schemas/recall-map.schema.json` +- Modify: `examples/protocol/recall-map.json` +- Modify: `services/control-api/src/server.mjs` +- Modify: `services/control-api/src/route-contracts.mjs` +- Modify: `tests/recall-map.test.mjs` +- Modify: `tests/control-api-boundary.test.mjs` + +**Interfaces:** +- `buildRecallMap(options)` adds `sourceGraphSnapshotService` and `refreshSourceGraph = false`. +- `createControlApiServer(options)` adds injectable `sourceGraphSnapshotService`. + +- [ ] **Step 1: Write the failing shared-build Control API test** + +In `tests/control-api-boundary.test.mjs`, import `buildJsTsSourceGraph` and `createSourceGraphSnapshotService`. Use the existing `startServer()` injectable harness so the test exercises both authenticated routes against the same real service and temporary repository: + +```js +test('Recall Map and source preview share one source snapshot', async (t) => { + const sourceGraphRoot = await mkdtemp(path.join(os.tmpdir(), 'oaf-shared-source-snapshot-')); + t.after(async () => rm(sourceGraphRoot, { recursive: true, force: true })); + await mkdir(path.join(sourceGraphRoot, 'src'), { recursive: true }); + await writeFile(path.join(sourceGraphRoot, 'src', 'app.js'), 'export const sharedSnapshotFixture = true;\n'); + + let buildCount = 0; + const sourceGraphSnapshotService = createSourceGraphSnapshotService({ + buildGraph: async (options) => { + buildCount += 1; + return buildJsTsSourceGraph(options); + } + }); + t.after(() => sourceGraphSnapshotService.close()); + const api = await startServer(t, { sourceGraphRoot, sourceGraphSnapshotService }); + const authHeaders = { cookie: api.auth.cookie, origin: api.base }; + + const recall = await request(api.base, '/api/recall/map?workspaceId=ws_local', { + headers: authHeaders + }); + const map = await request(api.base, '/api/context/graph/preview', { + method: 'POST', + headers: { + ...authHeaders, + 'content-type': 'application/json', + 'x-csrf-token': api.auth.csrf + }, + body: JSON.stringify({ workspaceId: 'ws_local', sampleLimit: 3 }) + }); + + assert.equal(recall.status, 200, recall.text); + assert.equal(map.status, 200, map.text); + assert.equal(buildCount, 1); + assert.equal(recall.body.support.sourceGraph.snapshot.reuse, 'cold'); + assert.equal(map.body.snapshot.reuse, 'cache'); + assert.equal(map.body.graph.summary.fileCount > 0, true); +}); +``` + +The test owns and closes its injected service. The server must never close a caller-owned service. + +Also add a stale fixture to `tests/recall-map.test.mjs` and assert `support.sourceGraph.coverage.status === 'stale'`, not `unavailable` and not a zero-result ready state. + +- [ ] **Step 2: Run focused tests and verify duplicate construction** + +Run: + +```bash +node --test --test-name-pattern="share one source snapshot|stale source snapshot" tests/control-api-boundary.test.mjs tests/recall-map.test.mjs +``` + +Expected: FAIL because Recall Map and preview build independently and Recall Map has no snapshot projection. + +- [ ] **Step 3: Inject the service into Recall Map** + +Call `buildSourceGraphPreview({ snapshotService: sourceGraphSnapshotService, refresh: refreshSourceGraph, ... })`. Project snapshot truth under `support.sourceGraph.snapshot`: + +```js +snapshot: { + status: preview.snapshot.status, + reuse: preview.snapshot.reuse, + reason: preview.snapshot.reason, + validationMode: preview.snapshot.validationMode, + builtAt: preview.snapshot.builtAt, + buildDurationMs: preview.snapshot.buildDurationMs +} +``` + +Set coverage to `stale` when the last valid snapshot is shown after a failed refresh, `partial` for bounded omission, `complete` only for complete current coverage, and `unavailable` only when no graph exists. + +- [ ] **Step 4: Own one service in the Control API server** + +Import `createSourceGraphSnapshotService`. In `createControlApiServer()`, use the injected service or create one: + +```js +const sourceSnapshots = sourceGraphSnapshotService ?? createSourceGraphSnapshotService(); +const ownsSourceSnapshots = !sourceGraphSnapshotService; +server.once('close', () => { + if (ownsSourceSnapshots) sourceSnapshots.close(); +}); +``` + +Pass `sourceSnapshots` to both `buildRecallMap()` cases and `buildSourceGraphPreview()` in `previewContextGraph`. Add an optional boolean `refresh` to the POST Recall Map and graph-preview request schemas. Pass it as `refreshSourceGraph` or `refresh`; a true value marks the root dirty before the request. GET remains cache-aware and read-only. Preserve all auth, rate-limit, validation, and side-effect behavior. + +- [ ] **Step 5: Extend Recall Map schema and examples additively** + +Allow coverage states `complete`, `partial`, `stale`, and `unavailable`. Add the bounded snapshot object and optional request `refresh` flag. Update the example and route contract fixtures. Do not rename existing source-graph or readiness fields. + +- [ ] **Step 6: Verify shared API behavior** + +Run: + +```bash +node --test tests/recall-map.test.mjs tests/control-api.test.mjs tests/control-api-boundary.test.mjs +npm run protocol:validate +``` + +Expected: all focused API, boundary, and protocol tests pass with one shared graph build. + +- [ ] **Step 7: Commit Control API reuse** + +```bash +git add packages/recall-map/src/index.mjs packages/protocol/schemas/recall-map.schema.json examples/protocol/recall-map.json services/control-api/src/server.mjs services/control-api/src/route-contracts.mjs tests/recall-map.test.mjs tests/control-api-boundary.test.mjs +git commit -m "feat: share graph snapshots across local map routes" +``` + +### Task 7: Add generated and real-repository performance proof + +**Files:** +- Create: `scripts/source-graph-large-repo-smoke.mjs` +- Modify: `package.json` +- Modify: `docs/usage/recall-map.md` +- Test: `tests/source-graph-preview.test.mjs` + +**Interfaces:** +- Produces command: `npm run source-graph:large-smoke`. +- Optional environment: `MEMORY_RECALL_LARGE_REPO_ROOT=/absolute/read-only/repository`. +- Emits one JSON object with cold/cached timings, counts, coverage, cache reuse, and validation state. + +- [ ] **Step 1: Write the smoke script assertions before implementation** + +Create the script entry with these terminal assertions: + +```js +must(first.graph.summary.fileCount > 0, 'large_repo_graph_empty'); +must(!first.graph.diagnostics.some(({ code }) => code.startsWith('source_graph_unavailable')), 'large_repo_graph_unavailable'); +must(first.graph.summary.nodeCount <= 20_000, 'large_repo_node_budget_exceeded'); +must(first.graph.summary.edgeCount <= 50_000, 'large_repo_edge_budget_exceeded'); +must(second.snapshot.reuse === 'cache', 'large_repo_snapshot_not_reused'); +must(cachedMs <= coldMs * 0.2, `large_repo_cache_not_80_percent_faster:${coldMs}:${cachedMs}`); +``` + +The generated fixture must include at least 1,100 supported files, more than 50,000 candidate relations, ordinary `users` paths, `.worktrees`, `.venv`, generated output, nested `.gitignore`, and `.recallignore`. Use bounded file bodies and remove the temporary fixture in `finally`. + +- [ ] **Step 2: Add the package command and run it red** + +Add: + +```json +"source-graph:large-smoke": "node scripts/source-graph-large-repo-smoke.mjs" +``` + +Run: + +```bash +npm run source-graph:large-smoke +``` + +Expected before the preceding tasks are complete: FAIL on unavailable graph, edge budget, ignored-tree traversal, or cache reuse. + +- [ ] **Step 3: Finish generated and optional real-repository measurement** + +Use one `createSourceGraphSnapshotService()` for both preview calls. When `MEMORY_RECALL_LARGE_REPO_ROOT` is present, run the same read-only checks against that canonical root without creating or editing files there. Emit: + +```js +console.log(JSON.stringify({ + schemaVersion: '1.0.0', + rootKind: configuredRoot ? 'configured' : 'generated', + coldMs, + cachedMs, + cacheReductionPercent: Number(((1 - cachedMs / coldMs) * 100).toFixed(2)), + snapshot: second.snapshot, + summary: first.graph.summary, + diagnostics: first.graph.diagnostics.map(({ code }) => code), + protocolValid: validateJsonSchema(sourceGraphPreviewSchema, first).valid +}, null, 2)); +``` + +- [ ] **Step 4: Document operator behavior** + +Update `docs/usage/recall-map.md` with default exclusions, `.recallignore`, supported-file budgeting, partial coverage reason codes, automatic invalidation, explicit refresh, and the large-repository command. State that timings are local measurements, not universal claims. + +- [ ] **Step 5: Run foundation verification** + +Run: + +```bash +npm run source-graph:large-smoke +MEMORY_RECALL_LARGE_REPO_ROOT=/Users/rebel/Desktop/polychads-clean npm run source-graph:large-smoke +node --test tests/source-graph-discovery.test.mjs tests/source-graph-snapshot-service.test.mjs tests/source-graph-preview.test.mjs tests/recall-map.test.mjs tests/control-api.test.mjs tests/control-api-boundary.test.mjs +npm run protocol:validate +``` + +Expected: both smoke modes return non-empty valid results, the cached path is at least 80% faster, and all focused tests pass. + +- [ ] **Step 6: Commit performance proof** + +```bash +git add scripts/source-graph-large-repo-smoke.mjs package.json docs/usage/recall-map.md tests/source-graph-preview.test.mjs +git commit -m "test: prove large repository graph performance" +``` + +### Task 8: Run the graph-foundation release gate + +**Files:** +- Verify only; fix failures in the owning task before continuing. + +**Interfaces:** +- Produces a clean, independently testable graph foundation for the UI plan. + +- [ ] **Step 1: Run all source, map, protocol, and consumer checks** + +```bash +npm run check +npm run protocol:validate +node --test tests/ast-code-candidate-source.test.mjs tests/source-graph-discovery.test.mjs tests/source-graph-snapshot-service.test.mjs tests/source-graph-preview.test.mjs tests/recall-map-ranking.test.mjs tests/recall-map.test.mjs tests/control-api.test.mjs tests/control-api-boundary.test.mjs +npm run source-graph:large-smoke +npm run consumer:smoke +``` + +Expected: every command exits 0. + +- [ ] **Step 2: Confirm no security or side-effect drift** + +Inspect both generated and real-repository smoke JSON. Confirm: + +```text +networkCalls = 0 +modelCalls = 0 +externalWritesEnabled = false +rawBodyIncluded = false +graphDatabaseUsed = false +protocolValid = true +``` + +- [ ] **Step 3: Record the foundation checkpoint** + +```bash +git status --short +git log --oneline -7 +``` + +Expected: clean worktree and one focused commit per task. Do not squash before the UI plan is verified. diff --git a/docs/superpowers/plans/2026-07-15-memory-recall-orientation-workbench-ui.md b/docs/superpowers/plans/2026-07-15-memory-recall-orientation-workbench-ui.md new file mode 100644 index 00000000..f5b649d7 --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-memory-recall-orientation-workbench-ui.md @@ -0,0 +1,1191 @@ +# Memory Recall Orientation Workbench UI Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the rejected Overview, Map, and memory-graph presentation with a compact, orientation-first workbench that shows repository structure, starting points, change impact, and trusted context without AI-style slogans or generic dashboard design. + +**Architecture:** Keep the existing static ESM shell and five-destination navigation. Split pure view models and route renderers out of `apps/web/app.js`, consume the bounded orientation/focus projections from the graph-foundation plan, run detailed graph layout in a worker, and provide semantic outlines as the canonical accessible representation. + +**Tech Stack:** Browser-native ESM, semantic HTML, CSS custom properties, Canvas 2D for data visualization, Web Workers, Node test runner, Playwright consumer browser smoke. + +## Prerequisite + +Complete and verify `docs/superpowers/plans/2026-07-15-memory-recall-large-repository-graph-foundation.md` first. This UI plan consumes `report.architecture.groups`, `report.architecture.groupRelations`, `preview.orientation`, `preview.focus`, and `preview.snapshot` exactly as defined there. + +## Global Constraints + +- Follow `DESIGN.md` and the approved `2026-07-15-memory-recall-orientation-workbench-design.md` specification. +- Preserve the existing five destinations, stable deep links, local-only boundary, setup flow, Memory review queue, Handoffs flow, and Settings behavior. +- Add no frontend framework, graph library, icon library, runtime dependency, model call, network service, or new product route. +- Use the existing native system sans, native monospace, warm off-white, graphite, restrained cobalt, 6 px controls, and 8 px bounded panels. +- Use lists, rules, aligned rows, and disclosures before cards; do not add gradients, glow, glass, heavy shadows, decorative art, ambient motion, or a metric-card wall. +- Minimal means fewer elements, not empty space: related gaps are 8 to 16 px and primary-region gaps use only 24 or 32 px tokens. +- No hero, slogan, promotional subtitle, fake chat, typing indicator, sparkle mark, robot imagery, or intelligence theater. +- Visible copy names an object, state, reason, or action. AI/model/provider terms appear only for a real configured provider, operation, setting, or technical boundary. +- Overview shows at most 12 deterministic groups, at most 20 inter-group relations, and exactly three `Start here` items when three are available. +- Detailed graphs render only a focused bounded payload and perform no unbounded quadratic layout on the main thread. +- Every visual graph has a keyboard-reachable outline with identical selection and inspector state. +- Preserve query and scope through submit, success, partial result, failure, reload, back, and forward. +- Empty governed memory renders no canvas and no zero-value metric strip. +- Meet WCAG 2.2 AA, visible focus, 44 px primary touch targets, reduced motion, light/dark modes, and no horizontal overflow at 320, 375, 414, 768, and 1440 px. + +--- + +## File Map + +### Create + +- `apps/web/api.js`: loopback JSON requests, CSRF handling, and bounded API errors. +- `apps/web/ui-primitives.js`: escaping, state panels, error recovery, date/fingerprint formatting, and status text. +- `apps/web/orientation-model.js`: pure Overview state, group layering, starting-point ranking, impact, and trust models. +- `apps/web/orientation-view.js`: Overview markup and selected-group interactions. +- `apps/web/source-map-view.js`: Map URL state, request payload, renderer, outline, inspector, and viewport controls. +- `apps/web/memory-graph-view.js`: governed-memory graph model, empty/populated rendering, outline, and inspector. +- `apps/web/graph-viewport.js`: shared canvas transform, hit testing, selection, outline parity, and worker lifecycle. +- `apps/web/graph-layout-worker.js`: deterministic bounded detailed-graph layout off the main thread. +- `tests/web-orientation.test.mjs`: Overview model and markup tests. +- `tests/web-source-map.test.mjs`: Map URL, focused graph, outline, and error-state tests. +- `tests/web-memory-graph.test.mjs`: empty/populated governed-memory graph tests. + +### Modify + +- `apps/web/app.js`: retain boot, routing, shared shell state, and delegation only for touched routes. +- `apps/web/index.html`: turn the existing repository search into the single deterministic command bar. +- `apps/web/styles.css`: add compact orientation, map, graph, outline, and responsive styles; remove obsolete touched-route styles. +- `apps/web/tokens.css`: reuse tokens; change only if a missing semantic alias is proven. +- `tests/web-shell.test.mjs`: update module imports and retain shell regression coverage. +- `scripts/consumer-browser-smoke.mjs`: exercise the new first-ten-seconds contract, Map, and memory graph across states and widths. +- `DESIGN.md`: change only if implementation finds an unrecorded canonical rule; do not weaken existing constraints. + +--- + +### Task 1: Extract API and shared UI primitives without visual change + +**Files:** +- Create: `apps/web/api.js` +- Create: `apps/web/ui-primitives.js` +- Modify: `apps/web/app.js` +- Modify: `tests/web-shell.test.mjs` + +**Interfaces:** +- Produces: `requestJson(path, options): Promise` and `ApiRequestError` from `api.js`. +- Produces: `escapeHtml`, `formatDate`, `shortFingerprint`, `buildApiErrorUiModel`, `renderApiErrorPanel`, and `statePanel` from `ui-primitives.js`. +- `app.js` imports and delegates to these functions; route behavior remains unchanged. + +- [ ] **Step 0: Capture the rejected UI as baseline evidence** + +Before changing any visual markup or CSS, run the existing browser smoke and preserve the current Overview, Map, and Memory screenshots outside the files later runs overwrite: + +```bash +mkdir -p .scratch/ui-redesign/before +npm run consumer:browser-smoke +cp .scratch/ui-redesign/overview-desktop-light-1440.png .scratch/ui-redesign/before/overview-desktop-light-1440.png +cp .scratch/ui-redesign/map-desktop-1440.png .scratch/ui-redesign/before/map-desktop-1440.png +cp .scratch/ui-redesign/memory-desktop-1440.png .scratch/ui-redesign/before/memory-desktop-1440.png +``` + +Expected: the three baseline files exist and remain uncommitted for the final visual comparison. + +- [ ] **Step 1: Write failing module-boundary tests** + +Add to `tests/web-shell.test.mjs`: + +```js +test('web API and UI primitives are focused modules', async () => { + const apiSource = await readFile(new URL('../apps/web/api.js', import.meta.url), 'utf8'); + const primitiveSource = await readFile(new URL('../apps/web/ui-primitives.js', import.meta.url), 'utf8'); + const appSource = await readFile(new URL('../apps/web/app.js', import.meta.url), 'utf8'); + assert.match(apiSource, /export async function requestJson/); + assert.match(apiSource, /export class ApiRequestError/); + assert.match(primitiveSource, /export function escapeHtml/); + assert.match(primitiveSource, /export function statePanel/); + assert.doesNotMatch(appSource, /async function api\(/); + assert.doesNotMatch(appSource, /function statePanel\(/); +}); +``` + +- [ ] **Step 2: Run the boundary test and verify missing modules** + +Run: + +```bash +node --test --test-name-pattern="focused modules" tests/web-shell.test.mjs +``` + +Expected: FAIL because `api.js` and `ui-primitives.js` do not exist. + +- [ ] **Step 3: Extract the API client** + +Move the existing CSRF lookup, request headers, JSON parsing, correlation ID, issue list, and error mapping into `apps/web/api.js`. Keep the public shape small: + +```js +export class ApiRequestError extends Error { + constructor(message, { status = null, code = '', correlationId = '', issues = [] } = {}) { + super(message); + this.name = 'ApiRequestError'; + this.status = status; + this.code = code; + this.correlationId = correlationId; + this.issues = issues; + } +} + +export async function requestJson(path, { method = 'GET', body, signal } = {}) { + const response = await fetch(path, { + method, + credentials: 'same-origin', + signal, + headers: requestHeaders(method), + body + }); + const payload = await readPayload(response); + if (!response.ok) throw apiError(response, payload); + return payload; +} + +function csrfToken() { + return /(?:^|;\s*)oaf_csrf=([^;]+)/u.exec(globalThis.document?.cookie ?? '')?.[1] ?? ''; +} + +function requestHeaders(method) { + const headers = new Headers(); + if (!['GET', 'HEAD'].includes(method)) { + headers.set('content-type', 'application/json'); + const token = csrfToken(); + if (token) headers.set('x-csrf-token', token); + } + return headers; +} + +async function readPayload(response) { + return response.clone().json().catch(() => null); +} + +function apiError(response, payload) { + const code = payload?.error?.code ?? ''; + const message = code === 'bootstrap_required' + ? 'Local owner setup is required.' + : code === 'invalid_credentials' + ? 'Username or password is incorrect.' + : response.status === 401 + ? 'Local authentication required.' + : payload?.error?.message ?? `Request failed with ${response.status}`; + return new ApiRequestError(message, { + status: response.status, + code, + correlationId: payload?.error?.correlationId ?? response.headers.get('x-correlation-id') ?? '', + issues: Array.isArray(payload?.error?.issues) ? payload.error.issues : [] + }); +} +``` + +Do not read storage, change auth semantics, retry automatically, or log response bodies. + +- [ ] **Step 4: Extract safe shared render helpers** + +Move only pure formatting, escaping, state-panel, and API-error rendering into `ui-primitives.js`. Use dependency injection for action markup instead of importing route state. The escaping implementation remains: + +```js +export function escapeHtml(value) { + return String(value ?? '').replace(/[&<>'"]/gu, (character) => ({ + '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' + })[character]); +} +``` + +Export legacy aliases from `app.js` temporarily if existing tests or untouched routes import them. Do not duplicate implementation. + +- [ ] **Step 5: Run shell regression tests** + +Run: + +```bash +node --test tests/web-shell.test.mjs tests/control-api-boundary.test.mjs +``` + +Expected: all existing shell and boundary tests pass with no visual behavior change. + +- [ ] **Step 6: Commit the extraction** + +```bash +git add apps/web/api.js apps/web/ui-primitives.js apps/web/app.js tests/web-shell.test.mjs +git commit -m "refactor: extract web API and UI primitives" +``` + +### Task 2: Build the deterministic orientation model + +**Files:** +- Create: `apps/web/orientation-model.js` +- Create: `tests/web-orientation.test.mjs` +- Modify: `apps/web/app.js` +- Modify: `apps/web/shell-model.js` + +**Interfaces:** +- Produces: `buildOrientationModel(input): OrientationModel`. +- Produces: `layerOrientationGroups(groups, relations): LayeredGroup[]`. +- Produces: `selectOrientationGroup(model, groupId): OrientationModel`. +- Consumes graph-foundation fields `architecture.groups` and `architecture.groupRelations`. +- Input accepts `loading = false`, `error = null`, `gitChanges = null`, `handoff = null`, and an injected `now` timestamp for deterministic age labels. + +- [ ] **Step 1: Write failing first-ten-seconds model tests** + +Create `tests/web-orientation.test.mjs`: + +```js +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { buildOrientationModel } from '../apps/web/orientation-model.js'; + +test('orientation model is deterministic and bounded', () => { + const report = orientationFixture({ groupCount: 16, entryPointCount: 8 }); + const first = buildOrientationModel({ report, gitChanges: changedFixture(), handoff: handoffFixture() }); + const second = buildOrientationModel({ report: structuredClone(report), gitChanges: changedFixture(), handoff: handoffFixture() }); + assert.deepEqual(first.groups, second.groups); + assert.equal(first.groups.length, 12); + assert.equal(first.relations.length <= 20, true); + assert.equal(first.startHere.length, 3); + assert.deepEqual(first.startHere.map(({ reason }) => reason), [ + 'application route', + 'package entry point', + 'changed central module' + ]); + assert.equal(first.repository.name, 'large-repository'); + assert.equal(first.repository.branch, 'main'); + assert.equal(first.coverage.status, 'partial'); + assert.equal(first.coverage.builtAt, '2026-07-15T09:59:00.000Z'); + assert.equal(first.impact.unrepresentedChangedCount, 1); + assert.equal(first.trust.memory.status, 'pending'); + assert.equal(first.trust.handoff.status, 'verified'); +}); + +test('small repositories keep real groups and do not invent filler', () => { + const model = buildOrientationModel({ report: orientationFixture({ groupCount: 3, entryPointCount: 2 }) }); + assert.equal(model.groups.length, 3); + assert.equal(model.startHere.length, 2); +}); + +test('orientation model distinguishes loading, clean, stale, empty, and failure truth', () => { + assert.equal(buildOrientationModel({ loading: true }).state, 'loading'); + assert.equal(buildOrientationModel({ report: null, loading: false }).state, 'empty'); + assert.equal(buildOrientationModel({ error: { message: 'Graph validation failed.' } }).state, 'failure'); + + const cleanReport = orientationFixture({ groupCount: 3, entryPointCount: 3 }); + cleanReport.repository.dirtyCount = 0; + const clean = buildOrientationModel({ report: cleanReport, gitChanges: { status: 'available', changedLocators: [], totalCount: 0, omittedCount: 0, truncated: false } }); + assert.equal(clean.impact.status, 'clean'); + assert.equal(clean.impact.label, 'No local changes detected'); + + const staleReport = orientationFixture({ groupCount: 3, entryPointCount: 3 }); + staleReport.support.sourceGraph.snapshot.status = 'stale'; + staleReport.support.sourceGraph.snapshot.reason = 'source_graph_refresh_failed'; + const stale = buildOrientationModel({ report: staleReport, now: '2026-07-15T10:00:00.000Z' }); + assert.equal(stale.state, 'stale'); + assert.equal(stale.coverage.lastValidSnapshotShown, true); +}); + +const GROUP_PREFIXES = [ + 'apps/web', 'apps/cli', 'packages/source-graph', 'packages/recall-map', + 'packages/protocol', 'services/control-api', 'providers/native/memory-sqlite', + 'providers/native/context-candidate-ast-code', 'scripts', 'tests', 'docs', + 'examples', 'evals', 'rfcs', 'deploy', 'planning' +]; + +function orientationFixture({ groupCount = 8, entryPointCount = 5 } = {}) { + const groups = GROUP_PREFIXES.slice(0, groupCount).map((prefix, index) => ({ + id: `group_${index}`, + label: prefix.split('/').at(-1), + prefix, + fileCount: 10 + index, + symbolCount: 30 + index, + changedFileCount: index === 0 ? 1 : 0, + coverageStatus: index === groupCount - 1 ? 'partial' : 'complete', + entryPoints: [] + })); + const reasons = ['application_route', 'package_entry_point', 'changed_central_module', 'executable_command', 'inbound_dependency_hub']; + const labels = ['route', 'createServer', 'renderOverview', 'recall', 'buildSourceGraphPreview']; + const entryPoints = Array.from({ length: entryPointCount }, (_, index) => ({ + nodeId: `sgnode_${String(index).padStart(32, '0')}`, + label: labels[index % labels.length], + locator: `workspace://${groups[index % groups.length].prefix}/entry-${index}.js#L1-L4`, + symbolKind: 'function', + reasonCodes: [reasons[index % reasons.length]], + score: 100 - index + })); + for (const item of entryPoints) { + const group = groups.find(({ prefix }) => item.locator.startsWith(`workspace://${prefix}/`)); + if (group && group.entryPoints.length < 2) group.entryPoints.push(item); + } + return { + schemaVersion: '1.0.0', + workspaceId: 'ws_local', + generatedAt: '2026-07-15T10:00:00.000Z', + repository: { name: 'large-repository', branch: 'main', commitSha: 'a'.repeat(40), dirtyCount: 2, gitStatusAvailable: true, reason: null }, + support: { + sourceGraph: { + status: 'implemented', + coverage: { status: 'partial', analyzedFileCount: 572, maxFiles: 1000, diagnosticCount: 2, reasonCodes: ['unsupported_extensions_skipped'] }, + snapshot: { status: 'fresh', reuse: 'cache', reason: null, builtAt: '2026-07-15T09:59:00.000Z', buildDurationMs: 15 } + } + }, + architecture: { + groups, + groupRelations: groups.slice(1).map((group, index) => ({ fromGroupId: groups[index].id, toGroupId: group.id, kind: 'imports', count: 2 })), + entryPoints, + hotspots: [], + impact: { changedLocators: ['workspace://apps/web/app.js'], representedChangedLocators: ['workspace://apps/web/app.js'], affectedSymbols: [], depth: 2 } + }, + memory: { status: 'available', activeFacts: [], pendingProposals: [{ id: 'mpq_1' }], staleFactCount: 0, conflictingFactCount: 0 }, + readiness: { handoff: { status: 'available', command: 'recall handoff' }, nextCommands: [] }, + safeguards: { readOnly: true, modelCalls: 0, networkCalls: 0, externalWritesEnabled: false } + }; +} + +function changedFixture() { + return { status: 'available', changedLocators: ['apps/web/app.js'], totalCount: 2, omittedCount: 1, truncated: false }; +} + +function handoffFixture() { + return { status: 'verified', createdAt: '2026-07-15T09:58:00.000Z' }; +} +``` + +Define fixture helpers in the same test file with complete repository, support, architecture, memory, safeguards, and readiness fields. + +- [ ] **Step 2: Run the model test and verify the missing module** + +Run: + +```bash +node --test tests/web-orientation.test.mjs +``` + +Expected: FAIL with module-not-found for `orientation-model.js`. + +- [ ] **Step 3: Implement bounded group and start-point selection** + +In `orientation-model.js`, normalize every external string and array. Cap groups at 12 and relations at 20. Rank start points with explicit reason priority: + +```js +const START_REASON_PRIORITY = new Map([ + ['application_route', 0], + ['package_entry_point', 1], + ['changed_central_module', 2], + ['executable_command', 3], + ['inbound_dependency_hub', 4] +]); +const START_REASON_LABEL = new Map([ + ['application_route', 'application route'], + ['executable_command', 'executable command'], + ['package_entry_point', 'package entry point'], + ['changed_central_module', 'changed central module'], + ['inbound_dependency_hub', 'inbound dependency hub'] +]); + +function rankStartHere(items) { + return [...items] + .filter(({ locator = '' }) => !/(?:^|\/)(?:test|tests|fixtures|generated|vendor)(?:\/|$)/iu.test(locator)) + .sort((left, right) => ( + (START_REASON_PRIORITY.get(left.reasonCode) ?? 99) - (START_REASON_PRIORITY.get(right.reasonCode) ?? 99) + || right.score - left.score + || left.locator.localeCompare(right.locator) + )) + .slice(0, 3) + .map((item) => ({ ...item, reason: START_REASON_LABEL.get(item.reasonCode) ?? 'ranked source entry point' })); +} +``` + +Collapse strongly connected group components before assigning left-to-right topological layers. Sort groups inside a layer by repository-relative prefix. Do not use randomness, measured DOM size, or force layout. + +- [ ] **Step 4: Build truthful impact and trust models** + +Expose represented and unrepresented changed counts separately. Map graph snapshot states to `complete`, `partial`, `stale`, `failed`, or `not scanned`. Keep memory counts as active, pending, stale, and conflicting. Include token reduction only when the report contains a measured baseline and `providerBillingClaimed === false`; otherwise set it to `null`. + +Return a frozen model with: + +```js +{ + state, + repository, + coverage, + groups, + relations, + selectedGroupId, + startHere, + impact, + trust: { memory, handoff, source, deliveryReduction }, + primaryAction +} +``` + +- [ ] **Step 5: Delegate Overview modeling from `app.js`** + +Replace `buildRecallMapHomeModel()` internals with an import/delegation to `buildOrientationModel()`. Keep a named re-export from `app.js` for compatibility until `tests/web-shell.test.mjs` imports the new module directly. Update `selectOverviewPrimaryAction()` only to consume the new model fields; keep its current route IDs and actions. + +- [ ] **Step 6: Verify model and shell behavior** + +Run: + +```bash +node --test tests/web-orientation.test.mjs tests/web-shell.test.mjs +``` + +Expected: deterministic orientation tests and existing shell state tests pass. + +- [ ] **Step 7: Commit orientation modeling** + +```bash +git add apps/web/orientation-model.js apps/web/app.js apps/web/shell-model.js tests/web-orientation.test.mjs tests/web-shell.test.mjs +git commit -m "feat: model repository orientation deterministically" +``` + +### Task 3: Render the compact Overview workbench + +**Files:** +- Create: `apps/web/orientation-view.js` +- Modify: `apps/web/index.html` +- Modify: `apps/web/app.js` +- Modify: `apps/web/styles.css` +- Modify: `tests/web-orientation.test.mjs` +- Modify: `tests/web-shell.test.mjs` + +**Interfaces:** +- Produces: `renderOrientation(model): string`. +- Produces: `bindOrientation(root, { onSelectGroup, onRefresh }): () => void`. +- Consumes: `OrientationModel` from Task 2 and primitives from Task 1. + +- [ ] **Step 1: Write failing minimalist Overview markup tests** + +Add to `tests/web-orientation.test.mjs`: + +```js +import { readFile } from 'node:fs/promises'; +import { renderOrientation } from '../apps/web/orientation-view.js'; + +test('Overview renders the first-ten-seconds contract without dashboard slop', () => { + const html = renderOrientation(buildOrientationModel({ report: orientationFixture({ groupCount: 8, entryPointCount: 5 }) })); + for (const label of ['Architecture', 'Start here', 'Current impact', 'Trusted context']) assert.match(html, new RegExp(label)); + assert.equal((html.match(/class="orientation-group/g) ?? []).length, 8); + assert.equal((html.match(/class="start-item/g) ?? []).length, 3); + assert.match(html, /aria-label="Repository architecture outline"/); + assert.doesNotMatch(html, /class="metric-strip"/); + assert.doesNotMatch(html, /hero|tagline|AI-powered|intelligent|smart|magical|seamless|unlock|supercharge|next-generation/iu); +}); + +test('repository command bar exposes four deterministic intents', async () => { + const html = await readFile(new URL('../apps/web/index.html', import.meta.url), 'utf8'); + for (const intent of ['explain', 'trace', 'impact', 'handoff']) { + assert.match(html, new RegExp(`value="${intent}"`)); + } + assert.doesNotMatch(html, /chat|ask AI|thinking/iu); +}); + +test('repository truth bar names local and external-write state', async () => { + const html = await readFile(new URL('../apps/web/index.html', import.meta.url), 'utf8'); + assert.match(html, /id="repository-boundary"/); + assert.match(html, /Local only/); + assert.match(html, /External writes off/); +}); +``` + +- [ ] **Step 2: Run the test and verify the missing renderer** + +Run: + +```bash +node --test --test-name-pattern="first-ten-seconds" tests/web-orientation.test.mjs +``` + +Expected: FAIL because `renderOrientation` does not exist. + +- [ ] **Step 3: Implement semantic orientation markup** + +Render this macrostructure, with no wrapper cards around the three secondary sections: + +```html +
+
+

Overview

+ Partial · scanned 2 minutes ago +
+
+
+ +
+
+``` + +Group nodes are `':''}`} export function renderSetupScreen(mode, copy, inputModel=null) { const model=inputModel ?? buildAuthViewModel({mode,copy,draft:authDraft,error:authError}); const isBootstrap = mode === 'bootstrap'; @@ -3512,12 +3437,8 @@ async function loadRunById(id,{push=true}={}){ } } -function esc(value){return String(value??'').replace(/[&<>'"]/g,char=>({'&':'&','<':'<','>':'>',"'":''','"':'"'}[char]))} function relativeAge(from,to){const start=Date.parse(String(from??''));const end=Date.parse(String(to??''));if(!Number.isFinite(start)||!Number.isFinite(end)||endpart[0]?.toUpperCase()+part.slice(1)).join(' ')||'Step'} function labelize(value){return titleize(value).replace(/\bId\b/g,'ID')} function safeText(value){return String(value??'').replace(/[\r\n\t]+/g,' ').slice(0,160)} function previewText(value){return safeText(value).slice(0,220)} diff --git a/apps/web/ui-primitives.js b/apps/web/ui-primitives.js new file mode 100644 index 00000000..09ea9fe6 --- /dev/null +++ b/apps/web/ui-primitives.js @@ -0,0 +1,91 @@ +const API_ISSUE_HINTS = new Map([ + ['$.body.changedLocators', ['Changed files', 'Use workspace-relative paths under this repository, one per line. Keep the list bounded and review it before building.']], + ['$.body.userSelectedFiles', ['Explicit files', 'Use workspace-relative paths under this repository. Do not paste file bodies, absolute paths, credentials, or provider URLs.']], + ['$.body.memoryConfig.memoryPaths', ['Memory preflight sources', 'Use reviewed workspace-relative files only. Do not use absolute paths, URLs, credentials, or generated/local state directories.']], + ['$.body.client', ['Client', 'Choose a supported local harness client from the menu.']], + ['$.body.objective', ['Objective', 'Use a plain task summary. Do not include secrets, provider URLs, session tokens, absolute paths, or hidden reasoning.']], + ['$.body.step', ['Step', 'Use a short current-step label. Do not include secrets, provider URLs, session tokens, absolute paths, or hidden reasoning.']], + ['$.body.tokenBudget', ['Token budget', 'Use a positive number within the field limit.']], + ['$.body.sourceLocator', ['Source locator', 'Use a workspace-relative source file such as notes/memory.md.']], + ['$.body.text', ['Memory text', 'Use simple Fact or Decision lines with safe subject, predicate, and object text.']], + ['$.body.targetHarness', ['Target', 'Choose Codex, Claude Code, Cursor, or Generic agent.']], + ['$.body.from', ['Source families', 'Use supported source families only, such as codex, cursor, or claude-code.']], + ['$.body.workspaceId', ['Workspace', 'Use the current local workspace.']] +]); + +export function escapeHtml(value) { + return String(value ?? '').replace(/[&<>'"]/gu, (character) => ({ + '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' + })[character]); +} + +export function formatDate(value) { + if (!value) return '-'; + const parsed = new Date(value); + if (!Number.isFinite(parsed.getTime())) return '-'; + return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(parsed); +} + +export function shortFingerprint(value) { + return `${String(value ?? '').slice(0, 19)}...`; +} + +export function titleize(value) { + return String(value ?? '').split(/[-_]/u).filter(Boolean).map((part) => part[0]?.toUpperCase() + part.slice(1)).join(' ') || 'Step'; +} + +export function safeErrorToken(value, fallback, maxLength = 120) { + const text = String(value ?? '').trim(); + if (!text || containsPrivateValue(text)) return fallback; + const normalized = text.replace(/[^\w$.[\]:-]/gu, '_').slice(0, maxLength); + return containsPrivateValue(normalized) ? fallback : normalized; +} + +export function buildApiErrorUiModel(errorLike) { + const error = typeof errorLike === 'object' && errorLike ? errorLike : { message: String(errorLike ?? 'Request failed.') }; + const issues = Array.isArray(error.issues) ? error.issues.slice(0, 5).map((issue) => { + const path = safeErrorToken(issue?.path, '$.body'); + const code = safeErrorToken(issue?.code, 'validation_failed', 64); + return { path, code, ...apiIssueHint(path) }; + }) : []; + return { + message: String(error.message ?? 'Request failed.'), + status: Number.isFinite(Number(error.status)) ? Number(error.status) : null, + code: safeErrorToken(error.code, '', 64), + correlationId: safeErrorToken(error.correlationId, '', 96), + issues + }; +} + +export function renderApiErrorPanel(heading, error) { + const model = buildApiErrorUiModel(error); + return statePanel('error', heading, model.message, false, renderApiErrorRecovery(model)); +} + +export function renderApiErrorRecovery(model) { + const issues = model.issues.length + ? `

Fix this field

    ${model.issues.map((issue) => `
  • ${escapeHtml(issue.label)}${escapeHtml(issue.detail)}${escapeHtml(issue.path)} · ${escapeHtml(issue.code)}
  • `).join('')}
` + : ''; + const correlation = model.correlationId + ? `

Correlation ${escapeHtml(model.correlationId)}

` + : ''; + return `${issues}${correlation}`; +} + +export function statePanel(kind, heading, copy, button = false, extra = '') { + const actions = button + ? '
' + : ''; + return `

${escapeHtml(heading)}

${escapeHtml(copy)}

${extra}${actions}
`; +} + +function apiIssueHint(path) { + const direct = API_ISSUE_HINTS.get(path); + if (direct) return { label: direct[0], detail: direct[1] }; + if (path.startsWith('$.body.')) return { label: titleize(path.slice('$.body.'.length)), detail: 'Review this field and use only supported local values.' }; + return { label: 'Request field', detail: 'Review the highlighted request field and retry with supported local values.' }; +} + +function containsPrivateValue(value) { + return /(?:\/Users|\/private|\/var\/folders|https?:|file:|token|secret|api[_-]?key|authorization|cookie)/iu.test(value); +} diff --git a/docs/superpowers/plans/2026-07-15-memory-recall-orientation-workbench-ui.md b/docs/superpowers/plans/2026-07-15-memory-recall-orientation-workbench-ui.md index f5b649d7..969841d8 100644 --- a/docs/superpowers/plans/2026-07-15-memory-recall-orientation-workbench-ui.md +++ b/docs/superpowers/plans/2026-07-15-memory-recall-orientation-workbench-ui.md @@ -72,7 +72,7 @@ Complete and verify `docs/superpowers/plans/2026-07-15-memory-recall-large-repos - Produces: `escapeHtml`, `formatDate`, `shortFingerprint`, `buildApiErrorUiModel`, `renderApiErrorPanel`, and `statePanel` from `ui-primitives.js`. - `app.js` imports and delegates to these functions; route behavior remains unchanged. -- [ ] **Step 0: Capture the rejected UI as baseline evidence** +- [x] **Step 0: Capture the rejected UI as baseline evidence** Before changing any visual markup or CSS, run the existing browser smoke and preserve the current Overview, Map, and Memory screenshots outside the files later runs overwrite: @@ -86,7 +86,7 @@ cp .scratch/ui-redesign/memory-desktop-1440.png .scratch/ui-redesign/before/memo Expected: the three baseline files exist and remain uncommitted for the final visual comparison. -- [ ] **Step 1: Write failing module-boundary tests** +- [x] **Step 1: Write failing module-boundary tests** Add to `tests/web-shell.test.mjs`: @@ -104,7 +104,7 @@ test('web API and UI primitives are focused modules', async () => { }); ``` -- [ ] **Step 2: Run the boundary test and verify missing modules** +- [x] **Step 2: Run the boundary test and verify missing modules** Run: @@ -114,7 +114,7 @@ node --test --test-name-pattern="focused modules" tests/web-shell.test.mjs Expected: FAIL because `api.js` and `ui-primitives.js` do not exist. -- [ ] **Step 3: Extract the API client** +- [x] **Step 3: Extract the API client** Move the existing CSRF lookup, request headers, JSON parsing, correlation ID, issue list, and error mapping into `apps/web/api.js`. Keep the public shape small: @@ -181,7 +181,7 @@ function apiError(response, payload) { Do not read storage, change auth semantics, retry automatically, or log response bodies. -- [ ] **Step 4: Extract safe shared render helpers** +- [x] **Step 4: Extract safe shared render helpers** Move only pure formatting, escaping, state-panel, and API-error rendering into `ui-primitives.js`. Use dependency injection for action markup instead of importing route state. The escaping implementation remains: @@ -195,7 +195,7 @@ export function escapeHtml(value) { Export legacy aliases from `app.js` temporarily if existing tests or untouched routes import them. Do not duplicate implementation. -- [ ] **Step 5: Run shell regression tests** +- [x] **Step 5: Run shell regression tests** Run: @@ -205,7 +205,7 @@ node --test tests/web-shell.test.mjs tests/control-api-boundary.test.mjs Expected: all existing shell and boundary tests pass with no visual behavior change. -- [ ] **Step 6: Commit the extraction** +- [x] **Step 6: Commit the extraction** ```bash git add apps/web/api.js apps/web/ui-primitives.js apps/web/app.js tests/web-shell.test.mjs diff --git a/tests/web-shell.test.mjs b/tests/web-shell.test.mjs index ee7f8960..512de8fb 100644 --- a/tests/web-shell.test.mjs +++ b/tests/web-shell.test.mjs @@ -63,6 +63,18 @@ import { writeClipboardText } from '../apps/web/app.js'; +test('web API and UI primitives are focused modules', async () => { + const apiSource = await readFile(new URL('../apps/web/api.js', import.meta.url), 'utf8'); + const primitiveSource = await readFile(new URL('../apps/web/ui-primitives.js', import.meta.url), 'utf8'); + const appSource = await readFile(new URL('../apps/web/app.js', import.meta.url), 'utf8'); + assert.match(apiSource, /export async function requestJson/); + assert.match(apiSource, /export class ApiRequestError/); + assert.match(primitiveSource, /export function escapeHtml/); + assert.match(primitiveSource, /export function statePanel/); + assert.doesNotMatch(appSource, /async function api\(/); + assert.doesNotMatch(appSource, /function statePanel\(/); +}); + test('setup is a focused workspace-security screen', () => { const html = renderSetupScreen('bootstrap', 'Create the first local owner.'); assert.match(html, /Set up this workspace/); From 5278a1386f450d136fc7169249d4decf1ff868e1 Mon Sep 17 00:00:00 2001 From: rebel Date: Thu, 16 Jul 2026 00:20:27 +0530 Subject: [PATCH 015/276] feat: model repository orientation deterministically --- apps/web/app.js | 154 +---- apps/web/orientation-model.js | 552 ++++++++++++++++++ apps/web/shell-model.js | 11 +- ...-memory-recall-orientation-workbench-ui.md | 14 +- tests/web-orientation.test.mjs | 147 +++++ 5 files changed, 722 insertions(+), 156 deletions(-) create mode 100644 apps/web/orientation-model.js create mode 100644 tests/web-orientation.test.mjs diff --git a/apps/web/app.js b/apps/web/app.js index ab97553d..fd3d0e35 100644 --- a/apps/web/app.js +++ b/apps/web/app.js @@ -1,5 +1,6 @@ import { navigationItemsFor, navigationOwner, selectOverviewPrimaryAction } from './shell-model.js'; import { csrfToken, requestJson as api } from './api.js'; +import { buildOrientationModel } from './orientation-model.js'; import { buildApiErrorUiModel, escapeHtml as esc, @@ -1218,141 +1219,15 @@ function renderHome() { } export function buildRecallMapHomeModel({ report=null, error=null, gitChanges=null, pinnedHandoffStatus=null, pinnedHandoffError=null }={}) { - if (error) { - return { - state:'error', - error:buildApiErrorUiModel(error), - title:'Recall Map unavailable', - copy:'The local API did not return a map. Retry the read-only request or inspect the correlation details below.', - commands:[] - }; - } - if (!report) { - return { - state:'loading', - title:'Loading Recall Map', - copy:'Reading bounded local source metadata and governed memory summaries.', - commands:[] - }; - } - const sourceGraph=report.support?.sourceGraph ?? {}; - const coverage=sourceGraph.coverage ?? {}; - const architecture=report.architecture ?? {}; - const impact=architecture.impact ?? {}; - const memory=report.memory ?? {}; - const repository=report.repository ?? { - name:'Local workspace', - branch:null, - commitSha:null, - dirtyCount:0, - gitStatusAvailable:false, - reason:'repository_identity_unavailable' - }; - const entryPoints=Array.isArray(architecture.entryPoints)?architecture.entryPoints:[]; - const hotspots=Array.isArray(architecture.hotspots)?architecture.hotspots:[]; - const changedLocators=Array.isArray(impact.changedLocators)?impact.changedLocators:[]; - const representedChangedLocators=Array.isArray(impact.representedChangedLocators)?impact.representedChangedLocators:[]; - const affectedSymbols=Array.isArray(impact.affectedSymbols)?impact.affectedSymbols:[]; - const detectedChanges=normalizeRecallMapGitChanges(gitChanges); - const activeFacts=Array.isArray(memory.activeFacts)?memory.activeFacts:[]; - const pendingProposals=Array.isArray(memory.pendingProposals)?memory.pendingProposals:[]; - const handoffStatus=String(pinnedHandoffStatus?.current?.status ?? ''); - const handoffEntryId=pinnedHandoffStatus?.current?.entryId ?? null; - const handoffEntry=(pinnedHandoffStatus?.entries ?? []).find((entry)=>entry.id===handoffEntryId) ?? null; - const handoffState=pinnedHandoffError - ? 'blocked' - : handoffStatus==='verified' - ? 'ready' - : handoffStatus==='stale'||handoffStatus==='review' - ? 'review' - : handoffStatus==='tampered' - ? 'blocked' - : report.readiness?.handoff?.status==='available' - ? 'pending' - : 'blocked'; - const sourceUnavailable=sourceGraph.status==='unavailable'||coverage.status==='unavailable'; - const noArchitecture=entryPoints.length===0&&hotspots.length===0&&changedLocators.length===0&&affectedSymbols.length===0; - const baseState=sourceUnavailable?'partial':noArchitecture?'empty':'success'; - const state=handoffState==='review'?'stale':baseState; - const nextCommands=[ - 'recall map --root . --sqlite .local/memory.sqlite --format summary', - ...(Array.isArray(report.readiness?.nextCommands)?report.readiness.nextCommands:[]), - report.readiness?.mcp?.command - ].filter((command,index,all)=>typeof command==='string'&&command.length>0&&all.indexOf(command)===index).slice(0,5); - return { - state, - generatedAt:report.generatedAt ?? null, - repository, - index:{ - status:sourceGraph.status ?? 'unavailable', - kind:sourceGraph.status==='implemented'?'success':'error', - label:sourceGraph.status==='implemented'?'JS/TS map indexed':'Source graph unavailable', - copy:sourceGraph.status==='implemented'?'Bounded static metadata only; raw source bodies stay local.':'The local source graph could not be read.' - }, - coverage:{ - status:coverage.status ?? 'unavailable', - kind:coverage.status==='unavailable'?'error':'partial', - label:`${Number(coverage.analyzedFileCount??0)} / ${Number(coverage.maxFiles??0)} files`, - diagnosticCount:Number(coverage.diagnosticCount??0), - reasonCodes:Array.isArray(coverage.reasonCodes)?coverage.reasonCodes:[] - }, - entryPoints, - hotspots, - impact:{ - changedLocators, - representedChangedLocators, - affectedSymbols, - changedCount:changedLocators.length, - representedCount:representedChangedLocators.length, - affectedCount:affectedSymbols.length, - totalChangedCount:detectedChanges.status==='available'?detectedChanges.totalCount:changedLocators.length, - omittedChangedCount:detectedChanges.status==='available'?detectedChanges.omittedCount:0, - truncated:detectedChanges.truncated, - detectionStatus:detectedChanges.status, - detectionReason:detectedChanges.reason, - detectionMessage:detectedChanges.message, - repositoryDirtyCount:Number(repository.dirtyCount??0), - depth:Number(impact.depth??0) - }, - memory:{ - status:memory.status ?? 'unavailable', - kind:memory.status==='available'?(Number(memory.staleFactCount??0)>0?'stale':'success'):'error', - activeCount:activeFacts.length, - pendingCount:pendingProposals.length, - staleCount:Number(memory.staleFactCount??0), - unavailableReason:memory.unavailableReason ?? null - }, - handoff:{ - state:handoffState, - kind:handoffState==='ready'?'success':handoffState==='review'||handoffState==='pending'?'partial':'error', - command:report.readiness?.handoff?.command ?? 'recall handoff', - createdAt:handoffEntry?.createdAt ?? null, - ageLabel:relativeAge(handoffEntry?.createdAt,pinnedHandoffStatus?.generatedAt??report.generatedAt), - copy:handoffState==='ready' - ? 'Pinned handoff is verified for the next coding agent.' - : handoffState==='review' - ? 'Pinned sources changed and need review before handoff.' - : handoffState==='pending' - ? 'The handoff command is available; no verified pinned packet is active.' - : 'Handoff verification is unavailable. Review the local registry before sharing context.' - }, - safeguards:report.safeguards ?? {}, - commands:nextCommands.map((command)=>({ label:recallMapCommandLabel(command), command })), - recentActivity:[ - ...pendingProposals.slice(0,3).map((proposal)=>({ - kind:'proposal', - label:'Memory proposed', - detail:proposal.sourceLocator, - at:proposal.enqueuedAt - })), - ...activeFacts.slice(0,3).map((fact)=>({ - kind:'memory', - label:'Memory current', - detail:fact.sourceLocator, - at:fact.validFrom - })) - ].sort((left,right)=>String(right.at).localeCompare(String(left.at))).slice(0,5) - }; + const model=buildOrientationModel({ + report, + error, + loading:!report&&!error, + gitChanges, + handoff:pinnedHandoffStatus, + handoffError:pinnedHandoffError + }); + return model.state==='failure' ? Object.freeze({...model,state:'error'}) : model; } export function renderOverview(model) { @@ -1415,14 +1290,6 @@ export function renderOverview(model) { export const renderRecallMapHome=renderOverview; -function recallMapCommandLabel(command) { - if (command.startsWith('recall map')) return 'Refresh map locally'; - if (command.startsWith('recall handoff')) return 'Create Next-Agent Handoff'; - if (command.includes('mcp inspect')) return 'Inspect read-only MCP'; - if (command.includes('graph stats')) return 'Check source graph'; - return 'Copy command'; -} - function renderRuns() { if (activeRunDetail) return renderRunDetail(activeRunDetail); return `

Run history

Stable links and current steps
${runList(dashboard?.runs)}
`; @@ -3437,7 +3304,6 @@ async function loadRunById(id,{push=true}={}){ } } -function relativeAge(from,to){const start=Date.parse(String(from??''));const end=Date.parse(String(to??''));if(!Number.isFinite(start)||!Number.isFinite(end)||end id)); + const relations = Object.freeze(arrayValue(architecture.groupRelations) + .map(normalizeRelation) + .filter((relation) => groupIds.has(relation.sourceGroupId) && groupIds.has(relation.targetGroupId)) + .sort(compareRelations) + .slice(0, MAX_RELATIONS)); + const groups = Object.freeze(layerOrientationGroups(rawGroups, relations)); + const impact = normalizeImpact(architecture.impact, gitChanges, repository); + const startHere = Object.freeze(rankStartHere(startCandidates(architecture, groups, impact))); + const memory = normalizeMemory(report.memory); + const handoffModel = normalizeHandoff({ report, handoff, handoffError, now }); + const selected = chooseSelectedGroup(groups, selectedGroupId); + const noArchitecture = groups.length === 0 + && startHere.length === 0 + && arrayValue(architecture.hotspots).length === 0 + && impact.changedLocators.length === 0 + && impact.affectedSymbols.length === 0; + const state = coverage.status === 'stale' || handoffModel.state === 'review' + ? 'stale' + : noArchitecture + ? 'empty' + : coverage.status === 'failed' + ? 'partial' + : 'success'; + const commands = normalizeCommands(report); + const recentActivity = normalizeActivity(report.memory); + const trust = Object.freeze({ + memory: Object.freeze({ + status: memory.pendingCount > 0 ? 'pending' : memory.staleCount > 0 ? 'stale' : memory.status === 'available' ? 'current' : memory.status, + activeCount: memory.activeCount, + pendingCount: memory.pendingCount, + staleCount: memory.staleCount, + conflictingCount: memory.conflictingCount + }), + handoff: Object.freeze({ + status: handoffModel.status, + state: handoffModel.state, + createdAt: handoffModel.createdAt, + ageLabel: handoffModel.ageLabel + }), + source: Object.freeze({ + status: coverage.status, + snapshotStatus: coverage.snapshotStatus, + reuse: coverage.reuse + }), + deliveryReduction: measuredDeliveryReduction(report) + }); + const model = { + state, + generatedAt: safeNullableText(report.generatedAt, 64), + repository, + coverage, + groups, + relations, + selectedGroupId: selected, + startHere, + impact, + trust, + index: Object.freeze({ + status: safeText(sourceGraph.status || 'unavailable', 32), + kind: sourceGraph.status === 'implemented' ? 'success' : 'error', + label: sourceGraph.status === 'implemented' ? 'JS/TS map indexed' : 'Source graph unavailable', + copy: sourceGraph.status === 'implemented' ? 'Bounded static metadata only; raw source bodies stay local.' : 'The local source graph could not be read.' + }), + entryPoints: Object.freeze(arrayValue(architecture.entryPoints).map(normalizeStartItem)), + hotspots: Object.freeze(arrayValue(architecture.hotspots).map(normalizeStartItem)), + memory, + handoff: handoffModel, + safeguards: Object.freeze({ ...objectValue(report.safeguards) }), + commands, + recentActivity + }; + model.primaryAction = selectOverviewPrimaryAction(model); + return Object.freeze(model); +} + +export function layerOrientationGroups(inputGroups = [], inputRelations = []) { + const groups = inputGroups.slice(0, MAX_GROUPS).map((group) => ({ ...group })); + const byId = new Map(groups.map((group) => [group.id, group])); + const adjacency = new Map(groups.map((group) => [group.id, []])); + for (const relation of inputRelations.slice(0, MAX_RELATIONS)) { + if (!byId.has(relation.sourceGroupId) || !byId.has(relation.targetGroupId)) continue; + adjacency.get(relation.sourceGroupId).push(relation.targetGroupId); + } + for (const targets of adjacency.values()) targets.sort(compareGroupIds(byId)); + const components = stronglyConnectedComponents(groups, adjacency); + const componentByGroup = new Map(); + components.forEach((component, index) => component.forEach((id) => componentByGroup.set(id, index))); + const predecessors = new Map(components.map((_, index) => [index, new Set()])); + for (const [source, targets] of adjacency) { + for (const target of targets) { + const sourceComponent = componentByGroup.get(source); + const targetComponent = componentByGroup.get(target); + if (sourceComponent !== targetComponent) predecessors.get(targetComponent).add(sourceComponent); + } + } + const componentLayers = new Map(); + const layerFor = (component) => { + if (componentLayers.has(component)) return componentLayers.get(component); + const previous = [...predecessors.get(component)].map(layerFor); + const layer = previous.length ? Math.max(...previous) + 1 : 0; + componentLayers.set(component, layer); + return layer; + }; + components.forEach((_, index) => layerFor(index)); + return groups + .map((group) => Object.freeze({ ...group, layer: componentLayers.get(componentByGroup.get(group.id)) ?? 0 })) + .sort((left, right) => left.layer - right.layer || left.prefix.localeCompare(right.prefix)); +} + +export function selectOrientationGroup(model, groupId) { + const selected = model?.groups?.some(({ id }) => id === groupId) ? groupId : model?.selectedGroupId ?? null; + return Object.freeze({ ...model, selectedGroupId: selected }); +} + +export function normalizeOrientationGitChanges(report = null, error = null) { + const empty = { changedLocators: [], totalCount: 0, omittedCount: 0, truncated: false }; + if (error) return { status: 'error', ...empty, reason: safeText(error?.code || 'git_detection_failed', 64), message: safeGitMessage(error?.message, 'Git change detection failed. Retry the local scan.') }; + if (report?.status === 'error') return { status: 'error', ...empty, reason: safeText(report.reason || 'git_detection_failed', 64), message: safeGitMessage(report.message, 'Git change detection failed. Retry the local scan.') }; + if (report?.status === 'unavailable') { + const reason = safeText(report.reason || 'git_unavailable', 64); + return { status: 'unavailable', ...empty, reason, message: gitUnavailableMessage(reason) }; + } + if (report?.status !== 'available') return { status: 'unknown', ...empty, reason: 'not_run', message: 'Git change detection has not run.' }; + const changedLocators = arrayValue(report.changedLocators).map((value) => safeText(value, 512).replace(/^workspace:\/\//u, '')).filter(Boolean).slice(0, 16); + const skippedCount = Math.max(0, finiteNumber(report.skippedCount)); + const totalBase = finiteNumber(report.totalCount ?? report.totalChangedLocatorCount ?? changedLocators.length); + const omittedBase = finiteNumber(report.omittedCount ?? report.omittedChangedLocatorCount ?? 0); + return { + status: 'available', + changedLocators, + totalCount: Math.max(changedLocators.length, totalBase + (report.totalCount == null ? skippedCount : 0)), + omittedCount: Math.max(0, omittedBase) + (report.omittedCount == null ? skippedCount : 0), + truncated: report.truncated === true + }; +} + +function normalizeRepository(value) { + const repository = objectValue(value); + return Object.freeze({ + name: safeText(repository.name || 'Local workspace', 160), + branch: safeNullableText(repository.branch, 160), + commitSha: safeNullableText(repository.commitSha, 64), + dirtyCount: finiteNumber(repository.dirtyCount), + gitStatusAvailable: repository.gitStatusAvailable === true, + reason: safeNullableText(repository.reason, 64) + }); +} + +function normalizeCoverage(sourceGraph, snapshot) { + const raw = objectValue(sourceGraph.coverage); + const rawStatus = safeText(raw.status || 'unavailable', 32); + const snapshotStatus = safeText(snapshot.status || (sourceGraph.status === 'implemented' ? 'fresh' : 'unavailable'), 32); + const status = snapshotStatus === 'stale' || rawStatus === 'stale' + ? 'stale' + : sourceGraph.status !== 'implemented' || rawStatus === 'unavailable' || snapshotStatus === 'unavailable' + ? 'failed' + : rawStatus === 'complete' + ? 'complete' + : 'partial'; + return Object.freeze({ + status, + rawStatus, + snapshotStatus, + reuse: safeText(snapshot.reuse || 'none', 32), + builtAt: safeNullableText(snapshot.builtAt, 64), + buildDurationMs: snapshot.buildDurationMs == null ? null : finiteNumber(snapshot.buildDurationMs), + analyzedFileCount: finiteNumber(raw.analyzedFileCount), + maxFiles: finiteNumber(raw.maxFiles), + label: `${finiteNumber(raw.analyzedFileCount)} / ${finiteNumber(raw.maxFiles)} files`, + diagnosticCount: finiteNumber(raw.diagnosticCount), + reasonCodes: Object.freeze(arrayValue(raw.reasonCodes).map((code) => safeText(code, 64)).filter(Boolean).slice(0, 16)), + reason: safeNullableText(snapshot.reason, 64), + lastValidSnapshotShown: status === 'stale' + }); +} + +function normalizeGroup(group, index) { + const value = objectValue(group); + const prefix = safeText(value.prefix || value.label || `group-${index + 1}`, 512); + return Object.freeze({ + id: safeText(value.id || `group_${index}`, 96), + label: safeText(value.label || prefix.split('/').at(-1) || prefix, 160), + prefix, + fileCount: finiteNumber(value.fileCount), + symbolCount: finiteNumber(value.symbolCount), + changedFileCount: finiteNumber(value.changedFileCount), + coverageStatus: safeText(value.coverageStatus || 'complete', 32), + entryPoints: Object.freeze(arrayValue(value.entryPoints).slice(0, 2).map(normalizeStartItem)) + }); +} + +function normalizeRelation(relation, index) { + const value = objectValue(relation); + return Object.freeze({ + id: safeText(value.id || `relation_${index}`, 96), + sourceGroupId: safeText(value.sourceGroupId || value.fromGroupId, 96), + targetGroupId: safeText(value.targetGroupId || value.toGroupId, 96), + sourcePrefix: safeText(value.sourcePrefix, 512), + targetPrefix: safeText(value.targetPrefix, 512), + kind: safeText(value.kind || dominantKind(value.edgeKindCounts), 32), + count: finiteNumber(value.count), + edgeKindCounts: Object.freeze({ ...objectValue(value.edgeKindCounts) }) + }); +} + +function normalizeImpact(rawImpact, rawGitChanges, repository) { + const impact = objectValue(rawImpact); + const changedLocators = Object.freeze(arrayValue(impact.changedLocators).map((value) => safeText(value, 512)).filter(Boolean).slice(0, 16)); + const representedChangedLocators = Object.freeze(arrayValue(impact.representedChangedLocators).map((value) => safeText(value, 512)).filter(Boolean).slice(0, 16)); + const affectedSymbols = Object.freeze(arrayValue(impact.affectedSymbols).map(normalizeStartItem).slice(0, 20)); + const detected = normalizeOrientationGitChanges(rawGitChanges); + const totalChangedCount = detected.status === 'available' ? detected.totalCount : changedLocators.length; + const unrepresentedChangedCount = Math.max(0, totalChangedCount - representedChangedLocators.length); + const status = detected.status === 'available' && totalChangedCount === 0 && repository.dirtyCount === 0 + ? 'clean' + : detected.status === 'unavailable' || detected.status === 'error' + ? 'unknown' + : totalChangedCount > 0 || changedLocators.length > 0 + ? 'changed' + : 'not scanned'; + return Object.freeze({ + status, + label: status === 'clean' ? 'No local changes detected' : status === 'changed' ? `${totalChangedCount} local change${totalChangedCount === 1 ? '' : 's'}` : status === 'unknown' ? 'Local change detection unavailable' : 'Local changes not scanned', + changedLocators, + representedChangedLocators, + affectedSymbols, + changedCount: changedLocators.length, + representedCount: representedChangedLocators.length, + unrepresentedChangedCount, + affectedCount: affectedSymbols.length, + totalChangedCount, + omittedChangedCount: detected.status === 'available' ? detected.omittedCount : 0, + truncated: detected.truncated === true, + detectionStatus: detected.status, + detectionReason: detected.reason, + detectionMessage: detected.message, + repositoryDirtyCount: repository.dirtyCount, + depth: finiteNumber(impact.depth) + }); +} + +function normalizeMemory(rawMemory) { + const memory = objectValue(rawMemory); + const activeFacts = Object.freeze(arrayValue(memory.activeFacts).slice(0, 20)); + const pendingProposals = Object.freeze(arrayValue(memory.pendingProposals).slice(0, 20)); + const staleCount = finiteNumber(memory.staleFactCount); + return Object.freeze({ + status: safeText(memory.status || 'unavailable', 32), + kind: memory.status === 'available' ? (staleCount > 0 ? 'stale' : 'success') : 'error', + activeFacts, + pendingProposals, + activeCount: activeFacts.length, + pendingCount: pendingProposals.length, + staleCount, + conflictingCount: finiteNumber(memory.conflictingFactCount), + unavailableReason: safeNullableText(memory.unavailableReason, 64) + }); +} + +function normalizeHandoff({ report, handoff, handoffError, now }) { + const raw = objectValue(handoff); + const current = objectValue(raw.current); + const status = safeText(raw.status || current.status || '', 32); + const entryId = current.entryId ?? raw.entryId ?? null; + const entry = arrayValue(raw.entries).find(({ id }) => id === entryId) ?? null; + const createdAt = safeNullableText(raw.createdAt ?? entry?.createdAt, 64); + const state = handoffError + ? 'blocked' + : status === 'verified' + ? 'ready' + : ['stale', 'review'].includes(status) + ? 'review' + : status === 'tampered' + ? 'blocked' + : report.readiness?.handoff?.status === 'available' + ? 'pending' + : 'blocked'; + const end = now ?? raw.generatedAt ?? report.generatedAt; + return Object.freeze({ + state, + status: status || (state === 'pending' ? 'available' : state), + kind: state === 'ready' ? 'success' : state === 'review' || state === 'pending' ? 'partial' : 'error', + command: safeText(report.readiness?.handoff?.command || 'recall handoff', 256), + createdAt, + ageLabel: relativeAge(createdAt, end), + copy: state === 'ready' + ? 'Pinned handoff is verified for the next coding agent.' + : state === 'review' + ? 'Pinned sources changed and need review before handoff.' + : state === 'pending' + ? 'The handoff command is available; no verified pinned packet is active.' + : 'Handoff verification is unavailable. Review the local registry before sharing context.' + }); +} + +function startCandidates(architecture, groups, impact) { + const changed = new Set(impact.changedLocators.map(fileLocator)); + const candidates = [ + ...arrayValue(architecture.entryPoints), + ...groups.flatMap(({ entryPoints }) => entryPoints), + ...arrayValue(architecture.hotspots).map((item) => ({ ...item, reasonCodes: item.reasonCodes ?? ['inbound_dependency_hub'], score: item.score ?? item.total ?? item.inbound ?? 0 })) + ]; + const seen = new Set(); + return candidates.map((item) => { + const normalized = normalizeStartItem(item); + const reasonCode = safeText(arrayValue(item?.reasonCodes)[0] || item?.reasonCode || inferStartReason(normalized, changed), 64); + return { ...normalized, reasonCode, score: finiteNumber(item?.score ?? item?.total ?? 0) }; + }).filter((item) => { + const key = `${item.locator}\u0000${item.label}`; + if (!item.locator || seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +function rankStartHere(items) { + const ranked = [...items] + .filter(({ locator = '' }) => !/(?:^|\/)(?:test|tests|fixtures|generated|vendor)(?:\/|$)/iu.test(locator.replace(/^workspace:\/\//u, ''))) + .sort((left, right) => ( + (START_REASON_PRIORITY.get(left.reasonCode) ?? 99) - (START_REASON_PRIORITY.get(right.reasonCode) ?? 99) + || right.score - left.score + || left.locator.localeCompare(right.locator) + || left.label.localeCompare(right.label) + )); + const selected = []; + const selectedReasons = new Set(); + for (const item of ranked) { + if (selectedReasons.has(item.reasonCode)) continue; + selected.push(item); + selectedReasons.add(item.reasonCode); + if (selected.length === MAX_START_ITEMS) break; + } + for (const item of ranked) { + if (selected.length === MAX_START_ITEMS) break; + if (!selected.includes(item)) selected.push(item); + } + return selected.map((item) => Object.freeze({ ...item, reason: START_REASON_LABEL.get(item.reasonCode) ?? 'ranked source entry point' })); +} + +function normalizeStartItem(value) { + const item = objectValue(value); + return Object.freeze({ + nodeId: safeNullableText(item.nodeId, 96), + label: safeText(item.label || item.name || 'Unnamed source', 240), + qualifiedLabel: safeNullableText(item.qualifiedLabel || item.qualifiedName, 240), + locator: safeNullableText(item.locator, 512), + symbolKind: safeText(item.symbolKind || item.kind || 'module', 32) + }); +} + +function inferStartReason(item, changed) { + const locator = String(item.locator ?? '').replace(/^workspace:\/\//u, '').split('#', 1)[0]; + if (/(?:^|\/)route\.(?:[cm]?[jt]sx?)$/iu.test(locator)) return 'application_route'; + if (/(?:^|\/)(?:index|main)\.(?:[cm]?[jt]sx?)$/iu.test(locator)) return 'package_entry_point'; + if (changed.has(fileLocator(locator))) return 'changed_central_module'; + if (/^(?:apps\/cli|bin\/)|\/bin\//u.test(locator)) return 'executable_command'; + return 'inbound_dependency_hub'; +} + +function stronglyConnectedComponents(groups, adjacency) { + let nextIndex = 0; + const stack = []; + const onStack = new Set(); + const indexById = new Map(); + const lowById = new Map(); + const components = []; + const visit = (id) => { + indexById.set(id, nextIndex); + lowById.set(id, nextIndex); + nextIndex += 1; + stack.push(id); + onStack.add(id); + for (const target of adjacency.get(id) ?? []) { + if (!indexById.has(target)) { + visit(target); + lowById.set(id, Math.min(lowById.get(id), lowById.get(target))); + } else if (onStack.has(target)) { + lowById.set(id, Math.min(lowById.get(id), indexById.get(target))); + } + } + if (lowById.get(id) !== indexById.get(id)) return; + const component = []; + let member; + do { + member = stack.pop(); + onStack.delete(member); + component.push(member); + } while (member !== id); + component.sort(compareGroupIds(new Map(groups.map((group) => [group.id, group])))); + components.push(component); + }; + [...groups].sort((left, right) => left.prefix.localeCompare(right.prefix)).forEach(({ id }) => { + if (!indexById.has(id)) visit(id); + }); + return components; +} + +function normalizeCommands(report) { + const commands = [ + 'recall map --root . --sqlite .local/memory.sqlite --format summary', + ...arrayValue(report.readiness?.nextCommands), + report.readiness?.mcp?.command + ].filter((command, index, all) => typeof command === 'string' && command.length > 0 && all.indexOf(command) === index).slice(0, 5); + return Object.freeze(commands.map((command) => Object.freeze({ label: commandLabel(command), command }))); +} + +function normalizeActivity(rawMemory) { + const memory = objectValue(rawMemory); + return Object.freeze([ + ...arrayValue(memory.pendingProposals).slice(0, 3).map((proposal) => ({ kind: 'proposal', label: 'Memory proposed', detail: safeNullableText(proposal.sourceLocator, 512), at: safeNullableText(proposal.enqueuedAt, 64) })), + ...arrayValue(memory.activeFacts).slice(0, 3).map((fact) => ({ kind: 'memory', label: 'Memory current', detail: safeNullableText(fact.sourceLocator, 512), at: safeNullableText(fact.validFrom, 64) })) + ].sort((left, right) => String(right.at).localeCompare(String(left.at))).slice(0, 5).map(Object.freeze)); +} + +function measuredDeliveryReduction(report) { + const measurement = objectValue(report.deliveryMeasurement ?? report.measurements?.delivery ?? report.memory?.deliveryMeasurement); + const before = finiteNumber(measurement.beforeDeliveryTokens ?? measurement.baselineTokens); + const after = finiteNumber(measurement.afterDeliveryTokens ?? measurement.deliveredTokens); + if (measurement.providerBillingClaimed !== false || before <= 0 || after < 0 || after > before) return null; + return Object.freeze({ before, after, tokensSaved: before - after, percent: Math.round(((before - after) / before) * 100), providerBillingClaimed: false }); +} + +function chooseSelectedGroup(groups, requested) { + if (groups.some(({ id }) => id === requested)) return requested; + return groups.find(({ changedFileCount }) => changedFileCount > 0)?.id ?? groups[0]?.id ?? null; +} + +function compareRelations(left, right) { + return right.count - left.count || left.sourceGroupId.localeCompare(right.sourceGroupId) || left.targetGroupId.localeCompare(right.targetGroupId); +} + +function compareGroupIds(byId) { + return (left, right) => (byId.get(left)?.prefix ?? left).localeCompare(byId.get(right)?.prefix ?? right); +} + +function dominantKind(counts) { + return Object.entries(objectValue(counts)).sort((left, right) => finiteNumber(right[1]) - finiteNumber(left[1]) || left[0].localeCompare(right[0]))[0]?.[0] ?? 'imports'; +} + +function commandLabel(command) { + if (command.startsWith('recall map')) return 'Refresh map locally'; + if (command.startsWith('recall handoff')) return 'Create handoff'; + if (command.includes('mcp inspect')) return 'Inspect read-only MCP'; + if (command.includes('graph stats')) return 'Check source graph'; + return 'Copy command'; +} + +function relativeAge(from, to) { + const start = Date.parse(String(from ?? '')); + const end = Date.parse(String(to ?? '')); + if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return 'Not pinned'; + const minutes = Math.floor((end - start) / 60_000); + if (minutes < 60) return `${minutes} minute${minutes === 1 ? '' : 's'} old`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'} old`; + const days = Math.floor(hours / 24); + return `${days} day${days === 1 ? '' : 's'} old`; +} + +function fileLocator(locator) { + return String(locator ?? '').replace(/^workspace:\/\//u, '').split('#', 1)[0]; +} + +function safeGitMessage(value, fallback) { + const message = safeText(value, 180); + return !message || /(?:\/Users|\/private|\/var\/folders|https?:|file:|token|secret|api[_-]?key|authorization|cookie)/iu.test(message) ? fallback : message; +} + +function gitUnavailableMessage(reason) { + return ({ + not_git_repository: 'Git change detection is unavailable because this workspace is not a Git repository.', + git_unavailable: 'Git change detection is unavailable because the local Git executable could not be used.', + git_status_failed: 'Git change detection is unavailable because local status could not be read.', + git_status_timeout: 'Git change detection is unavailable because local status timed out.' + })[reason] ?? 'Git change detection is unavailable. Retry the local scan.'; +} + +function frozenState(state, title, copy) { + return Object.freeze({ state, title, copy, groups: Object.freeze([]), relations: Object.freeze([]), startHere: Object.freeze([]) }); +} + +function arrayValue(value) { + return Array.isArray(value) ? value : []; +} + +function objectValue(value) { + return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; +} + +function safeText(value, maxLength = 240) { + return String(value ?? '').replace(/[\u0000-\u001f\u007f]/gu, ' ').trim().slice(0, maxLength); +} + +function safeNullableText(value, maxLength) { + const text = safeText(value, maxLength); + return text || null; +} + +function finiteNumber(value) { + const number = Number(value ?? 0); + return Number.isFinite(number) ? Math.max(0, number) : 0; +} diff --git a/apps/web/shell-model.js b/apps/web/shell-model.js index 2a7713ee..0014186b 100644 --- a/apps/web/shell-model.js +++ b/apps/web/shell-model.js @@ -35,12 +35,13 @@ export function navigationItemsFor(mode) { } export function selectOverviewPrimaryAction(model = {}) { - if (model.state === 'loading' || model.state === 'error') return null; + if (model.state === 'loading' || model.state === 'error' || model.state === 'failure') return null; if (model.state === 'empty') return { label: 'Scan repository', route: '/', action: 'refresh-recall-map' }; - const pendingCount = Number(model.memory?.pendingCount ?? 0); + const pendingCount = Number(model.trust?.memory?.pendingCount ?? model.memory?.pendingCount ?? 0); if (pendingCount > 0) return { label: `Review ${pendingCount} proposal${pendingCount === 1 ? '' : 's'}`, route: '/memory', routeId: 'memory' }; - if (model.handoff?.state === 'blocked') return { label: 'Repair handoff', route: '/handoffs', routeId: 'context-pack' }; - if (model.state === 'stale' || model.handoff?.state === 'review') return { label: 'Update handoff', route: '/handoffs', routeId: 'context-pack' }; - if (model.handoff?.state === 'ready') return { label: 'View current handoff', route: '/handoffs', routeId: 'context-pack' }; + const handoffState = model.trust?.handoff?.state ?? model.handoff?.state; + if (handoffState === 'blocked') return { label: 'Repair handoff', route: '/handoffs', routeId: 'context-pack' }; + if (model.state === 'stale' || handoffState === 'review') return { label: 'Update handoff', route: '/handoffs', routeId: 'context-pack' }; + if (handoffState === 'ready') return { label: 'View current handoff', route: '/handoffs', routeId: 'context-pack' }; return null; } diff --git a/docs/superpowers/plans/2026-07-15-memory-recall-orientation-workbench-ui.md b/docs/superpowers/plans/2026-07-15-memory-recall-orientation-workbench-ui.md index 969841d8..dfc85abf 100644 --- a/docs/superpowers/plans/2026-07-15-memory-recall-orientation-workbench-ui.md +++ b/docs/superpowers/plans/2026-07-15-memory-recall-orientation-workbench-ui.md @@ -227,7 +227,7 @@ git commit -m "refactor: extract web API and UI primitives" - Consumes graph-foundation fields `architecture.groups` and `architecture.groupRelations`. - Input accepts `loading = false`, `error = null`, `gitChanges = null`, `handoff = null`, and an injected `now` timestamp for deterministic age labels. -- [ ] **Step 1: Write failing first-ten-seconds model tests** +- [x] **Step 1: Write failing first-ten-seconds model tests** Create `tests/web-orientation.test.mjs`: @@ -351,7 +351,7 @@ function handoffFixture() { Define fixture helpers in the same test file with complete repository, support, architecture, memory, safeguards, and readiness fields. -- [ ] **Step 2: Run the model test and verify the missing module** +- [x] **Step 2: Run the model test and verify the missing module** Run: @@ -361,7 +361,7 @@ node --test tests/web-orientation.test.mjs Expected: FAIL with module-not-found for `orientation-model.js`. -- [ ] **Step 3: Implement bounded group and start-point selection** +- [x] **Step 3: Implement bounded group and start-point selection** In `orientation-model.js`, normalize every external string and array. Cap groups at 12 and relations at 20. Rank start points with explicit reason priority: @@ -396,7 +396,7 @@ function rankStartHere(items) { Collapse strongly connected group components before assigning left-to-right topological layers. Sort groups inside a layer by repository-relative prefix. Do not use randomness, measured DOM size, or force layout. -- [ ] **Step 4: Build truthful impact and trust models** +- [x] **Step 4: Build truthful impact and trust models** Expose represented and unrepresented changed counts separately. Map graph snapshot states to `complete`, `partial`, `stale`, `failed`, or `not scanned`. Keep memory counts as active, pending, stale, and conflicting. Include token reduction only when the report contains a measured baseline and `providerBillingClaimed === false`; otherwise set it to `null`. @@ -417,11 +417,11 @@ Return a frozen model with: } ``` -- [ ] **Step 5: Delegate Overview modeling from `app.js`** +- [x] **Step 5: Delegate Overview modeling from `app.js`** Replace `buildRecallMapHomeModel()` internals with an import/delegation to `buildOrientationModel()`. Keep a named re-export from `app.js` for compatibility until `tests/web-shell.test.mjs` imports the new module directly. Update `selectOverviewPrimaryAction()` only to consume the new model fields; keep its current route IDs and actions. -- [ ] **Step 6: Verify model and shell behavior** +- [x] **Step 6: Verify model and shell behavior** Run: @@ -431,7 +431,7 @@ node --test tests/web-orientation.test.mjs tests/web-shell.test.mjs Expected: deterministic orientation tests and existing shell state tests pass. -- [ ] **Step 7: Commit orientation modeling** +- [x] **Step 7: Commit orientation modeling** ```bash git add apps/web/orientation-model.js apps/web/app.js apps/web/shell-model.js tests/web-orientation.test.mjs tests/web-shell.test.mjs diff --git a/tests/web-orientation.test.mjs b/tests/web-orientation.test.mjs new file mode 100644 index 00000000..3c422780 --- /dev/null +++ b/tests/web-orientation.test.mjs @@ -0,0 +1,147 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + buildOrientationModel, + layerOrientationGroups, + selectOrientationGroup +} from '../apps/web/orientation-model.js'; + +test('orientation model is deterministic and bounded', () => { + const report = orientationFixture({ groupCount: 16, entryPointCount: 8 }); + const first = buildOrientationModel({ report, gitChanges: changedFixture(), handoff: handoffFixture() }); + const second = buildOrientationModel({ report: structuredClone(report), gitChanges: changedFixture(), handoff: handoffFixture() }); + assert.deepEqual(first.groups, second.groups); + assert.equal(first.groups.length, 12); + assert.equal(first.relations.length <= 20, true); + assert.equal(first.startHere.length, 3); + assert.deepEqual(first.startHere, second.startHere); +}); + +test('orientation start points explain why each source matters', () => { + const model = buildOrientationModel({ + report: orientationFixture({ groupCount: 16, entryPointCount: 8 }), + gitChanges: changedFixture(), + handoff: handoffFixture() + }); + assert.deepEqual(model.startHere.map(({ reason }) => reason), [ + 'application route', + 'package entry point', + 'changed central module' + ]); + assert.equal(model.repository.name, 'large-repository'); + assert.equal(model.repository.branch, 'main'); + assert.equal(model.coverage.status, 'partial'); + assert.equal(model.coverage.builtAt, '2026-07-15T09:59:00.000Z'); + assert.equal(model.impact.unrepresentedChangedCount, 1); + assert.equal(model.trust.memory.status, 'pending'); + assert.equal(model.trust.handoff.status, 'verified'); +}); + +test('small repositories keep real groups and do not invent filler', () => { + const model = buildOrientationModel({ report: orientationFixture({ groupCount: 3, entryPointCount: 2 }) }); + assert.equal(model.groups.length, 3); + assert.equal(model.startHere.length, 2); +}); + +test('orientation model distinguishes loading, clean, stale, empty, and failure truth', () => { + assert.equal(buildOrientationModel({ loading: true }).state, 'loading'); + assert.equal(buildOrientationModel({ report: null, loading: false }).state, 'empty'); + assert.equal(buildOrientationModel({ error: { message: 'Graph validation failed.' } }).state, 'failure'); + + const cleanReport = orientationFixture({ groupCount: 3, entryPointCount: 3 }); + cleanReport.repository.dirtyCount = 0; + const clean = buildOrientationModel({ report: cleanReport, gitChanges: { status: 'available', changedLocators: [], totalCount: 0, omittedCount: 0, truncated: false } }); + assert.equal(clean.impact.status, 'clean'); + assert.equal(clean.impact.label, 'No local changes detected'); + + const staleReport = orientationFixture({ groupCount: 3, entryPointCount: 3 }); + staleReport.support.sourceGraph.snapshot.status = 'stale'; + staleReport.support.sourceGraph.snapshot.reason = 'source_graph_refresh_failed'; + const stale = buildOrientationModel({ report: staleReport, now: '2026-07-15T10:00:00.000Z' }); + assert.equal(stale.state, 'stale'); + assert.equal(stale.coverage.lastValidSnapshotShown, true); +}); + +test('group layering collapses cycles and selection keeps the same bounded model', () => { + const groups = [ + { id: 'a', prefix: 'apps/a' }, + { id: 'b', prefix: 'apps/b' }, + { id: 'c', prefix: 'packages/c' } + ]; + const relations = [ + { sourceGroupId: 'a', targetGroupId: 'b' }, + { sourceGroupId: 'b', targetGroupId: 'a' }, + { sourceGroupId: 'b', targetGroupId: 'c' } + ]; + const layered = layerOrientationGroups(groups, relations); + assert.equal(layered.find(({ id }) => id === 'a').layer, layered.find(({ id }) => id === 'b').layer); + assert.equal(layered.find(({ id }) => id === 'c').layer, 1); + const selected = selectOrientationGroup({ groups: layered, selectedGroupId: null }, 'b'); + assert.equal(selected.selectedGroupId, 'b'); + assert.equal(Object.isFrozen(selected), true); +}); + +const GROUP_PREFIXES = [ + 'apps/web', 'apps/cli', 'packages/source-graph', 'packages/recall-map', + 'packages/protocol', 'services/control-api', 'providers/native/memory-sqlite', + 'providers/native/context-candidate-ast-code', 'scripts', 'tests', 'docs', + 'examples', 'evals', 'rfcs', 'deploy', 'planning' +]; + +function orientationFixture({ groupCount = 8, entryPointCount = 5 } = {}) { + const groups = GROUP_PREFIXES.slice(0, groupCount).map((prefix, index) => ({ + id: `group_${index}`, + label: prefix.split('/').at(-1), + prefix, + fileCount: 10 + index, + symbolCount: 30 + index, + changedFileCount: index === 0 ? 1 : 0, + coverageStatus: index === groupCount - 1 ? 'partial' : 'complete', + entryPoints: [] + })); + const reasons = ['application_route', 'package_entry_point', 'changed_central_module', 'executable_command', 'inbound_dependency_hub']; + const labels = ['route', 'createServer', 'renderOverview', 'recall', 'buildSourceGraphPreview']; + const entryPoints = Array.from({ length: entryPointCount }, (_, index) => ({ + nodeId: `sgnode_${String(index).padStart(32, '0')}`, + label: labels[index % labels.length], + locator: `workspace://${groups[index % groups.length].prefix}/entry-${index}.js#L1-L4`, + symbolKind: 'function', + reasonCodes: [reasons[index % reasons.length]], + score: 100 - index + })); + for (const item of entryPoints) { + const group = groups.find(({ prefix }) => item.locator.startsWith(`workspace://${prefix}/`)); + if (group && group.entryPoints.length < 2) group.entryPoints.push(item); + } + return { + schemaVersion: '1.0.0', + workspaceId: 'ws_local', + generatedAt: '2026-07-15T10:00:00.000Z', + repository: { name: 'large-repository', branch: 'main', commitSha: 'a'.repeat(40), dirtyCount: 2, gitStatusAvailable: true, reason: null }, + support: { + sourceGraph: { + status: 'implemented', + coverage: { status: 'partial', analyzedFileCount: 572, maxFiles: 1000, diagnosticCount: 2, reasonCodes: ['unsupported_extensions_skipped'] }, + snapshot: { status: 'fresh', reuse: 'cache', reason: null, builtAt: '2026-07-15T09:59:00.000Z', buildDurationMs: 15 } + } + }, + architecture: { + groups, + groupRelations: groups.slice(1).map((group, index) => ({ fromGroupId: groups[index].id, toGroupId: group.id, kind: 'imports', count: 2 })), + entryPoints, + hotspots: [], + impact: { changedLocators: ['workspace://apps/web/app.js'], representedChangedLocators: ['workspace://apps/web/app.js'], affectedSymbols: [], depth: 2 } + }, + memory: { status: 'available', activeFacts: [], pendingProposals: [{ id: 'mpq_1' }], staleFactCount: 0, conflictingFactCount: 0 }, + readiness: { handoff: { status: 'available', command: 'recall handoff' }, nextCommands: [] }, + safeguards: { readOnly: true, modelCalls: 0, networkCalls: 0, externalWritesEnabled: false } + }; +} + +function changedFixture() { + return { status: 'available', changedLocators: ['apps/web/app.js'], totalCount: 2, omittedCount: 1, truncated: false }; +} + +function handoffFixture() { + return { status: 'verified', createdAt: '2026-07-15T09:58:00.000Z' }; +} From 76deb3c00a9ca9c3e6aa22e5683335a34feff006 Mon Sep 17 00:00:00 2001 From: rebel Date: Thu, 16 Jul 2026 00:28:06 +0530 Subject: [PATCH 016/276] feat: render the orientation-first Overview --- apps/web/app.js | 139 +++++++++--------- apps/web/index.html | 12 +- apps/web/orientation-view.js | 110 ++++++++++++++ apps/web/styles.css | 69 ++++++++- ...-memory-recall-orientation-workbench-ui.md | 14 +- tests/web-orientation.test.mjs | 25 ++++ tests/web-shell.test.mjs | 35 ++--- 7 files changed, 301 insertions(+), 103 deletions(-) create mode 100644 apps/web/orientation-view.js diff --git a/apps/web/app.js b/apps/web/app.js index fd3d0e35..aeedeb02 100644 --- a/apps/web/app.js +++ b/apps/web/app.js @@ -1,6 +1,7 @@ -import { navigationItemsFor, navigationOwner, selectOverviewPrimaryAction } from './shell-model.js'; +import { navigationItemsFor, navigationOwner } from './shell-model.js'; import { csrfToken, requestJson as api } from './api.js'; -import { buildOrientationModel } from './orientation-model.js'; +import { buildOrientationModel, selectOrientationGroup } from './orientation-model.js'; +import { bindOrientation, renderOrientation } from './orientation-view.js'; import { buildApiErrorUiModel, escapeHtml as esc, @@ -83,6 +84,8 @@ let sourceGraphError=null; let harnessSetupResult=null; let harnessSetupError=null; let activeFabricNode='context'; +let orientationModel=null; +let orientationCleanup=()=>{}; export function legacyViewPath(view) { return legacyViews.get(String(view??'')) ?? '/'; @@ -1005,6 +1008,8 @@ async function submitMemoryIntake(event) { } function render() { + orientationCleanup(); + orientationCleanup=()=>{}; const route=currentRoute(); const setupScreen = shellState.kind === 'setup' || shellState.kind === 'denied'; const appShell = document.querySelector('.app-shell'); @@ -1020,7 +1025,7 @@ function render() { : renderRoute(route); root.querySelectorAll('[data-action=run]').forEach(button=>button.addEventListener('click',runDemo)); root.querySelectorAll('[data-action=reset]').forEach(button=>button.addEventListener('click',resetDemo)); - root.querySelectorAll('[data-action=refresh-recall-map]').forEach(button=>button.addEventListener('click',refreshRecallMap)); + if(route.id!=='home')root.querySelectorAll('[data-action=refresh-recall-map]').forEach(button=>button.addEventListener('click',refreshRecallMap)); root.querySelectorAll('[data-run-id]').forEach(link=>link.addEventListener('click',showRun)); root.querySelectorAll('[data-step-id],[data-record-id]').forEach(link=>link.addEventListener('click',navigateLocal)); root.querySelector('#auth-form')?.addEventListener('submit',submitAuthForm); @@ -1050,6 +1055,20 @@ function render() { root.querySelectorAll('[data-fabric-node]').forEach(button=>button.addEventListener('click',selectFabricNode)); if(route.id==='memory-graph')drawMemoryGraphCanvas(root.querySelector('#memory-graph-canvas'),memoryGraph,memoryGraphOptions); document.querySelectorAll('[data-route]').forEach(link=>link.onclick=navigate); + if(route.id==='home')bindCurrentOrientation(root); +} + +function bindCurrentOrientation(root) { + orientationCleanup=bindOrientation(root,{ + onSelectGroup:(groupId)=>{ + orientationModel=selectOrientationGroup(orientationModel,groupId); + root.innerHTML=renderOrientation(orientationModel); + document.querySelectorAll('[data-route]').forEach(link=>link.onclick=navigate); + orientationCleanup(); + bindCurrentOrientation(root); + }, + onRefresh:refreshRecallMap + }); } function renderNav(container, mode) { @@ -1068,6 +1087,10 @@ function renderRepositoryBar(route) { document.querySelector('#repository-name').textContent = repository?.name ?? 'Local workspace'; document.querySelector('#repository-branch').textContent = repository?.branch ?? 'Branch unavailable'; document.querySelector('#repository-scan').textContent = recallMap?.generatedAt ? `Scanned ${date(recallMap.generatedAt)}` : 'Not scanned'; + const boundary=document.querySelector('#repository-boundary'); + const externalWrites=recallMap?.safeguards?.externalWritesEnabled===true; + boundary.textContent=`Local only / External writes ${externalWrites?'on':'off'}`; + boundary.dataset.state=externalWrites?'warning':'safe'; const conditionNode = document.querySelector('#repository-condition'); conditionNode.textContent = condition.kind; conditionNode.dataset.state = condition.kind; @@ -1209,13 +1232,16 @@ function renderRoute(route) { } function renderHome() { - return renderOverview(buildRecallMapHomeModel({ + orientationModel=buildOrientationModel({ report: recallMap, error: recallMapError, + loading:!recallMap&&!recallMapError, gitChanges: recallMapGitChanges, - pinnedHandoffStatus, - pinnedHandoffError - })); + handoff:pinnedHandoffStatus, + handoffError:pinnedHandoffError, + selectedGroupId:orientationModel?.selectedGroupId + }); + return renderOrientation(orientationModel); } export function buildRecallMapHomeModel({ report=null, error=null, gitChanges=null, pinnedHandoffStatus=null, pinnedHandoffError=null }={}) { @@ -1230,65 +1256,8 @@ export function buildRecallMapHomeModel({ report=null, error=null, gitChanges=nu return model.state==='failure' ? Object.freeze({...model,state:'error'}) : model; } -export function renderOverview(model) { - if (model.state==='loading') return `

Overview

${statePanel('loading',model.title,model.copy)}
`; - if (model.state==='error') return `

Overview

${renderApiErrorPanel(model.title,model.error)}
`; - const action=selectOverviewPrimaryAction(model); - const actionHtml=action?.action - ? `` - : action - ? `${esc(action.label)}` - : 'No action queued'; - const stateCopy=model.state==='stale' - ? statePanel('stale','Source changes need review','Repository evidence changed after the current handoff was pinned. Review the affected sources before sharing context.') - : model.state==='empty' - ? statePanel('empty','No JS/TS entry points yet','The map is live, but this workspace did not yield a bounded JS/TS entry point. Inspect supported coverage in Map before broadening the workspace.') - : model.state==='partial'||model.coverage.status==='partial' - ? statePanel('partial','Bounded coverage','The bounded scan completed, but the Map only indexes supported JS/TS metadata within its scan limits. Review its coverage notes before treating the repository picture as complete.') - : ''; - const detectionFailed=model.impact.detectionStatus==='unavailable'||model.impact.detectionStatus==='error'; - const omittedChangeEvidence=model.impact.detectionStatus==='available'&&model.impact.omittedChangedCount>0; - const changed=model.impact.changedLocators.length - ? `
    ${model.impact.changedLocators.map((locator)=>`
  • ${esc(locator)}
  • `).join('')}
${model.impact.omittedChangedCount?`

${model.impact.changedCount} shown · ${model.impact.omittedChangedCount} omitted by safety or scan bounds.

`:''}` - : detectionFailed - ? `
${model.impact.repositoryDirtyCount>0?`${model.impact.repositoryDirtyCount} changed entr${model.impact.repositoryDirtyCount===1?'y':'ies'}; `:''}file detection unavailable.

${esc(model.impact.detectionMessage)}

` - : omittedChangeEvidence - ? `

0 shown · ${model.impact.omittedChangedCount} omitted by safety or scan bounds.

` - : model.impact.detectionStatus==='available' - ? '

No changed files detected.

' - : '

Changed-file detection has not run.

'; - const attention=[ - model.memory.pendingCount?`${model.memory.pendingCount} pendingReview proposed memory`:'', - model.memory.staleCount?`${model.memory.staleCount} staleCheck source changes`:'', - model.handoff.state==='blocked'?`Handoff blockedRepair registry or pinned artifacts`:'', - model.handoff.state==='review'?`Handoff needs reviewUpdate changed sources`:'', - detectionFailed?``:'', - omittedChangeEvidence?`${model.impact.omittedChangedCount} change${model.impact.omittedChangedCount===1?'':'s'} omittedInspect safety and scan bounds`:'', - model.coverage.status==='partial'||model.coverage.diagnosticCount?`${model.coverage.diagnosticCount?`${model.coverage.diagnosticCount} coverage note${model.coverage.diagnosticCount===1?'':'s'}`:'Bounded coverage'}Inspect supported files and scan scope`:'' - ].filter(Boolean).join('')||'

Nothing needs review.

'; - const affected=model.impact.affectedSymbols.length - ? `
    ${model.impact.affectedSymbols.slice(0,6).map((entry)=>`
  1. ${esc(entry.label)}${esc(entry.locator ?? 'locator unavailable')}
  2. `).join('')}
` - : '

No focused impact set.

'; - const activity=model.recentActivity.length - ? `
    ${model.recentActivity.map((item)=>`
  1. ${esc(item.label)}${esc(item.detail ?? 'source unavailable')}
  2. `).join('')}
` - : '

No memory activity recorded.

'; - return `
-
-

${esc(model.repository.name)}

${esc(model.repository.branch ?? 'Branch unavailable')} · ${model.repository.dirtyCount} changed · scanned ${esc(model.generatedAt?date(model.generatedAt):'not yet')}

- ${actionHtml} -
- ${stateCopy} -
-

Changes

Open Map
${changed}
-

Needs attention

Open Memory
${attention}
-

Impact

${model.impact.affectedCount} affected
${affected}
-

Current handoff

Open Handoffs
State
${esc(model.handoff.state)}
Age
${esc(model.handoff.ageLabel)}
Source check
${esc(model.handoff.copy)}
-

Recent activity

${model.recentActivity.length}
${activity}
-
-
`; -} - -export const renderRecallMapHome=renderOverview; +export const renderOverview=renderOrientation; +export const renderRecallMapHome=renderOrientation; function renderRuns() { if (activeRunDetail) return renderRunDetail(activeRunDetail); @@ -2088,11 +2057,12 @@ function contextPackFormDraft() { const payload=selectContextPackPinPayload({reviewedPayload:contextPackResult?.reviewedPayload ?? contextPackReviewedPayload}); const memoryConfig=normalizeMemoryWorkspaceConfig(contextPackResult?.memoryConfig ?? contextPackMemoryConfig); const sourceFamilies=String(payload?.from ?? 'codex').split(',').map((item)=>item.trim()).filter(Boolean); + const objectiveFromUrl=String(new URL(globalThis.location?.href??'http://127.0.0.1/handoffs').searchParams.get('objective')??'').trim().slice(0,2000); return { targetHarness:String(payload?.targetHarness ?? 'codex'), tokenBudget:String(Number(payload?.tokenBudget ?? 4096)), sourceFamilies:sourceFamilies.length ? sourceFamilies : ['codex'], - objective:String(payload?.objective ?? DEFAULT_CONTEXT_PACK_OBJECTIVE), + objective:objectiveFromUrl||String(payload?.objective ?? DEFAULT_CONTEXT_PACK_OBJECTIVE), step:String(payload?.step ?? DEFAULT_CONTEXT_PACK_STEP), userSelectedFiles:(payload?.userSelectedFiles ?? []).join('\n'), changedLocators:(payload?.changedLocators ?? []).map((locator)=>String(locator).replace(/^workspace:\/\//u,'')).join('\n'), @@ -2678,7 +2648,40 @@ function navigateLocal(event) { document.querySelector('#main').focus({preventScroll:true}); } -async function submitGlobalSearch(event){event.preventDefault();const input=event.currentTarget.elements.query;const query=String(input?.value??'').trim().slice(0,256);if(!query){input?.focus();return}const params=new URLSearchParams({query});const currentWorkspace=workspaceId();if(currentWorkspace!=='ws_local')params.set('workspaceId',currentWorkspace);history.pushState({},'',`/map?${params.toString()}`);document.querySelector('#live-status').textContent='Searching bounded repository metadata.';await loadRecallMap();render();document.querySelector('#main').focus({preventScroll:true});document.querySelector('#live-status').textContent=recallMapError?`Repository search failed. ${buildApiErrorUiModel(recallMapError).message}`:'Repository search loaded.'} +const COMMAND_TARGETS=Object.freeze({ + explain:(value)=>`/map?query=${encodeURIComponent(value)}`, + trace:(value)=>`/map?query=${encodeURIComponent(value)}&start=${encodeURIComponent(value)}`, + impact:(value)=>`/map?query=${encodeURIComponent(value)}&changed=${encodeURIComponent(value)}`, + handoff:(value)=>`/handoffs?objective=${encodeURIComponent(value)}` +}); + +async function submitGlobalSearch(event){ + event.preventDefault(); + const input=event.currentTarget.elements.query; + const query=String(input?.value??'').trim().slice(0,256); + if(!query){ + input?.focus(); + document.querySelector('#live-status').textContent='Enter a file, symbol, concept, or path.'; + return; + } + const intent=String(event.currentTarget.elements.intent?.value??'explain'); + const target=(COMMAND_TARGETS[intent]??COMMAND_TARGETS.explain)(query); + const url=new URL(target,'http://127.0.0.1'); + const currentWorkspace=workspaceId(); + if(currentWorkspace!=='ws_local')url.searchParams.set('workspaceId',currentWorkspace); + history.pushState({},'',`${url.pathname}${url.search}`); + if(intent==='handoff'){ + render(); + document.querySelector('#main').focus({preventScroll:true}); + document.querySelector('#live-status').textContent='Handoff objective filled in for review.'; + return; + } + document.querySelector('#live-status').textContent='Searching bounded repository metadata.'; + await loadRecallMap(); + render(); + document.querySelector('#main').focus({preventScroll:true}); + document.querySelector('#live-status').textContent=recallMapError?`Repository search failed. ${buildApiErrorUiModel(recallMapError).message}`:'Repository search loaded.'; +} function selectFabricNode(event) { activeFabricNode=event.currentTarget.dataset.fabricNode ?? 'context'; diff --git a/apps/web/index.html b/apps/web/index.html index 571712e3..54dedfbf 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -36,11 +36,19 @@
Branch unavailable Not scanned + Local only / External writes off
Loading diff --git a/apps/web/orientation-view.js b/apps/web/orientation-view.js new file mode 100644 index 00000000..d38e3b51 --- /dev/null +++ b/apps/web/orientation-view.js @@ -0,0 +1,110 @@ +import { escapeHtml, formatDate, renderApiErrorPanel, statePanel } from './ui-primitives.js'; + +export function renderOrientation(model) { + if (model.state === 'loading') return orientationState('loading', model.title, model.copy, 'Scan repository'); + if (model.state === 'failure' || model.state === 'error') { + return `

Overview

${renderApiErrorPanel(model.title, model.error)}
`; + } + if (model.state === 'empty' && !model.repository) return orientationState('empty', model.title, model.copy, 'Scan repository'); + + const groups = model.groups ?? []; + const selectedGroup = groups.find(({ id }) => id === model.selectedGroupId) ?? groups[0] ?? null; + const maxLayer = Math.max(0, ...groups.map(({ layer = 0 }) => layer)); + const coverageLabel = coverageText(model.coverage); + const architecture = groups.length + ? `
${groups.map((group) => groupButton(group, model.selectedGroupId)).join('')}${renderRelations(model.relations, groups)}
${architectureOutline(groups, model.selectedGroupId)}` + : statePanel('empty', 'No supported groups', 'The scan did not find JavaScript or TypeScript groups inside its current bounds.'); + + return `
+
+

Overview

${escapeHtml(repositoryLine(model))}

+ ${escapeHtml(coverageLabel)} +
+
+
+

Architecture

${escapeHtml(selectedGroupCopy(selectedGroup))}

${selectedGroup ? `Open in Map` : ''}
+ ${architecture} +
+ +
+
`; +} + +export function bindOrientation(root, { onSelectGroup, onRefresh } = {}) { + if (!root) return () => {}; + const controller = new AbortController(); + const options = { signal: controller.signal }; + root.querySelectorAll('[data-group-id]').forEach((button) => button.addEventListener('click', () => onSelectGroup?.(button.dataset.groupId), options)); + root.querySelectorAll('[data-action="refresh-recall-map"]').forEach((button) => button.addEventListener('click', (event) => onRefresh?.(event), options)); + return () => controller.abort(); +} + +function orientationState(kind, title, copy, actionLabel) { + return `

Overview

${statePanel(kind, title, copy)}
`; +} + +function groupButton(group, selectedGroupId) { + const selected = group.id === selectedGroupId; + return ``; +} + +function renderRelations(relations = [], groups = []) { + const byId = new Map(groups.map((group) => [group.id, group])); + if (!relations.length) return ''; + return ``; +} + +function architectureOutline(groups, selectedGroupId) { + return `
Repository architecture outline
    ${groups.map((group) => `
  1. `).join('')}
`; +} + +function renderStartHere(items = []) { + const content = items.length + ? `
    ${items.map((item) => `
  1. ${escapeHtml(item.label)}${escapeHtml(item.locator)}${escapeHtml(item.reason)}
  2. `).join('')}
` + : '

No ranked entry points in the current bounds.

'; + return `

Start here

${items.length} ranked
${content}
`; +} + +function renderImpact(impact = {}) { + const affected = impact.affectedSymbols ?? []; + const detail = impact.status === 'clean' + ? '

No local changes detected.

' + : `

${escapeHtml(impact.label ?? 'Change state unavailable')}

${affected.length ? `
    ${affected.slice(0, 3).map((item) => `
  1. ${escapeHtml(item.label)}${escapeHtml(item.locator)}
  2. `).join('')}
` : ''}${impact.unrepresentedChangedCount ? `${Number(impact.unrepresentedChangedCount)} changed file${impact.unrepresentedChangedCount === 1 ? '' : 's'} outside the represented graph.` : ''}`; + return `

Current impact

${Number(impact.affectedCount ?? 0)} affected
${detail}
`; +} + +function renderTrust(trust = {}) { + const memory = trust.memory ?? {}; + const handoff = trust.handoff ?? {}; + const source = trust.source ?? {}; + return `

Trusted context

Source map
${escapeHtml(source.status ?? 'not scanned')}
Memory
${escapeHtml(memory.status ?? 'unavailable')}${memory.pendingCount ? `, ${Number(memory.pendingCount)} pending` : ''}
Handoff
${escapeHtml(handoff.status ?? 'not pinned')}
${trust.deliveryReduction ? `
Measured delivery
${Number(trust.deliveryReduction.percent)}% fewer tokens
` : ''}
`; +} + +function coverageText(coverage = {}) { + const status = coverage.status === 'failed' ? 'Unavailable' : titleCase(coverage.status || 'not scanned'); + const built = coverage.builtAt ? ` / built ${formatDate(coverage.builtAt)}` : ''; + return `${status}${built}`; +} + +function repositoryLine(model) { + const repository = model.repository ?? {}; + return [repository.name, repository.branch, model.coverage?.label].filter(Boolean).join(' / '); +} + +function selectedGroupCopy(group) { + return group ? `${group.prefix}. ${group.fileCount} files and ${group.symbolCount} symbols.` : 'Bounded JavaScript and TypeScript structure.'; +} + +function titleCase(value) { + const text = String(value ?? '').replaceAll('-', ' '); + return text ? text[0].toUpperCase() + text.slice(1) : ''; +} diff --git a/apps/web/styles.css b/apps/web/styles.css index c6077022..4bf53b5b 100644 --- a/apps/web/styles.css +++ b/apps/web/styles.css @@ -50,13 +50,16 @@ main{min-width:0} .repository-popover{position:absolute;z-index:30;top:calc(100% + var(--space-2xs));left:0;width:min(360px,calc(100vw - 32px));display:grid;gap:var(--space-2xs);padding:var(--space-sm);border:1px solid var(--color-rule-strong);border-radius:var(--radius-panel);background:var(--color-panel);box-shadow:0 12px 32px color-mix(in oklch,var(--color-ink) 18%,transparent)} .repository-popover p{margin:0;color:var(--color-muted)} .repository-popover code{padding:var(--space-2xs);background:var(--color-panel-muted);border-radius:var(--radius-control)} -.global-search{flex:1 1 280px;max-width:520px;display:grid;grid-template-columns:minmax(120px,1fr) auto;align-items:center} -.global-search input{min-height:44px;min-width:0;border:1px solid var(--color-rule-strong);border-radius:var(--radius-control) 0 0 var(--radius-control);padding:0 var(--space-xs);color:var(--color-ink);background:var(--color-panel)} +.global-search{flex:1 1 360px;max-width:620px;display:grid;grid-template-columns:148px minmax(140px,1fr) auto;align-items:center} +.global-search select,.global-search input{min-height:44px;min-width:0;border:1px solid var(--color-rule-strong);padding:0 var(--space-xs);color:var(--color-ink);background:var(--color-panel);font:inherit} +.global-search select{border-radius:var(--radius-control) 0 0 var(--radius-control)} +.global-search input{min-height:44px;border-left:0;border-radius:0} .global-search button{min-height:44px;border:1px solid var(--color-rule-strong);border-left:0;border-radius:0 var(--radius-control) var(--radius-control) 0;padding:0 var(--space-xs);color:var(--color-ink);background:var(--color-panel);cursor:pointer} .global-search button:hover{border-color:var(--color-accent)} .condition{color:var(--color-muted);text-transform:capitalize} .condition[data-state="error"],.condition[data-state="denied"]{color:var(--color-danger)} .condition[data-state="stale"],.condition[data-state="partial"]{color:var(--color-ink);text-decoration:underline;text-decoration-color:var(--color-warning);text-underline-offset:2px} +#repository-boundary[data-state="warning"]{color:var(--color-danger);font-weight:650} .icon-link{min-height:44px;display:inline-flex;align-items:center;color:var(--color-ink)} #view-root{padding:var(--space-lg) clamp(var(--space-sm),4vw,var(--space-xl)) var(--space-2xl)} .eyebrow{margin:0 0 var(--space-3xs);color:var(--slate);font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.08em} @@ -298,6 +301,55 @@ th{color:var(--slate);font-size:12px;text-transform:uppercase;letter-spacing:.08 .change-detection-warning{display:grid;gap:var(--space-2xs)} .change-detection-warning p{margin:0;color:var(--color-muted)} .change-detection-warning .button{justify-self:start} +.orientation-workbench{max-width:1440px;margin:0 auto} +.orientation-heading{display:flex;align-items:baseline;justify-content:space-between;gap:var(--space-sm);padding-bottom:var(--space-sm);border-bottom:1px solid var(--color-rule)} +.orientation-heading>div{min-width:0} +.orientation-heading h1{font-size:24px;letter-spacing:-.02em} +.orientation-heading p{margin:var(--space-3xs) 0 0;color:var(--color-muted)} +.coverage-label{color:var(--color-muted);font-size:12px;text-align:right} +.coverage-label[data-status="partial"],.coverage-label[data-status="stale"],.coverage-label[data-status="failed"]{color:var(--color-ink)} +.orientation-layout{display:grid;grid-template-columns:minmax(0,7fr) minmax(280px,3fr);gap:var(--space-lg);padding-top:var(--space-md);align-items:start} +.architecture-region{min-width:0} +.region-heading{display:flex;align-items:start;justify-content:space-between;gap:var(--space-sm);margin-bottom:var(--space-sm)} +.region-heading h2,.orientation-inspector h2{margin:0;font-size:15px} +.region-heading p{margin:var(--space-3xs) 0 0;color:var(--color-muted)} +.region-heading a{min-height:44px;display:inline-flex;align-items:center;white-space:nowrap} +.architecture-board{position:relative;display:grid;grid-template-columns:repeat(var(--orientation-layers),minmax(0,1fr));grid-auto-flow:column;gap:var(--space-xs);align-items:start;padding:var(--space-sm);border:1px solid var(--color-rule);border-radius:var(--radius-panel);background:var(--color-panel-muted)} +.orientation-group{grid-column:calc(var(--orientation-layer) + 1);min-height:64px;display:grid;align-content:center;gap:2px;min-width:0;border:1px solid var(--color-rule);border-radius:var(--radius-control);padding:var(--space-2xs) var(--space-xs);background:var(--color-panel);color:var(--color-ink);box-shadow:none;text-align:left;cursor:pointer} +.orientation-group:hover{border-color:var(--color-rule-strong)} +.orientation-group.is-selected{border-color:var(--color-accent);box-shadow:inset 3px 0 0 var(--color-accent)} +.orientation-group strong,.orientation-group span,.orientation-group small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.orientation-group span,.orientation-group small{color:var(--color-muted)} +.orientation-group small{font-size:11px} +.orientation-relations{grid-column:1/-1;display:flex;flex-wrap:wrap;gap:var(--space-2xs) var(--space-sm);padding-top:var(--space-3xs);color:var(--color-muted);font-size:11px} +.orientation-relations span{display:inline-flex;align-items:center;gap:var(--space-3xs)} +.orientation-relations span:before{content:"";width:18px;border-top:1px solid var(--color-rule-strong)} +.orientation-relations b{font-weight:650;color:var(--color-ink)} +.architecture-outline{margin-top:var(--space-xs);border-top:1px solid var(--color-rule)} +.architecture-outline summary{min-height:44px;display:flex;align-items:center;cursor:pointer;font-weight:650} +.architecture-outline ol{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--space-2xs)} +.architecture-outline button{width:100%;min-height:44px;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:var(--space-xs);align-items:center;border:0;border-radius:var(--radius-control);padding:var(--space-2xs);background:transparent;color:var(--color-ink);text-align:left;cursor:pointer} +.architecture-outline button:hover{background:var(--color-panel-muted)} +.architecture-outline button span{color:var(--color-muted);font-size:11px;white-space:nowrap} +.orientation-inspector{min-width:0} +.orientation-inspector section{padding:var(--space-sm) 0;border-top:1px solid var(--color-rule)} +.orientation-inspector section:first-child{padding-top:0;border-top:0} +.orientation-inspector section>header{display:flex;align-items:baseline;justify-content:space-between;gap:var(--space-xs);margin-bottom:var(--space-xs)} +.orientation-inspector section>header span{color:var(--color-muted);font-size:12px} +.orientation-inspector ol{list-style:none;margin:0;padding:0} +.start-item a{min-height:52px;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:2px var(--space-xs);padding:var(--space-2xs) 0;border-top:1px solid var(--color-rule);color:inherit;text-decoration:none} +.start-item:first-child a{border-top:0} +.start-item code{grid-column:1/-1;color:var(--color-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.start-item span{color:var(--color-muted);font-size:12px;white-space:nowrap} +.orientation-inspector p{margin:0;color:var(--color-muted)} +.orientation-inspector small{display:block;margin-top:var(--space-2xs);color:var(--color-muted)} +.orientation-inspector section ol:not(:first-child){margin-top:var(--space-2xs)} +.orientation-inspector section ol li:not(.start-item){display:grid;gap:2px;padding:var(--space-2xs) 0} +.orientation-inspector section ol code{color:var(--color-muted)} +.trust-list{margin:0;display:grid;grid-template-columns:1fr 1fr;gap:var(--space-2xs) var(--space-sm)} +.trust-list div{min-width:0} +.trust-list dt{color:var(--color-muted);font-size:11px} +.trust-list dd{margin:2px 0 0;font-weight:650;overflow-wrap:anywhere} .tool-workspace{max-width:1280px;margin:0 auto} .tool-page-heading{display:flex;align-items:end;justify-content:space-between;gap:var(--space-lg);padding-bottom:var(--space-md);margin-bottom:var(--space-md);border-bottom:1px solid var(--color-rule)} .tool-page-heading>div{min-width:0} @@ -447,6 +499,9 @@ th{color:var(--slate);font-size:12px;text-transform:uppercase;letter-spacing:.08 @media(max-width:900px){ .overview-grid{grid-template-columns:1fr} .overview-section{grid-column:1;padding:var(--space-md) 0!important;border-right:0!important} + .orientation-layout{grid-template-columns:1fr} + .orientation-inspector{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--space-sm);border-top:1px solid var(--color-rule);padding-top:var(--space-sm)} + .orientation-inspector section,.orientation-inspector section:first-child{padding:0;border:0} } @media(max-width:700px){ @@ -457,9 +512,17 @@ th{color:var(--slate);font-size:12px;text-transform:uppercase;letter-spacing:.08 .repository-bar{min-height:56px;padding:var(--space-2xs) var(--space-xs)} .repository-identity{display:none} .repository-menu summary{max-width:96px} - .global-search{flex-basis:130px;grid-template-columns:minmax(80px,1fr)} + .global-search{flex-basis:170px;grid-template-columns:44px minmax(80px,1fr)} + .global-search select{width:44px;padding:0 4px;color:transparent} + .global-search select:focus{width:136px;color:var(--color-ink);position:relative;z-index:2} .global-search button{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0} .global-search input{border-radius:var(--radius-control)} + .orientation-heading{align-items:flex-start} + .orientation-heading p{display:none} + .architecture-board{grid-template-columns:1fr;grid-auto-flow:row;padding:var(--space-xs)} + .orientation-group{grid-column:1!important} + .architecture-outline ol{grid-template-columns:1fr} + .orientation-inspector{grid-template-columns:1fr} #repository-condition{display:none} #view-root{padding:var(--space-md) var(--space-xs) var(--space-xl)} h1{font-size:32px} diff --git a/docs/superpowers/plans/2026-07-15-memory-recall-orientation-workbench-ui.md b/docs/superpowers/plans/2026-07-15-memory-recall-orientation-workbench-ui.md index dfc85abf..1325a28f 100644 --- a/docs/superpowers/plans/2026-07-15-memory-recall-orientation-workbench-ui.md +++ b/docs/superpowers/plans/2026-07-15-memory-recall-orientation-workbench-ui.md @@ -453,7 +453,7 @@ git commit -m "feat: model repository orientation deterministically" - Produces: `bindOrientation(root, { onSelectGroup, onRefresh }): () => void`. - Consumes: `OrientationModel` from Task 2 and primitives from Task 1. -- [ ] **Step 1: Write failing minimalist Overview markup tests** +- [x] **Step 1: Write failing minimalist Overview markup tests** Add to `tests/web-orientation.test.mjs`: @@ -487,7 +487,7 @@ test('repository truth bar names local and external-write state', async () => { }); ``` -- [ ] **Step 2: Run the test and verify the missing renderer** +- [x] **Step 2: Run the test and verify the missing renderer** Run: @@ -497,7 +497,7 @@ node --test --test-name-pattern="first-ten-seconds" tests/web-orientation.test.m Expected: FAIL because `renderOrientation` does not exist. -- [ ] **Step 3: Implement semantic orientation markup** +- [x] **Step 3: Implement semantic orientation markup** Render this macrostructure, with no wrapper cards around the three secondary sections: @@ -518,7 +518,7 @@ Group nodes are `No model or network calls
${errorPanel}${sourceGraphResult?renderSourceGraphResult(sourceGraphResult):statePanel('empty','No map results','Run the map to inspect files, symbols, import neighbors, and likely starting points.')}`; + return renderSourceMap({ + state:parseMapUrl(globalThis.location?.href), + report:sourceGraphResult, + error:sourceGraphError, + loading:sourceGraphLoading + }); } function renderRecallMapSearchResults(report,query){const search=report?.architecture?.search;const results=Array.isArray(search?.results)?search.results:[];return `

Search results

${Number(search?.total??0)} matches for ${esc(query)}
${results.length?`
    ${results.map((item)=>`
  1. ${esc(item.label)}${esc(item.locator)}
  2. `).join('')}
`:'

No bounded source-graph matches.

'}
`} @@ -2320,76 +2367,6 @@ export function renderRepositorySearchState({query='',report=null,error=null}={} return renderRecallMapSearchResults(report,query); } -export function renderSourceGraphResult(report) { - const summary=report.graph?.summary ?? {}; - return `

Map results

Repo Map

Files
${Number(summary.fileCount??0)}
Symbols
${Number(summary.symbolCount??0)}
Relations
${Number(summary.edgeCount??0)}
Matches
${Number(report.search?.total??0)}
${renderRepoMap(report)}

Search results

${Number(report.search?.total??0)} matches
${sourceGraphSearchList(report.search?.results)}

Trace

${report.trace?.paths?.length??0} paths
${sourceGraphTraceList(report.trace?.paths)}

Changed impact

${report.impact?.affectedSymbols?.length??0} symbols
${sourceGraphImpactList(report.impact?.affectedSymbols)}
Scan details${sourceGraphSafeguards(report.safeguards)}

Sample nodes

${report.graph?.sampleNodes?.length??0}
${sourceGraphNodeList(report.graph?.sampleNodes)}${esc(shortFingerprint(report.graph?.graphFingerprint))}
`; -} - -function renderRepoMap(report) { - const nodes=report.graph?.sampleNodes ?? []; - const byId=new Map(nodes.map((node)=>[node.id,node])); - const files=nodes.filter((node)=>node.kind==='file').slice(0,6); - const symbols=uniqueBy([ - ...(report.graph?.summary?.entryPoints ?? []), - ...(report.graph?.summary?.hotspots ?? []), - ...nodes.filter((node)=>node.kind==='symbol') - ],(item)=>item.locator ?? item.label).slice(0,6); - const imports=(report.graph?.sampleEdges ?? []).filter((edge)=>edge.kind==='imports').map((edge)=>{ - const from=byId.get(edge.fromNodeId); - const to=byId.get(edge.toNodeId); - return from&&to?`${from.label} -> ${to.label}`:''; - }).filter(Boolean).slice(0,6); - const readFirst=(report.graph?.summary?.entryPoints ?? []).map((item)=>item.locator).filter(Boolean).slice(0,6); - return `

Repo Map

${files.length} files
${sourceGraphStartHere(report)}

Read first

${sourceGraphTextList(readFirst)}

Files

${sourceGraphNodeList(files)}
`; -} - -function uniqueBy(items,key) { - const seen=new Set(); - return items.filter((item)=>{const value=key(item);if(!value||seen.has(value))return false;seen.add(value);return true;}); -} - -function sourceGraphStartHere(report) { - const entry=report.graph?.summary?.entryPoints?.[0]; - const changed=report.impact?.representedChangedLocators?.[0] ?? report.impact?.changedLocators?.[0]; - const affected=report.impact?.affectedSymbols?.[0]; - if(!entry && !changed && !affected)return ''; - return `

Start here

${entry?`Begin at ${esc(entry.label)} (${esc(entry.locator)}).`:'Use the read-first list below.'}${changed?` Changed impact: ${esc(changed)}${affected?` touches ${esc(affected.name)}`:''}.`:''}

`; -} - -function sourceGraphSymbolList(symbols=[]) { - if(!symbols.length)return '

No key symbols in the bounded preview.

'; - return `
    ${symbols.map((symbol)=>`
  1. ${esc(symbol.label)}${esc(symbol.symbolKind??'symbol')} · ${esc(symbol.locator??`${Number(symbol.total??0)} links`)}
  2. `).join('')}
`; -} - -function sourceGraphTextList(items=[]) { - if(!items.length)return '

No bounded preview items.

'; - return `
    ${items.map((item)=>`
  1. ${esc(item)}
  2. `).join('')}
`; -} - -function sourceGraphSearchList(results=[]) { - if(!results.length)return '

No matching graph records.

'; - return `
    ${results.map((item)=>`
  1. ${esc(item.label)}${esc(item.kind)} · ${esc(item.locator??'no locator')} · ${Number(item.score??0).toFixed(3)}
  2. `).join('')}
`; -} - -function sourceGraphNodeList(nodes=[]) { - if(!nodes.length)return '

No sample nodes.

'; - return `
    ${nodes.map((node)=>`
  1. ${esc(node.label)}${esc(node.kind)} · ${esc(node.locator??node.sourceRef??node.id)}
  2. `).join('')}
`; -} - -function sourceGraphTraceList(paths=[]) { - if(!paths.length)return '

No trace paths for the selected symbol.

'; - return `
    ${paths.map((path)=>`
  1. ${esc(path.terminalLabel)}depth ${Number(path.depth??0)} · ${path.nodeIds?.length??0} nodes
  2. `).join('')}
`; -} - -function sourceGraphImpactList(symbols=[]) { - if(!symbols.length)return '

No impacted symbols for the supplied locator.

'; - return `
    ${symbols.map((symbol)=>`
  1. ${esc(symbol.name)}${esc(symbol.symbolKind)} · ${esc(symbol.locator)}
  2. `).join('')}
`; -} - -function sourceGraphSafeguards(safeguards={}) { - return `
Persisted
${safeguards.persisted?'yes':'no'}
Model calls
${Number(safeguards.modelCalls??0)}
Network
${Number(safeguards.networkCalls??0)}
Graph DB
${safeguards.graphDatabaseUsed?'yes':'no'}
Raw bodies
${safeguards.rawBodyIncluded?'included':'excluded'}
`; -} - function renderMemory() { if(memoryCockpitError)return statePanel('error','Memory cockpit unavailable',memoryCockpitError); return renderMemoryCockpit(memoryCockpit); @@ -2633,10 +2610,11 @@ function keyValueFacts(items){ return `
${items.map((item)=>`
${esc(item.key)}
${esc(item.value)}
`).join('')}
`; } -function navigate(event) { +async function navigate(event) { event.preventDefault(); activeRunDetail=null; history.pushState({},'',event.currentTarget.getAttribute('href')); + if(currentRoute().id==='source-graph')await loadSourceMap(parseMapUrl(globalThis.location?.href)); render(); document.querySelector('#main').focus({preventScroll:true}); } @@ -2676,11 +2654,11 @@ async function submitGlobalSearch(event){ document.querySelector('#live-status').textContent='Handoff objective filled in for review.'; return; } - document.querySelector('#live-status').textContent='Searching bounded repository metadata.'; - await loadRecallMap(); + document.querySelector('#live-status').textContent='Loading bounded repository metadata.'; + await loadSourceMap(parseMapUrl(globalThis.location?.href)); render(); document.querySelector('#main').focus({preventScroll:true}); - document.querySelector('#live-status').textContent=recallMapError?`Repository search failed. ${buildApiErrorUiModel(recallMapError).message}`:'Repository search loaded.'; + document.querySelector('#live-status').textContent=sourceGraphError?`Map request failed. ${buildApiErrorUiModel(sourceGraphError).message}`:'Map loaded.'; } function selectFabricNode(event) { @@ -3015,41 +2993,22 @@ async function pinCurrentContextPack(event) { } } -async function submitSourceGraph(event){ - event.preventDefault(); - const form=event.currentTarget; - const button=form.querySelector('button[type=submit]'); - const data=new FormData(form); - const query=String(data.get('query') ?? '').trim(); - const startName=String(data.get('startName') ?? '').trim(); - const changedLocator=String(data.get('changedLocator') ?? '').trim(); - const limit=Number(data.get('limit') ?? 8); - const depth=Number(data.get('depth') ?? 2); - const body={ - workspaceId:workspaceId(), - limit, - depth, - sampleLimit:6 - }; - if(query)body.query=query; - if(startName)body.startName=startName; - if(changedLocator)body.changedLocators=[changedLocator]; - button.disabled=true; - button.textContent='Previewing...'; - document.querySelector('#live-status').textContent='Previewing local source graph.'; - try{ - sourceGraphResult=await api('/api/context/graph/preview',{method:'POST',body:JSON.stringify(body)}); - sourceGraphError=null; - document.querySelector('#live-status').textContent='Source graph preview ready.'; - render(); - }catch(error){ - document.querySelector('#live-status').textContent=error.message; - sourceGraphError=error; - render(); - }finally{ - button.disabled=false; - button.textContent='Preview repo map'; - } +async function submitSourceGraph(state){ + const target=serializeMapUrl(state); + const current=`${globalThis.location?.pathname??''}${globalThis.location?.search??''}`; + if(target!==current)history.pushState({},'',target); + document.querySelector('#live-status').textContent='Loading the submitted map scope.'; + await loadSourceMap(state); + render(); + document.querySelector('#live-status').textContent=sourceGraphError?`Map request failed. ${buildApiErrorUiModel(sourceGraphError).message}`:'Map loaded.'; +} + +async function refreshSourceGraph(){ + const state=parseMapUrl(globalThis.location?.href); + document.querySelector('#live-status').textContent='Refreshing the local source scan.'; + await loadSourceMap(state,{refresh:true}); + render(); + document.querySelector('#live-status').textContent=sourceGraphError?`Map refresh failed. ${buildApiErrorUiModel(sourceGraphError).message}`:'Map refreshed.'; } async function submitMemoryGraph(event){ @@ -3478,7 +3437,7 @@ function boot(){ document.querySelector('#reset-button')?.addEventListener('click',resetDemo); document.querySelector('#global-search-form')?.addEventListener('submit',submitGlobalSearch); document.addEventListener('keydown',(event)=>{if(event.key==='Escape'){const menu=document.querySelector('#repository-menu[open]');if(menu){menu.open=false;menu.querySelector('summary')?.focus()}}}); - window.addEventListener('popstate',async()=>{activeRunDetail=null;const runId=new URL(location.href).searchParams.get('run');if(currentRoute().id==='runs'&&runId)loadRunById(runId,{push:false});else if(currentRoute().id==='source-graph'){await loadRecallMap();render()}else render()}); + window.addEventListener('popstate',async()=>{activeRunDetail=null;const runId=new URL(location.href).searchParams.get('run');if(currentRoute().id==='runs'&&runId)loadRunById(runId,{push:false});else if(currentRoute().id==='source-graph'){await loadSourceMap(parseMapUrl(location.href));render()}else render()}); const runId=new URL(location.href).searchParams.get('run'); load().then(()=>{if(runId)loadRunById(runId,{push:false})}); } diff --git a/apps/web/source-map-view.js b/apps/web/source-map-view.js new file mode 100644 index 00000000..48c07780 --- /dev/null +++ b/apps/web/source-map-view.js @@ -0,0 +1,234 @@ +import { escapeHtml, formatDate, renderApiErrorPanel, statePanel } from './ui-primitives.js'; + +const DEFAULT_DEPTH = 2; +const DEFAULT_LIMIT = 20; + +export function parseMapUrl(input = '/map') { + const url = new URL(String(input || '/map'), 'http://127.0.0.1'); + const query = boundedText(url.searchParams.get('query'), 512); + const group = boundedText(url.searchParams.get('group'), 512); + const startName = boundedText(url.searchParams.get('start'), 240); + const changedLocator = boundedText(url.searchParams.get('changed'), 512); + const depth = boundedInteger(url.searchParams.get('depth'), DEFAULT_DEPTH, 1, 5); + const limit = boundedInteger(url.searchParams.get('limit'), DEFAULT_LIMIT, 1, 100); + return { + query, + group, + startName, + changedLocator, + depth, + limit, + advanced: Boolean(startName || changedLocator || depth !== DEFAULT_DEPTH || limit !== DEFAULT_LIMIT) + }; +} + +export function serializeMapUrl(value = {}) { + const state = normalizeMapState(value); + const params = new URLSearchParams(); + if (state.query) params.set('query', state.query); + if (state.group) params.set('group', state.group); + if (state.startName) params.set('start', state.startName); + if (state.changedLocator) params.set('changed', state.changedLocator); + if (state.depth !== DEFAULT_DEPTH) params.set('depth', String(state.depth)); + if (state.limit !== DEFAULT_LIMIT) params.set('limit', String(state.limit)); + const query = params.toString(); + return query ? `/map?${query}` : '/map'; +} + +export function buildMapRequest(value = {}) { + const state = normalizeMapState(value); + return compactObject({ + query: state.query || null, + locatorPrefix: state.group || null, + startName: state.startName || null, + changedLocators: state.changedLocator ? [state.changedLocator] : null, + depth: state.depth, + limit: state.limit, + sampleLimit: 50 + }); +} + +export function mapStateFromForm(form) { + const data = new FormData(form); + return normalizeMapState({ + query: data.get('query'), + group: data.get('group'), + startName: data.get('startName'), + changedLocator: data.get('changedLocator'), + depth: data.get('depth'), + limit: data.get('limit') + }); +} + +export function renderSourceMap({ state: value = {}, report = null, error = null, loading = false } = {}) { + const state = normalizeMapState(value); + const result = error + ? renderApiErrorPanel('Map request failed', error) + : loading + ? statePanel('loading', 'Reading repository map', 'Loading bounded source metadata from the local API.') + : report + ? renderMapResult(report, state) + : statePanel('empty', 'Map not loaded', 'Run the map to inspect bounded repository structure.'); + + return `
+

Repository structure

Map

Find an entry point, trace a symbol, or inspect a changed file.

Read-only / bounded metadata
+ ${renderMapForm(state)} + ${result} +
`; +} + +export function bindSourceMap(root, { onSubmit, onRefresh, onSelectNode } = {}) { + if (!root) return () => {}; + const controller = new AbortController(); + const options = { signal: controller.signal }; + root.querySelector('#source-graph-form')?.addEventListener('submit', (event) => { + event.preventDefault(); + onSubmit?.(mapStateFromForm(event.currentTarget), event); + }, options); + root.querySelector('[data-action="refresh-source-map"]')?.addEventListener('click', (event) => onRefresh?.(event), options); + root.querySelectorAll('[data-node-id]').forEach((button) => button.addEventListener('click', () => onSelectNode?.(button.dataset.nodeId), options)); + return () => controller.abort(); +} + +function renderMapForm(state) { + return `
+ +
+ Scope and trace +
+ + + + + +
+
+
No model, network, or external writes
+
`; +} + +function renderMapResult(report, state) { + const coverage = coverageModel(report); + const hasFocus = Boolean(state.query || state.group || state.startName || state.changedLocator); + const focusNodes = arrayValue(report.focus?.nodes); + const focusEdges = arrayValue(report.focus?.edges); + const groups = arrayValue(report.orientation?.groups); + const outlineNodes = hasFocus ? focusNodes : groups; + const content = hasFocus + ? renderFocus(focusNodes, focusEdges, report.focus) + : renderGroups(groups, arrayValue(report.orientation?.relations ?? report.orientation?.groupRelations)); + + return `
+

${hasFocus ? 'Focused map' : 'Repository groups'}

${escapeHtml(coverage.summary)}

${snapshotLabel(report.snapshot)}
+ ${coverage.status === 'partial' ? renderCoverageWarning(coverage) : ''} +
+
${content}
+ +
+
`; +} + +function renderGroups(groups, relations) { + if (!groups.length) return statePanel('empty', 'No supported groups', 'No JavaScript or TypeScript groups were represented inside the current scan bounds.'); + const byId = new Map(groups.map((group) => [group.id, group])); + return `
${groups.map((group) => `
${escapeHtml(group.prefix ?? group.label)}${number(group.fileCount)} files / ${number(group.symbolCount)} symbols${group.changedFileCount ? `${number(group.changedFileCount)} changed` : ''}
`).join('')}
${relations.length ? `
    ${relations.map((relation) => { + const source = byId.get(relation.sourceGroupId); + const target = byId.get(relation.targetGroupId); + return source && target ? `
  1. ${escapeHtml(source.prefix)}to${escapeHtml(target.prefix)}${number(relation.count)} relationships
  2. ` : ''; + }).join('')}
` : '

No cross-group relationships inside the current bounds.

'}`; +} + +function renderFocus(nodes, edges, focus = {}) { + if (!nodes.length) return statePanel('empty', 'No focused records', 'The submitted scope produced no bounded nodes. Broaden the query or remove a group filter.'); + return `
${number(nodes.length)} nodes${number(edges.length)} relationships${number(focus.omittedNodes ?? focus.omittedNodeCount) ? `${number(focus.omittedNodes ?? focus.omittedNodeCount)} nodes omitted` : ''}${number(focus.omittedEdges ?? focus.omittedEdgeCount) ? `${number(focus.omittedEdges ?? focus.omittedEdgeCount)} relationships omitted` : ''}
    ${nodes.map((node) => `
  1. `).join('')}
`; +} + +function renderMapOutline(items, focused) { + if (!items.length) return '

No records in the current outline.

'; + return `
    ${items.map((item, index) => `
  1. `).join('')}
`; +} + +function renderSelection(item) { + if (!item) return '

No selection.

'; + return `
Name
${escapeHtml(item.label ?? item.prefix)}
Type
${escapeHtml(item.kind ?? 'repository group')}
${item.locator ? `
Locator
${escapeHtml(item.locator)}
` : ''}
`; +} + +function renderSourceTruth(report, coverage) { + const safeguards = report.safeguards ?? {}; + return `

Scan truth

Coverage
${escapeHtml(coverage.status)}
Represented
${number(coverage.representedFiles)} files
Model calls
${number(safeguards.modelCalls)}
Network calls
${number(safeguards.networkCalls)}
External writes
${safeguards.externalWritesEnabled ? 'on' : 'off'}
`; +} + +function renderCoverageWarning(coverage) { + const reasons = coverage.reasonCodes.map(reasonLabel); + return `
Partial coverage

${escapeHtml(coverage.summary)}

${reasons.length ? `
    ${reasons.map((reason) => `
  • ${escapeHtml(reason)}
  • `).join('')}
` : ''}
`; +} + +function coverageModel(report) { + const source = report.coverage ?? report.graph?.summary?.coverage ?? {}; + const representedFiles = number(source.representedFileCount ?? report.graph?.summary?.fileCount); + const omittedFiles = number(source.omittedFileCount ?? ( + number(source.skippedFileCount) + number(source.oversizedFileCount) + number(source.unsupportedFileCount) + )); + const omittedEdges = number(source.omittedEdgeCount); + const status = source.status === 'partial' ? 'partial' : 'complete'; + const parts = [`${formatNumber(representedFiles)} represented files`]; + if (omittedFiles) parts.push(`${formatNumber(omittedFiles)} omitted files`); + if (omittedEdges) parts.push(`${formatNumber(omittedEdges)} omitted relationships`); + return { status, representedFiles, omittedFiles, omittedEdges, reasonCodes: arrayValue(source.reasonCodes), summary: parts.join(' / ') }; +} + +function snapshotLabel(snapshot = {}) { + const status = escapeHtml(snapshot.status ?? 'unavailable'); + const reuse = snapshot.reuse && snapshot.reuse !== 'none' ? ` / ${escapeHtml(snapshot.reuse)}` : ''; + const built = snapshot.builtAt ? ` / ${escapeHtml(formatDate(snapshot.builtAt))}` : ''; + return `${status}${reuse}${built}`; +} + +function reasonLabel(value) { + const labels = { + file_budget_reached: 'File limit reached', + max_files_reached: 'File limit reached', + node_budget_reached: 'Node limit reached', + edge_budget_reached: 'Relationship limit reached' + }; + return labels[value] ?? String(value ?? '').replaceAll('_', ' '); +} + +function normalizeMapState(value) { + const query = boundedText(value.query, 512); + const group = boundedText(value.group, 512); + const startName = boundedText(value.startName, 240); + const changedLocator = boundedText(value.changedLocator, 512); + const depth = boundedInteger(value.depth, DEFAULT_DEPTH, 1, 5); + const limit = boundedInteger(value.limit, DEFAULT_LIMIT, 1, 100); + return { + query, group, startName, changedLocator, depth, limit, + advanced: Boolean(startName || changedLocator || depth !== DEFAULT_DEPTH || limit !== DEFAULT_LIMIT) + }; +} + +function boundedText(value, maximum) { + return String(value ?? '').trim().slice(0, maximum); +} + +function boundedInteger(value, fallback, minimum, maximum) { + const candidate = Number(value); + return Number.isInteger(candidate) && candidate >= minimum && candidate <= maximum ? candidate : fallback; +} + +function compactObject(value) { + return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== null && item !== undefined && item !== '')); +} + +function arrayValue(value) { + return Array.isArray(value) ? value : []; +} + +function number(value) { + const candidate = Number(value); + return Number.isFinite(candidate) && candidate >= 0 ? Math.floor(candidate) : 0; +} + +function formatNumber(value) { + return new Intl.NumberFormat('en-US').format(number(value)); +} diff --git a/apps/web/styles.css b/apps/web/styles.css index 4bf53b5b..f6e75ff0 100644 --- a/apps/web/styles.css +++ b/apps/web/styles.css @@ -357,6 +357,39 @@ th{color:var(--slate);font-size:12px;text-transform:uppercase;letter-spacing:.08 .tool-page-heading p{margin:var(--space-2xs) 0 0;color:var(--color-muted);max-width:var(--layout-prose)} .tool-page-heading>span{color:var(--color-muted);white-space:nowrap} .tool-query{padding:0 0 var(--space-md);margin-bottom:var(--space-md);border-bottom:1px solid var(--color-rule)} +.source-map-form{display:grid;grid-template-columns:minmax(240px,1fr) minmax(280px,1.4fr);gap:var(--space-sm);align-items:end} +.map-advanced{min-width:0;border:0} +.map-advanced summary{min-height:44px;display:flex;align-items:center;border-bottom:1px solid var(--color-rule);cursor:pointer;font-weight:650} +.map-advanced[open] summary{margin-bottom:var(--space-xs)} +.map-advanced-fields{display:grid;grid-template-columns:minmax(140px,1fr) minmax(140px,1fr) minmax(160px,1.2fr) 76px 76px;gap:var(--space-xs);align-items:end} +.map-query-actions{grid-column:1/-1;display:flex;align-items:center;gap:var(--space-xs);flex-wrap:wrap} +.map-query-actions>span{color:var(--color-muted);font-size:12px} +.source-map-result{display:grid;gap:var(--space-sm)} +.map-result-heading{display:flex;align-items:baseline;justify-content:space-between;gap:var(--space-sm);padding-bottom:var(--space-sm);border-bottom:1px solid var(--color-rule)} +.map-result-heading h2{margin:0;font-size:24px} +.map-result-heading p{margin:var(--space-3xs) 0 0;color:var(--color-muted)} +.map-snapshot{color:var(--color-muted);white-space:nowrap} +.map-coverage-warning{display:grid;grid-template-columns:max-content minmax(0,1fr) auto;gap:var(--space-xs);align-items:start;padding:var(--space-xs);border:1px solid var(--color-warning);border-radius:var(--radius-control)} +.map-coverage-warning p,.map-coverage-warning ul{margin:0} +.map-coverage-warning ul{display:flex;flex-wrap:wrap;gap:var(--space-2xs) var(--space-xs);padding:0;list-style:none;color:var(--color-muted)} +.source-map-layout{display:grid;grid-template-columns:minmax(0,7fr) minmax(280px,3fr);gap:var(--space-lg);align-items:start} +.source-map-stage{min-width:0} +.source-map-inspector{min-width:0;padding-left:var(--space-md);border-left:1px solid var(--color-rule)} +.source-map-inspector>h2,.source-map-truth h2{margin:0 0 var(--space-xs);font-size:15px} +.source-map-inspector>hr{margin:var(--space-sm) 0;border:0;border-top:1px solid var(--color-rule)} +.map-groups{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:var(--space-xs)} +.map-groups article{min-width:0;display:grid;gap:var(--space-3xs);padding:var(--space-xs);border:1px solid var(--color-rule);border-radius:var(--radius-control);background:var(--color-panel)} +.map-groups article span,.map-groups article small{color:var(--color-muted)} +.map-relations{margin:var(--space-sm) 0 0;padding:0;list-style:none;border-top:1px solid var(--color-rule)} +.map-relations li{min-height:44px;display:grid;grid-template-columns:minmax(0,1fr) auto minmax(0,1fr) auto;gap:var(--space-xs);align-items:center;border-bottom:1px solid var(--color-rule)} +.map-relations li span,.map-relations li small{color:var(--color-muted)} +.map-focus-summary{display:flex;flex-wrap:wrap;gap:var(--space-xs);padding-bottom:var(--space-xs);color:var(--color-muted)} +.map-focus-summary strong{color:var(--color-ink)} +.map-focus-list,.source-map-outline{margin:0;padding:0;list-style:none;border-top:1px solid var(--color-rule)} +.map-focus-list button,.source-map-outline button{width:100%;min-height:44px;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:var(--space-xs);align-items:center;padding:var(--space-2xs);border:0;border-bottom:1px solid var(--color-rule);border-radius:0;background:transparent;color:var(--color-ink);text-align:left;cursor:pointer} +.map-focus-list button:hover,.source-map-outline button:hover{background:var(--color-panel-muted)} +.map-focus-list button span,.source-map-outline button span{color:var(--color-muted);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.source-map-outline button[aria-current="true"]{box-shadow:inset 2px 0 var(--color-accent)} .query-options{display:grid;grid-template-columns:100px 100px max-content 1fr;gap:var(--space-xs);align-items:end} .query-options>.muted{align-self:center} .tool-result-heading{display:flex;align-items:end;justify-content:space-between;gap:var(--space-md);margin-bottom:var(--space-md)} @@ -480,6 +513,11 @@ th{color:var(--slate);font-size:12px;text-transform:uppercase;letter-spacing:.08 .tool-detail-grid>section:nth-child(odd){padding-left:0;border-right:1px solid var(--color-rule)} .tool-detail-grid>section:nth-child(even){padding-right:0} .tool-detail-grid>.tool-disclosure{padding-left:0;grid-column:1/-1} + .source-map-form{grid-template-columns:1fr} + .map-query-actions{grid-column:1} + .map-advanced-fields{grid-template-columns:repeat(2,minmax(0,1fr))} + .map-advanced-fields .field:nth-child(3){grid-column:1/-1} + .source-map-layout{grid-template-columns:minmax(0,1fr) minmax(240px,320px);gap:var(--space-md)} .flow-step:not(:last-child):after{display:none} } @@ -556,6 +594,16 @@ th{color:var(--slate);font-size:12px;text-transform:uppercase;letter-spacing:.08 .query-options .button,.query-options>.muted{grid-column:1/-1} .tool-detail-grid{grid-template-columns:1fr} .tool-detail-grid>section,.tool-detail-grid>section:nth-child(odd),.tool-detail-grid>section:nth-child(even),.tool-detail-grid>.tool-disclosure{padding:var(--space-sm) 0;border-right:0;grid-column:1} + .map-advanced-fields{grid-template-columns:1fr} + .map-advanced-fields .field:nth-child(3){grid-column:1} + .map-query-actions .button{flex:1 1 auto} + .map-result-heading{display:grid} + .map-snapshot{white-space:normal} + .map-coverage-warning{grid-template-columns:1fr} + .source-map-layout{grid-template-columns:1fr} + .source-map-inspector{padding:var(--space-sm) 0 0;border-left:0;border-top:1px solid var(--color-rule)} + .map-relations li{grid-template-columns:minmax(0,1fr) auto minmax(0,1fr)} + .map-relations li small{grid-column:1/-1} .memory-review-grid{grid-template-columns:1fr} .memory-review-queue,.memory-intake-panel{padding:var(--space-md) 0;border-right:0} .memory-intake-panel{border-top:1px solid var(--color-rule)} diff --git a/docs/superpowers/plans/2026-07-15-memory-recall-orientation-workbench-ui.md b/docs/superpowers/plans/2026-07-15-memory-recall-orientation-workbench-ui.md index 1325a28f..fb300fd0 100644 --- a/docs/superpowers/plans/2026-07-15-memory-recall-orientation-workbench-ui.md +++ b/docs/superpowers/plans/2026-07-15-memory-recall-orientation-workbench-ui.md @@ -596,7 +596,7 @@ git commit -m "feat: render the orientation-first Overview" - Produces: `renderSourceMap({ state, report, error }): string`. - Produces: `bindSourceMap(root, handlers): () => void`. -- [ ] **Step 1: Write failing URL round-trip and failure tests** +- [x] **Step 1: Write failing URL round-trip and failure tests** Create `tests/web-source-map.test.mjs`: @@ -668,7 +668,7 @@ function mapPreviewFixture({ coverage } = {}) { } ``` -- [ ] **Step 2: Run the tests and verify the missing module** +- [x] **Step 2: Run the tests and verify the missing module** Run: @@ -678,15 +678,15 @@ node --test tests/web-source-map.test.mjs Expected: FAIL with module-not-found for `source-map-view.js`. -- [ ] **Step 3: Implement bounded URL state and request mapping** +- [x] **Step 3: Implement bounded URL state and request mapping** Normalize strings to existing API maxima. `buildMapRequest()` maps `group` to `locatorPrefix`, a single `changedLocator` to `changedLocators`, and returns `sampleLimit: 50`. Default state is an empty query, depth 2, limit 20; do not inject `where should I start` into the field. `advanced` is true only when start, changed, depth other than 2, or limit other than 20 is present. -- [ ] **Step 4: Render query, disclosure, truth state, and outline** +- [x] **Step 4: Render query, disclosure, truth state, and outline** `Query` remains visible. Put group, trace, changed locator, depth, and limit inside `
`. Render snapshot/coverage state as text. Render grouped orientation before a focus exists; render `preview.focus` when a query/group/trace/impact exists. Always render a semantic outline and inspector. A failure uses `renderApiErrorPanel()` and never renders an empty-success message. -- [ ] **Step 5: Delegate Map submit and history handling from `app.js`** +- [x] **Step 5: Delegate Map submit and history handling from `app.js`** On submit: @@ -698,7 +698,7 @@ On submit: Global search must navigate to `/map?query=` and use the same path. Remove the old default query substitution. The visible `Refresh` action repeats the current request with `{ refresh: true }`; ordinary submit, reload, back, and forward use the cache-aware default. -- [ ] **Step 6: Verify URL, shell, and API behavior** +- [x] **Step 6: Verify URL, shell, and API behavior** Run: diff --git a/tests/web-shell.test.mjs b/tests/web-shell.test.mjs index f95a2cf9..cd2c16d3 100644 --- a/tests/web-shell.test.mjs +++ b/tests/web-shell.test.mjs @@ -49,7 +49,6 @@ import { selectContextPackPinPayload, renderMemoryCockpit, renderMemoryGraph, - renderSourceGraphResult, renderLoopWorkbenchMemoryFlow, renderSetupScreen, renderContextPackTokenSaverSummary, @@ -62,6 +61,7 @@ import { summarizeRunSteps, writeClipboardText } from '../apps/web/app.js'; +import { parseMapUrl, renderSourceMap } from '../apps/web/source-map-view.js'; test('web API and UI primitives are focused modules', async () => { const apiSource = await readFile(new URL('../apps/web/api.js', import.meta.url), 'utf8'); @@ -409,7 +409,7 @@ test('shell defers protected workspace loads until local session evidence exists assert.equal(shouldLoadProtectedShellData({ bootstrapRequired:false, csrfTokenValue:'csrf_1' }),true); }); -test('source graph preview renders repo map start points',()=>{ +test('source graph preview renders bounded focus with scan truth',()=>{ const report={ graph:{ graphFingerprint:'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', @@ -432,6 +432,13 @@ test('source graph preview renders repo map start points',()=>{ {kind:'calls',fromNodeId:'sgnode_workflow',toNodeId:'sgnode_auth'} ] }, + orientation:{groups:[{id:'group_src',prefix:'src',fileCount:2,symbolCount:2,changedFileCount:1}],relations:[]}, + focus:{nodes:[ + {id:'sgnode_workflow',kind:'symbol',label:'runAuthWorkflow',locator:'workspace://src/workflow.ts#L3-L7'}, + {id:'sgnode_auth',kind:'symbol',label:'TokenResetService',locator:'workspace://src/auth.ts#L1-L5'} + ],edges:[{id:'edge_1',kind:'calls',fromNodeId:'sgnode_workflow',toNodeId:'sgnode_auth'}],omittedNodes:0,omittedEdges:0}, + snapshot:{status:'fresh',reuse:'cache',builtAt:'2026-07-15T10:00:00.000Z'}, + coverage:{status:'complete',representedFileCount:2,omittedFileCount:0,omittedEdgeCount:0,reasonCodes:[]}, search:{total:1,results:[]}, trace:{paths:[]}, impact:{ @@ -441,21 +448,18 @@ test('source graph preview renders repo map start points',()=>{ }, safeguards:{persisted:false,modelCalls:0,networkCalls:0,graphDatabaseUsed:false,rawBodyIncluded:false} }; - const html=renderSourceGraphResult(report); - assert.match(html,/class="tool-workspace map-workspace"/); - assert.match(html,/Map results/); - assert.match(html,/Repo Map/); - assert.match(html,/Start here/); + const html=renderSourceMap({state:parseMapUrl('/map?query=runAuthWorkflow'),report}); + assert.match(html,/class="tool-workspace source-map-workspace"/); + assert.match(html,/Focused map/); assert.match(html,/runAuthWorkflow/); - assert.match(html,/Changed impact/); - assert.match(html,/Read first/); assert.match(html,/workspace:\/\/src\/workflow\.ts#L3-L7/); - assert.match(html,/Files/); assert.match(html,/workspace:\/\/src\/auth\.ts/); - assert.match(html,/Key symbols/); assert.match(html,/TokenResetService/); - assert.match(html,/Import neighbors/); - assert.match(html,/src\/workflow\.ts -> src\/auth\.ts/); + assert.match(html,/Source map outline/); + assert.match(html,/Scan truth/); + assert.match(html,/2 represented files/); + assert.match(html,/External writes/); + assert.match(html,/off/); assert.doesNotMatch(html,/class="metric-strip"/); }); @@ -682,6 +686,7 @@ test('context pack pin uses the reviewed build payload instead of a stale form p test('context pack user flow exposes artifact actions and safe harness commands',async()=>{ assert.deepEqual(parseSelectedFiles('docs/handoff.md\n docs/handoff.md,notes/context.md '),['docs/handoff.md','notes/context.md']); const app=await readFile('apps/web/app.js','utf8'); + const sourceMap=await readFile('apps/web/source-map-view.js','utf8'); assert.match(app,/Build context pack/); assert.match(app,/data-action="copy-pack"/); assert.match(app,/data-action="copy-launch-prompt"/); @@ -752,10 +757,11 @@ test('context pack user flow exposes artifact actions and safe harness commands' assert.match(app,/measure context-pack --read-only/); assert.match(app,/Read-only impact brief/); assert.match(app,/Change Impact/); - assert.match(app,/value="\$\{esc\(query\|\|'where should I start'\)\}"/); - assert.match(app,/name="startName" value="" placeholder="optional function or class name"/); - assert.match(app,/name="changedLocator" value="" placeholder="src\/index\.js"/); - assert.doesNotMatch(app,/name="changedLocator" value="apps\/web\/app\.js"/); + assert.match(sourceMap,/value="\$\{escapeHtml\(state\.query\)\}"/); + assert.match(sourceMap,/name="startName" value="\$\{escapeHtml\(state\.startName\)\}"/); + assert.match(sourceMap,/name="changedLocator" value="\$\{escapeHtml\(state\.changedLocator\)\}"/); + assert.doesNotMatch(sourceMap,/where should I start/iu); + assert.doesNotMatch(sourceMap,/name="changedLocator" value="apps\/web\/app\.js"/); assert.match(app,/Intake review/); assert.match(app,/Context pack proof metrics/); assert.match(app,/Pinned handoff status/); diff --git a/tests/web-source-map.test.mjs b/tests/web-source-map.test.mjs new file mode 100644 index 00000000..0cd85238 --- /dev/null +++ b/tests/web-source-map.test.mjs @@ -0,0 +1,84 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + buildMapRequest, + parseMapUrl, + renderSourceMap, + serializeMapUrl +} from '../apps/web/source-map-view.js'; + +test('Map query state round-trips through the URL', () => { + const state = parseMapUrl('http://127.0.0.1:4318/map?query=copytrading&group=apps%2Fterminal&start=execute&changed=src%2Ftrade.ts&depth=3&limit=24'); + assert.deepEqual(state, { + query: 'copytrading', group: 'apps/terminal', startName: 'execute', + changedLocator: 'src/trade.ts', depth: 3, limit: 24, advanced: true + }); + assert.equal(serializeMapUrl(state), '/map?query=copytrading&group=apps%2Fterminal&start=execute&changed=src%2Ftrade.ts&depth=3&limit=24'); +}); + +test('Map request is exact, bounded, and cache-aware by default', () => { + assert.deepEqual(buildMapRequest(parseMapUrl('/map?group=apps%2Fweb&changed=src%2Fapp.js')), { + locatorPrefix: 'apps/web', changedLocators: ['src/app.js'], depth: 2, limit: 20, sampleLimit: 50 + }); +}); + +test('Map failure keeps submitted values and diagnostic truth', () => { + const state = parseMapUrl('/map?query=copytrading&group=apps%2Fterminal'); + const html = renderSourceMap({ state, error: { message: 'Graph validation failed.', correlationId: 'req_map_1' } }); + assert.match(html, /value="copytrading"/); + assert.match(html, /value="apps\/terminal"/); + assert.match(html, /Graph validation failed/); + assert.match(html, /req_map_1/); + assert.doesNotMatch(html, /preview ready|No map results/iu); +}); + +test('Map partial state keeps useful results and names omitted coverage', () => { + const state = parseMapUrl('/map?query=router'); + const report = mapPreviewFixture({ + coverage: { status: 'partial', representedFileCount: 572, omittedFileCount: 428, omittedEdgeCount: 39012, reasonCodes: ['file_budget_reached', 'edge_budget_reached'] } + }); + const html = renderSourceMap({ state, report }); + assert.match(html, /572 represented files/); + assert.match(html, /428 omitted files/); + assert.match(html, /39,012 omitted relationships/); + assert.match(html, /File limit reached/); + assert.match(html, /Relationship limit reached/); + assert.doesNotMatch(html, /preview ready/iu); +}); + +test('Map renders architecture before focus and keeps an accessible outline', () => { + const html = renderSourceMap({ state: parseMapUrl('/map'), report: mapPreviewFixture() }); + assert.match(html, /Repository groups/); + assert.match(html, /aria-label="Source map outline"/); + assert.doesNotMatch(html, / Date: Thu, 16 Jul 2026 00:38:46 +0530 Subject: [PATCH 018/276] feat: add bounded interactive Map graph --- apps/web/app.js | 1 + apps/web/graph-layout-worker.js | 71 +++++ apps/web/graph-viewport.js | 256 ++++++++++++++++++ apps/web/source-map-view.js | 45 ++- apps/web/styles.css | 9 + ...-memory-recall-orientation-workbench-ui.md | 12 +- tests/web-source-map.test.mjs | 32 +++ 7 files changed, 414 insertions(+), 12 deletions(-) create mode 100644 apps/web/graph-layout-worker.js create mode 100644 apps/web/graph-viewport.js diff --git a/apps/web/app.js b/apps/web/app.js index afd41037..8b710042 100644 --- a/apps/web/app.js +++ b/apps/web/app.js @@ -1111,6 +1111,7 @@ function bindCurrentOrientation(root) { function bindCurrentSourceMap(root) { sourceMapCleanup=bindSourceMap(root,{ + report:sourceGraphResult, onSubmit:submitSourceGraph, onRefresh:refreshSourceGraph }); diff --git a/apps/web/graph-layout-worker.js b/apps/web/graph-layout-worker.js new file mode 100644 index 00000000..c196a747 --- /dev/null +++ b/apps/web/graph-layout-worker.js @@ -0,0 +1,71 @@ +export function layoutFocusedGraph(nodes = [], edges = [], width = 960, height = 560) { + if (nodes.length > 200 || edges.length > 400) throw new Error('graph_layout_bounds_exceeded'); + const ordered = [...nodes].sort((left, right) => String(left.id).localeCompare(String(right.id))); + const adjacency = new Map(ordered.map(({ id }) => [id, new Set()])); + for (const { fromNodeId, toNodeId } of edges) { + if (!adjacency.has(fromNodeId) || !adjacency.has(toNodeId)) continue; + adjacency.get(fromNodeId).add(toNodeId); + adjacency.get(toNodeId).add(fromNodeId); + } + + const roots = [...ordered].sort((left, right) => ( + adjacency.get(right.id).size - adjacency.get(left.id).size + || String(left.id).localeCompare(String(right.id)) + )); + const layerById = new Map(); + let componentOffset = 0; + for (const root of roots) { + if (layerById.has(root.id)) continue; + const queue = [{ id: root.id, layer: componentOffset }]; + let cursor = 0; + let componentMax = componentOffset; + while (cursor < queue.length) { + const current = queue[cursor++]; + if (layerById.has(current.id)) continue; + layerById.set(current.id, current.layer); + componentMax = Math.max(componentMax, current.layer); + for (const neighbor of [...adjacency.get(current.id)].sort()) { + if (!layerById.has(neighbor)) queue.push({ id: neighbor, layer: current.layer + 1 }); + } + } + componentOffset = componentMax + 2; + } + + const layers = new Map(); + for (const node of ordered) { + const layer = layerById.get(node.id) ?? 0; + if (!layers.has(layer)) layers.set(layer, []); + layers.get(layer).push(node.id); + } + const layerIds = [...layers.keys()].sort((left, right) => left - right); + const safeWidth = Math.max(160, Number(width) || 960); + const safeHeight = Math.max(160, Number(height) || 560); + const xStep = (safeWidth - 96) / Math.max(1, layerIds.length - 1); + const positions = {}; + layerIds.forEach((layer, column) => { + const ids = layers.get(layer); + const yStep = (safeHeight - 96) / Math.max(1, ids.length - 1); + ids.forEach((id, row) => { + positions[id] = { + x: 48 + column * xStep, + y: ids.length === 1 ? safeHeight / 2 : 48 + row * yStep + }; + }); + }); + return { + positions, + bounds: { minX: 48, minY: 48, maxX: safeWidth - 48, maxY: safeHeight - 48 } + }; +} + +if (typeof self !== 'undefined') { + self.onmessage = ({ data }) => { + const { requestId, nodes = [], edges = [], width = 960, height = 560 } = data ?? {}; + if (nodes.length > 200 || edges.length > 400) { + self.postMessage({ requestId, error: 'graph_layout_bounds_exceeded' }); + return; + } + const { positions, bounds } = layoutFocusedGraph(nodes, edges, width, height); + self.postMessage({ requestId, positions, bounds }); + }; +} diff --git a/apps/web/graph-viewport.js b/apps/web/graph-viewport.js new file mode 100644 index 00000000..3634b4f8 --- /dev/null +++ b/apps/web/graph-viewport.js @@ -0,0 +1,256 @@ +export function createGraphViewport(canvas, outline, inspector, { + nodes = [], + edges = [], + onSelect = () => {}, + onError = () => {} +} = {}) { + if (!canvas) return inertViewport(); + if (nodes.length > 200 || edges.length > 400) { + onError('graph_layout_bounds_exceeded'); + return inertViewport(); + } + + const worker = new Worker(new URL('./graph-layout-worker.js', import.meta.url), { type: 'module' }); + const controller = new AbortController(); + const signal = controller.signal; + const context = canvas.getContext('2d'); + const nodeById = new Map(nodes.map((node) => [node.id, node])); + let positions = {}; + let bounds = null; + let requestId = 0; + let selectedNodeId = outline?.querySelector('[data-node-id][aria-current="true"]')?.dataset.nodeId ?? nodes[0]?.id ?? null; + let scale = 1; + let panX = 0; + let panY = 0; + let pointer = null; + + const resizeObserver = typeof ResizeObserver === 'function' + ? new ResizeObserver(() => requestLayout()) + : null; + resizeObserver?.observe(canvas); + if (!resizeObserver && typeof window !== 'undefined') window.addEventListener('resize', requestLayout, { signal }); + + worker.addEventListener('message', ({ data }) => { + if (data?.requestId !== requestId) return; + if (data.error) { + onError(data.error); + return; + } + positions = data.positions ?? {}; + bounds = data.bounds ?? null; + reset(); + }, { signal }); + worker.addEventListener('error', () => onError('graph_layout_worker_failed'), { signal }); + + outline?.addEventListener('click', (event) => { + const button = event.target.closest('[data-node-id]'); + if (!button) return; + select(button.dataset.nodeId, { focusView: true }); + }, { signal }); + canvas.addEventListener('pointerdown', (event) => { + pointer = { id: event.pointerId, x: event.clientX, y: event.clientY, moved: false }; + canvas.setPointerCapture?.(event.pointerId); + }, { signal }); + canvas.addEventListener('pointermove', (event) => { + if (!pointer || pointer.id !== event.pointerId) return; + const dx = event.clientX - pointer.x; + const dy = event.clientY - pointer.y; + if (Math.abs(dx) + Math.abs(dy) > 2) pointer.moved = true; + pointer.x = event.clientX; + pointer.y = event.clientY; + panX += dx; + panY += dy; + draw(); + }, { signal }); + canvas.addEventListener('pointerup', (event) => { + if (!pointer || pointer.id !== event.pointerId) return; + if (!pointer.moved) { + const nodeId = hitTest(event); + if (nodeId) select(nodeId); + } + pointer = null; + canvas.releasePointerCapture?.(event.pointerId); + }, { signal }); + canvas.addEventListener('pointercancel', () => { pointer = null; }, { signal }); + canvas.addEventListener('wheel', (event) => { + event.preventDefault(); + const rect = canvas.getBoundingClientRect(); + const anchorX = event.clientX - rect.left; + const anchorY = event.clientY - rect.top; + const nextScale = clamp(scale * Math.exp(-event.deltaY * 0.0015), 0.5, 2.5); + const graphX = (anchorX - panX) / scale; + const graphY = (anchorY - panY) / scale; + scale = nextScale; + panX = anchorX - graphX * scale; + panY = anchorY - graphY * scale; + draw(); + }, { passive: false, signal }); + + const stage = canvas.closest('.source-map-stage'); + stage?.querySelector('[data-graph-action="fit"]')?.addEventListener('click', () => fit(), { signal }); + stage?.querySelector('[data-graph-action="reset"]')?.addEventListener('click', () => reset(), { signal }); + + if (selectedNodeId) select(selectedNodeId); + requestLayout(); + + function requestLayout() { + const rect = canvas.getBoundingClientRect(); + const width = Math.max(320, Math.round(rect.width || canvas.width || 960)); + const height = Math.max(280, Math.round(rect.height || canvas.height || 560)); + syncCanvas(width, height); + requestId += 1; + worker.postMessage({ requestId, nodes, edges, width, height }); + } + + function syncCanvas(width, height) { + const ratio = Math.min(2, globalThis.devicePixelRatio || 1); + canvas.width = Math.round(width * ratio); + canvas.height = Math.round(height * ratio); + canvas.dataset.pixelRatio = String(ratio); + draw(); + } + + function select(nodeId, { focusView = false } = {}) { + if (!nodeById.has(nodeId)) return; + selectedNodeId = nodeId; + outline?.querySelectorAll('[data-node-id]').forEach((button) => { + if (button.dataset.nodeId === nodeId) button.setAttribute('aria-current', 'true'); + else button.removeAttribute('aria-current'); + }); + if (inspector) inspector.dataset.selectedNodeId = nodeId; + onSelect(nodeById.get(nodeId)); + if (focusView) focus(nodeId); + else draw(); + } + + function focus(nodeId = selectedNodeId) { + const point = positions[nodeId]; + if (!point) return; + const { width, height } = cssSize(); + scale = Math.max(1, scale); + panX = width / 2 - point.x * scale; + panY = height / 2 - point.y * scale; + draw(); + } + + function fit() { + if (selectedNodeId && positions[selectedNodeId]) { + scale = 1.35; + focus(selectedNodeId); + return; + } + fitBounds(); + } + + function fitBounds() { + if (!bounds) return reset(); + const { width, height } = cssSize(); + const graphWidth = Math.max(1, bounds.maxX - bounds.minX); + const graphHeight = Math.max(1, bounds.maxY - bounds.minY); + scale = clamp(Math.min((width - 48) / graphWidth, (height - 48) / graphHeight), 0.5, 2.5); + panX = (width - (bounds.minX + bounds.maxX) * scale) / 2; + panY = (height - (bounds.minY + bounds.maxY) * scale) / 2; + draw(); + } + + function reset() { + scale = 1; + panX = 0; + panY = 0; + draw(); + } + + function draw() { + if (!context) return; + const ratio = Number(canvas.dataset.pixelRatio) || 1; + const { width, height } = cssSize(); + const palette = graphPalette(canvas); + context.setTransform(ratio, 0, 0, ratio, 0, 0); + context.clearRect(0, 0, width, height); + context.fillStyle = palette.panel; + context.fillRect(0, 0, width, height); + context.save(); + context.translate(panX, panY); + context.scale(scale, scale); + context.lineWidth = 1 / scale; + context.strokeStyle = palette.rule; + for (const edge of edges) { + const from = positions[edge.fromNodeId]; + const to = positions[edge.toNodeId]; + if (!from || !to) continue; + context.beginPath(); + context.moveTo(from.x, from.y); + context.lineTo(to.x, to.y); + context.stroke(); + } + context.font = '11px ui-monospace, SFMono-Regular, Menlo, monospace'; + context.textBaseline = 'middle'; + for (const node of nodes) { + const point = positions[node.id]; + if (!point) continue; + const selected = node.id === selectedNodeId; + context.fillStyle = selected ? palette.accent : palette.ink; + context.beginPath(); + context.arc(point.x, point.y, selected ? 6 : 4, 0, Math.PI * 2); + context.fill(); + if (selected || nodes.length <= 60) { + context.fillStyle = palette.ink; + context.fillText(String(node.label ?? '').slice(0, 32), point.x + 10, point.y); + } + } + context.restore(); + } + + function hitTest(event) { + const rect = canvas.getBoundingClientRect(); + const x = ((event.clientX - rect.left) - panX) / scale; + const y = ((event.clientY - rect.top) - panY) / scale; + let selected = null; + let distance = 12 / scale; + for (const node of nodes) { + const point = positions[node.id]; + if (!point) continue; + const candidate = Math.hypot(point.x - x, point.y - y); + if (candidate <= distance) { + selected = node.id; + distance = candidate; + } + } + return selected; + } + + function cssSize() { + const ratio = Number(canvas.dataset.pixelRatio) || 1; + return { width: canvas.width / ratio, height: canvas.height / ratio }; + } + + return { + fit, + reset, + focus, + destroy() { + controller.abort(); + resizeObserver?.disconnect(); + worker.terminate(); + } + }; +} + +function graphPalette(canvas) { + const styles = getComputedStyle(canvas); + const token = (name, fallback) => styles.getPropertyValue(name).trim() || fallback; + return { + panel: token('--color-panel', '#ffffff'), + rule: token('--color-rule-strong', '#a7a7a7'), + ink: token('--color-ink', '#202020'), + accent: token('--color-accent', '#345cff') + }; +} + +function clamp(value, minimum, maximum) { + return Math.max(minimum, Math.min(maximum, value)); +} + +function inertViewport() { + return { fit() {}, reset() {}, focus() {}, destroy() {} }; +} diff --git a/apps/web/source-map-view.js b/apps/web/source-map-view.js index 48c07780..db3a65a3 100644 --- a/apps/web/source-map-view.js +++ b/apps/web/source-map-view.js @@ -1,4 +1,5 @@ import { escapeHtml, formatDate, renderApiErrorPanel, statePanel } from './ui-primitives.js'; +import { createGraphViewport } from './graph-viewport.js'; const DEFAULT_DEPTH = 2; const DEFAULT_LIMIT = 20; @@ -77,17 +78,49 @@ export function renderSourceMap({ state: value = {}, report = null, error = null `; } -export function bindSourceMap(root, { onSubmit, onRefresh, onSelectNode } = {}) { +export function bindSourceMap(root, { report = null, onSubmit, onRefresh, onSelectNode } = {}) { if (!root) return () => {}; const controller = new AbortController(); const options = { signal: controller.signal }; + let viewport = null; root.querySelector('#source-graph-form')?.addEventListener('submit', (event) => { event.preventDefault(); onSubmit?.(mapStateFromForm(event.currentTarget), event); }, options); root.querySelector('[data-action="refresh-source-map"]')?.addEventListener('click', (event) => onRefresh?.(event), options); - root.querySelectorAll('[data-node-id]').forEach((button) => button.addEventListener('click', () => onSelectNode?.(button.dataset.nodeId), options)); - return () => controller.abort(); + const canvas = root.querySelector('#source-map-canvas'); + if (canvas) { + const nodes = arrayValue(report?.focus?.nodes); + viewport = createGraphViewport( + canvas, + root.querySelector('.source-map-outline'), + root.querySelector('#source-map-selection'), + { + nodes, + edges: arrayValue(report?.focus?.edges), + onSelect: (node) => { + const selection = root.querySelector('#source-map-selection'); + if (selection) selection.innerHTML = renderSelection(node); + onSelectNode?.(node.id); + }, + onError: (code) => { + const failure = root.querySelector('[data-graph-error]'); + if (failure) { + failure.hidden = false; + failure.textContent = code === 'graph_layout_bounds_exceeded' + ? 'Focused graph exceeds the 200-node or 400-relationship display limit.' + : 'Graph layout could not start. Use the outline to inspect records.'; + } + } + } + ); + } else { + root.querySelectorAll('[data-node-id]').forEach((button) => button.addEventListener('click', () => onSelectNode?.(button.dataset.nodeId), options)); + } + return () => { + controller.abort(); + viewport?.destroy(); + }; } function renderMapForm(state) { @@ -123,7 +156,7 @@ function renderMapResult(report, state) { ${coverage.status === 'partial' ? renderCoverageWarning(coverage) : ''}
${content}
- +
`; } @@ -140,12 +173,12 @@ function renderGroups(groups, relations) { function renderFocus(nodes, edges, focus = {}) { if (!nodes.length) return statePanel('empty', 'No focused records', 'The submitted scope produced no bounded nodes. Broaden the query or remove a group filter.'); - return `
${number(nodes.length)} nodes${number(edges.length)} relationships${number(focus.omittedNodes ?? focus.omittedNodeCount) ? `${number(focus.omittedNodes ?? focus.omittedNodeCount)} nodes omitted` : ''}${number(focus.omittedEdges ?? focus.omittedEdgeCount) ? `${number(focus.omittedEdges ?? focus.omittedEdgeCount)} relationships omitted` : ''}
    ${nodes.map((node) => `
  1. `).join('')}
`; + return `
${number(nodes.length)} nodes${number(edges.length)} relationships${number(focus.omittedNodes ?? focus.omittedNodeCount) ? `${number(focus.omittedNodes ?? focus.omittedNodeCount)} nodes omitted` : ''}${number(focus.omittedEdges ?? focus.omittedEdgeCount) ? `${number(focus.omittedEdges ?? focus.omittedEdgeCount)} relationships omitted` : ''}
`; } function renderMapOutline(items, focused) { if (!items.length) return '

No records in the current outline.

'; - return `
    ${items.map((item, index) => `
  1. `).join('')}
`; + return `
    ${items.map((item, index) => `
  1. `).join('')}
`; } function renderSelection(item) { diff --git a/apps/web/styles.css b/apps/web/styles.css index f6e75ff0..05c2d5c0 100644 --- a/apps/web/styles.css +++ b/apps/web/styles.css @@ -385,6 +385,12 @@ th{color:var(--slate);font-size:12px;text-transform:uppercase;letter-spacing:.08 .map-relations li span,.map-relations li small{color:var(--color-muted)} .map-focus-summary{display:flex;flex-wrap:wrap;gap:var(--space-xs);padding-bottom:var(--space-xs);color:var(--color-muted)} .map-focus-summary strong{color:var(--color-ink)} +.map-graph-toolbar{display:flex;align-items:center;justify-content:space-between;gap:var(--space-xs);padding-bottom:var(--space-xs)} +.map-graph-toolbar>div:last-child{display:flex;gap:var(--space-2xs)} +.map-graph-error{margin:0 0 var(--space-xs);padding:var(--space-xs);border:1px solid var(--color-danger);border-radius:var(--radius-control);color:var(--color-danger)} +.source-map-canvas-wrap{height:clamp(380px,56vh,620px);overflow:hidden;border:1px solid var(--color-rule);border-radius:var(--radius-control);background:var(--color-panel);touch-action:none} +.source-map-canvas-wrap canvas{display:block;width:100%;height:100%;cursor:grab} +.source-map-canvas-wrap canvas:active{cursor:grabbing} .map-focus-list,.source-map-outline{margin:0;padding:0;list-style:none;border-top:1px solid var(--color-rule)} .map-focus-list button,.source-map-outline button{width:100%;min-height:44px;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:var(--space-xs);align-items:center;padding:var(--space-2xs);border:0;border-bottom:1px solid var(--color-rule);border-radius:0;background:transparent;color:var(--color-ink);text-align:left;cursor:pointer} .map-focus-list button:hover,.source-map-outline button:hover{background:var(--color-panel-muted)} @@ -597,6 +603,9 @@ th{color:var(--slate);font-size:12px;text-transform:uppercase;letter-spacing:.08 .map-advanced-fields{grid-template-columns:1fr} .map-advanced-fields .field:nth-child(3){grid-column:1} .map-query-actions .button{flex:1 1 auto} + .map-graph-toolbar{display:grid} + .map-graph-toolbar>div:last-child .button{flex:1} + .source-map-canvas-wrap{height:420px} .map-result-heading{display:grid} .map-snapshot{white-space:normal} .map-coverage-warning{grid-template-columns:1fr} diff --git a/docs/superpowers/plans/2026-07-15-memory-recall-orientation-workbench-ui.md b/docs/superpowers/plans/2026-07-15-memory-recall-orientation-workbench-ui.md index fb300fd0..5ef99fd3 100644 --- a/docs/superpowers/plans/2026-07-15-memory-recall-orientation-workbench-ui.md +++ b/docs/superpowers/plans/2026-07-15-memory-recall-orientation-workbench-ui.md @@ -730,7 +730,7 @@ git commit -m "feat: preserve Map query and failure state" - Worker produces `{ requestId, positions, bounds }`. - `graph-viewport.js` exports `createGraphViewport(canvas, outline, inspector, options)` with `fit`, `reset`, `focus`, `destroy` methods. -- [ ] **Step 1: Write failing worker and outline-parity tests** +- [x] **Step 1: Write failing worker and outline-parity tests** Add: @@ -748,7 +748,7 @@ test('focused graph uses a worker and keeps outline selection canonical', async }); ``` -- [ ] **Step 2: Run the test and verify no worker exists** +- [x] **Step 2: Run the test and verify no worker exists** Run: @@ -758,7 +758,7 @@ node --test --test-name-pattern="focused graph uses a worker" tests/web-source-m Expected: FAIL with missing worker file or missing Worker construction. -- [ ] **Step 3: Implement deterministic bounded layout** +- [x] **Step 3: Implement deterministic bounded layout** The worker rejects more than 200 nodes or 400 edges. Use a deterministic breadth-first layered layout, avoiding force simulation entirely. Return plain serializable objects. Do not read the DOM or use randomness. @@ -822,15 +822,15 @@ function layoutFocusedGraph(nodes, edges, width, height) { } ``` -- [ ] **Step 4: Implement viewport controls and shared selection** +- [x] **Step 4: Implement viewport controls and shared selection** Use one `selectedNodeId` inside `createGraphViewport()`. Canvas hit testing and outline clicks both call the injected `onSelect(nodeId)`, update `aria-current`, render the same inspector through the route callback, and call `focus()` only when requested. Add pointer pan, wheel zoom clamped to `0.5..2.5`, fit-to-selection, reset, and resize handling. `destroy()` terminates the worker and removes listeners. `source-map-view.js` owns the outline and inspector markup, then passes their elements and route callbacks to the viewport. -- [ ] **Step 5: Style the detailed graph without fake visual assets** +- [x] **Step 5: Style the detailed graph without fake visual assets** The graph is a real data view drawn from response nodes and edges. Use no decorative SVG, CSS illustration, glow, or gradient. The canvas and outline sit in a flat split region. Status is visible in text, not color alone. Hide the canvas entirely when focus has no nodes. -- [ ] **Step 6: Verify worker and Map tests** +- [x] **Step 6: Verify worker and Map tests** Run: diff --git a/tests/web-source-map.test.mjs b/tests/web-source-map.test.mjs index 0cd85238..8998b789 100644 --- a/tests/web-source-map.test.mjs +++ b/tests/web-source-map.test.mjs @@ -1,11 +1,13 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; import { buildMapRequest, parseMapUrl, renderSourceMap, serializeMapUrl } from '../apps/web/source-map-view.js'; +import { layoutFocusedGraph } from '../apps/web/graph-layout-worker.js'; test('Map query state round-trips through the URL', () => { const state = parseMapUrl('http://127.0.0.1:4318/map?query=copytrading&group=apps%2Fterminal&start=execute&changed=src%2Ftrade.ts&depth=3&limit=24'); @@ -53,6 +55,36 @@ test('Map renders architecture before focus and keeps an accessible outline', () assert.doesNotMatch(html, / { + const source = await readFile(new URL('../apps/web/source-map-view.js', import.meta.url), 'utf8'); + const viewport = await readFile(new URL('../apps/web/graph-viewport.js', import.meta.url), 'utf8'); + const worker = await readFile(new URL('../apps/web/graph-layout-worker.js', import.meta.url), 'utf8'); + assert.match(viewport, /new Worker\(new URL\('\.\/graph-layout-worker\.js'/); + assert.match(source, /data-node-id/); + assert.match(source, /Fit selection/); + assert.match(source, /Reset view/); + assert.doesNotMatch(source, /for \(let tick = 0; tick < 90/); + assert.match(worker, /postMessage\(\{ requestId, positions, bounds \}\)/); +}); + +test('focused layout is deterministic and enforces display bounds', () => { + const nodes = [{ id: 'b' }, { id: 'a' }, { id: 'c' }]; + const edges = [{ fromNodeId: 'a', toNodeId: 'b' }, { fromNodeId: 'b', toNodeId: 'c' }]; + const first = layoutFocusedGraph(nodes, edges, 640, 400); + assert.deepEqual(first, layoutFocusedGraph(structuredClone(nodes), structuredClone(edges), 640, 400)); + assert.deepEqual(Object.keys(first.positions).sort(), ['a', 'b', 'c']); + assert.throws(() => layoutFocusedGraph(Array.from({ length: 201 }, (_, index) => ({ id: String(index) })), []), /graph_layout_bounds_exceeded/); +}); + +test('focused Map renders a real canvas with outline parity', () => { + const report = mapPreviewFixture(); + const html = renderSourceMap({ state: parseMapUrl('/map?query=router'), report }); + assert.match(html, /id="source-map-canvas"/); + assert.match(html, /Fit selection/); + assert.equal((html.match(/data-node-id="sgnode_/g) ?? []).length, 1); + assert.match(html, /aria-label="Source map outline"/); +}); + function mapPreviewFixture({ coverage } = {}) { const resolvedCoverage = coverage ?? { status: 'complete', representedFileCount: 1, omittedFileCount: 0, From 7a09a99d17b2793b2278506d12d0656635b9aaea Mon Sep 17 00:00:00 2001 From: rebel Date: Thu, 16 Jul 2026 00:45:55 +0530 Subject: [PATCH 019/276] feat: simplify the governed memory graph --- apps/web/app.js | 252 ++---------------- apps/web/memory-graph-view.js | 213 +++++++++++++++ apps/web/styles.css | 38 ++- ...-memory-recall-orientation-workbench-ui.md | 12 +- tests/web-memory-graph.test.mjs | 83 ++++++ tests/web-shell.test.mjs | 11 +- 6 files changed, 362 insertions(+), 247 deletions(-) create mode 100644 apps/web/memory-graph-view.js create mode 100644 tests/web-memory-graph.test.mjs diff --git a/apps/web/app.js b/apps/web/app.js index 8b710042..e4c8b795 100644 --- a/apps/web/app.js +++ b/apps/web/app.js @@ -9,6 +9,11 @@ import { renderSourceMap, serializeMapUrl } from './source-map-view.js'; +import { + bindMemoryGraph, + buildMemoryGraphViewModel, + renderMemoryGraphView +} from './memory-graph-view.js'; import { buildApiErrorUiModel, escapeHtml as esc, @@ -84,6 +89,7 @@ let memoryIntakeDraft={sourceLocator:'memory/inbox.md',text:''}; let memoryGraph=null; let memoryGraphError=null; let memoryGraphOptions={history:false,query:'',entity:'',communities:false}; +let memoryGraphCleanup=()=>{}; let contextSourcePreviewResult=null; let contextSourcePreviewError=null; let sourceGraphResult=null; @@ -1048,6 +1054,8 @@ function render() { orientationCleanup=()=>{}; sourceMapCleanup(); sourceMapCleanup=()=>{}; + memoryGraphCleanup(); + memoryGraphCleanup=()=>{}; const route=currentRoute(); const setupScreen = shellState.kind === 'setup' || shellState.kind === 'denied'; const appShell = document.querySelector('.app-shell'); @@ -1072,10 +1080,7 @@ function render() { root.querySelectorAll('[data-action=detect-git-changes]').forEach(button=>button.addEventListener('click',detectContextPackGitChanges)); root.querySelectorAll('[data-action=refresh-pinned-handoff]').forEach(button=>button.addEventListener('click',refreshPinnedHandoff)); root.querySelectorAll('[data-action=receive-pinned-handoff]').forEach(button=>button.addEventListener('click',receivePinnedHandoff)); - root.querySelector('#memory-graph-form')?.addEventListener('submit',submitMemoryGraph); root.querySelector('#memory-intake-form')?.addEventListener('submit',submitMemoryIntake); - root.querySelector('#memory-graph-history')?.addEventListener('change',toggleMemoryGraphHistory); - root.querySelector('#memory-graph-communities')?.addEventListener('change',toggleMemoryGraphCommunities); root.querySelector('#harness-setup-form')?.addEventListener('submit',submitHarnessSetupPlan); root.querySelectorAll('[data-action=copy-pack]').forEach(button=>button.addEventListener('click',copyContextPack)); root.querySelectorAll('[data-action=copy-receiver-packet]').forEach(button=>button.addEventListener('click',copyPinnedReceiverPacket)); @@ -1090,10 +1095,10 @@ function render() { root.querySelectorAll('[data-action=preview-pack-setup]').forEach(button=>button.addEventListener('click',previewContextPackSetup)); root.querySelectorAll('[data-action=copy-launch-prompt]').forEach(button=>button.addEventListener('click',copyContextPackLaunchPrompt)); root.querySelectorAll('[data-fabric-node]').forEach(button=>button.addEventListener('click',selectFabricNode)); - if(route.id==='memory-graph')drawMemoryGraphCanvas(root.querySelector('#memory-graph-canvas'),memoryGraph,memoryGraphOptions); document.querySelectorAll('[data-route]').forEach(link=>link.onclick=navigate); if(route.id==='home')bindCurrentOrientation(root); if(route.id==='source-graph')bindCurrentSourceMap(root); + if(route.id==='memory-graph')bindCurrentMemoryGraph(root); } function bindCurrentOrientation(root) { @@ -1117,6 +1122,16 @@ function bindCurrentSourceMap(root) { }); } +function bindCurrentMemoryGraph(root) { + const model=buildMemoryGraphViewModel(memoryGraph,memoryGraphOptions,memoryGraphError); + memoryGraphCleanup=bindMemoryGraph(root,{ + model, + onSubmit:submitMemoryGraph, + onHistoryChange:toggleMemoryGraphHistory, + onGroupChange:toggleMemoryGraphCommunities + }); +} + function renderNav(container, mode) { const currentOwner = navigationOwner(currentRoute().id); container.innerHTML = navigationItemsFor(mode).map((item) => ` @@ -1268,7 +1283,7 @@ function renderRoute(route) { if (route.id === 'context-pack') return renderContextPack(); if (route.id === 'source-graph') return renderSourceGraph(); if (route.id === 'memory') return renderMemory(); - if (route.id === 'memory-graph') return renderMemoryGraph(memoryGraph,memoryGraphOptions,memoryGraphError); + if (route.id === 'memory-graph') return renderMemoryGraphView(buildMemoryGraphViewModel(memoryGraph,memoryGraphOptions,memoryGraphError)); if (route.id === 'evidence') return renderEvidence(); if (route.id === 'approvals') return renderApprovals(); if (route.id === 'content') return renderContentLab(); @@ -2373,58 +2388,6 @@ function renderMemory() { return renderMemoryCockpit(memoryCockpit); } -export function buildMemoryGraphModel(report = null) { - if(!report?.graph)return {ready:false,summary:{nodeCount:0,edgeCount:0,communityCount:0},nodes:[],edges:[],focus:null}; - const nodes=Array.isArray(report.graph.nodes)?report.graph.nodes:[]; - const edges=Array.isArray(report.graph.edges)?report.graph.edges:[]; - const summary={nodeCount:nodes.length,edgeCount:edges.length,communityCount:0,...(report.summary??{})}; - return { - ready:true, - provider:report.provider??'provider:native:memory:sqlite', - mode:report.mode??'current', - generatedAt:report.generatedAt, - reportFingerprint:report.reportFingerprint, - communityMethod:report.communityMethod??'label-propagation', - summary, - nodes, - edges, - focus:report.focus, - safeguards:report.safeguards??{} - }; -} - -export function renderMemoryGraph(report = null, options = {}, error = null) { - if(error)return statePanel('error','Memory graph unavailable',error); - const model=buildMemoryGraphModel(report); - if(!model.ready)return statePanel('empty','No governed graph loaded','Ingest and approve temporal memory facts, then refresh this local graph view.'); - const query=options.query??''; - const historyChecked=options.history?' checked':''; - const communityChecked=options.communities?' checked':''; - const focus=model.focus?.nodes?.length?model.focus:null; - const focusList=focus - ? `
    ${focus.nodes.slice(0,12).map((node)=>`
  1. ${esc(node.name)}${esc(node.type)} · degree ${Number(node.degree??0)} · community ${Number(node.community??0)}
  2. `).join('')}
` - : '

Click a node or search for an entity to focus its governed neighborhood.

'; - const currentEdges=model.edges.filter((edge)=>edge.current).slice(0,12); - const staleEdges=model.edges.filter((edge)=>!edge.current).slice(0,12); - const currentList=currentEdges.length - ? memoryGraphEdgeList(currentEdges) - : '

No current graph facts are visible.

'; - const historyList=staleEdges.length - ? memoryGraphEdgeList(staleEdges) - : '

No superseded graph edges are visible in this mode.

'; - return `
${metric(model.summary.nodeCount,'Nodes',`${model.summary.currentNodeCount??0} current`)}${metric(model.summary.edgeCount,'Edges',`${model.summary.currentEdgeCount??0} current`)}${metric(model.summary.communityCount,'Communities',model.communityMethod)}${metric(model.summary.historyEdgeCount??0,'History edges',model.mode==='history'?'visible':'hidden')}

Governed knowledge graph

${esc(model.mode)} · ${esc(shortFingerprint(model.reportFingerprint))}
${memoryGraphLegend(model.nodes)}
`; -} - -function memoryGraphEdgeList(edges=[]) { - return `
    ${edges.map((edge)=>`
  1. ${esc(edge.from)} ${esc(edge.predicate)} ${esc(edge.to)}${edge.current?'Current':'Superseded'} · ${edge.supersededBy?`superseded by ${esc(edge.supersededBy)}`:'current winner'}Valid from ${date(edge.validFrom)} · Valid until ${edge.validUntil?date(edge.validUntil):'open'} · Provenance ${esc(edge.source)}
  2. `).join('')}
`; -} - -function memoryGraphLegend(nodes=[]) { - const types=[...new Set(nodes.map((node)=>node.type))].sort(); - if(!types.length)return '

No node types to render.

'; - return types.map((type)=>`${esc(type)}`).join(''); -} - export function renderMemoryIntakePanel(result=null,error=null,draft={sourceLocator:'memory/inbox.md',text:''}) { const rows=Array.isArray(result?.proposalFacts) && result.proposalFacts.length ? `
    ${result.proposalFacts.map((item)=>`
  1. ${esc(item.subject)} ${esc(item.predicate)}${esc(item.object)}${esc(item.id)} · ${esc(item.status)} · ${esc(item.sourceLocator)}
  2. `).join('')}
` @@ -3012,29 +2975,20 @@ async function refreshSourceGraph(){ document.querySelector('#live-status').textContent=sourceGraphError?`Map refresh failed. ${buildApiErrorUiModel(sourceGraphError).message}`:'Map refreshed.'; } -async function submitMemoryGraph(event){ - event.preventDefault(); - const form=event.currentTarget; - const button=form.querySelector('button[type=submit]'); - const data=new FormData(form); - const query=String(data.get('query')??'').trim(); - const history=data.get('history')==='on'; - const communities=data.get('communities')==='on'; - button.disabled=true; - button.textContent='Refreshing...'; +async function submitMemoryGraph(options){ document.querySelector('#live-status').textContent='Refreshing governed memory graph.'; - await loadMemoryGraph({history,query,entity:'',communities}); + await loadMemoryGraph(options); render(); document.querySelector('#live-status').textContent=memoryGraphError??'Governed memory graph ready.'; } -async function toggleMemoryGraphHistory(event){ - await loadMemoryGraph({...memoryGraphOptions,history:event.currentTarget.checked,entity:''}); +async function toggleMemoryGraphHistory(checked){ + await loadMemoryGraph({...memoryGraphOptions,history:checked,entity:''}); render(); } -function toggleMemoryGraphCommunities(event){ - memoryGraphOptions={...memoryGraphOptions,communities:event.currentTarget.checked}; +function toggleMemoryGraphCommunities(checked){ + memoryGraphOptions={...memoryGraphOptions,communities:checked}; render(); } @@ -3280,160 +3234,6 @@ function memoryDisplayText(value){const text=String(value??'');return /sk-[A-Za- function safeKeyValueList(value){if(!value||typeof value!=='object'||Array.isArray(value))return [];return Object.entries(value).filter(([key])=>!/prompt|body|credential|token|secret|path|url|reasoning|sql/i.test(key)).slice(0,8).map(([key,raw])=>({key:labelize(key),value:Array.isArray(raw)?raw.slice(0,4).map(safeText).join(', '):safeText(raw)}))} function quoteShell(value){return `'${String(value??'').replaceAll("'","'\"'\"'")}'`} -function memoryGraphNodeColor(type,community=0,useCommunity=false){ - if(useCommunity){ - const palette=['#4cc9a6','#7aa2ff','#f7b955','#e56b8b','#b38cff','#62d3ff','#9bd66f','#f08f4f']; - return palette[Math.abs(Number(community??0))%palette.length]; - } - return ({project:'#4cc9a6',provider:'#7aa2ff',port:'#f7b955',decision:'#e56b8b',module:'#b38cff',entity:'#8a96a8'})[type]??'#8a96a8'; -} - -function memoryGraphVisiblePayload(report){ - const nodes=Array.isArray(report?.graph?.nodes)?report.graph.nodes:[]; - const edges=Array.isArray(report?.graph?.edges)?report.graph.edges:[]; - const focusNames=new Set((report?.focus?.nodes??[]).map((node)=>node.name)); - if(!focusNames.size)return {nodes,edges}; - return { - nodes:nodes.filter((node)=>focusNames.has(node.name)), - edges:edges.filter((edge)=>focusNames.has(edge.from)&&focusNames.has(edge.to)) - }; -} - -function layoutMemoryGraph(nodes,edges,width,height){ - const positions=new Map(); - const centerX=width/2; - const centerY=height/2; - const radius=Math.max(80,Math.min(width,height)*0.36); - nodes.forEach((node,index)=>{ - const angle=(Math.PI*2*index)/Math.max(1,nodes.length); - positions.set(node.id,{x:centerX+Math.cos(angle)*radius,y:centerY+Math.sin(angle)*radius,vx:0,vy:0,node}); - }); - const linked=edges.map((edge)=>({source:positions.get(edge.from),target:positions.get(edge.to),edge})).filter((item)=>item.source&&item.target); - for(let tick=0;tick<90;tick+=1){ - for(let i=0;inode.name)); - const query=String(options.query??'').toLowerCase(); - ctx.lineCap='round'; - for(const edge of edges){ - const source=positions.get(edge.from); - const target=positions.get(edge.to); - if(!source||!target)continue; - const focused=!focusNodes.size||focusNodes.has(edge.from)||focusNodes.has(edge.to); - ctx.globalAlpha=edge.current?(focused?0.72:0.32):0.18; - ctx.strokeStyle=edge.current?'#4f5d75':'#9aa3b2'; - ctx.lineWidth=edge.current?1.4:1; - ctx.setLineDash(edge.current?[]:[5,5]); - ctx.beginPath(); - ctx.moveTo(source.x,source.y); - ctx.lineTo(target.x,target.y); - ctx.stroke(); - ctx.setLineDash([]); - const labelX=(source.x+target.x)/2; - const labelY=(source.y+target.y)/2; - ctx.globalAlpha=edge.current?0.75:0.32; - ctx.fillStyle='#c7ced9'; - ctx.font='11px Inter, system-ui, sans-serif'; - ctx.fillText(edge.predicate.slice(0,28),labelX+4,labelY-4); - } - ctx.globalAlpha=1; - for(const node of nodes){ - const point=positions.get(node.id); - if(!point)continue; - const matched=query&&node.name.toLowerCase().includes(query); - const focused=!focusNodes.size||focusNodes.has(node.name); - const r=Number(node.size??10)+(matched?4:0); - ctx.globalAlpha=node.current?(focused?1:0.52):0.34; - ctx.fillStyle=memoryGraphNodeColor(node.type,node.community,options.communities); - ctx.beginPath(); - ctx.arc(point.x,point.y,r,0,Math.PI*2); - ctx.fill(); - if(node.governedDecision||matched){ - ctx.strokeStyle=node.governedDecision?'#ff7395':'#f7b955'; - ctx.lineWidth=3; - ctx.stroke(); - } - ctx.globalAlpha=node.current?0.92:0.46; - ctx.fillStyle='#f5f7fb'; - ctx.font='12px Inter, system-ui, sans-serif'; - ctx.fillText(node.name.slice(0,34),point.x+r+5,point.y+4); - } - ctx.globalAlpha=1; - canvas.onclick=async (event)=>{ - const box=canvas.getBoundingClientRect(); - const x=(event.clientX-box.left)*(width/box.width); - const y=(event.clientY-box.top)*(height/box.height); - let selected=null; - let best=Infinity; - for(const node of nodes){ - const point=positions.get(node.id); - if(!point)continue; - const distance=Math.hypot(point.x-x,point.y-y); - const hit=(Number(node.size??10)+8); - if(distance current !== false); + const currentEdges = history ? sourceEdges : sourceEdges.filter(({ current }) => current !== false); + const focusIds = new Set(arrayValue(report?.focus?.nodes).flatMap((node) => [node.id, node.name]).filter(Boolean)); + const focusedNodes = focusIds.size ? currentNodes.filter((node) => focusIds.has(node.id) || focusIds.has(node.name)) : currentNodes; + const { nodes, edges } = filterMemoryGraph(focusedNodes, currentEdges, query); + const orderedNodes = groupRelated + ? [...nodes].sort((left, right) => nonNegativeInteger(left.community) - nonNegativeInteger(right.community) || String(left.name ?? left.id).localeCompare(String(right.name ?? right.id))) + : nodes; + const boundedNodes = orderedNodes.length > 200 + ? [...orderedNodes].sort((left, right) => nonNegativeInteger(right.degree) - nonNegativeInteger(left.degree) || String(left.id).localeCompare(String(right.id))).slice(0, 200) + : orderedNodes; + const boundedEdges = edges.length > 400 + ? [...edges].sort((left, right) => Number(right.current !== false) - Number(left.current !== false) || String(left.id).localeCompare(String(right.id))).slice(0, 400) + : edges; + const nodeIds = new Set(boundedNodes.map(({ id }) => id)); + const viewportEdges = boundedEdges + .filter((edge) => nodeIds.has(edge.from) && nodeIds.has(edge.to)) + .map((edge) => ({ ...edge, fromNodeId: edge.from, toNodeId: edge.to, kind: edge.predicate })); + const summary = { + nodeCount: sourceNodes.length, + edgeCount: sourceEdges.length, + currentNodeCount: sourceNodes.filter(({ current }) => current !== false).length, + currentEdgeCount: sourceEdges.filter(({ current }) => current !== false).length, + historyNodeCount: sourceNodes.filter(({ current }) => current === false).length, + historyEdgeCount: sourceEdges.filter(({ current }) => current === false).length, + ...(report?.summary ?? {}) + }; + return Object.freeze({ + ready: Boolean(report?.graph), + error, + workspaceId: report?.workspaceId ?? 'ws_local', + provider: report?.provider ?? 'provider:native:memory:sqlite', + generatedAt: report?.generatedAt ?? null, + reportFingerprint: report?.reportFingerprint ?? null, + safeguards: report?.safeguards ?? {}, + summary, + query, + history, + groupRelated, + nodes: Object.freeze(boundedNodes.map((node) => Object.freeze({ ...node, label: node.name ?? node.id }))), + edges: Object.freeze(boundedEdges), + viewportEdges: Object.freeze(viewportEdges), + omittedNodeCount: Math.max(0, orderedNodes.length - boundedNodes.length), + omittedEdgeCount: Math.max(0, edges.length - boundedEdges.length), + sourceNodeCount: sourceNodes.length, + sourceEdgeCount: sourceEdges.length + }); +} + +export function renderMemoryGraphView(model) { + if (model.error) return `
${memoryGraphHeading()}${renderApiErrorPanel('Memory graph unavailable', model.error)}
`; + if (!model.ready) return `
${memoryGraphHeading()}${statePanel('empty', 'Memory graph not loaded', 'The local memory provider did not return a graph report.')}
`; + + const controls = renderMemoryGraphControls(model); + if (model.sourceNodeCount === 0) { + return `
${memoryGraphHeading()}${controls}

No governed memory yet

Checked workspace ${escapeHtml(model.workspaceId)} through ${escapeHtml(model.provider)}. No approved temporal facts are available.

Review or add a source-backed proposal on the Memory route, then refresh this view.

${memoryGraphBoundary(model)}
`; + } + if (model.nodes.length === 0) { + return `
${memoryGraphHeading()}${controls}${statePanel('empty', 'No matching memory', 'No governed facts match the current search and history scope.')}${memoryGraphBoundary(model)}
`; + } + + const first = model.nodes[0]; + return `
+ ${memoryGraphHeading()} + ${controls} +
+
+

Governed facts

${model.nodes.length} visible records / ${model.edges.length} visible relationships${model.omittedNodeCount || model.omittedEdgeCount ? ` / ${model.omittedNodeCount} records and ${model.omittedEdgeCount} relationships omitted by display bounds` : ''}

+ +
+
+ +
+
`; +} + +export function bindMemoryGraph(root, { + model, + onSubmit, + onHistoryChange, + onGroupChange, + onSelectNode +} = {}) { + if (!root) return () => {}; + const controller = new AbortController(); + const signal = controller.signal; + let viewport = null; + root.querySelector('#memory-graph-form')?.addEventListener('submit', (event) => { + event.preventDefault(); + onSubmit?.(memoryGraphOptionsFromForm(event.currentTarget), event); + }, { signal }); + root.querySelector('#memory-graph-history')?.addEventListener('change', (event) => onHistoryChange?.(event.currentTarget.checked, event), { signal }); + root.querySelector('#memory-graph-communities')?.addEventListener('change', (event) => onGroupChange?.(event.currentTarget.checked, event), { signal }); + + const canvas = root.querySelector('#memory-graph-canvas'); + if (canvas && model) { + viewport = createGraphViewport( + canvas, + root.querySelector('.memory-graph-outline'), + root.querySelector('#memory-graph-selection'), + { + nodes: model.nodes, + edges: model.viewportEdges, + onSelect: (node) => { + const selection = root.querySelector('#memory-graph-selection'); + if (selection) selection.innerHTML = memoryNodeSelection(node); + onSelectNode?.(node); + }, + onError: (code) => { + const failure = root.querySelector('[data-memory-graph-error]'); + if (!failure) return; + failure.hidden = false; + failure.textContent = code === 'graph_layout_bounds_exceeded' + ? 'The visible memory graph exceeds the 200-node or 400-relationship display limit.' + : 'Graph layout could not start. Use the governed memory outline.'; + } + } + ); + root.querySelector('[data-memory-graph-action="fit"]')?.addEventListener('click', () => viewport.fit(), { signal }); + root.querySelector('[data-memory-graph-action="reset"]')?.addEventListener('click', () => viewport.reset(), { signal }); + } + return () => { + controller.abort(); + viewport?.destroy(); + }; +} + +function memoryGraphHeading() { + return '

Governed repository memory

Graph

Inspect current and historical temporal facts from the local memory store.

Read-only / local SQLite
'; +} + +function renderMemoryGraphControls(model) { + return `
`; +} + +function memoryGraphOutline(nodes, groupRelated) { + let lastCommunity = null; + return `
    ${nodes.map((node, index) => { + const community = nonNegativeInteger(node.community); + const groupLabel = groupRelated && community !== lastCommunity ? `
  1. Related group ${community}
  2. ` : ''; + lastCommunity = community; + return `${groupLabel}
  3. `; + }).join('')}
`; +} + +function memoryNodeSelection(node) { + if (!node) return '

No selection.

'; + return `
Name
${escapeHtml(node.name ?? node.id)}
Status
${escapeHtml(memoryNodeStatus(node))}
Type
${escapeHtml(node.type ?? 'entity')}
Degree
${nonNegativeInteger(node.degree)}
${node.governedDecision ? '
Governance
Decision record
' : ''}
`; +} + +function memoryEdgeList(edges) { + if (!edges.length) return '

No relationships in the current scope.

'; + return `
    ${edges.slice(0, 16).map((edge) => `
  1. ${escapeHtml(edge.from)} ${escapeHtml(edge.predicate)} ${escapeHtml(edge.to)}${escapeHtml(edge.current === false ? 'Superseded' : 'Current')}Valid from ${escapeHtml(formatDate(edge.validFrom))} / Valid until ${edge.validUntil ? escapeHtml(formatDate(edge.validUntil)) : 'open'} / Provenance ${escapeHtml(edge.source ?? 'unavailable')}
  2. `).join('')}
`; +} + +function memoryGraphBoundary(model) { + const safeguards = model.safeguards ?? {}; + return `

Provider check

Provider
${escapeHtml(model.provider)}
Generated
${escapeHtml(formatDate(model.generatedAt))}
Read-only
${safeguards.readOnly === false ? 'no' : 'yes'}
Model calls
${nonNegativeInteger(safeguards.modelCalls)}
Network calls
${nonNegativeInteger(safeguards.networkCalls)}
External writes
${safeguards.externalWritesEnabled ? 'on' : 'off'}
`; +} + +function memoryGraphOptionsFromForm(form) { + const data = new FormData(form); + return { + query: String(data.get('query') ?? '').trim().slice(0, 512), + history: data.get('history') === 'on', + communities: data.get('communities') === 'on', + entity: '' + }; +} + +function filterMemoryGraph(nodes, edges, query) { + if (!query) return { nodes: [...nodes], edges: [...edges] }; + const needle = query.toLocaleLowerCase(); + const selectedIds = new Set(nodes.filter((node) => [node.id, node.name, node.type].some((value) => String(value ?? '').toLocaleLowerCase().includes(needle))).map(({ id }) => id)); + for (const edge of edges) { + const edgeMatches = [edge.from, edge.to, edge.predicate, edge.source].some((value) => String(value ?? '').toLocaleLowerCase().includes(needle)); + if (edgeMatches || selectedIds.has(edge.from) || selectedIds.has(edge.to)) { + selectedIds.add(edge.from); + selectedIds.add(edge.to); + } + } + return { + nodes: nodes.filter(({ id }) => selectedIds.has(id)), + edges: edges.filter((edge) => selectedIds.has(edge.from) && selectedIds.has(edge.to)) + }; +} + +function memoryNodeStatus(node) { + return node.current === false ? 'Historical' : 'Current'; +} + +function arrayValue(value) { + return Array.isArray(value) ? value : []; +} + +function nonNegativeInteger(value) { + const number = Number(value); + return Number.isFinite(number) && number >= 0 ? Math.floor(number) : 0; +} diff --git a/apps/web/styles.css b/apps/web/styles.css index 05c2d5c0..b8e116af 100644 --- a/apps/web/styles.css +++ b/apps/web/styles.css @@ -465,16 +465,34 @@ th{color:var(--slate);font-size:12px;text-transform:uppercase;letter-spacing:.08 .fabric-links .is-active strong:before,.fabric-links .is-active strong:after{border-color:color-mix(in srgb,var(--signal) 70%,var(--border))} .fabric-links .is-blocked strong{color:var(--danger)} .fabric-context-flow{margin-bottom:0} -.memory-graph-shell{display:grid;grid-template-columns:minmax(0,1fr) minmax(300px,var(--layout-inspector));gap:var(--space-16);align-items:start;margin-bottom:var(--space-16)} -.memory-graph-toolbar{display:grid;grid-template-columns:minmax(220px,1fr) auto auto auto;gap:var(--space-10,10px);align-items:end;margin-bottom:var(--space-14,14px)} +.memory-graph-query{padding-bottom:var(--space-md);margin-bottom:var(--space-md);border-bottom:1px solid var(--color-rule)} +.memory-graph-toolbar{display:grid;grid-template-columns:minmax(220px,1fr) auto auto auto;gap:var(--space-xs);align-items:end} .memory-graph-search{min-width:0} .toggle-field{min-height:44px;display:inline-flex;align-items:center;gap:var(--space-8);border:1px solid var(--border);border-radius:var(--radius-control);background:var(--surface-2);padding:8px 12px;font-weight:720;color:var(--paper);white-space:nowrap} .toggle-field input{inline-size:18px;block-size:18px;accent-color:var(--signal)} -.memory-graph-canvas-wrap{width:100%;height:clamp(420px,58vh,700px);border:1px solid var(--border);border-radius:var(--radius-panel);overflow:hidden;background:#111826} -.memory-graph-canvas-wrap canvas{display:block;width:100%;height:100%} -.memory-graph-legend{display:flex;flex-wrap:wrap;gap:var(--space-8);margin-top:var(--space-10,10px)} -.memory-graph-legend span{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--border);border-radius:999px;background:var(--surface-2);padding:5px 9px;color:var(--slate);font-size:12px;font-weight:720} -.memory-graph-legend i{width:10px;height:10px;border-radius:50%;display:inline-block} +.memory-graph-empty{max-width:720px;padding:var(--space-md) 0} +.memory-graph-empty h2{margin:0 0 var(--space-xs);font-size:24px} +.memory-graph-empty p{color:var(--color-muted)} +.memory-graph-layout{display:grid;grid-template-columns:minmax(0,7fr) minmax(280px,3fr);gap:var(--space-lg);align-items:start} +.memory-graph-stage{min-width:0} +.memory-graph-stage-heading{display:flex;align-items:end;justify-content:space-between;gap:var(--space-xs);padding-bottom:var(--space-xs)} +.memory-graph-stage-heading h2{margin:0} +.memory-graph-stage-heading p{margin:var(--space-3xs) 0 0;color:var(--color-muted)} +.memory-graph-stage-heading>div:last-child{display:flex;gap:var(--space-2xs)} +.memory-graph-canvas-wrap{width:100%;aspect-ratio:16/9;max-height:620px;border:1px solid var(--color-rule);border-radius:var(--radius-control);overflow:hidden;background:var(--color-panel);touch-action:none} +.memory-graph-canvas-wrap canvas{display:block;width:100%;height:100%;cursor:grab} +.memory-graph-canvas-wrap canvas:active{cursor:grabbing} +.memory-graph-inspector{min-width:0;padding-left:var(--space-md);border-left:1px solid var(--color-rule)} +.memory-graph-inspector>h2,.memory-graph-boundary h2{margin:0 0 var(--space-xs);font-size:15px} +.memory-graph-inspector>hr{margin:var(--space-sm) 0;border:0;border-top:1px solid var(--color-rule)} +.memory-graph-outline,.memory-fact-list{margin:0;padding:0;list-style:none;border-top:1px solid var(--color-rule)} +.memory-graph-outline button{width:100%;min-height:44px;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:var(--space-xs);align-items:center;padding:var(--space-2xs);border:0;border-bottom:1px solid var(--color-rule);border-radius:0;background:transparent;color:var(--color-ink);text-align:left;cursor:pointer} +.memory-graph-outline button:hover{background:var(--color-panel-muted)} +.memory-graph-outline button[aria-current="true"]{box-shadow:inset 2px 0 var(--color-accent)} +.memory-graph-outline button span{color:var(--color-muted);font-size:12px} +.memory-group-label{padding:var(--space-xs) var(--space-2xs) var(--space-3xs);color:var(--color-muted);font-size:11px;font-weight:650;text-transform:uppercase;letter-spacing:.06em} +.memory-fact-list li{display:grid;gap:var(--space-3xs);padding:var(--space-xs) 0;border-bottom:1px solid var(--color-rule)} +.memory-fact-list span,.memory-fact-list small{color:var(--color-muted)} .flow-steps{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:var(--space-12)} .flow-step{position:relative;border:1px solid var(--border);border-radius:var(--radius-card);background:var(--surface-2);padding:var(--space-14,14px);min-height:132px;display:grid;gap:var(--space-8)} .flow-step>span{width:28px;height:28px;border-radius:999px;background:var(--surface-3);display:grid;place-items:center;color:var(--slate);font-size:12px;font-weight:760} @@ -505,7 +523,8 @@ th{color:var(--slate);font-size:12px;text-transform:uppercase;letter-spacing:.08 .use-path-grid{grid-template-columns:repeat(2,minmax(0,1fr))} .facts-wide{grid-template-columns:1fr} .fabric-hero,.fabric-layout{grid-template-columns:1fr} - .memory-graph-shell{grid-template-columns:1fr} + .memory-graph-layout{grid-template-columns:1fr} + .memory-graph-inspector{padding:var(--space-md) 0 0;border-left:0;border-top:1px solid var(--color-rule)} .memory-graph-toolbar{grid-template-columns:1fr 1fr} .memory-graph-toolbar .button,.memory-graph-search{grid-column:1/-1} .primary-flow{grid-template-columns:1fr} @@ -592,7 +611,8 @@ th{color:var(--slate);font-size:12px;text-transform:uppercase;letter-spacing:.08 .fabric-scoreboard{grid-template-columns:repeat(2,minmax(0,1fr))} .memory-graph-toolbar{grid-template-columns:1fr} .memory-graph-toolbar .button,.memory-graph-search{grid-column:auto} - .memory-graph-canvas-wrap{height:460px} + .memory-graph-stage-heading{display:grid} + .memory-graph-canvas-wrap{aspect-ratio:4/3} .tool-page-heading,.tool-result-heading{display:grid;align-items:start} .tool-page-heading>span{white-space:normal} .tool-summary{justify-content:flex-start;gap:var(--space-sm)} diff --git a/docs/superpowers/plans/2026-07-15-memory-recall-orientation-workbench-ui.md b/docs/superpowers/plans/2026-07-15-memory-recall-orientation-workbench-ui.md index 5ef99fd3..09f5dfad 100644 --- a/docs/superpowers/plans/2026-07-15-memory-recall-orientation-workbench-ui.md +++ b/docs/superpowers/plans/2026-07-15-memory-recall-orientation-workbench-ui.md @@ -862,7 +862,7 @@ git commit -m "feat: add bounded interactive Map graph" - Produces: `bindMemoryGraph(root, handlers): () => void`. - Reuses `graph-viewport.js` and `graph-layout-worker.js` from Task 5. -- [ ] **Step 1: Write failing empty and populated tests** +- [x] **Step 1: Write failing empty and populated tests** Create `tests/web-memory-graph.test.mjs`: @@ -926,7 +926,7 @@ function memoryGraphFixture({ nodes, edges } = {}) { } ``` -- [ ] **Step 2: Run tests and verify current empty-canvas behavior** +- [x] **Step 2: Run tests and verify current empty-canvas behavior** Run: @@ -936,19 +936,19 @@ node --test tests/web-memory-graph.test.mjs Expected: FAIL because the current renderer always emits metrics and a fixed canvas. -- [ ] **Step 3: Implement empty, populated, and selected states** +- [x] **Step 3: Implement empty, populated, and selected states** Empty state shows workspace/provider check, read-only state, and one factual next action. Populated state uses compact search/history controls, a real bounded canvas, an always-present outline, and one inspector. Replace `Community colors` with `Group related facts`; status labels include text such as `Current`, `Superseded`, and `Historical` so color is never the only channel. -- [ ] **Step 4: Remove old memory layout and delegate from `app.js`** +- [x] **Step 4: Remove old memory layout and delegate from `app.js`** Delete `layoutMemoryGraph()` and `drawMemoryGraphCanvas()` from `app.js`. Delegate rendering and binding to `memory-graph-view.js`. Reuse the worker; do not create a second layout implementation. Keep proposal approval exclusively on the Memory review route. -- [ ] **Step 5: Remove fixed empty-canvas styling** +- [x] **Step 5: Remove fixed empty-canvas styling** Replace `.memory-graph-canvas-wrap{height:clamp(...)}` with a populated-only aspect/viewport rule and no minimum height in the empty state. Remove the four-column metric strip and legend pills from this route. Preserve dark/light contrast with current tokens. -- [ ] **Step 6: Verify memory and shell tests** +- [x] **Step 6: Verify memory and shell tests** Run: diff --git a/tests/web-memory-graph.test.mjs b/tests/web-memory-graph.test.mjs new file mode 100644 index 00000000..fccba31e --- /dev/null +++ b/tests/web-memory-graph.test.mjs @@ -0,0 +1,83 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { buildMemoryGraphViewModel, renderMemoryGraphView } from '../apps/web/memory-graph-view.js'; + +test('empty governed memory renders no canvas or zero metrics', () => { + const model = buildMemoryGraphViewModel(memoryGraphFixture({ nodes: [], edges: [] }), {}); + const html = renderMemoryGraphView(model); + assert.match(html, /No governed memory yet/); + assert.match(html, /Checked workspace/); + assert.doesNotMatch(html, / { + const model = buildMemoryGraphViewModel(memoryGraphFixture(), { history: true, query: 'provider' }); + const html = renderMemoryGraphView(model); + assert.match(html, /id="memory-graph-canvas"/); + assert.match(html, /aria-label="Governed memory outline"/); + assert.match(html, /data-node-id="provider:native:memory:sqlite"/); + assert.match(html, /Current|Superseded/); + assert.doesNotMatch(html, /Community colors/); +}); + +test('memory graph query preserves controls and filters visible nodes', () => { + const model = buildMemoryGraphViewModel(memoryGraphFixture(), { history: false, query: 'provider' }); + assert.equal(model.nodes.length, 2); + assert.equal(model.edges.length, 1); + const html = renderMemoryGraphView(model); + assert.match(html, /value="provider"/); + assert.doesNotMatch(html, /legacy-view/); + assert.doesNotMatch(html, /checked[^>]*name="history"/); +}); + +test('memory graph stays inside the shared viewport bounds', () => { + const nodes = Array.from({ length: 205 }, (_, index) => ({ + id: `node-${index}`, name: `node-${index}`, type: 'entity', current: true, + governedDecision: false, degree: index, size: 10, community: index % 4 + })); + const edges = Array.from({ length: 405 }, (_, index) => ({ + id: `edge-${index}`, from: `node-${index % 205}`, to: `node-${(index + 1) % 205}`, + predicate: 'links', current: true, status: 'active' + })); + const model = buildMemoryGraphViewModel(memoryGraphFixture({ nodes, edges }), { history: true }); + assert.equal(model.nodes.length, 200); + assert.equal(model.edges.length, 400); + assert.equal(model.omittedNodeCount, 5); + assert.equal(model.omittedEdgeCount, 5); +}); + +function memoryGraphFixture({ nodes, edges } = {}) { + const defaultNodes = [ + { id: 'provider:native:memory:sqlite', name: 'provider:native:memory:sqlite', type: 'provider', current: true, governedDecision: false, degree: 1, size: 14, community: 1 }, + { id: 'MemoryBackendPort', name: 'MemoryBackendPort', type: 'port', current: true, governedDecision: false, degree: 1, size: 14, community: 1 }, + { id: 'legacy-view', name: 'legacy-view', type: 'decision', current: false, governedDecision: true, degree: 1, size: 13, community: 2 } + ]; + const defaultEdges = [ + { id: 'medge_current', from: 'provider:native:memory:sqlite', to: 'MemoryBackendPort', predicate: 'implements_port', factId: 'memfact_current', current: true, status: 'active', validFrom: '2026-07-15T08:00:00.000Z', validUntil: null, supersededBy: null, source: 'workspace://providers/native/memory-sqlite/provider.json' }, + { id: 'medge_history', from: 'legacy-view', to: 'MemoryBackendPort', predicate: 'replaced_by', factId: 'memfact_history', current: false, status: 'superseded', validFrom: '2026-07-14T08:00:00.000Z', validUntil: '2026-07-15T08:00:00.000Z', supersededBy: 'memfact_current', source: 'workspace://docs/decisions.md' } + ]; + const graphNodes = nodes ?? defaultNodes; + const graphEdges = edges ?? defaultEdges; + return { + schemaVersion: '1.0.0', + workspaceId: 'ws_local', + generatedAt: '2026-07-15T10:00:00.000Z', + provider: 'provider:native:memory:sqlite', + mode: 'history', + communityMethod: 'label-propagation', + summary: { + nodeCount: graphNodes.length, + edgeCount: graphEdges.length, + currentNodeCount: graphNodes.filter(({ current }) => current).length, + currentEdgeCount: graphEdges.filter(({ current }) => current).length, + historyNodeCount: graphNodes.filter(({ current }) => !current).length, + historyEdgeCount: graphEdges.filter(({ current }) => !current).length, + communityCount: new Set(graphNodes.map(({ community }) => community)).size + }, + graph: { nodes: graphNodes, edges: graphEdges }, + focus: null, + safeguards: { readOnly: true, modelCalls: 0, networkCalls: 0, externalWritesEnabled: false }, + reportFingerprint: `sha256:${'e'.repeat(64)}` + }; +} diff --git a/tests/web-shell.test.mjs b/tests/web-shell.test.mjs index cd2c16d3..60852892 100644 --- a/tests/web-shell.test.mjs +++ b/tests/web-shell.test.mjs @@ -30,7 +30,6 @@ import { authFailureTransition, normalizeRecallMapGitChanges, buildMemoryCockpitModel, - buildMemoryGraphModel, buildMemoryReviewModel, buildPinnedHandoffStatusModel, canReceivePinnedHandoff, @@ -48,7 +47,6 @@ import { safeEventSummary, selectContextPackPinPayload, renderMemoryCockpit, - renderMemoryGraph, renderLoopWorkbenchMemoryFlow, renderSetupScreen, renderContextPackTokenSaverSummary, @@ -62,6 +60,7 @@ import { writeClipboardText } from '../apps/web/app.js'; import { parseMapUrl, renderSourceMap } from '../apps/web/source-map-view.js'; +import { buildMemoryGraphViewModel, renderMemoryGraphView } from '../apps/web/memory-graph-view.js'; test('web API and UI primitives are focused modules', async () => { const apiSource = await readFile(new URL('../apps/web/api.js', import.meta.url), 'utf8'); @@ -632,18 +631,18 @@ test('memory graph route renders governed graph canvas controls', async () => { safeguards: { readOnly: true, networkCalls: 0, modelCalls: 0, externalWritesEnabled: false }, reportFingerprint: 'sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee' }; - const model = buildMemoryGraphModel(report); - const html = renderMemoryGraph(report, { history: true, query: 'provider', communities: true }); + const model = buildMemoryGraphViewModel(report, { history: true, query: '', communities: true }); + const html = renderMemoryGraphView(model); const source = await readFile(new URL('../apps/web/app.js', import.meta.url), 'utf8'); assert.equal(model.summary.edgeCount, 2); assert.equal(model.nodes.some((node) => node.governedDecision), true); assert.match(html, /id="memory-graph-canvas"/); assert.match(html, /id="memory-graph-history"/); assert.match(html, /id="memory-graph-communities"/); + assert.match(html, /Group related facts/); assert.match(html, /provider:native:memory:sqlite/); assert.match(html, /legacy-view/); - assert.match(html, /Current facts/); - assert.match(html, /History facts/); + assert.match(html, /Fact history/); assert.match(html, /Superseded/); assert.match(html, /Provenance/); assert.match(html, /Valid from/); From c23da379e55ade8c1e883adba4073e27878718b4 Mon Sep 17 00:00:00 2001 From: rebel Date: Thu, 16 Jul 2026 00:47:17 +0530 Subject: [PATCH 020/276] refactor: remove AI-style copy and dashboard chrome --- apps/web/app.js | 2 +- ...07-15-memory-recall-orientation-workbench-ui.md | 10 +++++----- tests/web-shell.test.mjs | 14 ++++++++++++++ 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/apps/web/app.js b/apps/web/app.js index e4c8b795..ebac8ea7 100644 --- a/apps/web/app.js +++ b/apps/web/app.js @@ -1334,7 +1334,7 @@ function renderRunDetail(data) { function renderWorkflows() { const steps=['collect','normalize','analyze-patterns','compile-context','generate-angles','verify-recommendations','local-draft-outcome']; - return `

Content Intelligence

workflow:content-intelligence
    ${steps.map((step,index)=>`
  1. ${index+1}${step}${workflowStepCopy(step)}
  2. `).join('')}
`; + return `

Content analysis

workflow:content-intelligence
    ${steps.map((step,index)=>`
  1. ${index+1}${step}${workflowStepCopy(step)}
  2. `).join('')}
`; } export function buildLoopWorkbenchModel(report=null,{dashboard=null,error=null}={}) { diff --git a/docs/superpowers/plans/2026-07-15-memory-recall-orientation-workbench-ui.md b/docs/superpowers/plans/2026-07-15-memory-recall-orientation-workbench-ui.md index 09f5dfad..5dd4aab6 100644 --- a/docs/superpowers/plans/2026-07-15-memory-recall-orientation-workbench-ui.md +++ b/docs/superpowers/plans/2026-07-15-memory-recall-orientation-workbench-ui.md @@ -979,7 +979,7 @@ git commit -m "feat: simplify the governed memory graph" **Interfaces:** - Produces no new runtime interface; locks the canonical copy and visual constraints in tests. -- [ ] **Step 1: Expand the prohibited-copy test** +- [x] **Step 1: Expand the prohibited-copy test** In `tests/web-shell.test.mjs`, scan all web HTML/JS view files and assert that promotional terms are absent outside a small technical allowlist: @@ -999,7 +999,7 @@ test('visible web copy is factual and contains no intelligence theater', async ( }); ``` -- [ ] **Step 2: Run the copy test and inventory failures** +- [x] **Step 2: Run the copy test and inventory failures** Run: @@ -1010,7 +1010,7 @@ rg -n -i '\b(ai|intelligent|intelligence|smart|magical|seamless|unlock|superchar Expected: any current visible violations, including `Content Intelligence`, are listed. Technical workflow IDs such as `workflow:content-intelligence` may remain internal and must not be renamed in this task. -- [ ] **Step 3: Replace visible hype with object, state, reason, or action labels** +- [x] **Step 3: Replace visible hype with object, state, reason, or action labels** Examples: @@ -1024,7 +1024,7 @@ Run map -> Search map Keep technical IDs, compatibility URIs, model/provider settings, and explicit boundary statements where accurate. Do not replace precise language with vague synonyms. -- [ ] **Step 4: Audit touched-route CSS mechanically** +- [x] **Step 4: Audit touched-route CSS mechanically** Run: @@ -1034,7 +1034,7 @@ rg -n 'gradient|backdrop-filter|filter:blur|box-shadow|border-radius:999|metric- For Overview, Map, and memory graph selectors, remove decorative shadows, pills, fixed empty height, nested rounded panels, and metric strips. Keep small status chips on untouched routes only when they communicate state. -- [ ] **Step 5: Run copy and visual-contract unit tests** +- [x] **Step 5: Run copy and visual-contract unit tests** Run: diff --git a/tests/web-shell.test.mjs b/tests/web-shell.test.mjs index 60852892..db0465b6 100644 --- a/tests/web-shell.test.mjs +++ b/tests/web-shell.test.mjs @@ -281,6 +281,20 @@ test('product shell omits prohibited marketing and removed dashboard patterns', } }); +test('visible web copy is factual and contains no intelligence theater', async () => { + const files = [ + 'apps/web/index.html', 'apps/web/app.js', 'apps/web/orientation-view.js', + 'apps/web/source-map-view.js', 'apps/web/memory-graph-view.js' + ]; + const source = (await Promise.all(files.map((file) => readFile(file, 'utf8')))).join('\n'); + for (const phrase of [ + 'AI-powered', 'intelligent workspace', 'smart insights', 'magical', 'seamless', + 'unlock', 'supercharge', 'revolutionary', 'next-generation', 'nervous system', + 'mission control', 'command center', 'content intelligence' + ]) assert.doesNotMatch(source, new RegExp(phrase, 'iu')); + assert.doesNotMatch(source, /[✨🤖🪄]/u); +}); + test('Recall Map home presents a repository-first daily Overview',()=>{ const report={ schemaVersion:'1.0.0', From 31e420b221a3bfd7cfabce259a10631dd3a1adf4 Mon Sep 17 00:00:00 2001 From: rebel Date: Thu, 16 Jul 2026 10:42:55 +0530 Subject: [PATCH 021/276] test: verify orientation workbench in browser --- apps/web/graph-viewport.js | 23 +- apps/web/index.html | 2 +- apps/web/memory-graph-view.js | 10 +- apps/web/orientation-view.js | 6 +- apps/web/source-map-view.js | 6 +- apps/web/styles.css | 35 +- apps/web/tokens.css | 4 +- ...-memory-recall-orientation-workbench-ui.md | 26 +- packages/ui/tokens.json | 8 +- scripts/consumer-browser-smoke.mjs | 494 ++++++++---------- tests/web-memory-graph.test.mjs | 20 + tests/web-shell.test.mjs | 5 +- tests/web-source-map.test.mjs | 15 + 13 files changed, 338 insertions(+), 316 deletions(-) diff --git a/apps/web/graph-viewport.js b/apps/web/graph-viewport.js index 3634b4f8..d98e9401 100644 --- a/apps/web/graph-viewport.js +++ b/apps/web/graph-viewport.js @@ -38,6 +38,7 @@ export function createGraphViewport(canvas, outline, inspector, { } positions = data.positions ?? {}; bounds = data.bounds ?? null; + canvas.dataset.layoutReady = 'true'; reset(); }, { signal }); worker.addEventListener('error', () => onError('graph_layout_worker_failed'), { signal }); @@ -194,8 +195,17 @@ export function createGraphViewport(canvas, outline, inspector, { context.arc(point.x, point.y, selected ? 6 : 4, 0, Math.PI * 2); context.fill(); if (selected || nodes.length <= 60) { + const label = String(node.label ?? '').slice(0, 32); + const placement = graphLabelPlacement({ + pointX: point.x, + labelWidth: context.measureText(label).width, + scale, + panX, + viewportWidth: width + }); context.fillStyle = palette.ink; - context.fillText(String(node.label ?? '').slice(0, 32), point.x + 10, point.y); + context.textAlign = placement.align; + context.fillText(label, point.x + placement.offset, point.y); } } context.restore(); @@ -236,6 +246,17 @@ export function createGraphViewport(canvas, outline, inspector, { }; } +export function graphLabelPlacement({ pointX = 0, labelWidth = 0, scale = 1, panX = 0, viewportWidth = 0 } = {}) { + const resolvedScale = Math.max(0.01, Number(scale) || 1); + const screenX = Number(panX) + Number(pointX) * resolvedScale; + const scaledLabelWidth = Math.max(0, Number(labelWidth) || 0) * resolvedScale; + const rightEdge = screenX + (10 * resolvedScale) + scaledLabelWidth; + const leftEdge = screenX - (10 * resolvedScale) - scaledLabelWidth; + return rightEdge > Number(viewportWidth) - 8 && leftEdge >= 8 + ? { align: 'right', offset: -10 } + : { align: 'left', offset: 10 }; +} + function graphPalette(canvas) { const styles = getComputedStyle(canvas); const token = (name, fallback) => styles.getPropertyValue(name).trim() || fallback; diff --git a/apps/web/index.html b/apps/web/index.html index 54dedfbf..7886bf2b 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -51,7 +51,7 @@
- Loading + Loading Settings
diff --git a/apps/web/memory-graph-view.js b/apps/web/memory-graph-view.js index c130c059..1ee7c18f 100644 --- a/apps/web/memory-graph-view.js +++ b/apps/web/memory-graph-view.js @@ -74,17 +74,19 @@ export function renderMemoryGraphView(model) { ${controls}
-

Governed facts

${model.nodes.length} visible records / ${model.edges.length} visible relationships${model.omittedNodeCount || model.omittedEdgeCount ? ` / ${model.omittedNodeCount} records and ${model.omittedEdgeCount} relationships omitted by display bounds` : ''}

+

Governed facts

${model.nodes.length} visible records / ${model.edges.length} visible relationships${model.omittedNodeCount || model.omittedEdgeCount ? ` / ${model.omittedNodeCount} records and ${model.omittedEdgeCount} relationships omitted by display bounds` : ''}

+
+

Fact history

${memoryEdgeList(model.edges)}
+ ${memoryGraphBoundary(model)} +
`; } @@ -140,7 +142,7 @@ export function bindMemoryGraph(root, { } function memoryGraphHeading() { - return '

Governed repository memory

Graph

Inspect current and historical temporal facts from the local memory store.

Read-only / local SQLite
'; + return '

Graph

Inspect current and historical temporal facts from the local memory store.

Read-only / local SQLite
'; } function renderMemoryGraphControls(model) { diff --git a/apps/web/orientation-view.js b/apps/web/orientation-view.js index d38e3b51..e5acb432 100644 --- a/apps/web/orientation-view.js +++ b/apps/web/orientation-view.js @@ -71,7 +71,7 @@ function renderStartHere(items = []) { const content = items.length ? `
    ${items.map((item) => `
  1. ${escapeHtml(item.label)}${escapeHtml(item.locator)}${escapeHtml(item.reason)}
  2. `).join('')}
` : '

No ranked entry points in the current bounds.

'; - return `

Start here

${items.length} ranked
${content}
`; + return `

Start here

${items.length} ranked
${content}
`; } function renderImpact(impact = {}) { @@ -79,14 +79,14 @@ function renderImpact(impact = {}) { const detail = impact.status === 'clean' ? '

No local changes detected.

' : `

${escapeHtml(impact.label ?? 'Change state unavailable')}

${affected.length ? `
    ${affected.slice(0, 3).map((item) => `
  1. ${escapeHtml(item.label)}${escapeHtml(item.locator)}
  2. `).join('')}
` : ''}${impact.unrepresentedChangedCount ? `${Number(impact.unrepresentedChangedCount)} changed file${impact.unrepresentedChangedCount === 1 ? '' : 's'} outside the represented graph.` : ''}`; - return `

Current impact

${Number(impact.affectedCount ?? 0)} affected
${detail}
`; + return `

Current impact

${Number(impact.affectedCount ?? 0)} affected
${detail}
`; } function renderTrust(trust = {}) { const memory = trust.memory ?? {}; const handoff = trust.handoff ?? {}; const source = trust.source ?? {}; - return `

Trusted context

Source map
${escapeHtml(source.status ?? 'not scanned')}
Memory
${escapeHtml(memory.status ?? 'unavailable')}${memory.pendingCount ? `, ${Number(memory.pendingCount)} pending` : ''}
Handoff
${escapeHtml(handoff.status ?? 'not pinned')}
${trust.deliveryReduction ? `
Measured delivery
${Number(trust.deliveryReduction.percent)}% fewer tokens
` : ''}
`; + return `

Trusted context

Source map
${escapeHtml(source.status ?? 'not scanned')}
Memory
${escapeHtml(memory.status ?? 'unavailable')}${memory.pendingCount ? `, ${Number(memory.pendingCount)} pending` : ''}
Handoff
${escapeHtml(handoff.status ?? 'not pinned')}
${trust.deliveryReduction ? `
Measured delivery
${Number(trust.deliveryReduction.percent)}% fewer tokens
` : ''}
`; } function coverageText(coverage = {}) { diff --git a/apps/web/source-map-view.js b/apps/web/source-map-view.js index db3a65a3..13bc8dee 100644 --- a/apps/web/source-map-view.js +++ b/apps/web/source-map-view.js @@ -72,7 +72,7 @@ export function renderSourceMap({ state: value = {}, report = null, error = null : statePanel('empty', 'Map not loaded', 'Run the map to inspect bounded repository structure.'); return `
-

Repository structure

Map

Find an entry point, trace a symbol, or inspect a changed file.

Read-only / bounded metadata
+

Map

Find an entry point, trace a symbol, or inspect a changed file.

Read-only / bounded metadata
${renderMapForm(state)} ${result}
`; @@ -136,7 +136,7 @@ function renderMapForm(state) {
-
No model, network, or external writes
+
No model, network, or external writes
`; } @@ -173,7 +173,7 @@ function renderGroups(groups, relations) { function renderFocus(nodes, edges, focus = {}) { if (!nodes.length) return statePanel('empty', 'No focused records', 'The submitted scope produced no bounded nodes. Broaden the query or remove a group filter.'); - return `
${number(nodes.length)} nodes${number(edges.length)} relationships${number(focus.omittedNodes ?? focus.omittedNodeCount) ? `${number(focus.omittedNodes ?? focus.omittedNodeCount)} nodes omitted` : ''}${number(focus.omittedEdges ?? focus.omittedEdgeCount) ? `${number(focus.omittedEdges ?? focus.omittedEdgeCount)} relationships omitted` : ''}
`; + return `
${number(nodes.length)} nodes${number(edges.length)} relationships${number(focus.omittedNodes ?? focus.omittedNodeCount) ? `${number(focus.omittedNodes ?? focus.omittedNodeCount)} nodes omitted` : ''}${number(focus.omittedEdges ?? focus.omittedEdgeCount) ? `${number(focus.omittedEdges ?? focus.omittedEdgeCount)} relationships omitted` : ''}
`; } function renderMapOutline(items, focused) { diff --git a/apps/web/styles.css b/apps/web/styles.css index b8e116af..2139848d 100644 --- a/apps/web/styles.css +++ b/apps/web/styles.css @@ -69,11 +69,13 @@ h3{font-size:15px;line-height:1.3} .action-row{display:flex;gap:var(--space-8);flex-wrap:wrap} .button{min-height:40px;border:1px solid var(--color-rule-strong);border-radius:var(--radius-control);padding:var(--space-2xs) var(--space-xs);color:var(--color-ink);background:var(--color-panel);font:inherit;font-weight:650;cursor:pointer} a.button{text-decoration:none;display:inline-flex;align-items:center;justify-content:center} -.button.primary{min-height:44px;color:var(--color-accent-ink);background:var(--color-accent);border-color:var(--color-accent)} +.button.primary{min-height:44px;color:var(--color-canvas);background:var(--color-ink);border-color:var(--color-ink)} .button.secondary{background:var(--color-panel);color:var(--color-ink)} +.button.quiet{min-height:44px;border-color:transparent;background:transparent;color:var(--color-muted)} .button:hover:not(:disabled){border-color:var(--color-accent)} +.button.quiet:hover:not(:disabled){border-color:transparent;background:var(--color-panel-muted);color:var(--color-ink)} .button:active:not(:disabled){background:var(--color-panel-muted)} -.button.primary:active:not(:disabled){filter:brightness(.9)} +.button.primary:active:not(:disabled){background:color-mix(in oklch,var(--color-ink) 88%,var(--color-canvas))} .button:disabled{opacity:.55;cursor:not-allowed} .button:focus-visible,a:focus-visible,main:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,button:focus-visible,summary:focus-visible{outline:2px solid var(--color-accent);outline-offset:1px} .status-chip{display:inline-flex;align-items:center;gap:var(--space-8);border:1px solid var(--border);background:var(--surface-2);border-radius:999px;padding:5px 10px;min-height:32px} @@ -138,7 +140,7 @@ code,dd,.meta-row span,.reason{overflow-wrap:anywhere;word-break:break-word} .stacked-form{display:grid;gap:var(--space-16)} .field-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--space-12)} .field{display:grid;gap:var(--space-3xs);min-width:0} -.field span{font-size:12px;font-weight:720;color:var(--slate);text-transform:uppercase;letter-spacing:.08em} +.field span{font-size:12px;font-weight:650;color:var(--slate)} .field small{color:var(--slate);font-size:12px;line-height:1.4} .field input,.field select,.field textarea{width:100%;min-height:44px;border:1px solid var(--border);border-radius:var(--radius-control);background:var(--surface-2);color:var(--paper);padding:var(--space-2xs) var(--space-xs);font:inherit;outline:2px solid transparent;outline-offset:1px} .field textarea{min-height:96px;resize:vertical} @@ -146,7 +148,7 @@ code,dd,.meta-row span,.reason{overflow-wrap:anywhere;word-break:break-word} .field input:focus-visible,.field select:focus-visible,.field textarea:focus-visible{outline-color:var(--focus)} .field input:disabled,.field select:disabled,.field textarea:disabled{opacity:.55;cursor:not-allowed;background:var(--color-panel-muted)} .source-family-field{display:grid;gap:var(--space-8);border:1px solid var(--border);border-radius:var(--radius-control);padding:var(--space-12);margin:0;background:var(--surface-2)} -.source-family-field legend{padding:0 var(--space-6,6px);font-size:12px;font-weight:720;color:var(--slate);text-transform:uppercase;letter-spacing:.08em} +.source-family-field legend{padding:0 var(--space-6,6px);font-size:12px;font-weight:650;color:var(--slate)} .source-family-options{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--space-8)} .source-family-options label{display:flex;align-items:center;gap:var(--space-8);min-height:42px;border:1px solid var(--border);border-radius:var(--radius-control);background:var(--surface-3);padding:8px 10px;font-weight:720} .source-family-options input{inline-size:18px;block-size:18px;accent-color:var(--signal)} @@ -337,14 +339,14 @@ th{color:var(--slate);font-size:12px;text-transform:uppercase;letter-spacing:.08 .orientation-inspector section>header{display:flex;align-items:baseline;justify-content:space-between;gap:var(--space-xs);margin-bottom:var(--space-xs)} .orientation-inspector section>header span{color:var(--color-muted);font-size:12px} .orientation-inspector ol{list-style:none;margin:0;padding:0} -.start-item a{min-height:52px;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:2px var(--space-xs);padding:var(--space-2xs) 0;border-top:1px solid var(--color-rule);color:inherit;text-decoration:none} +.start-item a{min-height:44px;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:2px var(--space-xs);padding:var(--space-3xs) 0;border-top:1px solid var(--color-rule);color:inherit;text-decoration:none} .start-item:first-child a{border-top:0} .start-item code{grid-column:1/-1;color:var(--color-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .start-item span{color:var(--color-muted);font-size:12px;white-space:nowrap} .orientation-inspector p{margin:0;color:var(--color-muted)} .orientation-inspector small{display:block;margin-top:var(--space-2xs);color:var(--color-muted)} .orientation-inspector section ol:not(:first-child){margin-top:var(--space-2xs)} -.orientation-inspector section ol li:not(.start-item){display:grid;gap:2px;padding:var(--space-2xs) 0} +.orientation-inspector section ol li:not(.start-item){display:grid;gap:2px;padding:var(--space-3xs) 0} .orientation-inspector section ol code{color:var(--color-muted)} .trust-list{margin:0;display:grid;grid-template-columns:1fr 1fr;gap:var(--space-2xs) var(--space-sm)} .trust-list div{min-width:0} @@ -485,12 +487,15 @@ th{color:var(--slate);font-size:12px;text-transform:uppercase;letter-spacing:.08 .memory-graph-inspector{min-width:0;padding-left:var(--space-md);border-left:1px solid var(--color-rule)} .memory-graph-inspector>h2,.memory-graph-boundary h2{margin:0 0 var(--space-xs);font-size:15px} .memory-graph-inspector>hr{margin:var(--space-sm) 0;border:0;border-top:1px solid var(--color-rule)} +.memory-graph-meta{display:grid;grid-template-columns:minmax(0,7fr) minmax(260px,3fr);gap:var(--space-lg);margin-top:var(--space-md);padding-top:var(--space-md);border-top:1px solid var(--color-rule);align-items:start} +.memory-graph-history h2{margin:0 0 var(--space-xs);font-size:15px} +.memory-graph-history .memory-fact-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));column-gap:var(--space-md)} .memory-graph-outline,.memory-fact-list{margin:0;padding:0;list-style:none;border-top:1px solid var(--color-rule)} .memory-graph-outline button{width:100%;min-height:44px;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:var(--space-xs);align-items:center;padding:var(--space-2xs);border:0;border-bottom:1px solid var(--color-rule);border-radius:0;background:transparent;color:var(--color-ink);text-align:left;cursor:pointer} .memory-graph-outline button:hover{background:var(--color-panel-muted)} .memory-graph-outline button[aria-current="true"]{box-shadow:inset 2px 0 var(--color-accent)} .memory-graph-outline button span{color:var(--color-muted);font-size:12px} -.memory-group-label{padding:var(--space-xs) var(--space-2xs) var(--space-3xs);color:var(--color-muted);font-size:11px;font-weight:650;text-transform:uppercase;letter-spacing:.06em} +.memory-group-label{padding:var(--space-xs) var(--space-2xs) var(--space-3xs);color:var(--color-muted);font-size:11px;font-weight:650} .memory-fact-list li{display:grid;gap:var(--space-3xs);padding:var(--space-xs) 0;border-bottom:1px solid var(--color-rule)} .memory-fact-list span,.memory-fact-list small{color:var(--color-muted)} .flow-steps{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:var(--space-12)} @@ -542,7 +547,8 @@ th{color:var(--slate);font-size:12px;text-transform:uppercase;letter-spacing:.08 .map-query-actions{grid-column:1} .map-advanced-fields{grid-template-columns:repeat(2,minmax(0,1fr))} .map-advanced-fields .field:nth-child(3){grid-column:1/-1} - .source-map-layout{grid-template-columns:minmax(0,1fr) minmax(240px,320px);gap:var(--space-md)} + .source-map-layout{grid-template-columns:1fr} + .source-map-inspector{padding:var(--space-md) 0 0;border-left:0;border-top:1px solid var(--color-rule)} .flow-step:not(:last-child):after{display:none} } @@ -572,21 +578,21 @@ th{color:var(--slate);font-size:12px;text-transform:uppercase;letter-spacing:.08 .setup-screen{grid-template-columns:1fr;gap:var(--space-md)} .sidebar{display:none} main{min-height:0;overflow:auto} - .repository-bar{min-height:56px;padding:var(--space-2xs) var(--space-xs)} + .repository-bar{min-height:56px;display:grid;grid-template-columns:minmax(0,1fr) auto;padding:var(--space-2xs) var(--space-xs)} .repository-identity{display:none} + .repository-menu{grid-column:1;grid-row:1} .repository-menu summary{max-width:96px} - .global-search{flex-basis:170px;grid-template-columns:44px minmax(80px,1fr)} - .global-search select{width:44px;padding:0 4px;color:transparent} - .global-search select:focus{width:136px;color:var(--color-ink);position:relative;z-index:2} + .repository-actions{grid-column:2;grid-row:1} + .global-search{grid-column:1/-1;grid-row:2;width:100%;max-width:none;grid-template-columns:124px minmax(80px,1fr)} + .global-search select{width:auto;padding:0 var(--space-2xs);color:var(--color-ink)} .global-search button{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0} - .global-search input{border-radius:var(--radius-control)} + .global-search input{border-radius:0 var(--radius-control) var(--radius-control) 0} .orientation-heading{align-items:flex-start} .orientation-heading p{display:none} .architecture-board{grid-template-columns:1fr;grid-auto-flow:row;padding:var(--space-xs)} .orientation-group{grid-column:1!important} .architecture-outline ol{grid-template-columns:1fr} .orientation-inspector{grid-template-columns:1fr} - #repository-condition{display:none} #view-root{padding:var(--space-md) var(--space-xs) var(--space-xl)} h1{font-size:32px} .metric-strip{grid-template-columns:1fr 1fr} @@ -613,6 +619,7 @@ th{color:var(--slate);font-size:12px;text-transform:uppercase;letter-spacing:.08 .memory-graph-toolbar .button,.memory-graph-search{grid-column:auto} .memory-graph-stage-heading{display:grid} .memory-graph-canvas-wrap{aspect-ratio:4/3} + .memory-graph-meta,.memory-graph-history .memory-fact-list{grid-template-columns:1fr} .tool-page-heading,.tool-result-heading{display:grid;align-items:start} .tool-page-heading>span{white-space:normal} .tool-summary{justify-content:flex-start;gap:var(--space-sm)} diff --git a/apps/web/tokens.css b/apps/web/tokens.css index c36a248e..46161ced 100644 --- a/apps/web/tokens.css +++ b/apps/web/tokens.css @@ -7,7 +7,7 @@ --color-muted:oklch(48% 0.018 255); --color-rule:oklch(86% 0.01 255); --color-rule-strong:oklch(72% 0.018 255); - --color-accent:oklch(52% 0.19 258); + --color-accent:oklch(50% 0.12 258); --color-accent-ink:oklch(98% 0.005 258); --color-success:oklch(55% 0.13 150); --color-warning:oklch(67% 0.14 78); @@ -67,7 +67,7 @@ --color-muted:oklch(68% 0.014 255); --color-rule:oklch(31% 0.014 255); --color-rule-strong:oklch(43% 0.018 255); - --color-accent:oklch(67% 0.16 258); + --color-accent:oklch(70% 0.1 258); --color-accent-ink:oklch(17% 0.008 255); } } diff --git a/docs/superpowers/plans/2026-07-15-memory-recall-orientation-workbench-ui.md b/docs/superpowers/plans/2026-07-15-memory-recall-orientation-workbench-ui.md index 5dd4aab6..bf40b6e3 100644 --- a/docs/superpowers/plans/2026-07-15-memory-recall-orientation-workbench-ui.md +++ b/docs/superpowers/plans/2026-07-15-memory-recall-orientation-workbench-ui.md @@ -574,7 +574,7 @@ node --test tests/web-orientation.test.mjs tests/web-shell.test.mjs Expected: all Overview and shell tests pass with no retired markup. -- [ ] **Step 8: Commit the Overview workbench** +- [x] **Step 8: Commit the Overview workbench** ```bash git add apps/web/orientation-view.js apps/web/index.html apps/web/app.js apps/web/styles.css tests/web-orientation.test.mjs tests/web-shell.test.mjs @@ -708,7 +708,7 @@ node --test tests/web-source-map.test.mjs tests/web-shell.test.mjs tests/control Expected: Map state tests, existing shell tests, and Control API tests pass. -- [ ] **Step 7: Commit deterministic Map state** +- [x] **Step 7: Commit deterministic Map state** ```bash git add apps/web/source-map-view.js apps/web/app.js apps/web/styles.css tests/web-source-map.test.mjs tests/web-shell.test.mjs @@ -840,7 +840,7 @@ node --test tests/web-source-map.test.mjs tests/web-shell.test.mjs Expected: worker, viewport contract, outline parity, and shell tests pass. -- [ ] **Step 7: Commit focused graph interaction** +- [x] **Step 7: Commit focused graph interaction** ```bash git add apps/web/graph-viewport.js apps/web/graph-layout-worker.js apps/web/source-map-view.js apps/web/app.js apps/web/styles.css tests/web-source-map.test.mjs @@ -958,7 +958,7 @@ node --test tests/web-memory-graph.test.mjs tests/web-shell.test.mjs tests/memor Expected: empty/populated graph tests, shell tests, and memory integrity tests pass. -- [ ] **Step 7: Commit the governed-memory graph** +- [x] **Step 7: Commit the governed-memory graph** ```bash git add apps/web/memory-graph-view.js apps/web/app.js apps/web/styles.css tests/web-memory-graph.test.mjs tests/web-shell.test.mjs @@ -1044,7 +1044,7 @@ node --test tests/web-orientation.test.mjs tests/web-source-map.test.mjs tests/w Expected: all tests pass and the prohibited-copy scan is clean except allowlisted technical IDs/settings. -- [ ] **Step 6: Commit the anti-slop sweep** +- [x] **Step 6: Commit the anti-slop sweep** ```bash git add apps/web/index.html apps/web/app.js apps/web/orientation-view.js apps/web/source-map-view.js apps/web/memory-graph-view.js apps/web/styles.css tests/web-shell.test.mjs @@ -1063,11 +1063,11 @@ git commit -m "refactor: remove AI-style copy and dashboard chrome" - Produces inspected screenshots under `.scratch/ui-redesign/` without committing generated files. - Produces browser-smoke assertions for Overview, Map, populated memory graph, and empty memory graph. -- [ ] **Step 1: Update the browser fixture for six real groups** +- [x] **Step 1: Update the browser fixture for six real groups** Create realistic fixture directories under `apps`, `packages`, `services`, `providers`, `scripts`, and `tests`; include imports between them, three ranked entry points, represented and unrepresented changes, active/pending/stale memory, and a verified handoff. Do not use Lorem Ipsum, fake customers, or invented metrics. -- [ ] **Step 2: Assert the first-ten-seconds contract at 1440 × 900** +- [x] **Step 2: Assert the first-ten-seconds contract at 1440 × 900** After login, assert repository name, branch, coverage, 6 to 12 groups, exactly three start items, current impact, and trusted context are visible. Use bounding boxes to prove each required region ends above 900 px: @@ -1090,19 +1090,19 @@ for (const width of [320, 375, 414, 768, 1440]) { must(await page.locator('[role="status"]').count() > 0, 'missing screen-reader status'); ``` -- [ ] **Step 3: Exercise Overview group selection and Map deep link** +- [x] **Step 3: Exercise Overview group selection and Map deep link** Select a group by keyboard, verify the inspector changes, activate `Open in Map`, and assert the encoded `group` remains in the URL and form. Submit a query, trigger a mocked recoverable failure, reload, go back, and go forward; the exact query/group/scope must survive every transition. -- [ ] **Step 4: Exercise detailed graph controls and outline parity** +- [x] **Step 4: Exercise detailed graph controls and outline parity** Assert focus nodes are bounded, then use `Fit selection`, wheel zoom, reset, canvas selection, and outline selection. Verify both selection paths produce the same inspector node ID. Emulate reduced motion and ensure no transition/animation exceeds 1 ms. -- [ ] **Step 5: Exercise populated and empty memory graph states** +- [x] **Step 5: Exercise populated and empty memory graph states** For populated memory, assert canvas, outline, current/superseded labels, and provenance. For an intercepted empty response, assert `No governed memory yet`, checked workspace/provider state, and absence of `` and `.metric-strip`. -- [ ] **Step 6: Capture and inspect desktop/mobile/light/dark screenshots** +- [x] **Step 6: Capture and inspect desktop/mobile/light/dark screenshots** Capture: @@ -1118,7 +1118,7 @@ memory-graph-empty-1440.png Inspect each screenshot for clipped text, excessive gaps, wrong font weight, nested cards, broken borders, inconsistent radii, horizontal overflow, poor focus treatment, and AI-style copy. Compare the same viewport/state before and after when assessing visible improvement. -- [ ] **Step 7: Run browser smoke** +- [x] **Step 7: Run browser smoke** ```bash npm run consumer:browser-smoke @@ -1126,7 +1126,7 @@ npm run consumer:browser-smoke Expected: the script exits 0 with no browser console/page errors and all screenshots are present. -- [ ] **Step 8: Commit browser proof** +- [x] **Step 8: Commit browser proof** ```bash git add scripts/consumer-browser-smoke.mjs tests/web-shell.test.mjs diff --git a/packages/ui/tokens.json b/packages/ui/tokens.json index a98f4615..14916569 100644 --- a/packages/ui/tokens.json +++ b/packages/ui/tokens.json @@ -7,7 +7,7 @@ "muted": "oklch(48% 0.018 255)", "rule": "oklch(86% 0.01 255)", "ruleStrong": "oklch(72% 0.018 255)", - "accent": "oklch(52% 0.19 258)", + "accent": "oklch(50% 0.12 258)", "accentInk": "oklch(98% 0.005 258)", "warning": "oklch(67% 0.14 78)", "danger": "oklch(56% 0.18 25)", @@ -20,14 +20,14 @@ "muted": "oklch(68% 0.014 255)", "rule": "oklch(31% 0.014 255)", "ruleStrong": "oklch(43% 0.018 255)", - "accent": "oklch(67% 0.16 258)", + "accent": "oklch(70% 0.1 258)", "accentInk": "oklch(17% 0.008 255)" }, "ink": "oklch(97.8% 0.006 80)", "paper": "oklch(20% 0.012 255)", "slate": "oklch(48% 0.018 255)", - "signal": "oklch(52% 0.19 258)", - "proof": "oklch(52% 0.19 258)", + "signal": "oklch(50% 0.12 258)", + "proof": "oklch(50% 0.12 258)", "caution": "oklch(67% 0.14 78)" }, "font": { diff --git a/scripts/consumer-browser-smoke.mjs b/scripts/consumer-browser-smoke.mjs index cb72fada..3eef6b09 100644 --- a/scripts/consumer-browser-smoke.mjs +++ b/scripts/consumer-browser-smoke.mjs @@ -10,36 +10,17 @@ const require = createRequire(import.meta.url); const { chromium } = require('playwright'); const root = process.cwd(); -const temp = await mkdtemp(path.join(os.tmpdir(), 'oaf-consumer-browser-')); +const temp = await mkdtemp(path.join(os.tmpdir(), 'memory-recall-browser-')); const workspace = path.join(temp, 'workspace'); const home = path.join(temp, 'home'); const data = path.join(workspace, '.local'); -const password = 'correct horse battery staple'; const screenshots = path.join(root, '.scratch', 'ui-redesign'); +const password = 'correct horse battery staple'; let server = null; let browser = null; try { - await mkdir(path.join(workspace, 'src'), { recursive: true }); - await mkdir(home, { recursive: true }); - await mkdir(screenshots, { recursive: true }); - await writeFile(path.join(workspace, 'package.json'), JSON.stringify({ name: 'consumer-browser-target' }, null, 2)); - await writeFile(path.join(workspace, 'AGENTS.md'), 'Use local context handoffs and proposal-gated memory.'); - await writeFile(path.join(workspace, 'src', 'app.js'), 'export function launchSmoke(){ return "ready"; }\n'); - await writeFile(path.join(workspace, 'src', 'huge.js'), Array.from({ length: 500 }, (_, index) => `export function helper${index}(){ return "local proof ${index}"; }`).join('\n')); - await mkdir(path.join(workspace, 'memory'), { recursive: true }); - await writeFile(path.join(workspace, 'memory', 'status.md'), 'Decision: project:memory-recall release_status ready supersedes draft.'); - await writeFile(path.join(workspace, '.gitignore'), '.local/\n'); - runJson(process.execPath, ['apps/cli/oaf.mjs', 'memory', 'remember', '--root', workspace, '--sqlite', '.local/memory.sqlite', '--subject', 'project:memory-recall', '--predicate', 'release_status', '--object', 'draft', '--source', 'workspace://memory/status.md', '--format', 'json']); - runJson(process.execPath, ['apps/cli/oaf.mjs', 'memory', 'remember', '--root', workspace, '--sqlite', '.local/memory.sqlite', '--subject', 'project:memory-recall', '--predicate', 'release_status', '--object', 'ready', '--supersedes-subject', 'project:memory-recall', '--supersedes-predicate', 'release_status', '--source', 'workspace://memory/status.md', '--format', 'json']); - run('git', ['init'], workspace); - run('git', ['config', 'user.email', 'browser-smoke@example.invalid'], workspace); - run('git', ['config', 'user.name', 'Browser Smoke'], workspace); - run('git', ['add', '.'], workspace); - run('git', ['commit', '-m', 'fixture baseline'], workspace); - await writeFile(path.join(workspace, 'src', 'app.js'), 'export function launchSmoke(){ return changedHelper(); }\nexport function changedHelper(){ return "ready"; }\n'); - await writeFile(path.join(workspace, 'src', 'changed.js'), 'import { launchSmoke } from "./app.js";\nexport const changedResult = launchSmoke();\n'); - + await createRepositoryFixture(); const port = await freePort(); server = spawn(process.execPath, ['services/control-api/src/server.mjs'], { cwd: root, @@ -51,8 +32,11 @@ try { browser = await launchBrowser(); const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); const browserErrors = []; + let expectedGraphFailureActive = false; page.on('console', (message) => { - if (message.type() === 'error') browserErrors.push(message.text()); + if (message.type() !== 'error') return; + if (expectedGraphFailureActive && /^Failed to load resource: the server responded with a status of 503 \(Service Unavailable\)$/u.test(message.text())) return; + browserErrors.push(message.text()); }); page.on('pageerror', (error) => browserErrors.push(error.message)); @@ -60,273 +44,203 @@ try { await page.goto(base, { waitUntil: 'domcontentloaded' }); await waitForText(page, 'Set up this workspace'); await waitForText(page, 'Workspace security'); - await waitForText(page, 'Run the first scan after sign-in'); await page.screenshot({ path: path.join(screenshots, 'setup-desktop-1440.png'), fullPage: true }); await page.setViewportSize({ width: 390, height: 844 }); await page.screenshot({ path: path.join(screenshots, 'setup-mobile-390.png'), fullPage: true }); await page.setViewportSize({ width: 1440, height: 900 }); - await mustNotContain(page, 'Choose what you need first'); - await mustNotContain(page, 'Developer-first'); await page.fill('input[name="username"]', 'owner'); await page.fill('input[name="displayName"]', 'Owner'); await page.fill('input[name="password"]', password); await page.getByRole('button', { name: 'Create local owner' }).click(); - for (const label of ['Changes', 'Needs attention', 'Impact', 'Current handoff', 'Recent activity']) await waitForText(page, label); - await waitForText(page, 'workspace://src/app.js'); - await waitForText(page, 'workspace://src/changed.js'); - const affectedCount = await page.locator('.overview-impact header span').textContent(); - must(Number.parseInt(affectedCount, 10) > 0, `ordinary Overview did not hydrate meaningful impact: ${affectedCount}`); + + await page.getByRole('heading', { name: 'Overview', exact: true }).waitFor(); + for (const label of ['Architecture', 'Start here', 'Current impact', 'Trusted context']) await waitForText(page, label); + const overviewTruth = await page.evaluate(() => ({ + repository: document.querySelector('#repository-name')?.textContent?.trim(), + branch: document.querySelector('#repository-branch')?.textContent?.trim(), + coverage: document.querySelector('.coverage-label')?.textContent?.trim(), + groupCount: document.querySelectorAll('.orientation-group').length, + startCount: document.querySelectorAll('.start-item').length, + impact: document.querySelector('.current-impact')?.innerText ?? '', + trust: document.querySelector('.trusted-context')?.innerText ?? '', + metricCount: document.querySelectorAll('.metric-strip').length, + heroCount: document.querySelectorAll('.fabric-hero').length, + overflow: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1, + height: document.querySelector('.orientation-workbench')?.getBoundingClientRect().height ?? 0, + statusCount: document.querySelectorAll('[role="status"]').length, + regionBoxes: Object.fromEntries(['.orientation-heading', '.architecture-region', '.start-here', '.current-impact', '.trusted-context'].map((selector) => { + const box = document.querySelector(selector)?.getBoundingClientRect(); + return [selector, box ? { y: box.y, height: box.height, bottom: box.bottom } : null]; + })) + })); + must(overviewTruth.repository === 'workspace', `unexpected repository name: ${overviewTruth.repository}`); + must(Boolean(overviewTruth.branch) && overviewTruth.branch !== 'Branch unavailable', `missing branch truth: ${overviewTruth.branch}`); + must(Boolean(overviewTruth.coverage) && !/unavailable/iu.test(overviewTruth.coverage), `missing coverage truth: ${overviewTruth.coverage}`); + must(overviewTruth.groupCount >= 6 && overviewTruth.groupCount <= 12, `unexpected orientation group count: ${overviewTruth.groupCount}`); + must(overviewTruth.startCount === 3, `Overview must show exactly three ranked starts: ${overviewTruth.startCount}`); + must(/affected|outside the represented graph/iu.test(overviewTruth.impact), `changed-file impact is not useful: ${overviewTruth.impact}`); + must(/pending/iu.test(overviewTruth.trust), `pending governed memory is not visible: ${overviewTruth.trust}`); + must(/verified/iu.test(overviewTruth.trust), `verified handoff is not visible: ${overviewTruth.trust}`); + must(overviewTruth.metricCount === 0 && overviewTruth.heroCount === 0, 'Overview contains retired dashboard or hero chrome'); + must(!overviewTruth.overflow, 'desktop Overview has horizontal overflow'); + must(overviewTruth.height > 0 && overviewTruth.height < 820, `Overview contains excessive empty height: ${overviewTruth.height}; ${JSON.stringify(overviewTruth.regionBoxes)}`); + must(overviewTruth.statusCount > 0, 'screen-reader status is missing'); + for (const selector of ['.orientation-heading', '.architecture-region', '.start-here', '.current-impact', '.trusted-context']) { + const box = await page.locator(selector).boundingBox(); + must(box && box.y + box.height <= 900, `${selector} is outside the first desktop viewport`); + } + for (const phrase of ['AI-powered', 'smart insights', 'mission control', 'command center', 'nervous system']) await mustNotContain(page, phrase); + await page.screenshot({ path: path.join(screenshots, 'overview-desktop-light-1440.png'), fullPage: true }); await page.emulateMedia({ colorScheme: 'dark' }); await page.screenshot({ path: path.join(screenshots, 'overview-desktop-dark-1440.png'), fullPage: true }); await page.emulateMedia({ colorScheme: 'light' }); - await mustNotContain(page, 'Developer-first'); - await mustNotContain(page, 'Read the local picture before the next change'); - await mustNotContain(page, 'Index health'); - await assertNoElementHorizontalOverflow(page, '.overview-section', 'desktop Overview section'); - const unavailableGitChanges = (route) => route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ status: 'unavailable', reason: 'git_status_failed' }) - }); - await page.route('**/api/context/git-changes', unavailableGitChanges); - await page.reload({ waitUntil: 'domcontentloaded' }); - await waitForText(page, 'file detection unavailable'); - await waitForText(page, 'Change detection unavailable'); - await mustNotContain(page, 'No changed files detected.'); - await page.unroute('**/api/context/git-changes', unavailableGitChanges); - await page.reload({ waitUntil: 'domcontentloaded' }); - await waitForText(page, 'workspace://src/app.js'); + const initialGroupCopy = await page.locator('.region-heading p').textContent(); + const selectedGroupButton = page.locator('.orientation-group').nth(1); + await selectedGroupButton.focus(); + await page.keyboard.press('Enter'); + must(await selectedGroupButton.getAttribute('aria-pressed') === 'true', 'keyboard selection did not update the active repository group'); + const selectedGroupCopy = await page.locator('.region-heading p').textContent(); + must(selectedGroupCopy !== initialGroupCopy, 'repository group selection did not update the inspector'); + const selectedPrefix = (await selectedGroupButton.locator('span').textContent()).trim(); + await page.getByRole('link', { name: 'Open in Map' }).click(); + await page.waitForURL(/\/map\?.*group=/u); + must(new URL(page.url()).searchParams.get('group') === selectedPrefix, 'Overview deep link did not preserve the selected group'); + must(await page.inputValue('input[name="group"]') === selectedPrefix, 'Map form did not hydrate the group from the URL'); - await page.fill('#global-search-input', '/private/secret'); - await page.press('#global-search-input', 'Enter'); - await page.waitForURL(/\/map\?query=%2Fprivate%2Fsecret$/u); - await waitForText(page, 'Repository search failed'); - must(await page.inputValue('#global-search-input') === '/private/secret', 'failed repository query was not preserved'); - await mustNotContain(page, 'No bounded source-graph matches'); - must((await page.locator('#live-status').textContent()).includes('Repository search failed'), 'failed repository query was announced as loaded'); + await page.fill('#source-graph-form input[name="query"]', 'startApp'); + const startAppResponse = page.waitForResponse((response) => graphPreviewQuery(response.request()) === 'startApp'); + await Promise.all([startAppResponse, page.getByRole('button', { name: 'Run map' }).click()]); + await page.waitForFunction(() => document.querySelector('#live-status')?.textContent === 'Map loaded.'); + await waitForText(page, 'Focused map'); + await page.locator('#source-map-canvas').waitFor(); + const successfulMapUrl = page.url(); - await page.fill('#global-search-input', 'launchSmoke'); - await page.press('#global-search-input', 'Enter'); - await page.waitForURL(/\/map\?query=launchSmoke$/u); - await waitForText(page, 'Search results'); - await waitForText(page, 'launchSmoke'); - await page.goBack(); - await page.waitForURL(/\/map\?query=%2Fprivate%2Fsecret$/u); - await waitForText(page, 'Repository search failed'); - await page.goBack(); - await page.waitForURL(base + '/'); - await page.goForward(); - await page.waitForURL(/\/map\?query=%2Fprivate%2Fsecret$/u); - await waitForText(page, 'Repository search failed'); - await page.goForward(); - await page.waitForURL(/\/map\?query=launchSmoke$/u); - await waitForText(page, 'launchSmoke'); - await page.goto(base, { waitUntil: 'domcontentloaded' }); - - const stateMatrix = await page.evaluate(async () => { - const { buildRecallMapHomeModel, renderOverview } = await import('/app.js'); - const response = await fetch('/api/recall/map?workspaceId=ws_local'); - const report = await response.json(); - const cleanReport = structuredClone(report); - cleanReport.memory = { ...cleanReport.memory, pendingProposals: [], staleFactCount: 0 }; - const gitChanges = { status: 'available', changedLocators: [], totalCount: 0, omittedCount: 0, truncated: false }; - const pinned = (status) => ({ - generatedAt: cleanReport.generatedAt, - current: { status, entryId: 'handoff-browser-matrix' }, - entries: [{ id: 'handoff-browser-matrix', createdAt: cleanReport.generatedAt }] + const recoverableGraphFailure = async (route) => { + let payload = {}; + try { payload = route.request().postDataJSON() ?? {}; } catch {} + if (payload.query !== 'recoverableFailure') return route.continue(); + return route.fulfill({ + status: 503, + contentType: 'application/json', + body: JSON.stringify({ error: { code: 'source_graph_temporarily_unavailable', message: 'Recoverable graph failure.', correlationId: 'req_browser_failure' } }) }); - const empty = structuredClone(cleanReport); - empty.architecture = { ...empty.architecture, entryPoints: [], hotspots: [], impact: { changedLocators: [], representedChangedLocators: [], affectedSymbols: [], depth: 0 } }; - const omitted = structuredClone(cleanReport); - omitted.architecture = { ...omitted.architecture, impact: { changedLocators: [], representedChangedLocators: [], affectedSymbols: [], depth: 0 } }; - omitted.repository = { ...omitted.repository, dirtyCount: 2 }; - const partial = structuredClone(cleanReport); - partial.support.sourceGraph.status = 'unavailable'; - partial.support.sourceGraph.coverage.status = 'unavailable'; - const cases = [ - ['loading', { report: null }, 'loading', 'Loading Recall Map', 'Scan repository'], - ['empty', { report: empty, gitChanges }, 'empty', 'No JS/TS entry points yet', 'Scan repository'], - ['all-omitted', { report: omitted, gitChanges: { status: 'available', changedLocators: [], totalCount: 2, omittedCount: 2, truncated: true } }, 'success', '0 shown · 2 omitted by safety or scan bounds', ''], - ['partial', { report: partial, gitChanges }, 'partial', 'Bounded coverage', ''], - ['stale', { report: cleanReport, gitChanges, pinnedHandoffStatus: pinned('stale') }, 'stale', 'Source changes need review', 'Update handoff'], - ['blocked', { report: cleanReport, gitChanges, pinnedHandoffStatus: pinned('tampered') }, 'success', 'Handoff blocked', 'Repair handoff'], - ['success', { report: cleanReport, gitChanges, pinnedHandoffStatus: pinned('verified') }, 'success', 'Current handoff', 'View current handoff'], - ['auth-error', { error: { status: 401, code: 'authentication_required', message: 'Authentication is required.' } }, 'error', 'Recall Map unavailable', 'Retry scan'] - ]; - const host = document.createElement('div'); - document.querySelector('#view-root').append(host); - const results = cases.map(([name, args, expectedState, expectedText, expectedAction]) => { - const model = buildRecallMapHomeModel(args); - host.innerHTML = renderOverview(model); - const action = host.querySelector('.page-heading .button, .page-heading a.button')?.textContent?.trim() ?? ''; - return { - name, - modelState: model.state, - expectedState, - expectedText, - expectedAction, - text: host.innerText, - action, - overflow: host.scrollWidth > host.clientWidth + 1 - }; - }); - host.remove(); - return results; - }); - for (const state of stateMatrix) { - must(state.modelState === state.expectedState, `${state.name} Overview model state mismatch: ${state.modelState}`); - must(state.text.includes(state.expectedText), `${state.name} Overview state missing ${state.expectedText}`); - must(!state.expectedAction || state.action === state.expectedAction, `${state.name} Overview action mismatch: ${state.action}`); - must(!state.overflow, `${state.name} Overview state has horizontal overflow`); - } - - await page.context().clearCookies(); + }; + expectedGraphFailureActive = true; + await page.route('**/api/context/graph/preview', recoverableGraphFailure); + await page.fill('#source-graph-form input[name="query"]', 'recoverableFailure'); + const failureResponse = page.waitForResponse((response) => graphPreviewQuery(response.request()) === 'recoverableFailure'); + await Promise.all([failureResponse, page.getByRole('button', { name: 'Run map' }).click()]); + await waitForText(page, 'Recoverable graph failure.'); + const failedMapUrl = page.url(); + await assertMapState(page, { query: 'recoverableFailure', group: selectedPrefix }); await page.reload({ waitUntil: 'domcontentloaded' }); - await waitForText(page, 'Sign in'); - await page.fill('input[name="username"]', 'owner'); - await page.fill('input[name="password"]', 'incorrect password'); - await page.getByRole('button', { name: 'Sign in' }).click(); - await waitForText(page, 'Username or password is incorrect.'); - must(await page.inputValue('input[name="username"]') === 'owner', 'login username was not preserved after recoverable failure'); - must(await page.inputValue('input[name="password"]') === 'incorrect password', 'live password input was reconstructed after recoverable failure'); - await page.fill('input[name="password"]', password); - await page.getByRole('button', { name: 'Sign in' }).click(); - await waitForText(page, 'Changes'); - browserErrors.length = 0; + await waitForText(page, 'Recoverable graph failure.'); + await assertMapState(page, { query: 'recoverableFailure', group: selectedPrefix }); + await page.goBack(); + await page.waitForURL(successfulMapUrl); + await waitForText(page, 'Focused map'); + await assertMapState(page, { query: 'startApp', group: selectedPrefix }); + await page.goForward(); + await page.waitForURL(failedMapUrl); + await waitForText(page, 'Recoverable graph failure.'); + await assertMapState(page, { query: 'recoverableFailure', group: selectedPrefix }); + await page.goBack(); + await page.waitForURL(successfulMapUrl); + await page.locator('#source-map-canvas').waitFor(); + await page.unroute('**/api/context/graph/preview', recoverableGraphFailure); + expectedGraphFailureActive = false; + await page.waitForFunction(() => document.querySelector('#source-map-canvas')?.dataset.layoutReady === 'true'); + const focusNodeCount = await page.locator('.source-map-outline [data-node-id]').count(); + must(focusNodeCount > 0 && focusNodeCount <= 200, `focused graph is outside node bounds: ${focusNodeCount}`); + const initialNodeId = await page.locator('#source-map-selection').getAttribute('data-selected-node-id'); + must(Boolean(initialNodeId), 'focused graph has no canonical selection'); + await page.getByRole('button', { name: 'Fit selection' }).click(); + await page.locator('#source-map-canvas').hover(); + await page.mouse.wheel(0, -180); + await page.getByRole('button', { name: 'Reset view' }).click(); + const outlineButtons = page.locator('.source-map-outline [data-node-id]'); + if (focusNodeCount > 1) await outlineButtons.nth(1).click(); + const outlineNodeId = await page.locator('#source-map-selection').getAttribute('data-selected-node-id'); + must(Boolean(outlineNodeId), 'outline selection did not update the canonical node'); + await page.getByRole('button', { name: 'Fit selection' }).click(); + const canvasBox = await page.locator('#source-map-canvas').boundingBox(); + must(Boolean(canvasBox), 'focused graph canvas has no bounds'); + await page.locator('#source-map-canvas').click({ position: { x: canvasBox.width / 2, y: canvasBox.height / 2 } }); + must(await page.locator('#source-map-selection').getAttribute('data-selected-node-id') === outlineNodeId, 'canvas and outline selection diverged'); await page.emulateMedia({ reducedMotion: 'reduce' }); - for (const width of [701, 768, 900, 1080]) { - await page.setViewportSize({ width, height: 900 }); - await page.goto(base, { waitUntil: 'domcontentloaded' }); - const responsiveTablet = await page.evaluate(() => ({ - rail: document.querySelector('.sidebar')?.getBoundingClientRect().width, - navHeights: [...document.querySelectorAll('#primary-nav a')].map((item) => item.getBoundingClientRect().height), - overflow: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1 - })); - must(responsiveTablet.rail === 72, `tablet ${width}px rail is not compact: ${JSON.stringify(responsiveTablet)}`); - must(responsiveTablet.navHeights.every((height) => height >= 44), `tablet ${width}px nav touch target below 44px: ${JSON.stringify(responsiveTablet.navHeights)}`); - must(!responsiveTablet.overflow, `tablet ${width}px shell has horizontal overflow`); - } - await page.setViewportSize({ width: 900, height: 900 }); - await page.goto(base, { waitUntil: 'domcontentloaded' }); - await waitForText(page, 'Changes'); - const tablet = await page.evaluate(() => ({ - rail: document.querySelector('.sidebar')?.getBoundingClientRect().width, - navHeights: [...document.querySelectorAll('#primary-nav a')].map((item) => item.getBoundingClientRect().height), - overflow: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1, - reducedMotionTransition: getComputedStyle(document.querySelector('#primary-nav a')).transitionDuration, - reducedMotionAnimation: getComputedStyle(document.querySelector('#primary-nav a')).animationDuration + const motion = await page.locator('#source-map-canvas').evaluate((element) => ({ + transition: getComputedStyle(element).transitionDuration, + animation: getComputedStyle(element).animationDuration })); - must(tablet.rail === 72, `tablet rail is not compact: ${JSON.stringify(tablet)}`); - must(tablet.navHeights.every((height) => height >= 44), `tablet nav touch target below 44px: ${JSON.stringify(tablet.navHeights)}`); - must(!tablet.overflow, 'tablet shell has horizontal overflow'); - must(parseCssSeconds(tablet.reducedMotionTransition) <= 0.001, `reduced motion transition remains active: ${tablet.reducedMotionTransition}`); - must(parseCssSeconds(tablet.reducedMotionAnimation) <= 0.001, `reduced motion animation remains active: ${tablet.reducedMotionAnimation}`); - const focusedControls = []; - for (let index = 0; index < 12; index += 1) { - await page.keyboard.press('Tab'); - focusedControls.push(await page.evaluate(() => ({ - id: document.activeElement?.id ?? '', - tag: document.activeElement?.tagName ?? '', - href: document.activeElement?.getAttribute?.('href') ?? '', - outline: getComputedStyle(document.activeElement).outlineWidth - }))); - } - must(focusedControls.some((item) => item.id === 'global-search-input'), `keyboard traversal did not reach global search: ${JSON.stringify(focusedControls)}`); - must(focusedControls.some((item) => item.href === '/map'), `keyboard traversal did not reach primary navigation: ${JSON.stringify(focusedControls)}`); - must(focusedControls.filter((item) => item.id === 'global-search-input' || item.href === '/map').every((item) => item.outline !== '0px'), `keyboard focus is not visible: ${JSON.stringify(focusedControls)}`); - await page.screenshot({ path: path.join(screenshots, 'overview-tablet-900.png'), fullPage: true }); - - await page.goto(`${base}/agents-tools`, { waitUntil: 'domcontentloaded' }); - await waitForText(page, 'Harness setup preview'); - await page.locator('#harness-setup-form button[type="submit"]').click(); - await waitForText(page, 'Harness setup preview ready.'); - await waitForText(page, 'Home writes'); - - await page.locator('a[href="/handoffs"][data-route="context-pack"]').first().click(); - await waitForText(page, 'Handoffs'); - await waitForText(page, 'Build handoff'); - await page.screenshot({ path: path.join(screenshots, 'handoffs-desktop-1440.png'), fullPage: true }); - await page.fill('textarea[name="objective"]', 'Consumer browser smoke'); - await page.fill('input[name="step"]', 'verify rendered first-run flow'); - await page.fill('textarea[name="userSelectedFiles"]', 'src/huge.js'); - await page.fill('textarea[name="changedLocators"]', 'src/app.js'); - await page.locator('#context-pack-form button[type="submit"]').click(); - await waitForText(page, 'PRACTICAL HANDOFF'); - await waitForText(page, 'Status\nready'); - await waitForText(page, 'TOKEN SAVER'); - await waitForText(page, 'Provider billing\nnot claimed'); - const tokenSaverProof = await page.evaluate(() => /TOKEN SAVER\s+\d+ -> \d+ tokens\s+([1-9]\d*)% saved/u.test(document.body.innerText)); - must(tokenSaverProof, 'Token Saver did not render a non-zero saved percentage'); - await mustNotContain(page, 'SMOKE RAW BODY'); - - await page.locator('a[data-route="memory"]').first().click(); - await waitForText(page, 'Review queue'); - await waitForText(page, 'Add memory'); - await page.screenshot({ path: path.join(screenshots, 'memory-desktop-1440.png'), fullPage: true }); - await page.fill('#memory-intake-form textarea[name="text"]', 'Fact: project:memory-recall consumer_browser_smoke rendered.'); - await page.locator('#memory-intake-form button[value="preview"]').click(); - await waitForText(page, '1 proposal'); - await waitForText(page, '0 active memory created'); - page.once('dialog', (dialog) => dialog.accept()); - await page.locator('#memory-intake-form button[value="queue"]').click(); - await waitForText(page, 'Memory proposals queued for approval.'); - const approveProposal = page.locator('[data-action="approve-memory-proposal"]').first(); - await approveProposal.waitFor(); - page.once('dialog', (dialog) => dialog.accept()); - await approveProposal.click(); - await waitForText(page, 'Memory proposal approved.'); - await waitForText(page, 'consumer_browser_smoke'); + must(parseCssSeconds(motion.transition) <= 0.001 && parseCssSeconds(motion.animation) <= 0.001, `reduced motion is not bounded: ${JSON.stringify(motion)}`); + await page.setViewportSize({ width: 900, height: 900 }); + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => document.querySelector('#source-map-canvas')?.dataset.layoutReady === 'true'); + await page.screenshot({ path: path.join(screenshots, 'map-focused-tablet-900.png'), fullPage: true }); + await page.setViewportSize({ width: 1440, height: 900 }); + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => document.querySelector('#source-map-canvas')?.dataset.layoutReady === 'true'); + await page.screenshot({ path: path.join(screenshots, 'map-focused-desktop-1440.png'), fullPage: true }); + await page.setViewportSize({ width: 390, height: 844 }); + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => document.querySelector('#source-map-canvas')?.dataset.layoutReady === 'true'); + must(!(await hasHorizontalOverflow(page)), 'focused Map has horizontal overflow at 390px'); + await page.screenshot({ path: path.join(screenshots, 'map-focused-mobile-390.png'), fullPage: true }); + await page.setViewportSize({ width: 1440, height: 900 }); await page.goto(`${base}/memory-graph`, { waitUntil: 'domcontentloaded' }); - await waitForText(page, 'Governed knowledge graph'); - await waitForText(page, 'Current facts'); - await waitForText(page, 'project:memory-recall release_status ready'); + await page.getByRole('heading', { name: 'Graph', exact: true }).waitFor(); await page.locator('#memory-graph-history').check(); await waitForText(page, 'Superseded'); - await waitForText(page, 'project:memory-recall release_status draft'); await waitForText(page, 'Provenance workspace://memory/status.md'); + must(await page.locator('#memory-graph-canvas').count() === 1, 'populated memory graph has no canvas'); + must(await page.locator('.memory-graph-outline [data-node-id]').count() > 0, 'populated memory graph has no outline'); + await page.screenshot({ path: path.join(screenshots, 'memory-graph-populated-1440.png'), fullPage: true }); + await page.setViewportSize({ width: 900, height: 900 }); + await page.waitForTimeout(100); + await page.screenshot({ path: path.join(screenshots, 'memory-graph-populated-900.png'), fullPage: true }); + await page.setViewportSize({ width: 1440, height: 900 }); - await page.locator('a[href="/map"][data-route="source-graph"]').first().click(); - await waitForText(page, 'Map'); - await page.fill('input[name="query"]', 'launchSmoke'); - await page.fill('input[name="changedLocator"]', 'src/app.js'); - await page.locator('#source-graph-form button[type="submit"]').click(); - await waitForText(page, 'Search results'); - await waitForText(page, 'Changed impact'); - await waitForText(page, 'Run map'); - await page.screenshot({ path: path.join(screenshots, 'map-desktop-1440.png'), fullPage: true }); - - for (const [width, height, size] of [[900, 900, 'tablet-900'], [390, 844, 'mobile-390']]) { - for (const [route, label, name] of [['/map', 'Map', 'map'], ['/memory', 'Memory', 'memory'], ['/handoffs', 'Handoffs', 'handoffs']]) { - await page.setViewportSize({ width, height }); - await page.goto(`${base}${route}`, { waitUntil: 'domcontentloaded' }); - await page.getByRole('heading', { name: label, exact: true }).waitFor(); - const overflow = await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1); - must(!overflow, `${route} has horizontal overflow at ${width}px`); - await page.screenshot({ path: path.join(screenshots, `${name}-${size}.png`), fullPage: true }); - } - } + const emptyMemoryResponse = (route) => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + schemaVersion: '1.0.0', workspaceId: 'ws_local', generatedAt: new Date().toISOString(), + provider: 'provider:native:memory:sqlite', mode: 'current', + summary: { nodeCount: 0, edgeCount: 0, currentNodeCount: 0, currentEdgeCount: 0, historyNodeCount: 0, historyEdgeCount: 0, communityCount: 0 }, + graph: { nodes: [], edges: [] }, focus: null, + safeguards: { readOnly: true, modelCalls: 0, networkCalls: 0, externalWritesEnabled: false } + }) + }); + await page.route('**/api/memory/graph?*', emptyMemoryResponse); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitForText(page, 'No governed memory yet'); + await waitForText(page, 'Checked workspace'); + must(await page.locator('#memory-graph-canvas').count() === 0, 'empty memory state rendered a canvas'); + must(await page.locator('.metric-strip').count() === 0, 'empty memory state rendered zero metrics'); + await page.screenshot({ path: path.join(screenshots, 'memory-graph-empty-1440.png'), fullPage: true }); + await page.unroute('**/api/memory/graph?*', emptyMemoryResponse); - for (const width of [320, 390]) { - await page.setViewportSize({ width, height: 844 }); + await page.emulateMedia({ colorScheme: 'light', reducedMotion: 'reduce' }); + for (const width of [320, 375, 414, 768, 1440]) { + await page.setViewportSize({ width, height: width === 1440 ? 900 : 844 }); await page.goto(base, { waitUntil: 'domcontentloaded' }); - for (const label of ['Changes', 'Needs attention', 'Impact', 'Current handoff', 'Recent activity']) await waitForText(page, label); - const mobile = await page.evaluate(() => ({ - overflow: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1, - navHeights: [...document.querySelectorAll('#mobile-nav a')].map((item) => item.getBoundingClientRect().height) - })); - must(!mobile.overflow, `mobile ${width}px first-use shell has horizontal overflow`); - must(mobile.navHeights.every((height) => height >= 44), `mobile ${width}px nav touch target below 44px: ${JSON.stringify(mobile.navHeights)}`); - await assertNoElementHorizontalOverflow(page, '.overview-section', `mobile ${width}px Overview section`); + await page.getByRole('heading', { name: 'Overview', exact: true }).waitFor(); + must(!(await hasHorizontalOverflow(page)), `Overview has horizontal overflow at ${width}px`); + const navSelector = width <= 700 ? '#mobile-nav a' : '#primary-nav a'; + const navHeights = await page.locator(navSelector).evaluateAll((items) => items.map((item) => item.getBoundingClientRect().height)); + must(navHeights.length > 0 && navHeights.every((height) => height >= 44), `navigation target below 44px at ${width}px: ${JSON.stringify(navHeights)}`); } await page.setViewportSize({ width: 390, height: 844 }); + await page.goto(base, { waitUntil: 'domcontentloaded' }); + await page.getByRole('heading', { name: 'Overview', exact: true }).waitFor(); await page.screenshot({ path: path.join(screenshots, 'overview-mobile-390.png'), fullPage: true }); - must(browserErrors.length === 0, `browser console/page errors: ${browserErrors.join('\n')}`); - console.log('PASS consumer browser smoke: Recall Map, handoff, harness, Token Saver, memory, graph, mobile shell'); + must(browserErrors.length === 0, `browser console/page errors: ${browserErrors.join('\n')}`); + console.log('PASS consumer browser smoke: orientation, deterministic Map, governed memory, responsive shell'); } finally { if (browser) await browser.close().catch(() => {}); if (server) { @@ -336,6 +250,45 @@ try { await rm(temp, { recursive: true, force: true }); } +async function createRepositoryFixture() { + for (const directory of [ + 'apps/web', 'packages/core', 'services/control-api', + 'providers/native/memory', 'scripts', 'tests', 'memory', 'docs' + ]) await mkdir(path.join(workspace, directory), { recursive: true }); + await mkdir(home, { recursive: true }); + await mkdir(screenshots, { recursive: true }); + await writeFile(path.join(workspace, 'package.json'), JSON.stringify({ name: 'memory-recall-browser-target', type: 'module' }, null, 2)); + await writeFile(path.join(workspace, 'AGENTS.md'), 'Use local context handoffs and proposal-gated memory.'); + await writeFile(path.join(workspace, 'packages', 'core', 'index.js'), 'export function buildContext(value){ return { value, source: "local" }; }\n'); + await writeFile(path.join(workspace, 'providers', 'native', 'memory', 'index.js'), 'export function readMemory(){ return "approved facts"; }\n'); + await writeFile(path.join(workspace, 'services', 'control-api', 'server.js'), 'import { buildContext } from "../../packages/core/index.js";\nimport { readMemory } from "../../providers/native/memory/index.js";\nexport function serveControl(){ return buildContext(readMemory()); }\n'); + await writeFile(path.join(workspace, 'apps', 'web', 'main.js'), 'import { serveControl } from "../../services/control-api/server.js";\nexport function startApp(){ return serveControl(); }\n'); + await writeFile(path.join(workspace, 'scripts', 'check.mjs'), 'import { startApp } from "../apps/web/main.js";\nexport function runCheck(){ return startApp(); }\n'); + await writeFile(path.join(workspace, 'tests', 'main.test.js'), 'import { startApp } from "../apps/web/main.js";\nexport function verifyMain(){ return startApp().source === "local"; }\n'); + await writeFile(path.join(workspace, 'packages', 'core', 'large-fixture.js'), Array.from({ length: 500 }, (_, index) => `export function helper${index}(){ return "local proof ${index}"; }`).join('\n')); + await writeFile(path.join(workspace, 'memory', 'status.md'), 'Decision: project:memory-recall release_status ready supersedes draft.'); + await writeFile(path.join(workspace, '.gitignore'), '.local/\n'); + runJson(process.execPath, ['apps/cli/oaf.mjs', 'memory', 'remember', '--root', workspace, '--sqlite', '.local/memory.sqlite', '--subject', 'project:memory-recall', '--predicate', 'release_status', '--object', 'draft', '--source', 'workspace://memory/status.md', '--format', 'json']); + runJson(process.execPath, ['apps/cli/oaf.mjs', 'memory', 'remember', '--root', workspace, '--sqlite', '.local/memory.sqlite', '--subject', 'project:memory-recall', '--predicate', 'release_status', '--object', 'ready', '--supersedes-subject', 'project:memory-recall', '--supersedes-predicate', 'release_status', '--source', 'workspace://memory/status.md', '--format', 'json']); + run('git', ['init'], workspace); + run('git', ['config', 'user.email', 'browser-smoke@example.invalid'], workspace); + run('git', ['config', 'user.name', 'Browser Smoke'], workspace); + run('git', ['add', '.'], workspace); + run('git', ['commit', '-m', 'fixture baseline'], workspace); + await writeFile(path.join(workspace, 'apps', 'web', 'main.js'), 'import { serveControl } from "../../services/control-api/server.js";\nexport function startApp(){ return normalizeResult(serveControl()); }\nexport function normalizeResult(value){ return value; }\n'); + await writeFile(path.join(workspace, 'docs', 'operator-note.md'), 'One changed non-JavaScript file remains outside source-graph coverage.\n'); + runJson(process.execPath, ['apps/cli/oaf.mjs', 'context', 'pack', '--from', 'codex', '--root', workspace, '--objective', 'Continue the verified local repository change', '--step', 'inspect bounded repository context', '--target', 'codex', '--include-file', 'AGENTS.md', '--changed', 'apps/web/main.js', '--write', '--pin', '--out', 'context-packs/CONTEXT_PACK.md', '--format', 'json']); + runJson(process.execPath, ['apps/cli/oaf.mjs', 'memory', 'ingest', '--root', workspace, '--sqlite', '.local/memory.sqlite', '--format', 'json']); +} + +async function assertMapState(page, expected) { + const url = new URL(page.url()); + must(url.searchParams.get('query') === expected.query, `Map URL query drifted: ${url}`); + must(url.searchParams.get('group') === expected.group, `Map URL group drifted: ${url}`); + must(await page.inputValue('#source-graph-form input[name="query"]') === expected.query, 'Map query field drifted from the URL'); + must(await page.inputValue('input[name="group"]') === expected.group, 'Map group field drifted from the URL'); +} + async function launchBrowser() { try { return await chromium.launch({ headless: true }); @@ -354,7 +307,15 @@ function must(condition, message) { } function parseCssSeconds(value) { - return Math.max(...String(value ?? '0s').split(',').map((part) => Number.parseFloat(part) || 0)); + return Math.max(...String(value ?? '0s').split(',').map((part) => { + const number = Number.parseFloat(part) || 0; + return part.trim().endsWith('ms') ? number / 1000 : number; + })); +} + +function graphPreviewQuery(request) { + if (!request.url().endsWith('/api/context/graph/preview')) return ''; + try { return request.postDataJSON()?.query ?? ''; } catch { return ''; } } function runJson(command, args) { @@ -372,25 +333,18 @@ async function waitForText(page, text) { try { await page.waitForFunction((value) => document.body.innerText.toLocaleLowerCase().includes(String(value).toLocaleLowerCase()), text, { timeout: 15_000 }); } catch { - const body = await page.evaluate(() => document.body.innerText.slice(0, 4000)).catch(() => ''); + const body = await page.evaluate(() => document.body.innerText.slice(0, 5000)).catch(() => ''); throw new Error(`Missing text: ${text}\nVisible text:\n${body}`); } } async function mustNotContain(page, text) { - const found = await page.evaluate((value) => document.body.innerText.includes(value), text); + const found = await page.evaluate((value) => document.body.innerText.toLocaleLowerCase().includes(String(value).toLocaleLowerCase()), text); must(!found, `page leaked forbidden text: ${text}`); } -async function assertNoElementHorizontalOverflow(page, selector, label) { - const overflows = await page.locator(selector).evaluateAll((elements) => elements - .map((element) => ({ - text: element.innerText.replace(/\s+/gu, ' ').trim().slice(0, 120), - clientWidth: element.clientWidth, - scrollWidth: element.scrollWidth - })) - .filter((item) => item.scrollWidth > item.clientWidth + 1)); - must(overflows.length === 0, `${label} has horizontal overflow: ${JSON.stringify(overflows)}`); +async function hasHorizontalOverflow(page) { + return page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1); } async function freePort() { diff --git a/tests/web-memory-graph.test.mjs b/tests/web-memory-graph.test.mjs index fccba31e..702a352c 100644 --- a/tests/web-memory-graph.test.mjs +++ b/tests/web-memory-graph.test.mjs @@ -21,6 +21,26 @@ test('populated governed memory has graph and equivalent outline', () => { assert.doesNotMatch(html, /Community colors/); }); +test('governed memory heading and graph actions avoid template-like chrome', () => { + const model = buildMemoryGraphViewModel(memoryGraphFixture(), { history: true }); + const html = renderMemoryGraphView(model); + assert.doesNotMatch(html, /class="eyebrow"/); + assert.match(html, /data-memory-graph-action="fit">Fit selection/); + assert.match(html, /class="button quiet" type="button" data-memory-graph-action="reset">Reset view/); +}); + +test('history and provider truth stay outside the graph inspector', () => { + const model = buildMemoryGraphViewModel(memoryGraphFixture(), { history: true }); + const html = renderMemoryGraphView(model); + const inspectorStart = html.indexOf('', inspectorStart); + assert.ok(inspectorStart >= 0 && inspectorEnd > inspectorStart); + const inspector = html.slice(inspectorStart, inspectorEnd); + assert.doesNotMatch(inspector, /Fact history|Provider check/); + assert.match(html.slice(inspectorEnd), /class="memory-graph-history"/); + assert.match(html.slice(inspectorEnd), /class="memory-graph-boundary"/); +}); + test('memory graph query preserves controls and filters visible nodes', () => { const model = buildMemoryGraphViewModel(memoryGraphFixture(), { history: false, query: 'provider' }); assert.equal(model.nodes.length, 2); diff --git a/tests/web-shell.test.mjs b/tests/web-shell.test.mjs index db0465b6..b93d3c6e 100644 --- a/tests/web-shell.test.mjs +++ b/tests/web-shell.test.mjs @@ -171,7 +171,8 @@ test('web tokens use the approved restrained workbench system', async () => { assert.match(css, /--space-md:24px/); assert.doesNotMatch(css, /#56e0c4|gradient|glow/i); assert.equal(shared.color.canvas, 'oklch(97.8% 0.006 80)'); - assert.equal(shared.color.accent, 'oklch(52% 0.19 258)'); + assert.equal(shared.color.accent, 'oklch(50% 0.12 258)'); + assert.equal(shared.color.dark.accent, 'oklch(70% 0.1 258)'); assert.equal(shared.color.dark.canvas, 'oklch(17% 0.008 255)'); assert.match(shared.font.sans, /^ui-sans-serif/); assert.match(shared.font.mono, /^ui-monospace/); @@ -190,6 +191,8 @@ test('web tokens use the approved restrained workbench system', async () => { assert.match(shellCss, /\.button:hover:not\(:disabled\)/); assert.match(shellCss, /\.button:active:not\(:disabled\)/); assert.match(shellCss, /\.field input,.field select,.field textarea\{[^}]*outline:2px solid transparent[^}]*outline-offset:1px/); + assert.match(shellCss, /\.field span\{[^}]*font-weight:650[^}]*color:var\(--slate\)\}/); + assert.doesNotMatch(shellCss, /\.field span\{[^}]*text-transform:uppercase/); assert.match(shellCss, /\.field input:disabled,.field select:disabled,.field textarea:disabled\{[^}]*opacity:\.55[^}]*cursor:not-allowed/); assert.match(shellCss, /h1\{[^}]*overflow-wrap:anywhere[^}]*min-width:0/); assert.match(shellCss, /code,pre\{font-family:var\(--font-mono\)\}/); diff --git a/tests/web-source-map.test.mjs b/tests/web-source-map.test.mjs index 8998b789..9167b689 100644 --- a/tests/web-source-map.test.mjs +++ b/tests/web-source-map.test.mjs @@ -8,6 +8,7 @@ import { serializeMapUrl } from '../apps/web/source-map-view.js'; import { layoutFocusedGraph } from '../apps/web/graph-layout-worker.js'; +import { graphLabelPlacement } from '../apps/web/graph-viewport.js'; test('Map query state round-trips through the URL', () => { const state = parseMapUrl('http://127.0.0.1:4318/map?query=copytrading&group=apps%2Fterminal&start=execute&changed=src%2Ftrade.ts&depth=3&limit=24'); @@ -55,6 +56,15 @@ test('Map renders architecture before focus and keeps an accessible outline', () assert.doesNotMatch(html, / { + const html = renderSourceMap({ state: parseMapUrl('/map?query=router'), report: mapPreviewFixture() }); + assert.doesNotMatch(html, /class="eyebrow"/); + assert.match(html, /class="button primary" type="submit">Run map/); + assert.match(html, /class="button quiet" type="button" data-action="refresh-source-map">Refresh scan/); + assert.match(html, /data-graph-action="fit">Fit selection/); + assert.match(html, /class="button quiet" type="button" data-graph-action="reset">Reset view/); +}); + test('focused graph uses a worker and keeps outline selection canonical', async () => { const source = await readFile(new URL('../apps/web/source-map-view.js', import.meta.url), 'utf8'); const viewport = await readFile(new URL('../apps/web/graph-viewport.js', import.meta.url), 'utf8'); @@ -76,6 +86,11 @@ test('focused layout is deterministic and enforces display bounds', () => { assert.throws(() => layoutFocusedGraph(Array.from({ length: 201 }, (_, index) => ({ id: String(index) })), []), /graph_layout_bounds_exceeded/); }); +test('graph labels stay inside the visible canvas edge', () => { + assert.deepEqual(graphLabelPlacement({ pointX: 600, labelWidth: 80, scale: 1, panX: 0, viewportWidth: 640 }), { align: 'right', offset: -10 }); + assert.deepEqual(graphLabelPlacement({ pointX: 80, labelWidth: 80, scale: 1, panX: 0, viewportWidth: 640 }), { align: 'left', offset: 10 }); +}); + test('focused Map renders a real canvas with outline parity', () => { const report = mapPreviewFixture(); const html = renderSourceMap({ state: parseMapUrl('/map?query=router'), report }); From 56b53cb0133848eead3c3d5dbe6ad1d6abecbeb2 Mon Sep 17 00:00:00 2001 From: rebel Date: Thu, 16 Jul 2026 10:46:29 +0530 Subject: [PATCH 022/276] docs: record orientation workbench release gate --- PROJECT_STATUS.json | 12 +++++++++++- docs/release/1.0-PROVENANCE.json | 4 ++-- docs/release/1.0-READINESS-REPORT.md | 2 +- docs/release/1.0-REPRODUCIBILITY.md | 2 +- ...6-07-15-memory-recall-orientation-workbench-ui.md | 8 ++++---- tests/release-readiness.test.mjs | 6 +++--- 6 files changed, 22 insertions(+), 12 deletions(-) diff --git a/PROJECT_STATUS.json b/PROJECT_STATUS.json index b967aacd..50999ee4 100644 --- a/PROJECT_STATUS.json +++ b/PROJECT_STATUS.json @@ -69,9 +69,19 @@ "evidence": [ "apps/web/index.html", "apps/web/app.js", + "apps/web/orientation-model.js", + "apps/web/orientation-view.js", + "apps/web/source-map-view.js", + "apps/web/graph-layout-worker.js", + "apps/web/graph-viewport.js", + "apps/web/memory-graph-view.js", "apps/web/styles.css", "apps/web/tokens.css", + "tests/web-orientation.test.mjs", + "tests/web-source-map.test.mjs", + "tests/web-memory-graph.test.mjs", "tests/web-shell.test.mjs", + "scripts/consumer-browser-smoke.mjs", "docs/implementation/OAF-022-production-web-shell-note.md", "docs/implementation/OAF-023-run-context-inspector-note.md", "docs/implementation/OAF-024-memory-evidence-approval-views-note.md", @@ -1048,7 +1058,7 @@ "npm run consumer:smoke", "npm run consumer:browser-smoke" ], - "testsExpected": 664, + "testsExpected": 705, "protocolFixturesExpected": 182, "evaluationsExpected": 144, "note": "Counts are a dated snapshot; command results and HANDOFF_VERIFICATION.json are authoritative." diff --git a/docs/release/1.0-PROVENANCE.json b/docs/release/1.0-PROVENANCE.json index 1c2b6222..44c8fc96 100644 --- a/docs/release/1.0-PROVENANCE.json +++ b/docs/release/1.0-PROVENANCE.json @@ -21,9 +21,9 @@ "mergePerformedByThisTask": false }, "sourceHashes": { - "package.json": "sha256:69062661e8988c9d18d38201caf0e4d62547c5e2b1d86de4a57d9582e2ffaf19", + "package.json": "sha256:24bce104b8c5dce7d4dc2163b4d688955cdf96b6a66c787766348ba4c261cf11", "package-lock.json": "sha256:e7af858186750dbd7386fce41dd5e37303dca712915d31bc41fd22f7ecb0a368", - "PROJECT_STATUS.json": "sha256:c45a2069a55fe9d917ba7f1d463726670f5c91fe00aa6dbc48b9f60e7b75b151", + "PROJECT_STATUS.json": "sha256:ffb76f268fc59d0a1e883907c26ed9fd1fd57442d8c1ef3a8dd5770db75ec0d7", "planning/backlog.json": "sha256:a404e5704ba3bf3be635a655d2d73d84eff7eba3ce43ab511b83967cceab6a5f", "adapters/catalog.json": "sha256:11c22a150f847a560be3e64c8280c7757368d813b5f70304938ab7915694c283", "docs/usage/support-matrix.md": "sha256:dd4108828b742e8d06112e6abdfa4119f90a8b034b417b9e7e41f6924dcf26a6", diff --git a/docs/release/1.0-READINESS-REPORT.md b/docs/release/1.0-READINESS-REPORT.md index 75f5cc1c..8bd84bb5 100644 --- a/docs/release/1.0-READINESS-REPORT.md +++ b/docs/release/1.0-READINESS-REPORT.md @@ -24,7 +24,7 @@ Publication state: npm-published. This evidence is prepared for a maintainer mer | External writes | false | | Model mode | deterministic | | Quality snapshot date | 2026-07-15 | -| Recorded tests | 664 | +| Recorded tests | 705 | | Recorded protocol fixtures | 182 | | Recorded evaluation assertions | 144 | | Quality snapshot note | Counts are a dated snapshot; command results and HANDOFF_VERIFICATION.json are authoritative. | diff --git a/docs/release/1.0-REPRODUCIBILITY.md b/docs/release/1.0-REPRODUCIBILITY.md index 77461203..8aff6974 100644 --- a/docs/release/1.0-REPRODUCIBILITY.md +++ b/docs/release/1.0-REPRODUCIBILITY.md @@ -24,7 +24,7 @@ Counts are a dated snapshot; command results and HANDOFF_VERIFICATION.json are a | Gate | Expected Count | | --- | --- | -| Tests | 664 | +| Tests | 705 | | Protocol fixtures | 182 | | Evaluation assertions | 144 | diff --git a/docs/superpowers/plans/2026-07-15-memory-recall-orientation-workbench-ui.md b/docs/superpowers/plans/2026-07-15-memory-recall-orientation-workbench-ui.md index bf40b6e3..4ceb6628 100644 --- a/docs/superpowers/plans/2026-07-15-memory-recall-orientation-workbench-ui.md +++ b/docs/superpowers/plans/2026-07-15-memory-recall-orientation-workbench-ui.md @@ -1141,7 +1141,7 @@ git commit -m "test: verify orientation workbench in browser" **Interfaces:** - Produces a clean implementation branch ready for review, not npm publication or merge. -- [ ] **Step 1: Run focused UI and graph verification** +- [x] **Step 1: Run focused UI and graph verification** ```bash node --test tests/source-graph-discovery.test.mjs tests/source-graph-snapshot-service.test.mjs tests/source-graph-preview.test.mjs tests/recall-map-ranking.test.mjs tests/recall-map.test.mjs tests/control-api.test.mjs tests/control-api-boundary.test.mjs tests/web-orientation.test.mjs tests/web-source-map.test.mjs tests/web-memory-graph.test.mjs tests/web-shell.test.mjs @@ -1152,7 +1152,7 @@ npm run consumer:browser-smoke Expected: every command exits 0. -- [ ] **Step 2: Run full repository gates** +- [x] **Step 2: Run full repository gates** ```bash npm run ci @@ -1163,7 +1163,7 @@ npm pack --dry-run Expected: CI, consumer smoke, release readiness, and package manifest checks pass. -- [ ] **Step 3: Test the packed artifact in a fresh repository** +- [x] **Step 3: Test the packed artifact in a fresh repository** ```bash SOURCE_ROOT="/Users/rebel/Downloads/memoryforge-launch" @@ -1179,7 +1179,7 @@ npx recall verify --root . --format summary Expected: the installed package reports the intended version and verifies the fresh consumer repository without relying on the source checkout. -- [ ] **Step 4: Confirm implementation truth** +- [x] **Step 4: Confirm implementation truth** ```bash git status --short diff --git a/tests/release-readiness.test.mjs b/tests/release-readiness.test.mjs index 8b9f9a1f..c78e4609 100644 --- a/tests/release-readiness.test.mjs +++ b/tests/release-readiness.test.mjs @@ -123,9 +123,9 @@ test('npm package contains the runtime contract without checkout-only test weigh assert.equal(paths.some((filePath) => filePath.startsWith('tests/')), false); assert.equal(paths.some((filePath) => filePath.startsWith('adapters/') && filePath.endsWith('/README.md')), false); assert.equal(paths.some((filePath) => filePath.startsWith('adapters/') && filePath.endsWith('/UPSTREAM.lock')), true); - assert.ok(pack.entryCount <= 830, `npm package has ${pack.entryCount} files; expected at most 830`); - assert.ok(pack.size <= 1_220_000, `npm package is ${pack.size} compressed bytes; expected at most 1,220,000`); - assert.ok(pack.unpackedSize <= 5_250_000, `npm package is ${pack.unpackedSize} unpacked bytes; expected at most 5,250,000`); + assert.ok(pack.entryCount <= 845, `npm package has ${pack.entryCount} files; expected at most 845`); + assert.ok(pack.size <= 1_260_000, `npm package is ${pack.size} compressed bytes; expected at most 1,260,000`); + assert.ok(pack.unpackedSize <= 5_400_000, `npm package is ${pack.unpackedSize} unpacked bytes; expected at most 5,400,000`); }); test('installed npm package setup does not re-pack generated local state', () => { From 8d43464b761f108aa85cdbfeb09db256897e6466 Mon Sep 17 00:00:00 2001 From: rebel Date: Thu, 16 Jul 2026 11:16:36 +0530 Subject: [PATCH 023/276] docs: plan task-first code intelligence --- ...-16-memory-recall-mcp-code-intelligence.md | 46 ++++++++ ...26-07-16-memory-recall-persistent-index.md | 46 ++++++++ ...7-16-memory-recall-task-first-workbench.md | 66 +++++++++++ ...all-task-first-code-intelligence-design.md | 109 ++++++++++++++++++ 4 files changed, 267 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-16-memory-recall-mcp-code-intelligence.md create mode 100644 docs/superpowers/plans/2026-07-16-memory-recall-persistent-index.md create mode 100644 docs/superpowers/plans/2026-07-16-memory-recall-task-first-workbench.md create mode 100644 docs/superpowers/specs/2026-07-16-memory-recall-task-first-code-intelligence-design.md diff --git a/docs/superpowers/plans/2026-07-16-memory-recall-mcp-code-intelligence.md b/docs/superpowers/plans/2026-07-16-memory-recall-mcp-code-intelligence.md new file mode 100644 index 00000000..1859dc6c --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-memory-recall-mcp-code-intelligence.md @@ -0,0 +1,46 @@ +# Read-Only MCP Code Intelligence Implementation Plan + +> Execute with contract tests first. All outputs must stay bounded, workspace-locator-safe, and free of raw source bodies. + +**Goal:** Expand the MCP surface from five to twelve useful tools by exposing and composing the native source graph. + +**Architecture:** Add pure intelligence helpers under `packages/source-graph/src/` and thin validation/transport handlers in `apps/cli/oaf.mjs`. Reuse `buildSourceGraphPreview`, `searchSourceGraph`, `traceSourceGraph`, `mapSourceGraphDiffImpact`, `rankArchitectureNodes`, and the orientation model. Do not duplicate parsers in the MCP layer. + +## Task 1: Shared graph delivery and validation + +**Files:** `packages/source-graph/src/intelligence.mjs`, `packages/source-graph/src/index-store.mjs`, `apps/cli/oaf.mjs`, `tests/source-graph-intelligence.test.mjs`, `tests/mcp-token-saver.test.mjs` + +1. Add failing tests for bounded enum/list/string arguments and locator normalization. +2. Add a shared graph loader that prefers a valid read-only persisted index and otherwise performs the existing bounded in-memory scan without writing. +3. Return source, freshness, coverage, and safeguard metadata with every structural response. +4. Run focused tests to green. + +## Task 2: Architecture and index status + +1. Add failing MCP contract tests for `repo.architecture` and `repo.index_status`. +2. Implement architecture groups, ranked entry points, hotspots, relationship counts, and coverage from sanitized graph data. +3. Implement index status as metadata-only inspection; absent and stale indexes are valid states, not implicit refresh triggers. +4. Verify both tools report `read-only` and zero local writes. + +## Task 3: Search, context, and trace + +1. Add failing tests for `code.search`, `code.context`, and `code.trace`, including ambiguous symbols, direction/depth bounds, pagination, and locator prefixes. +2. Implement search as a compact wrapper over `searchSourceGraph`. +3. Implement context as one selected node plus bounded incoming/outgoing edges, containing file/chunk, callers, callees, references, and ambiguity candidates. +4. Implement trace over `traceSourceGraph` with terminal locators and bounded paths. +5. Assert raw fixture source does not appear in any response. + +## Task 4: Dependencies and routes + +1. Add failing tests for `code.dependencies` on file, module, and symbol starting points. +2. Add failing route tests for conventional server route files and handler symbols, including no-route results. +3. Implement dependency traversal over import, contains, defined-in, call, and reference edges with explicit relation kinds. +4. Implement route discovery as evidence-based ranking using file patterns, handler/export signals, and connected graph nodes. Label it static discovery, not runtime tracing. +5. Run focused tests to green. + +## Task 5: Impact integration and tool manifest + +1. Extend `code.impact` tests for risk summary, direct/transitive counts, unrepresented changes, and freshness. +2. Update the MCP manifest and reference descriptions to exactly twelve tools. +3. Run MCP inspection and smoke tests against a temporary multi-file repository. +4. Verify every structural tool is read-only, bounded, locator-only, and deterministic for a fixed clock. diff --git a/docs/superpowers/plans/2026-07-16-memory-recall-persistent-index.md b/docs/superpowers/plans/2026-07-16-memory-recall-persistent-index.md new file mode 100644 index 00000000..f4f55cdf --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-memory-recall-persistent-index.md @@ -0,0 +1,46 @@ +# Persistent Incremental Source Index Implementation Plan + +> Execute with temporary repositories and process-restart tests. MCP reads indexes but never writes them. + +**Goal:** Add a safe persistent local source-graph index that reuses unchanged file metadata and supports larger repositories without false scale claims. + +**Architecture:** Persist sanitized per-file parse shards plus a merged graph and manifest. The explicit CLI writer owns mutation. The read-only loader validates schema, workspace containment, parser version, ignore identity, and freshness before serving data. + +## Task 1: Per-file shard contract + +**Files:** `providers/native/context-candidate-ast-code/src/index.mjs`, `packages/source-graph/src/index-store.mjs`, `tests/source-graph-index-store.test.mjs` + +1. Add failing tests that a file shard contains structural metadata and hashes but no raw body. +2. Add an `onlyIncludes` scan option that prunes traversal to requested workspace-relative JS/TS files while preserving ignore and safety rules. +3. Export a safe shard builder and a merge function that rebuilds the global symbol index from merged chunks/file outlines. +4. Verify merged output matches a clean full scan for the same fixture. + +## Task 2: Atomic persistent format + +1. Add failing tests for schema validation, workspace-contained paths, corrupt files, parser-version mismatch, and atomic replacement. +2. Implement `index.v1.json` with schema version, workspace ID, root identity hash, scan settings, ignore fingerprint, file manifest, shards, sanitized graph, generated time, and content fingerprint. +3. Write through a same-directory temporary file, fsync/close, then rename. +4. Enforce permissions and never store absolute paths or source bodies. + +## Task 3: Incremental refresh + +1. Add a fixture with changed, added, deleted, and unchanged files. +2. Assert refresh parses only changed/added paths, reuses unchanged shards, removes deleted shards, and produces the same public graph as a clean full build. +3. Treat ignore-rule, parser-version, or scan-setting changes as explicit full rebuild reasons. +4. Report parsed, reused, added, changed, deleted, duration, and coverage counts without making provider token or scale claims. + +## Task 4: CLI lifecycle + +**Files:** `apps/cli/oaf.mjs`, `tests/cli.test.mjs` + +1. Add failing help and behavior tests for `recall graph index --write`, `--refresh`, `--status`, `--out`, and `--watch`. +2. Require explicit `--write` or `--refresh` for mutation. `--status` is read-only. +3. Implement bounded debounced watch mode with clean shutdown and visible refresh results. +4. Keep the index under `.local/` by default and update ignore/setup guidance. + +## Task 5: Restart, scale, and safety verification + +1. Build an index, exit, load it in a new process, and verify graph fingerprint equality. +2. Call every MCP structural tool against the persisted index and assert the index mtime does not change. +3. Generate a representative large JS/TS fixture and record cold build, warm load, and one-file refresh time plus index size. +4. Document the measured fixture size and current bounds. Do not claim million-node or cross-repository support. diff --git a/docs/superpowers/plans/2026-07-16-memory-recall-task-first-workbench.md b/docs/superpowers/plans/2026-07-16-memory-recall-task-first-workbench.md new file mode 100644 index 00000000..e845d190 --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-memory-recall-task-first-workbench.md @@ -0,0 +1,66 @@ +# Task-First Workbench Implementation Plan + +> Execute with test-driven development. Preserve the existing visual system and verify every primary route in the in-app browser. + +**Goal:** Make the workbench immediately understandable, make Map truthful and readable, and remove misleading or legacy UI. + +**Architecture:** Keep the existing browser shell, orientation model/view, source-map view, and graph viewport. Add small pure view-model helpers where behavior needs unit coverage. No new frontend framework or design dependency. + +## Task 1: Task-oriented navigation and first screen + +**Files:** `apps/web/shell-model.js`, `apps/web/app.js`, `apps/web/orientation-view.js`, `tests/web-shell.test.mjs`, `tests/web-orientation.test.mjs` + +1. Change tests to require `Start`, `Explore code`, `Review memory`, `Prepare handoff`, and `Settings` in desktop navigation, with the first four on mobile. +2. Add tests that Overview exposes direct actions for exploring code, reviewing memory, and preparing a handoff. +3. Run the focused tests and observe the expected failures. +4. Update navigation labels and concise route descriptions without changing stable paths. +5. Simplify Overview copy and actions around the three developer jobs. +6. Run focused tests to green. + +## Task 2: Default Map graph and query semantics + +**Files:** `apps/web/source-map-view.js`, `apps/web/app.js`, `apps/web/graph-viewport.js`, `tests/web-source-map.test.mjs`, `tests/web-shell.test.mjs` + +1. Add failing tests that empty-query output includes a repository architecture canvas and group outline. +2. Add failing tests that the primary submit action is `Search code`, while `Refresh scan` is separate. +3. Implement a default architecture graph derived from repository groups and their bounded relationships. +4. Keep focused query/change/group behavior and stable URL state. +5. Run the focused tests to green. + +## Task 3: Progressive labels and legible outline + +**Files:** `apps/web/graph-viewport.js`, `apps/web/source-map-view.js`, `apps/web/styles.css`, `tests/web-source-map.test.mjs` + +1. Add failing pure tests for a bounded visible-label policy: selected node, hovered node, immediate neighbors, then a small high-value budget. +2. Add failing markup/style tests for stacked outline copy, `min-width:0`, and controlled locator overflow. +3. Replace the `nodes.length <= 60` all-label rule with the tested visibility policy. +4. Render outline rows with dedicated label and locator elements and fix grid sizing. +5. Run focused tests to green. + +## Task 4: Truthful Memory metrics + +**Files:** `apps/web/app.js`, `tests/web-shell.test.mjs` + +1. Add failing tests for missing or zero baseline returning `Not measured`. +2. Add model state that distinguishes measured reduction, measured overhead, no reduction, and unmeasured. +3. Update the Memory header and disclosure copy to explain unmeasured state. +4. Run focused tests to green. + +## Task 5: Real Settings and concise Handoffs + +**Files:** `apps/web/app.js`, `apps/web/styles.css`, `tests/web-shell.test.mjs` + +1. Add failing tests requiring a Settings H1, local storage/scan/privacy sections, and no token swatch gallery. +2. Add failing tests requiring Handoffs to lead with one recommended flow and user-facing `recall` commands. +3. Replace Settings with actual local runtime information already available to the shell. +4. Reorder Handoffs so build, pin, verify, and receive are the primary path; move reference detail into disclosure. +5. Convert public command strings from `npm run oaf --` to `recall`, retaining internal test/dev commands only where explicitly labeled. +6. Run focused tests to green. + +## Task 6: Browser verification + +1. Run `node --test tests/web-orientation.test.mjs tests/web-source-map.test.mjs tests/web-memory-graph.test.mjs tests/web-shell.test.mjs`. +2. Open Overview, Map default, Map focused, Memory, Handoffs, and Settings in the user's in-app browser. +3. Click every primary action, verify URL/state synchronization, inspect console errors, and capture desktop plus narrow screenshots. +4. Compare the focused Map screenshot against the supplied overlap screenshot and fix remaining collisions. +5. Re-run focused tests after visual fixes. diff --git a/docs/superpowers/specs/2026-07-16-memory-recall-task-first-code-intelligence-design.md b/docs/superpowers/specs/2026-07-16-memory-recall-task-first-code-intelligence-design.md new file mode 100644 index 00000000..8b5a499f --- /dev/null +++ b/docs/superpowers/specs/2026-07-16-memory-recall-task-first-code-intelligence-design.md @@ -0,0 +1,109 @@ +# Memory Recall Task-First Code Intelligence Design + +**Date:** 2026-07-16 +**Status:** Approved for implementation + +## Product decision + +Memory Recall is a local developer workbench for two connected jobs: + +1. understand a repository before changing it; +2. preserve and deliver trustworthy context after the work. + +The first screen must answer three questions without teaching product vocabulary: + +- Where should I start? +- What changed or needs attention? +- What context can I trust? + +The UI remains a restrained workbench. It does not imitate a generic analytics dashboard, a graph demo, or an AI assistant. Existing native typography, off-white and graphite surfaces, cobalt interaction color, flat geometry, spacing, and accessible controls remain the design system. + +## Navigation and information architecture + +Use task labels in the persistent navigation while keeping short page titles: + +| Navigation | Page title | Job | +| --- | --- | --- | +| Start | Overview | Orient in ten seconds | +| Explore code | Map | Find a file or symbol, inspect dependencies, and trace impact | +| Review memory | Memory | Review proposals and current governed facts | +| Prepare handoff | Handoffs | Build, pin, verify, and receive compact context | +| Settings | Settings | Inspect local paths, scan limits, privacy, and read-only guarantees | + +The Overview presents a small set of direct next actions. It does not repeat the same repository status in multiple cards. + +## Map behavior + +The current empty-query failure is removed. Map has two valid states: + +- **Repository architecture:** the default and empty-query result renders the bounded repository group graph and a group outline. +- **Focused code view:** a query, changed file, or selected group renders a smaller node graph and a matching accessible outline. + +The submit label is **Search code**, because submitting a query searches the current scan. **Refresh scan** remains the explicit operation that re-reads local files. + +Graph labels use progressive disclosure: + +- always show the selected node; +- show immediate neighbors when space permits; +- show a bounded set of high-value labels at the default zoom; +- reveal additional labels on hover and zoom; +- never render every label merely because the graph contains fewer than an arbitrary threshold. + +The outline is the legible equivalent of the canvas. Each row uses a stacked label and locator, both with controlled wrapping or ellipsis. Selection remains synchronized between canvas, outline, and inspector. + +## Honest measurements + +No reduction percentage is shown when the baseline is zero, absent, or not comparable. The UI says **Not measured** and explains what action produces a measurement. Local token estimates remain explicitly separate from provider billing. + +## Read-only MCP surface + +Expand from five to twelve focused read-only tools. Every tool returns bounded, locator-safe metadata and excludes raw source bodies. + +| Tool | Purpose | +| --- | --- | +| `repo.map` | Combined bounded repository, memory, and handoff orientation | +| `repo.architecture` | Repository groups, entry points, hotspots, and coverage | +| `repo.index_status` | Persistent-index availability, freshness, limits, and coverage | +| `code.search` | Structural and lexical search across files, symbols, and relationships | +| `code.context` | One symbol or file with incoming, outgoing, containing, and related context | +| `code.trace` | Bounded inbound, outbound, or bidirectional relationship paths | +| `code.dependencies` | Direct and transitive module or file dependencies and dependants | +| `code.routes` | Route and handler entry points with connected symbols and files | +| `code.impact` | Changed-file coverage, affected symbols, and relationship evidence | +| `memory.recall` | Governed current and temporal memory facts | +| `context.profile` | Budgeted memory profile for an objective | +| `context.pack` | Sanitized handoff context | + +Tool count is not the success metric. Each added tool must reuse verified source-graph primitives, provide bounded pagination or depth, retain workspace locators, and include freshness and coverage truth. + +## Persistent local index + +The persistent index is derived local state, never canonical memory. + +- The explicit `recall graph index --write` command creates or refreshes it. +- MCP tools may read a valid index but never create, refresh, or mutate it. +- The default location is `.local/source-graph/index.v1.json`; callers may supply another workspace-contained path. +- The file contains schema/version metadata, workspace identity, scan settings, a content manifest, per-file metadata shards, and a sanitized graph. It contains no raw source bodies. +- Refresh compares content hashes and ignore-rule identity. Unchanged shards are reused; changed and added JS/TS files are rescanned; deleted shards are removed; the global symbol graph is rebuilt from the merged metadata. +- Unsupported languages and configured bounds remain visible as partial coverage. Million-node claims are prohibited until a benchmark proves them. +- Watch mode only invalidates and schedules explicit local refresh behavior. It must be bounded, debounced, and stoppable. + +## Competitive boundary + +GitNexus and Codebase Memory MCP remain current references for breadth, persistent indexing, and route/call-graph ergonomics. Memory Recall will not claim parity with their language counts, semantic search, cross-repository analysis, or index scale until those behaviors exist and are benchmarked here. + +Memory Recall's product-owned advantage is the connection between code structure and governed temporal context: proposals, review, current truth, source evidence, cursor deltas, compact delivery, and verified handoffs. + +## Acceptance criteria + +- A first-time developer can identify the three main jobs from the first screen and navigation. +- Empty Map submission produces a visible repository graph, not a canvas-free state. +- A 50-100 node focused graph remains readable; outline text does not collide. +- Settings contains real runtime and privacy information, not design swatches. +- Handoffs leads with one recommended flow and public `recall` commands. +- Unmeasured savings never render as 100% reduction. +- MCP inspection lists twelve read-only tools and each new tool has focused contract tests. +- Read-only MCP calls make zero local writes. +- Explicit index creation survives process restart and refreshes changed shards without retaining raw source. +- README and reference docs state verified capabilities and explicit gaps. +- Focused tests, the full suite, browser interaction QA, and a large-repository benchmark pass before release claims. From 6ca74bc77377c79b779363124cd4e7a4221621e1 Mon Sep 17 00:00:00 2001 From: rebel Date: Thu, 16 Jul 2026 11:32:20 +0530 Subject: [PATCH 024/276] feat: make workbench task-first and map readable --- apps/web/app.js | 64 +++++++++++--------- apps/web/graph-viewport.js | 107 +++++++++++++++++++++++++++++----- apps/web/shell-model.js | 8 +-- apps/web/source-map-view.js | 51 +++++++++++----- apps/web/styles.css | 5 +- tests/web-shell.test.mjs | 57 ++++++++++++------ tests/web-source-map.test.mjs | 46 ++++++++++++--- 7 files changed, 254 insertions(+), 84 deletions(-) diff --git a/apps/web/app.js b/apps/web/app.js index ebac8ea7..8c30584f 100644 --- a/apps/web/app.js +++ b/apps/web/app.js @@ -30,7 +30,7 @@ export { buildApiErrorUiModel } from './ui-primitives.js'; export const SHELL_STATES = new Set(['loading','setup','empty','error','denied','stale','partial','success']); -const OAF_CHECKOUT_COMMAND_PREFIX = 'npm --silent run oaf --'; +const OAF_CHECKOUT_COMMAND_PREFIX = 'recall'; const OAF_COMPATIBILITY_URL = 'https://github.com/rebel0789/Memory-Recall/blob/main/docs/usage/oaf-compatibility.md'; const OAF_URI_COMPATIBILITY_NOTE = 'Legacy oaf:// URIs remain supported compatibility identifiers; normal commands use recall.'; @@ -559,7 +559,8 @@ export function buildMemoryCockpitModel(cockpit = null) { }; } -export function deliveryChangeLabel(percent) { +export function deliveryChangeLabel(percent, baselineTokens = null) { + if (baselineTokens !== null && !(Number(baselineTokens) > 0)) return 'Not measured'; const value=Math.round(Number(percent) || 0); if(value>0)return `${value}% reduction`; if(value<0)return `${Math.abs(value)}% overhead`; @@ -795,8 +796,8 @@ export function buildHarnessSetupUiModel(report = null) { serverStatus:safeText(report?.status?.server ?? 'not checked'), operation:operation ? safeText(operation.summary) : 'No MCP config change needed', operationKind:safeText(operation?.op ?? 'none'), - command:report ? `npm run oaf -- harness setup plan --client ${safeText(report.client)} --server oaf --dry-run --format json` : 'npm run oaf -- harness setup plan --client codex --server oaf --dry-run --format json', - bridgeCommand:report?.desiredServer ? [report.desiredServer.command,...report.desiredServer.args].join(' ') : oafCommand('mcp resources --read-only --stdio'), + command:report ? `recall harness setup plan --client ${safeText(report.client)} --server oaf --dry-run --format json` : 'recall harness setup plan --client codex --server oaf --dry-run --format json', + bridgeCommand:report?.desiredServer ? publicRecallCommand([report.desiredServer.command,...report.desiredServer.args].join(' ')) : oafCommand('mcp resources --read-only --stdio'), manualConfigSnippet:report?.manualConfigSnippet ? { format:safeText(report.manualConfigSnippet.format), configRef:safeText(report.manualConfigSnippet.configRef), @@ -1240,7 +1241,7 @@ export function buildPinnedHandoffStatusModel(report=null,error=null) { usePlanFingerprint:currentEntry?.usePlan?.fingerprint ? shortFingerprint(currentEntry.usePlan.fingerprint) : 'unavailable', sourceChecks, targetLabel:harnessClientLabel(targetHarness), - primaryCommand:verified ? {label:'Receive pinned pack',command:`npm run oaf -- context receive --read-only --root . --target ${targetHarness} --format json`} : null, + primaryCommand:verified ? {label:'Receive pinned pack',command:`recall context receive --read-only --root . --target ${targetHarness} --format json`} : null, commands:pinnedHandoffCommands(targetHarness,verified), facts:[ ['Registry', registryExists ? report.registry.fingerprintStatus : 'missing'], @@ -1259,13 +1260,13 @@ export function canReceivePinnedHandoff(state) { function pinnedHandoffCommands(targetHarness='codex',includeUsePlan=false) { const commands=[ - {label:'Receive pinned pack',command:`npm run oaf -- context receive --read-only --root . --target ${targetHarness} --format json`}, - {label:'Receive summary',command:`npm run oaf -- context receive --read-only --root . --target ${targetHarness} --format summary`}, - {label:'Check registry',command:'npm run oaf -- context registry status --read-only --format json'}, - {label:'Read registry',command:'npm run oaf -- mcp resources --read-only --uri oaf://workspace/ws_local/context-pack/registry/current --format json'} + {label:'Receive pinned pack',command:`recall context receive --read-only --root . --target ${targetHarness} --format json`}, + {label:'Receive summary',command:`recall context receive --read-only --root . --target ${targetHarness} --format summary`}, + {label:'Check registry',command:'recall context registry status --read-only --format json'}, + {label:'Read registry',command:'recall mcp resources --read-only --uri oaf://workspace/ws_local/context-pack/registry/current --format json'} ]; if(includeUsePlan){ - commands.splice(2,0,{label:'Read pinned use plan',command:'npm run oaf -- mcp resources --read-only --uri oaf://workspace/ws_local/context-pack/use-plan/current --format json'}); + commands.splice(2,0,{label:'Read pinned use plan',command:'recall mcp resources --read-only --uri oaf://workspace/ws_local/context-pack/use-plan/current --format json'}); } return commands; } @@ -1935,7 +1936,7 @@ function zeroCount(value) { function contextPackHarnessCommands(pack,usePlan=null,{memoryConfig=null}={}) { const packCommands=Array.isArray(pack?.handoff?.commands) ? pack.handoff.commands.filter((command)=>typeof command==='string'&&command.trim()) : []; if(packCommands.length){ - const commands=packCommands.map((command)=>({ label:contextPackCommandLabel(command), command })); + const commands=packCommands.map((command)=>({ label:contextPackCommandLabel(command), command:publicRecallCommand(command) })); insertContextPackReceiveCommand(commands,pack); const generated=[ {label:'Test local handoff',command:contextPackPreflightCommand(pack,{memoryConfig})}, @@ -1964,18 +1965,18 @@ function contextPackHarnessCommands(pack,usePlan=null,{memoryConfig=null}={}) { { label:'Test local handoff', command:contextPackPreflightCommand(pack,{memoryConfig}) }, { label:'Test handoff summary', command:contextPackPreflightCommand(pack,{memoryConfig,format:'summary'}) }, { label:'Copy impact command', command:contextPackImpactCommand(pack) }, - { label:'Rebuild from CLI', command:`npm run oaf -- context pack --from ${from} --root . --objective ${objective} --step ${step} --target ${target}${selected}${changed} --dry-run --format markdown` }, - { label:'Pin locally', command:`npm run oaf -- context pack --from ${from} --root . --objective ${objective} --step ${step} --target ${target}${selected}${changed} --write --pin --out context-packs/CONTEXT_PACK.md --format json` }, - { label:'Verify pin', command:'npm run oaf -- context registry status --read-only --format json' }, + { label:'Rebuild from CLI', command:`recall context pack --from ${from} --root . --objective ${objective} --step ${step} --target ${target}${selected}${changed} --dry-run --format markdown` }, + { label:'Pin locally', command:`recall context pack --from ${from} --root . --objective ${objective} --step ${step} --target ${target}${selected}${changed} --write --pin --out context-packs/CONTEXT_PACK.md --format json` }, + { label:'Verify pin', command:'recall context registry status --read-only --format json' }, { label:'Receive pinned pack', command:contextPackReceiveCommand(pack) }, { label:'Receive summary', command:contextPackReceiveSummaryCommand(pack) }, { label:'Start MCP bridge', command:oafCommand('mcp resources --read-only --stdio') }, - { label:'Preview harness setup', command:`npm run oaf -- harness setup plan --client ${setupClient} --server oaf --dry-run --format json` }, - { label:'Read use plan', command:'npm run oaf -- mcp resources --read-only --uri oaf://workspace/ws_local/context-pack/use-plan/current --format json' }, - { label:'Read registry', command:'npm run oaf -- mcp resources --read-only --uri oaf://workspace/ws_local/context-pack/registry/current --format json' }, - { label:'Read current context pack', command:`npm run oaf -- mcp resources --read-only --context-pack --from ${from} --root . --objective ${objective} --step ${step} --target ${target}${selected}${changed} --uri oaf://workspace/ws_local/context-pack/current --format json` }, - { label:'Read MCP resources', command:'npm run oaf -- mcp resources --read-only --format json' }, - { label:'Read latest handoff', command:'npm run oaf -- mcp resources --read-only --uri oaf://workspace/ws_local/handoff/latest --format json' } + { label:'Preview harness setup', command:`recall harness setup plan --client ${setupClient} --server oaf --dry-run --format json` }, + { label:'Read use plan', command:'recall mcp resources --read-only --uri oaf://workspace/ws_local/context-pack/use-plan/current --format json' }, + { label:'Read registry', command:'recall mcp resources --read-only --uri oaf://workspace/ws_local/context-pack/registry/current --format json' }, + { label:'Read current context pack', command:`recall mcp resources --read-only --context-pack --from ${from} --root . --objective ${objective} --step ${step} --target ${target}${selected}${changed} --uri oaf://workspace/ws_local/context-pack/current --format json` }, + { label:'Read MCP resources', command:'recall mcp resources --read-only --format json' }, + { label:'Read latest handoff', command:'recall mcp resources --read-only --uri oaf://workspace/ws_local/handoff/latest --format json' } ]; } @@ -2027,12 +2028,12 @@ function contextPackPreflightCommand(pack,{memoryConfig=null,format='json'}={}) function contextPackReceiveCommand(pack) { const target=String(pack?.targetHarness ?? 'generic'); - return `npm run oaf -- context receive --read-only --root . --target ${target} --format json`; + return `recall context receive --read-only --root . --target ${target} --format json`; } function contextPackReceiveSummaryCommand(pack) { const target=String(pack?.targetHarness ?? 'generic'); - return `npm run oaf -- context receive --read-only --root . --target ${target} --format summary`; + return `recall context receive --read-only --root . --target ${target} --format summary`; } function contextPackGeneratedUsePlanCommands(pack,usePlan=null) { @@ -2047,13 +2048,13 @@ function contextPackGeneratedUsePlanCommands(pack,usePlan=null) { const changed=(pack?.sourceGraph?.impact?.changedLocators ?? []).map((locator)=>` --changed ${quoteShell(locator.replace(/^workspace:\/\//u,''))}`).join(''); const uri=String(usePlan?.resource?.uri ?? 'oaf://workspace/ws_local/context-pack/use-plan/current'); return [ - { label:'Pin locally', command:`npm run oaf -- context pack --from ${from} --root . --objective ${objective} --step ${step} --target ${target}${selected}${changed} --write --pin --out context-packs/CONTEXT_PACK.md --format json` }, - { label:'Verify pin', command:'npm run oaf -- context registry status --read-only --format json' }, + { label:'Pin locally', command:`recall context pack --from ${from} --root . --objective ${objective} --step ${step} --target ${target}${selected}${changed} --write --pin --out context-packs/CONTEXT_PACK.md --format json` }, + { label:'Verify pin', command:'recall context registry status --read-only --format json' }, { label:'Receive pinned pack', command:contextPackReceiveCommand(pack) }, { label:'Receive summary', command:contextPackReceiveSummaryCommand(pack) }, { label:'Start MCP bridge', command:oafCommand('mcp resources --read-only --stdio') }, - { label:'Read registry', command:'npm run oaf -- mcp resources --read-only --uri oaf://workspace/ws_local/context-pack/registry/current --format json' }, - { label:'Read use plan', command:`npm run oaf -- mcp resources --read-only --uri ${uri} --format json` } + { label:'Read registry', command:'recall mcp resources --read-only --uri oaf://workspace/ws_local/context-pack/registry/current --format json' }, + { label:'Read use plan', command:`recall mcp resources --read-only --uri ${uri} --format json` } ]; } @@ -2084,6 +2085,12 @@ function oafCommand(args) { return `${OAF_CHECKOUT_COMMAND_PREFIX} ${args}`; } +function publicRecallCommand(command) { + return String(command ?? '') + .replace(/^npm\s+--silent\s+run\s+oaf\s+--\s+/u, 'recall ') + .replace(/^npm\s+run\s+oaf\s+--\s+/u, 'recall '); +} + function contextPackSetupClient(pack) { const target=String(pack?.targetHarness ?? 'codex'); return target === 'cursor' || target === 'claude-code' || target === 'codex' ? target : 'codex'; @@ -2418,7 +2425,8 @@ export function renderMemoryCockpit(cockpit = null) { const toolStats=model.mcpStats.byTool.length ? `
    ${model.mcpStats.byTool.map((item)=>`
  1. ${esc(item.toolName)} · ${item.callCount}${item.deliveredTokens} delivered · ${item.tokensSaved} saved
  2. `).join('')}
` : '

No MCP delivery calls recorded for this workspace yet.

'; - return `

Governed repository memory

Memory

Review proposed facts, inspect current truth, and add explicit source-backed memory.

Pending
${model.summary.pendingProposalCount}
Active
${model.summary.activeFactCount}
Delivery
${deliveryChangeLabel(model.savings.percent)}

Review queue

${model.summary.pendingProposalCount} pending
${queue}

Active memory

${model.facts.length} facts
${facts}
Delivery details · ${deliveryChangeLabel(model.savings.percent)}
Active facts
${model.summary.activeFactCount}
Pending proposals
${model.summary.pendingProposalCount}
Naive baseline
${model.savings.beforeDeliveryTokens}
Memory Recall delivery
${model.savings.afterDeliveryTokens}
Delivery tokens saved
${model.savings.tokensSaved}
MCP calls
${model.mcpStats.callCount}
MCP delivered
${model.mcpStats.deliveredTokens}
MCP saved
${model.mcpStats.tokensSaved}
Provider billing
${model.savings.providerBillingClaimed||model.mcpStats.providerBillingClaimed?'claimed':'not claimed'}
Provider
${esc(model.provider)}
${toolStats}${history}

Delivery values are local estimates, not provider billing claims. No model, network, or raw source body is used on this route.

`; + const deliveryLabel=deliveryChangeLabel(model.savings.percent,model.savings.beforeDeliveryTokens); + return `

Governed repository memory

Memory

Review proposed facts, inspect current truth, and add explicit source-backed memory.

Pending
${model.summary.pendingProposalCount}
Active
${model.summary.activeFactCount}
Delivery
${deliveryLabel}

Review queue

${model.summary.pendingProposalCount} pending
${queue}

Active memory

${model.facts.length} facts
${facts}
Delivery details · ${deliveryLabel}
Active facts
${model.summary.activeFactCount}
Pending proposals
${model.summary.pendingProposalCount}
Naive baseline
${model.savings.beforeDeliveryTokens||'Not measured'}
Memory Recall delivery
${model.savings.afterDeliveryTokens}
Delivery tokens saved
${model.savings.beforeDeliveryTokens>0?model.savings.tokensSaved:'Not measured'}
MCP calls
${model.mcpStats.callCount}
MCP delivered
${model.mcpStats.deliveredTokens}
MCP saved
${model.mcpStats.tokensSaved}
Provider billing
${model.savings.providerBillingClaimed||model.mcpStats.providerBillingClaimed?'claimed':'not claimed'}
Provider
${esc(model.provider)}
${toolStats}${history}

${model.savings.beforeDeliveryTokens>0?'Delivery values are local estimates, not provider billing claims.':'Run a context delivery with a comparable baseline to measure reduction.'} No model, network, or raw source body is used on this route.

`; } function renderEvidence() { @@ -2456,7 +2464,7 @@ function renderHarnessSetupResult(report) { } function renderSettings() { - return `

Local system

Shared tokens
${[['Ink','--ink'],['Paper','--paper'],['Signal','--signal'],['Proof','--proof'],['Caution','--caution'],['Danger','--danger'],['Success','--success']].map(([name,token])=>`
${name}${token}
`).join('')}
`; + return `

Settings

Inspect the local paths, scan bounds, and privacy rules used by this workspace.

Local workspace

Local storage

Derived and governed state
Memory
.local/memory.sqlite
Source graph
In-memory scan; persistent index optional
Context packs
context-packs/ when explicitly pinned

Scan limits

Bounded by default
Languages
JavaScript and TypeScript
File size
512 KiB default
Graph display
200 nodes / 400 relationships

Privacy

Offline default
${localBoundary()}

Map and read-only MCP responses expose bounded locators and metadata, never raw source bodies.

`; } function metric(value,label,copy){return `
${Number(value??0)}${label}${copy}
`} diff --git a/apps/web/graph-viewport.js b/apps/web/graph-viewport.js index d98e9401..08870501 100644 --- a/apps/web/graph-viewport.js +++ b/apps/web/graph-viewport.js @@ -23,6 +23,7 @@ export function createGraphViewport(canvas, outline, inspector, { let panX = 0; let panY = 0; let pointer = null; + let hoveredNodeId = null; const resizeObserver = typeof ResizeObserver === 'function' ? new ResizeObserver(() => requestLayout()) @@ -53,7 +54,15 @@ export function createGraphViewport(canvas, outline, inspector, { canvas.setPointerCapture?.(event.pointerId); }, { signal }); canvas.addEventListener('pointermove', (event) => { - if (!pointer || pointer.id !== event.pointerId) return; + if (!pointer || pointer.id !== event.pointerId) { + const nextHoveredNodeId = hitTest(event); + if (nextHoveredNodeId !== hoveredNodeId) { + hoveredNodeId = nextHoveredNodeId; + canvas.style.cursor = hoveredNodeId ? 'pointer' : 'grab'; + draw(); + } + return; + } const dx = event.clientX - pointer.x; const dy = event.clientY - pointer.y; if (Math.abs(dx) + Math.abs(dy) > 2) pointer.moved = true; @@ -73,6 +82,13 @@ export function createGraphViewport(canvas, outline, inspector, { canvas.releasePointerCapture?.(event.pointerId); }, { signal }); canvas.addEventListener('pointercancel', () => { pointer = null; }, { signal }); + canvas.addEventListener('pointerleave', () => { + if (!pointer && hoveredNodeId) { + hoveredNodeId = null; + canvas.style.cursor = 'grab'; + draw(); + } + }, { signal }); canvas.addEventListener('wheel', (event) => { event.preventDefault(); const rect = canvas.getBoundingClientRect(); @@ -186,6 +202,14 @@ export function createGraphViewport(canvas, outline, inspector, { } context.font = '11px ui-monospace, SFMono-Regular, Menlo, monospace'; context.textBaseline = 'middle'; + const visibleLabelIds = visibleGraphLabelIds({ + nodes, + edges, + selectedNodeId, + hoveredNodeId, + scale, + budget: scale < 0.75 ? 5 : scale < 1.35 ? 8 : 14 + }); for (const node of nodes) { const point = positions[node.id]; if (!point) continue; @@ -194,19 +218,38 @@ export function createGraphViewport(canvas, outline, inspector, { context.beginPath(); context.arc(point.x, point.y, selected ? 6 : 4, 0, Math.PI * 2); context.fill(); - if (selected || nodes.length <= 60) { - const label = String(node.label ?? '').slice(0, 32); - const placement = graphLabelPlacement({ - pointX: point.x, - labelWidth: context.measureText(label).width, - scale, - panX, - viewportWidth: width - }); - context.fillStyle = palette.ink; - context.textAlign = placement.align; - context.fillText(label, point.x + placement.offset, point.y); - } + } + const placedLabelBoxes = []; + const labelNodes = nodes + .filter((node) => visibleLabelIds.has(node.id) && positions[node.id]) + .sort((left, right) => labelPriority(right.id, selectedNodeId, hoveredNodeId) - labelPriority(left.id, selectedNodeId, hoveredNodeId)); + for (const node of labelNodes) { + const point = positions[node.id]; + const label = String(node.label ?? '').slice(0, 32); + const labelWidth = context.measureText(label).width; + const placement = graphLabelPlacement({ + pointX: point.x, + labelWidth, + scale, + panX, + viewportWidth: width + }); + const anchorX = point.x + placement.offset; + const screenAnchorX = panX + anchorX * scale; + const screenY = panY + point.y * scale; + const scaledWidth = labelWidth * scale; + const box = { + left: placement.align === 'right' ? screenAnchorX - scaledWidth : screenAnchorX, + right: placement.align === 'right' ? screenAnchorX : screenAnchorX + scaledWidth, + top: screenY - 7, + bottom: screenY + 7 + }; + const forced = node.id === selectedNodeId || node.id === hoveredNodeId; + if (!forced && graphLabelBoxesOverlap(box, placedLabelBoxes)) continue; + placedLabelBoxes.push(box); + context.fillStyle = palette.ink; + context.textAlign = placement.align; + context.fillText(label, anchorX, point.y); } context.restore(); } @@ -246,6 +289,42 @@ export function createGraphViewport(canvas, outline, inspector, { }; } +export function visibleGraphLabelIds({ nodes = [], edges = [], selectedNodeId = null, hoveredNodeId = null, scale = 1, budget = 8 } = {}) { + const nodeIds = new Set(nodes.map((node) => node.id)); + const limit = Math.max(1, Math.min(nodes.length, Math.round(Number(budget) || 8) + (Number(scale) >= 1.75 ? 4 : 0))); + const visible = new Set(); + const add = (nodeId) => { + if (visible.size < limit && nodeIds.has(nodeId)) visible.add(nodeId); + }; + add(selectedNodeId); + add(hoveredNodeId); + for (const anchor of [selectedNodeId, hoveredNodeId]) { + if (!anchor) continue; + for (const edge of edges) { + if (edge.fromNodeId === anchor) add(edge.toNodeId); + if (edge.toNodeId === anchor) add(edge.fromNodeId); + } + } + for (const node of nodes) add(node.id); + return visible; +} + +export function graphLabelBoxesOverlap(box, placedBoxes = [], gap = 4) { + const padding = Math.max(0, Number(gap) || 0); + return placedBoxes.some((placed) => !( + box.right + padding < placed.left + || box.left - padding > placed.right + || box.bottom + padding < placed.top + || box.top - padding > placed.bottom + )); +} + +function labelPriority(nodeId, selectedNodeId, hoveredNodeId) { + if (nodeId === selectedNodeId) return 2; + if (nodeId === hoveredNodeId) return 1; + return 0; +} + export function graphLabelPlacement({ pointX = 0, labelWidth = 0, scale = 1, panX = 0, viewportWidth = 0 } = {}) { const resolvedScale = Math.max(0.01, Number(scale) || 1); const screenX = Number(panX) + Number(pointX) * resolvedScale; diff --git a/apps/web/shell-model.js b/apps/web/shell-model.js index 0014186b..65915b90 100644 --- a/apps/web/shell-model.js +++ b/apps/web/shell-model.js @@ -1,8 +1,8 @@ export const PRIMARY_NAV = Object.freeze([ - { id: 'overview', routeId: 'home', path: '/', label: 'Overview' }, - { id: 'map', routeId: 'source-graph', path: '/map', label: 'Map' }, - { id: 'memory', routeId: 'memory', path: '/memory', label: 'Memory' }, - { id: 'handoffs', routeId: 'context-pack', path: '/handoffs', label: 'Handoffs' }, + { id: 'overview', routeId: 'home', path: '/', label: 'Start' }, + { id: 'map', routeId: 'source-graph', path: '/map', label: 'Explore code' }, + { id: 'memory', routeId: 'memory', path: '/memory', label: 'Review memory' }, + { id: 'handoffs', routeId: 'context-pack', path: '/handoffs', label: 'Prepare handoff' }, { id: 'settings', routeId: 'settings', path: '/settings', label: 'Settings' } ]); diff --git a/apps/web/source-map-view.js b/apps/web/source-map-view.js index 13bc8dee..a8cd98c8 100644 --- a/apps/web/source-map-view.js +++ b/apps/web/source-map-view.js @@ -90,14 +90,16 @@ export function bindSourceMap(root, { report = null, onSubmit, onRefresh, onSele root.querySelector('[data-action="refresh-source-map"]')?.addEventListener('click', (event) => onRefresh?.(event), options); const canvas = root.querySelector('#source-map-canvas'); if (canvas) { - const nodes = arrayValue(report?.focus?.nodes); + const state = mapStateFromRoot(root); + const graph = sourceMapGraphData(report, state); + const nodes = graph.nodes; viewport = createGraphViewport( canvas, root.querySelector('.source-map-outline'), root.querySelector('#source-map-selection'), { nodes, - edges: arrayValue(report?.focus?.edges), + edges: graph.edges, onSelect: (node) => { const selection = root.querySelector('#source-map-selection'); if (selection) selection.innerHTML = renderSelection(node); @@ -136,7 +138,7 @@ function renderMapForm(state) { -
No model, network, or external writes
+
No model, network, or external writes
`; } @@ -147,12 +149,13 @@ function renderMapResult(report, state) { const focusEdges = arrayValue(report.focus?.edges); const groups = arrayValue(report.orientation?.groups); const outlineNodes = hasFocus ? focusNodes : groups; + const groupRelations = arrayValue(report.orientation?.relations ?? report.orientation?.groupRelations); const content = hasFocus ? renderFocus(focusNodes, focusEdges, report.focus) - : renderGroups(groups, arrayValue(report.orientation?.relations ?? report.orientation?.groupRelations)); + : renderArchitecture(groups, groupRelations); return `
-

${hasFocus ? 'Focused map' : 'Repository groups'}

${escapeHtml(coverage.summary)}

${snapshotLabel(report.snapshot)}
+

${hasFocus ? 'Focused map' : 'Repository architecture'}

${escapeHtml(coverage.summary)}

${snapshotLabel(report.snapshot)}
${coverage.status === 'partial' ? renderCoverageWarning(coverage) : ''}
${content}
@@ -161,14 +164,9 @@ function renderMapResult(report, state) {
`; } -function renderGroups(groups, relations) { +function renderArchitecture(groups, relations) { if (!groups.length) return statePanel('empty', 'No supported groups', 'No JavaScript or TypeScript groups were represented inside the current scan bounds.'); - const byId = new Map(groups.map((group) => [group.id, group])); - return `
${groups.map((group) => `
${escapeHtml(group.prefix ?? group.label)}${number(group.fileCount)} files / ${number(group.symbolCount)} symbols${group.changedFileCount ? `${number(group.changedFileCount)} changed` : ''}
`).join('')}
${relations.length ? `
    ${relations.map((relation) => { - const source = byId.get(relation.sourceGroupId); - const target = byId.get(relation.targetGroupId); - return source && target ? `
  1. ${escapeHtml(source.prefix)}to${escapeHtml(target.prefix)}${number(relation.count)} relationships
  2. ` : ''; - }).join('')}
` : '

No cross-group relationships inside the current bounds.

'}`; + return `
${number(groups.length)} groups${number(relations.length)} connections
`; } function renderFocus(nodes, edges, focus = {}) { @@ -178,7 +176,34 @@ function renderFocus(nodes, edges, focus = {}) { function renderMapOutline(items, focused) { if (!items.length) return '

No records in the current outline.

'; - return `
    ${items.map((item, index) => `
  1. `).join('')}
`; + return `
    ${items.map((item, index) => `
  1. `).join('')}
`; +} + +export function sourceMapGraphData(report = {}, state = {}) { + const hasFocus = Boolean(state.query || state.group || state.startName || state.changedLocator); + if (hasFocus) return { + nodes: arrayValue(report.focus?.nodes), + edges: arrayValue(report.focus?.edges) + }; + return { + nodes: arrayValue(report.orientation?.groups).map((group) => ({ + ...group, + kind: 'repository-group', + label: group.prefix ?? group.label + })), + edges: arrayValue(report.orientation?.relations ?? report.orientation?.groupRelations).map((relation, index) => ({ + id: relation.id ?? `group_relation_${index}`, + kind: 'group-dependency', + fromNodeId: relation.sourceGroupId, + toNodeId: relation.targetGroupId, + count: number(relation.count) + })) + }; +} + +function mapStateFromRoot(root) { + const form = root.querySelector('#source-graph-form'); + return form ? mapStateFromForm(form) : normalizeMapState(); } function renderSelection(item) { diff --git a/apps/web/styles.css b/apps/web/styles.css index 2139848d..10c67abf 100644 --- a/apps/web/styles.css +++ b/apps/web/styles.css @@ -394,7 +394,10 @@ th{color:var(--slate);font-size:12px;text-transform:uppercase;letter-spacing:.08 .source-map-canvas-wrap canvas{display:block;width:100%;height:100%;cursor:grab} .source-map-canvas-wrap canvas:active{cursor:grabbing} .map-focus-list,.source-map-outline{margin:0;padding:0;list-style:none;border-top:1px solid var(--color-rule)} -.map-focus-list button,.source-map-outline button{width:100%;min-height:44px;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:var(--space-xs);align-items:center;padding:var(--space-2xs);border:0;border-bottom:1px solid var(--color-rule);border-radius:0;background:transparent;color:var(--color-ink);text-align:left;cursor:pointer} +.map-focus-list button,.source-map-outline button{width:100%;min-height:44px;display:block;padding:var(--space-2xs);border:0;border-bottom:1px solid var(--color-rule);border-radius:0;background:transparent;color:var(--color-ink);text-align:left;cursor:pointer} +.source-map-outline-copy{min-width:0;display:grid;gap:2px} +.source-map-outline-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.source-map-outline-locator{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--color-muted);font-size:12px} .map-focus-list button:hover,.source-map-outline button:hover{background:var(--color-panel-muted)} .map-focus-list button span,.source-map-outline button span{color:var(--color-muted);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .source-map-outline button[aria-current="true"]{box-shadow:inset 2px 0 var(--color-accent)} diff --git a/tests/web-shell.test.mjs b/tests/web-shell.test.mjs index b93d3c6e..f1db4b06 100644 --- a/tests/web-shell.test.mjs +++ b/tests/web-shell.test.mjs @@ -152,9 +152,9 @@ test('repository search distinguishes a bounded failure from valid zero results' }); test('workbench navigation has five desktop and four mobile destinations', () => { - assert.deepEqual(PRIMARY_NAV.map((item) => item.label), ['Overview', 'Map', 'Memory', 'Handoffs', 'Settings']); + assert.deepEqual(PRIMARY_NAV.map((item) => item.label), ['Start', 'Explore code', 'Review memory', 'Prepare handoff', 'Settings']); assert.deepEqual(PRIMARY_NAV.map((item) => item.path), ['/', '/map', '/memory', '/handoffs', '/settings']); - assert.deepEqual(MOBILE_NAV.map((item) => item.label), ['Overview', 'Map', 'Memory', 'Handoffs']); + assert.deepEqual(MOBILE_NAV.map((item) => item.label), ['Start', 'Explore code', 'Review memory', 'Prepare handoff']); assert.equal(navigationItemsFor('rail'), PRIMARY_NAV); assert.equal(navigationItemsFor('bottom'), MOBILE_NAV); }); @@ -483,6 +483,7 @@ test('memory route renders real temporal fact fields and computed token number', assert.equal(deliveryChangeLabel(42),'42% reduction'); assert.equal(deliveryChangeLabel(0),'No reduction'); assert.equal(deliveryChangeLabel(-363),'363% overhead'); + assert.equal(deliveryChangeLabel(100,0),'Not measured'); const provider = new SQLiteMemoryProvider({ filename: ':memory:', clock: () => '2026-06-26T10:00:00.000Z' }); t.after(() => provider.close()); await provider.put({ @@ -624,6 +625,28 @@ test('memory route renders real temporal fact fields and computed token number', assert.match(html, new RegExp(`
${profile.contextBudget.estimatedDeliveryTokens}
`)); }); +test('memory route never reports savings when the baseline is absent', () => { + const html = renderMemoryCockpit({ + workspaceId:'ws_local', + summary:{activeFactCount:0,pendingProposalCount:0}, + facts:[], proposalQueue:[], + savings:{beforeDeliveryTokens:0,afterDeliveryTokens:0,tokensSaved:0,percent:100}, + mcpStats:{available:true,callCount:0,deliveredTokens:0,baselineTokens:0,tokensSaved:0,byTool:[]} + }); + assert.match(html,/Not measured/); + assert.doesNotMatch(html,/100% reduction/); +}); + +test('Settings presents real local controls and boundaries instead of design swatches', async () => { + const app = await readFile(new URL('../apps/web/app.js', import.meta.url), 'utf8'); + const settings = app.match(/function renderSettings\(\) \{([\s\S]*?)\n\}/)?.[1] ?? ''; + assert.match(settings, /

Settings<\/h1>/); + assert.match(settings, /Local storage/); + assert.match(settings, /Scan limits/); + assert.match(settings, /Privacy/); + assert.doesNotMatch(settings, /token-grid|class="swatch"|--signal/); +}); + test('memory graph route renders governed graph canvas controls', async () => { const report = { schemaVersion: '1.0.0', @@ -911,7 +934,7 @@ test('context pack user flow exposes artifact actions and safe harness commands' selectedFiles:['workspace://AGENTS.md'], requiredReadFiles:['workspace://AGENTS.md','workspace://apps/web/app.js'], excludedFiles:['workspace://.cursor/rules/fabric.mdc'], - command:"npm --silent run oaf -- measure context-pack --read-only --root . --from 'codex,cursor' --objective 'Ship user'\"'\"'s change safely' --step 'select useful context' --target codex --changed 'apps/web/app.js' --format json" + command:"recall measure context-pack --read-only --root . --from 'codex,cursor' --objective 'Ship user'\"'\"'s change safely' --step 'select useful context' --target codex --changed 'apps/web/app.js' --format json" }); const tokenSaverHtml=renderContextPackTokenSaverSummary(model); assert.match(tokenSaverHtml,/Token Saver/); @@ -1049,29 +1072,29 @@ test('context pack user flow exposes artifact actions and safe harness commands' assert.equal(summaryIndex,receiveIndex+1); assert.match(model.commands[verifyIndex].command,/context registry status --read-only --format json/); const receiveCommand=model.commands[receiveIndex].command; - assert.equal(receiveCommand,'npm run oaf -- context receive --read-only --root . --target codex --format json'); + assert.equal(receiveCommand,'recall context receive --read-only --root . --target codex --format json'); assert.doesNotMatch(receiveCommand,/--objective|--step|--write|--pin|--out|--home|--config|--stdio/); const summaryCommand=model.commands[summaryIndex].command; - assert.equal(summaryCommand,'npm run oaf -- context receive --read-only --root . --target codex --format summary'); + assert.equal(summaryCommand,'recall context receive --read-only --root . --target codex --format summary'); assert.doesNotMatch(summaryCommand,/--objective|--step|--write|--pin|--out|--home|--config|--stdio/); - assert.equal(model.commands.some((item)=>item.command==='npm --silent run oaf -- mcp resources --read-only --stdio'),true); + assert.equal(model.commands.some((item)=>item.command==='recall mcp resources --read-only --stdio'),true); const preflightCommand=model.commands.find((item)=>item.label==='Test local handoff')?.command ?? ''; - assert.match(preflightCommand,/^npm --silent run oaf -- context handoff --read-only /); + assert.match(preflightCommand,/^recall context handoff --read-only /); assert.match(preflightCommand,/--from 'codex,cursor'/); assert.match(preflightCommand,/--target codex --changed 'apps\/web\/app\.js' --memory-config oaf\.memory\.json --format json/); assert.doesNotMatch(preflightCommand,/--write|--pin|--out|install/); const preflightSummaryCommand=model.commands.find((item)=>item.label==='Test handoff summary')?.command ?? ''; - assert.match(preflightSummaryCommand,/^npm --silent run oaf -- context handoff --read-only /); + assert.match(preflightSummaryCommand,/^recall context handoff --read-only /); assert.match(preflightSummaryCommand,/--from 'codex,cursor'/); assert.match(preflightSummaryCommand,/--target codex --changed 'apps\/web\/app\.js' --memory-config oaf\.memory\.json --format summary/); assert.doesNotMatch(preflightSummaryCommand,/--write|--pin|--out|install/); const impactCommand=model.commands.find((item)=>item.label==='Copy impact command')?.command ?? ''; - assert.match(impactCommand,/^npm --silent run oaf -- measure context-pack --read-only /); + assert.match(impactCommand,/^recall measure context-pack --read-only /); assert.match(impactCommand,/--from 'codex,cursor'/); assert.match(impactCommand,/--target codex --changed 'apps\/web\/app\.js' --format json/); assert.doesNotMatch(impactCommand,/--write|--pin|--out|install/); - assert.equal(model.commands.some((item)=>item.command==='npm run oaf -- mcp resources --read-only --uri oaf://workspace/ws_local/context-pack/registry/current --format json'),true); - assert.equal(model.commands.some((item)=>item.command==='npm run oaf -- mcp resources --read-only --uri oaf://workspace/ws_local/context-pack/use-plan/current --format json'),true); + assert.equal(model.commands.some((item)=>item.command==='recall mcp resources --read-only --uri oaf://workspace/ws_local/context-pack/registry/current --format json'),true); + assert.equal(model.commands.some((item)=>item.command==='recall mcp resources --read-only --uri oaf://workspace/ws_local/context-pack/use-plan/current --format json'),true); assert.equal(model.commands.some((item)=>/harness setup plan --client codex --server oaf --dry-run --format json/.test(item.command)),true); assert.equal(model.commands.some((item)=>item.command.includes('mcp resources --read-only')),true); assert.equal(model.commands.some((item)=>item.command.includes('context-pack/registry/current')),true); @@ -1196,10 +1219,10 @@ test('pinned handoff status model gates receive commands by registry verificatio assert.equal(ready.statusLabel,'verified'); assert.equal(ready.targetLabel,'Codex'); assert.equal(ready.primaryCommand.label,'Receive pinned pack'); - assert.equal(ready.primaryCommand.command,'npm run oaf -- context receive --read-only --root . --target codex --format json'); + assert.equal(ready.primaryCommand.command,'recall context receive --read-only --root . --target codex --format json'); assert.equal(canReceivePinnedHandoff(ready.state),true); assert.equal(ready.commands.some((item)=>item.label==='Receive pinned pack'),true); - assert.equal(ready.commands.find((item)=>item.label==='Receive summary')?.command,'npm run oaf -- context receive --read-only --root . --target codex --format summary'); + assert.equal(ready.commands.find((item)=>item.label==='Receive summary')?.command,'recall context receive --read-only --root . --target codex --format summary'); assert.equal(ready.commands.some((item)=>item.label==='Read pinned use plan'),true); assert.equal(ready.facts.some(([key,value])=>key==='Use plan'&&value==='available'),true); @@ -1302,10 +1325,10 @@ test('first-use readiness proves local handoff gates before recommending use',() assert.equal(handoffStatus.safeguards.configWrites,false); assert.equal(handoffStatus.safeguards.externalWritesEnabled,false); assert.equal(handoffStatus.safeguards.externalAdaptersEnabled,0); - assert.match(handoffStatus.preflightCommand,/^npm --silent run oaf -- context handoff --read-only /); + assert.match(handoffStatus.preflightCommand,/^recall context handoff --read-only /); assert.doesNotMatch(handoffStatus.preflightCommand,/--memory-config/); assert.doesNotMatch(handoffStatus.preflightCommand,/--write|--pin|--out|install/); - assert.match(handoffStatus.preflightSummaryCommand,/^npm --silent run oaf -- context handoff --read-only /); + assert.match(handoffStatus.preflightSummaryCommand,/^recall context handoff --read-only /); assert.match(handoffStatus.preflightSummaryCommand,/--format summary/); assert.doesNotMatch(handoffStatus.preflightSummaryCommand,/--memory-config/); assert.doesNotMatch(handoffStatus.preflightSummaryCommand,/--write|--pin|--out|install/); @@ -1452,8 +1475,8 @@ test('agents tools exposes dry-run harness setup planning without install afford assert.equal(model.client,'Cursor'); assert.equal(model.configRef,'home://.cursor/mcp.json'); assert.equal(model.operation,'add oaf with read-only OAF MCP stdio resource bridge'); - assert.equal(model.command,'npm run oaf -- harness setup plan --client cursor --server oaf --dry-run --format json'); - assert.equal(model.bridgeCommand,'npm --silent run oaf -- mcp resources --read-only --stdio'); + assert.equal(model.command,'recall harness setup plan --client cursor --server oaf --dry-run --format json'); + assert.equal(model.bridgeCommand,'recall mcp resources --read-only --stdio'); assert.equal(model.manualConfigSnippet.configRef,'home://.cursor/mcp.json'); assert.match(model.manualConfigSnippet.content,/mcpServers/); assert.deepEqual(model.safeguards.find(([label])=>label==='External writes'),['External writes','disabled']); diff --git a/tests/web-source-map.test.mjs b/tests/web-source-map.test.mjs index 9167b689..073b0075 100644 --- a/tests/web-source-map.test.mjs +++ b/tests/web-source-map.test.mjs @@ -8,7 +8,7 @@ import { serializeMapUrl } from '../apps/web/source-map-view.js'; import { layoutFocusedGraph } from '../apps/web/graph-layout-worker.js'; -import { graphLabelPlacement } from '../apps/web/graph-viewport.js'; +import { graphLabelBoxesOverlap, graphLabelPlacement, visibleGraphLabelIds } from '../apps/web/graph-viewport.js'; test('Map query state round-trips through the URL', () => { const state = parseMapUrl('http://127.0.0.1:4318/map?query=copytrading&group=apps%2Fterminal&start=execute&changed=src%2Ftrade.ts&depth=3&limit=24'); @@ -49,17 +49,18 @@ test('Map partial state keeps useful results and names omitted coverage', () => assert.doesNotMatch(html, /preview ready/iu); }); -test('Map renders architecture before focus and keeps an accessible outline', () => { +test('Map renders architecture graph before focus and keeps an accessible outline', () => { const html = renderSourceMap({ state: parseMapUrl('/map'), report: mapPreviewFixture() }); - assert.match(html, /Repository groups/); + assert.match(html, /Repository architecture/); + assert.match(html, /id="source-map-canvas"/); + assert.match(html, /Interactive repository architecture graph/); assert.match(html, /aria-label="Source map outline"/); - assert.doesNotMatch(html, / { const html = renderSourceMap({ state: parseMapUrl('/map?query=router'), report: mapPreviewFixture() }); assert.doesNotMatch(html, /class="eyebrow"/); - assert.match(html, /class="button primary" type="submit">Run map/); + assert.match(html, /class="button primary" type="submit">Search code/); assert.match(html, /class="button quiet" type="button" data-action="refresh-source-map">Refresh scan/); assert.match(html, /data-graph-action="fit">Fit selection/); assert.match(html, /class="button quiet" type="button" data-graph-action="reset">Reset view/); @@ -91,6 +92,34 @@ test('graph labels stay inside the visible canvas edge', () => { assert.deepEqual(graphLabelPlacement({ pointX: 80, labelWidth: 80, scale: 1, panX: 0, viewportWidth: 640 }), { align: 'left', offset: 10 }); }); +test('graph label collision check preserves breathing room', () => { + const placed = [{ left: 20, right: 120, top: 40, bottom: 54 }]; + assert.equal(graphLabelBoxesOverlap({ left: 80, right: 160, top: 46, bottom: 60 }, placed), true); + assert.equal(graphLabelBoxesOverlap({ left: 80, right: 160, top: 70, bottom: 84 }, placed), false); +}); + +test('graph labels use a bounded progressive disclosure policy', () => { + const nodes = Array.from({ length: 70 }, (_, index) => ({ id: `node_${index}`, label: `node ${index}` })); + const edges = [ + { fromNodeId: 'node_0', toNodeId: 'node_1' }, + { fromNodeId: 'node_2', toNodeId: 'node_0' }, + { fromNodeId: 'node_3', toNodeId: 'node_4' } + ]; + const visible = visibleGraphLabelIds({ nodes, edges, selectedNodeId: 'node_0', hoveredNodeId: 'node_3', scale: 1, budget: 8 }); + assert.deepEqual([...visible].sort(), ['node_0', 'node_1', 'node_2', 'node_3', 'node_4', 'node_5', 'node_6', 'node_7'].sort()); + assert.equal(visible.size, 8); +}); + +test('source map outline separates labels from locators for readable overflow', async () => { + const html = renderSourceMap({ state: parseMapUrl('/map?query=router'), report: mapPreviewFixture() }); + const css = await readFile(new URL('../apps/web/styles.css', import.meta.url), 'utf8'); + assert.match(html, /class="source-map-outline-copy"/); + assert.match(html, /class="source-map-outline-label"/); + assert.match(html, /class="source-map-outline-locator"/); + assert.match(css, /\.source-map-outline-copy\{[^}]*min-width:0/); + assert.match(css, /\.source-map-outline-locator\{[^}]*overflow:hidden[^}]*text-overflow:ellipsis/); +}); + test('focused Map renders a real canvas with outline parity', () => { const report = mapPreviewFixture(); const html = renderSourceMap({ state: parseMapUrl('/map?query=router'), report }); @@ -118,8 +147,11 @@ function mapPreviewFixture({ coverage } = {}) { }, coverage: resolvedCoverage, orientation: { - groups: [{ id: 'group_apps_web', label: 'web', prefix: 'apps/web', fileCount: 1, symbolCount: 1, changedFileCount: 0, coverageStatus: resolvedCoverage.status, entryPoints: [node] }], - groupRelations: [], + groups: [ + { id: 'group_apps_web', label: 'web', prefix: 'apps/web', fileCount: 1, symbolCount: 1, changedFileCount: 0, coverageStatus: resolvedCoverage.status, entryPoints: [node] }, + { id: 'group_packages_core', label: 'core', prefix: 'packages/core', fileCount: 2, symbolCount: 4, changedFileCount: 0, coverageStatus: resolvedCoverage.status, entryPoints: [] } + ], + groupRelations: [{ sourceGroupId: 'group_apps_web', targetGroupId: 'group_packages_core', count: 2 }], omittedGroupCount: 0, omittedRelationCount: 0 }, From 5f7a5d59841b9f985e7d28349ba37f7b1cf488ca Mon Sep 17 00:00:00 2001 From: rebel Date: Thu, 16 Jul 2026 11:50:46 +0530 Subject: [PATCH 025/276] feat: add structural MCP tools and persistent index --- apps/cli/help.mjs | 16 +- apps/cli/oaf.mjs | 407 +++++++++++++++++- packages/source-graph/src/index-store.mjs | 349 +++++++++++++++ packages/source-graph/src/index.mjs | 17 + packages/source-graph/src/intelligence.mjs | 234 ++++++++++ .../context-candidate-ast-code/src/index.mjs | 76 +++- tests/cli-graph-index.test.mjs | 86 ++++ tests/cli.test.mjs | 4 +- tests/mcp-code-intelligence.test.mjs | 146 +++++++ tests/skill-catalog.test.mjs | 2 +- tests/source-graph-index-store.test.mjs | 94 ++++ 11 files changed, 1421 insertions(+), 10 deletions(-) create mode 100644 packages/source-graph/src/index-store.mjs create mode 100644 packages/source-graph/src/intelligence.mjs create mode 100644 tests/cli-graph-index.test.mjs create mode 100644 tests/mcp-code-intelligence.test.mjs create mode 100644 tests/source-graph-index-store.test.mjs diff --git a/apps/cli/help.mjs b/apps/cli/help.mjs index 7f74275c..8894652e 100644 --- a/apps/cli/help.mjs +++ b/apps/cli/help.mjs @@ -11,7 +11,7 @@ function renderHelpText(text) { if (command === 'oaf') return text; return text .replace( - /\boaf (?=(status|setup|verify|doctor|connect|disconnect|task|demo|serve|check|eval|manifest|map|semantic|handoff|token-saver|context|loop|skill|measure|benchmark|bench|memory|mcp|harness|hook|version)\b)/g, + /\boaf (?=(status|setup|verify|doctor|connect|disconnect|task|demo|serve|check|eval|manifest|map|semantic|handoff|token-saver|context|graph|loop|skill|measure|benchmark|bench|memory|mcp|harness|hook|version)\b)/g, `${command} ` ); } @@ -68,6 +68,9 @@ Usage: oaf graph search --root . --query "route registration hooks" --format summary oaf graph trace --root . --symbol runAuthWorkflow --direction outbound --format summary oaf graph impact --root . --changed src/auth.ts --format summary + oaf graph index --status --root . --format summary + oaf graph index --write --root . --format json + oaf graph index --refresh --watch --root . --format summary oaf loop plan --read-only --root . --objective "Ship safely" --stop-condition "focused tests pass" --validation "node --test tests/web-shell.test.mjs" --format json oaf loop observe --root . --plan loop-plan.json --execute-commands --format json oaf loop verify --root . --plan loop-plan.json --worktree ../isolated-worktree --sqlite .local/memory.sqlite --execute-commands --format json @@ -240,11 +243,16 @@ Usage: oaf graph trace --root . --symbol runAuthWorkflow --direction outbound --format summary oaf graph impact --root . --changed src/auth.ts --format summary oaf graph impact --root . --changed-from-git --format json + oaf graph index --status --root . --format summary + oaf graph index --write --root . --format json + oaf graph index --refresh --root . --format json + oaf graph index --refresh --watch --root . --format summary Graph commands build a bounded local JS/TS source graph and return locator-only -stats, search, trace, or changed-file impact reports. They are read-only by default: -no files are written, no model calls are made, no network calls are made, and -raw source bodies are not included.`], +stats, search, trace, or changed-file impact reports. Index writes are explicit. +The index stores structural metadata under .local/source-graph by default and +never stores raw source bodies. MCP reads the index but never builds or refreshes it. +No graph command makes model or network calls.`], ['context handoff', `Memory Recall CLI: context handoff Usage: diff --git a/apps/cli/oaf.mjs b/apps/cli/oaf.mjs index bda4f0c4..de964a01 100755 --- a/apps/cli/oaf.mjs +++ b/apps/cli/oaf.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node import { createHash, randomUUID } from 'node:crypto'; import { execFileSync, spawn } from 'node:child_process'; -import { constants as fsConstants, createReadStream, existsSync, realpathSync } from 'node:fs'; +import { constants as fsConstants, createReadStream, existsSync, realpathSync, watch as watchFs } from 'node:fs'; import { appendFile, lstat, mkdir, mkdtemp, open, readFile, readdir, realpath, rename as renameFile, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; @@ -73,6 +73,17 @@ import { loadReviewedToolCatalog } from '../../packages/tool-registry/src/index. import { DEFAULT_SOURCE_GRAPH_PREVIEW_MAX_FILES, DEFAULT_SOURCE_GRAPH_PREVIEW_MAX_FILE_BYTES, + buildArchitectureIntelligence, + buildCodeContextIntelligence, + buildCodeDependenciesIntelligence, + buildCodeRoutesIntelligence, + buildCodeSearchIntelligence, + buildCodeTraceIntelligence, + buildPersistentSourceGraphIndex, + buildSourceGraphIntelligence, + readPersistentSourceGraphIndexStatus, + readSourceGraphIndexStatus, + refreshPersistentSourceGraphIndex, buildSourceGraphPreview } from '../../packages/source-graph/src/index.mjs'; @@ -2876,10 +2887,157 @@ async function graphCommand(values) { if (subcommand === 'search') return graphSearchCommand(rest); if (subcommand === 'trace') return graphTraceCommand(rest); if (subcommand === 'impact') return graphImpactCommand(rest); - console.error('graph requires stats, search, trace, or impact'); + if (subcommand === 'index') return graphIndexCommand(rest); + console.error('graph requires stats, search, trace, impact, or index'); process.exitCode = 2; } +async function graphIndexCommand(values) { + const modes = ['--status', '--write', '--refresh'].filter((flag) => values.includes(flag)); + if (modes.length !== 1) { + console.error('graph index requires --status, --write, or --refresh'); + process.exitCode = 2; + return; + } + const watch = values.includes('--watch'); + if (watch && modes[0] !== '--refresh') { + console.error('graph index --watch requires --refresh'); + process.exitCode = 2; + return; + } + const allowedFlags = new Set(['--status', '--write', '--refresh', '--watch', '--root', '--workspace', '--out', '--format', '--max-files', '--max-file-bytes']); + const valueFlags = new Set(['--root', '--workspace', '--out', '--format', '--max-files', '--max-file-bytes']); + const unsupported = unsupportedFlags(values, allowedFlags, valueFlags); + if (unsupported.length) { + console.error('graph index options are invalid'); + process.exitCode = 2; + return; + } + const format = option(values, '--format') ?? 'summary'; + if (!['json', 'summary'].includes(format)) { + console.error('graph index only supports --format json or summary'); + process.exitCode = 2; + return; + } + const root = option(values, '--root') ?? process.cwd(); + const workspaceId = option(values, '--workspace') ?? 'ws_local'; + const relativePath = option(values, '--out') ?? '.local/source-graph/index.v1.json'; + const indexOptions = { + root, + workspaceId, + relativePath, + maxFiles: strictIntegerOption(values, '--max-files', DEFAULT_SOURCE_GRAPH_PREVIEW_MAX_FILES), + maxFileBytes: strictIntegerOption(values, '--max-file-bytes', DEFAULT_SOURCE_GRAPH_PREVIEW_MAX_FILE_BYTES), + clock: fixedNow + }; + try { + if (modes[0] === '--status') { + const status = await readPersistentSourceGraphIndexStatus(indexOptions); + const report = { command: 'graph index status', ...status, safeguards: graphIndexSafeguards({ localFilesWritten: 0 }) }; + console.log(format === 'json' ? JSON.stringify(report, null, 2) : renderGraphIndexSummary(report)); + return; + } + const execute = async () => { + const result = modes[0] === '--write' + ? await buildPersistentSourceGraphIndex(indexOptions) + : await refreshPersistentSourceGraphIndex(indexOptions); + const report = compactGraphIndexReport(result); + console.log(format === 'json' ? JSON.stringify(report, null, 2) : renderGraphIndexSummary(report)); + return report; + }; + await execute(); + if (watch) await watchGraphIndex({ root: path.resolve(root), execute }); + } catch (error) { + console.error(error?.code === 'source_graph_index_invalid' ? 'source_graph_index_invalid' : error.message); + process.exitCode = 2; + } +} + +function compactGraphIndexReport(result) { + return { + schemaVersion: result.schemaVersion, + command: 'graph index', + status: result.status, + indexLocator: result.indexLocator, + graph: { + fingerprint: result.graph.graphFingerprint, + nodeCount: result.graph.nodes.length, + edgeCount: result.graph.edges.length + }, + measurements: result.measurements, + safeguards: graphIndexSafeguards({ localFilesWritten: 1 }) + }; +} + +function graphIndexSafeguards({ localFilesWritten }) { + return { + readOnly: localFilesWritten === 0, + localFilesWritten, + networkCalls: 0, + modelCalls: 0, + externalWritesEnabled: false, + rawSourceBodiesIncluded: false, + absoluteFilesystemLocationsIncluded: false + }; +} + +function renderGraphIndexSummary(report) { + const measurements = report.measurements ?? {}; + return [ + '# Source Graph Index', + `Status: ${report.status}`, + `Index: ${report.indexLocator}`, + `Files: ${report.fileCount ?? (measurements.parsedFileCount ?? 0) + (measurements.reusedFileCount ?? 0)}`, + `Nodes: ${report.nodeCount ?? report.graph?.nodeCount ?? 0}`, + `Edges: ${report.edgeCount ?? report.graph?.edgeCount ?? 0}`, + `Parsed: ${measurements.parsedFileCount ?? 0}`, + `Reused: ${measurements.reusedFileCount ?? 0}`, + `Changed: ${measurements.changedFileCount ?? 0}`, + `Added: ${measurements.addedFileCount ?? 0}`, + `Deleted: ${measurements.deletedFileCount ?? 0}`, + `Raw source bodies stored: no` + ].join('\n'); +} + +async function watchGraphIndex({ root, execute }) { + let timer = null; + let running = false; + let pending = false; + const refresh = async () => { + if (running) { + pending = true; + return; + } + running = true; + try { + await execute(); + } finally { + running = false; + if (pending) { + pending = false; + await refresh(); + } + } + }; + const watcher = watchFs(root, { recursive: true }, (_event, filename) => { + const relative = String(filename ?? '').replaceAll('\\', '/'); + if (!relative || relative.startsWith('.local/source-graph/') || !/(?:\.(?:[cm]?[jt]sx?)|(?:^|\/)\.gitignore|(?:^|\/)\.recallignore)$/u.test(relative)) return; + if (timer) clearTimeout(timer); + timer = setTimeout(() => void refresh(), 250); + }); + await new Promise((resolve) => { + const stop = () => { + if (timer) clearTimeout(timer); + watcher.close(); + process.off('SIGINT', stop); + process.off('SIGTERM', stop); + resolve(); + }; + process.on('SIGINT', stop); + process.on('SIGTERM', stop); + }); +} + async function recallMapCommand(values) { const options = parseRecallMapOptions(values); if (!options) return; @@ -6356,7 +6514,202 @@ async function buildMcpRealisticSavingsBenchmark({ values, root, workspaceId, ge } function buildMcpTokenSaverTools({ values, root, workspaceId, generatedAt, statsRecorder = null, cursorStore = null }) { + let intelligencePromise = null; + const loadIntelligence = () => { + intelligencePromise ??= buildSourceGraphIntelligence({ + root, + workspaceId, + maxFiles: DEFAULT_SOURCE_GRAPH_PREVIEW_MAX_FILES, + maxFileBytes: DEFAULT_SOURCE_GRAPH_PREVIEW_MAX_FILE_BYTES, + clock: fixedNow + }); + return intelligencePromise; + }; return [ + { + name: 'repo.architecture', + description: 'Return bounded architecture groups, entry points, and structural hotspots from local source metadata.', + operation: 'repo.architecture', + sideEffectClass: 'read-only', + inputSchema: { + type: 'object', + additionalProperties: false, + properties: { + limit: { type: 'integer', minimum: 1, maximum: 50, default: 20 } + } + }, + handler: async ({ arguments: args }) => { + const input = mcpMapArguments(args, ['limit'], 'repo.architecture'); + const limit = mcpStrictBoundedInteger(input.limit, 20, { min: 1, max: 50, name: 'limit' }); + const intelligence = await loadIntelligence(); + return mcpToolJsonResult(mcpStructuralPayload({ + command: 'repo.architecture', + workspaceId, + generatedAt: fixedNow(), + data: { ...buildArchitectureIntelligence(intelligence.graph, { limit }), source: intelligence.source } + })); + } + }, + { + name: 'repo.index_status', + description: 'Report whether the local persistent source index exists; this tool never builds or refreshes it.', + operation: 'repo.index_status', + sideEffectClass: 'read-only', + inputSchema: { type: 'object', additionalProperties: false, properties: {} }, + handler: async ({ arguments: args }) => { + mcpMapArguments(args, [], 'repo.index_status'); + return mcpToolJsonResult(mcpStructuralPayload({ + command: 'repo.index_status', + workspaceId, + generatedAt: fixedNow(), + data: await readSourceGraphIndexStatus({ root }) + })); + } + }, + { + name: 'code.search', + description: 'Search bounded symbols, files, modules, and relationships using local structural metadata.', + operation: 'code.search', + sideEffectClass: 'read-only', + inputSchema: { + type: 'object', + additionalProperties: false, + required: ['query'], + properties: { + query: { type: 'string', minLength: 1, maxLength: 512 }, + nodeKinds: { type: 'array', maxItems: 4, items: { type: 'string', enum: ['file', 'chunk', 'symbol', 'module'] } }, + edgeKinds: { type: 'array', maxItems: 6, items: { type: 'string', enum: ['contains', 'defined_in', 'imports', 'exports', 'references', 'calls'] } }, + locatorPrefix: { type: 'string', minLength: 1, maxLength: 512 }, + limit: { type: 'integer', minimum: 1, maximum: 50, default: 20 }, + offset: { type: 'integer', minimum: 0, maximum: 10000, default: 0 } + } + }, + handler: async ({ arguments: args }) => { + const input = mcpMapArguments(args, ['query', 'nodeKinds', 'edgeKinds', 'locatorPrefix', 'limit', 'offset'], 'code.search'); + const query = mcpStructuralString(input.query, 'code.search query', { required: true, max: 512 }); + const nodeKinds = mcpStructuralKinds(input.nodeKinds, ['file', 'chunk', 'symbol', 'module']); + const edgeKinds = mcpStructuralKinds(input.edgeKinds, ['contains', 'defined_in', 'imports', 'exports', 'references', 'calls']); + const locatorPrefix = mcpStructuralLocatorPrefix(input.locatorPrefix); + const limit = mcpStrictBoundedInteger(input.limit, 20, { min: 1, max: 50, name: 'limit' }); + const offset = mcpStrictBoundedInteger(input.offset, 0, { min: 0, max: 10000, name: 'offset' }); + const intelligence = await loadIntelligence(); + return mcpToolJsonResult(mcpStructuralPayload({ + command: 'code.search', + workspaceId, + generatedAt: fixedNow(), + data: { ...buildCodeSearchIntelligence(intelligence.graph, { query, nodeKinds, edgeKinds, locatorPrefix, limit, offset }), source: intelligence.source } + })); + } + }, + { + name: 'code.context', + description: 'Return one selected symbol with bounded incoming and outgoing structural relationships.', + operation: 'code.context', + sideEffectClass: 'read-only', + inputSchema: { + type: 'object', + additionalProperties: false, + required: ['query'], + properties: { + query: { type: 'string', minLength: 1, maxLength: 512 }, + limit: { type: 'integer', minimum: 1, maximum: 50, default: 20 } + } + }, + handler: async ({ arguments: args }) => { + const input = mcpMapArguments(args, ['query', 'limit'], 'code.context'); + const query = mcpStructuralString(input.query, 'code.context query', { required: true, max: 512 }); + const limit = mcpStrictBoundedInteger(input.limit, 20, { min: 1, max: 50, name: 'limit' }); + const intelligence = await loadIntelligence(); + return mcpToolJsonResult(mcpStructuralPayload({ + command: 'code.context', workspaceId, generatedAt: fixedNow(), + data: { ...buildCodeContextIntelligence(intelligence.graph, { query, limit }), source: intelligence.source } + })); + } + }, + { + name: 'code.trace', + description: 'Trace bounded inbound or outbound call paths from a local symbol.', + operation: 'code.trace', + sideEffectClass: 'read-only', + inputSchema: { + type: 'object', + additionalProperties: false, + required: ['symbol'], + properties: { + symbol: { type: 'string', minLength: 1, maxLength: 240 }, + direction: { type: 'string', enum: ['outbound', 'inbound', 'both'], default: 'outbound' }, + depth: { type: 'integer', enum: [1, 2, 3], default: 2 }, + limit: { type: 'integer', minimum: 1, maximum: 50, default: 20 }, + locatorPrefix: { type: 'string', minLength: 1, maxLength: 512 } + } + }, + handler: async ({ arguments: args }) => { + const input = mcpMapArguments(args, ['symbol', 'direction', 'depth', 'limit', 'locatorPrefix'], 'code.trace'); + const symbol = mcpStructuralString(input.symbol, 'code.trace symbol', { required: true, max: 240 }); + const direction = mcpStructuralDirection(input.direction); + const depth = mcpStrictBoundedInteger(input.depth, 2, { min: 1, max: 3, name: 'depth' }); + const limit = mcpStrictBoundedInteger(input.limit, 20, { min: 1, max: 50, name: 'limit' }); + const locatorPrefix = mcpStructuralLocatorPrefix(input.locatorPrefix); + const intelligence = await loadIntelligence(); + return mcpToolJsonResult(mcpStructuralPayload({ + command: 'code.trace', workspaceId, generatedAt: fixedNow(), + data: { ...buildCodeTraceIntelligence(intelligence.graph, { symbol, direction, depth, limit, locatorPrefix }), source: intelligence.source } + })); + } + }, + { + name: 'code.dependencies', + description: 'Walk a bounded local dependency neighborhood for a file, module, or symbol.', + operation: 'code.dependencies', + sideEffectClass: 'read-only', + inputSchema: { + type: 'object', + additionalProperties: false, + required: ['query'], + properties: { + query: { type: 'string', minLength: 1, maxLength: 512 }, + direction: { type: 'string', enum: ['outbound', 'inbound', 'both'], default: 'outbound' }, + depth: { type: 'integer', enum: [1, 2, 3], default: 2 }, + limit: { type: 'integer', minimum: 1, maximum: 50, default: 20 } + } + }, + handler: async ({ arguments: args }) => { + const input = mcpMapArguments(args, ['query', 'direction', 'depth', 'limit'], 'code.dependencies'); + const query = mcpStructuralString(input.query, 'code.dependencies query', { required: true, max: 512 }); + const direction = mcpStructuralDirection(input.direction); + const depth = mcpStrictBoundedInteger(input.depth, 2, { min: 1, max: 3, name: 'depth' }); + const limit = mcpStrictBoundedInteger(input.limit, 20, { min: 1, max: 50, name: 'limit' }); + const intelligence = await loadIntelligence(); + return mcpToolJsonResult(mcpStructuralPayload({ + command: 'code.dependencies', workspaceId, generatedAt: fixedNow(), + data: { ...buildCodeDependenciesIntelligence(intelligence.graph, { query, direction, depth, limit }), source: intelligence.source } + })); + } + }, + { + name: 'code.routes', + description: 'Discover bounded HTTP route exports with locator-safe static evidence.', + operation: 'code.routes', + sideEffectClass: 'read-only', + inputSchema: { + type: 'object', + additionalProperties: false, + properties: { + query: { type: 'string', maxLength: 240 }, + limit: { type: 'integer', minimum: 1, maximum: 50, default: 20 } + } + }, + handler: async ({ arguments: args }) => { + const input = mcpMapArguments(args, ['query', 'limit'], 'code.routes'); + const query = mcpStructuralString(input.query, 'code.routes query', { required: false, max: 240 }); + const limit = mcpStrictBoundedInteger(input.limit, 20, { min: 1, max: 50, name: 'limit' }); + const intelligence = await loadIntelligence(); + return mcpToolJsonResult(mcpStructuralPayload({ + command: 'code.routes', workspaceId, generatedAt: fixedNow(), + data: { ...buildCodeRoutesIntelligence(intelligence.graph, { query, limit }), source: intelligence.source } + })); + } + }, { name: 'repo.map', description: 'Return a bounded Recall Map of local source coverage, governed memory, and handoff readiness.', @@ -6629,6 +6982,45 @@ function mcpStrictBoundedInteger(value, fallback, { min, max, name }) { return value; } +function mcpStructuralString(value, name, { required, max }) { + if (value === undefined || value === null) { + if (required) throw new Error(`${name} is invalid`); + return ''; + } + if (typeof value !== 'string' || value.length > max || /[\0\r\n]/u.test(value) || MCP_PRIVATE_MATERIAL.test(value)) { + throw new Error(`${name} is invalid`); + } + const normalized = value.trim(); + if (required && !normalized) throw new Error(`${name} is invalid`); + return normalized; +} + +function mcpStructuralKinds(value, allowed) { + if (value === undefined || value === null) return null; + if (!Array.isArray(value) || value.length > allowed.length || value.some((item) => !allowed.includes(item))) { + throw new Error('mcp structural kinds are invalid'); + } + return [...new Set(value)]; +} + +function mcpStructuralDirection(value) { + const direction = value ?? 'outbound'; + if (!['outbound', 'inbound', 'both'].includes(direction)) throw new Error('mcp direction is invalid'); + return direction; +} + +function mcpStructuralLocatorPrefix(value) { + if (value === undefined || value === null || value === '') return null; + if (typeof value !== 'string' || value.length > 512 || /[\0\r\n]/u.test(value) || MCP_PRIVATE_MATERIAL.test(value)) { + throw new Error('mcp locator prefix is invalid'); + } + try { + return normalizeSourceGraphWorkspaceLocator(value, { stripFragment: true }); + } catch { + throw new Error('mcp locator prefix is invalid'); + } +} + async function buildMcpMemoryRecallPayload({ values, root, workspaceId, generatedAt, args }) { const query = mcpRequiredString(args.query, 'query', 240); const scope = mcpSafeScope(args.scope ?? 'workspace'); @@ -7211,6 +7603,17 @@ function mcpNoWritePayload({ command, workspaceId, generatedAt, data }) { }; } +function mcpStructuralPayload({ command, workspaceId, generatedAt, data }) { + const payload = mcpNoWritePayload({ command, workspaceId, generatedAt, data }); + return { + ...payload, + safeguards: { + ...payload.safeguards, + rawSourceBodiesIncluded: false + } + }; +} + async function createMcpStatsRecorder({ values, root, workspaceId, generatedAt }) { const statsPath = await resolveWorkspaceStatsPath(root, option(values, '--stats') ?? '.local/mcp-stats.jsonl', 'mcp server stats', { mustExist: false }); const sessionId = `mcpsess_${randomUUID().replaceAll('-', '').slice(0, 24)}`; diff --git a/packages/source-graph/src/index-store.mjs b/packages/source-graph/src/index-store.mjs new file mode 100644 index 00000000..4faf1ded --- /dev/null +++ b/packages/source-graph/src/index-store.mjs @@ -0,0 +1,349 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { mkdir, open, readFile, realpath, rename, rm, stat } from 'node:fs/promises'; +import path from 'node:path'; +import { + AST_CODE_PARSER_VERSION, + buildJsTsSourceIndexFromShards, + buildSourceGraphFromIndex, + sanitizeSourceGraphPublicOutput, + scanAstCodeWorkspace +} from '../../../providers/native/context-candidate-ast-code/src/index.mjs'; + +export const DEFAULT_SOURCE_GRAPH_INDEX_PATH = '.local/source-graph/index.v1.json'; +const INDEX_SCHEMA_VERSION = '1.0.0'; +const INDEX_FORMAT_VERSION = 'memory-recall-source-graph-index-1'; + +export async function buildPersistentSourceGraphIndex(options = {}) { + const settings = normalizedSettings(options); + const startedAt = Date.now(); + const scan = await scanAstCodeWorkspace({ + root: settings.root, + workspaceId: settings.workspaceId, + maxFiles: settings.maxFiles, + maxFileBytes: settings.maxFileBytes, + clock: settings.clock + }); + const shards = shardsFromScan(scan); + const sourceIndex = buildJsTsSourceIndexFromShards({ + workspaceId: settings.workspaceId, + parserVersion: scan.parserVersion, + shards, + coverage: scan.coverage, + discoveryIdentity: scan.discoveryIdentity, + diagnostics: scan.diagnostics, + clock: settings.clock + }); + const graph = sanitizeSourceGraphPublicOutput(buildSourceGraphFromIndex(sourceIndex, { builtAt: settings.clock() })); + const document = await indexDocument({ settings, scan, shards, graph }); + await writeIndexDocument(settings.indexPath, document); + return indexResult(document, graph, { + parsedFileCount: shards.length, + reusedFileCount: 0, + addedFileCount: shards.length, + changedFileCount: 0, + deletedFileCount: 0, + durationMs: Date.now() - startedAt, + rebuildReason: 'explicit_build' + }); +} + +export async function refreshPersistentSourceGraphIndex(options = {}) { + const settings = normalizedSettings(options); + const startedAt = Date.now(); + let current; + try { + current = await readIndexDocument(settings); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + const built = await buildPersistentSourceGraphIndex(options); + return Object.freeze({ + ...built, + measurements: Object.freeze({ ...built.measurements, rebuildReason: 'index_missing' }) + }); + } + const discovery = await scanAstCodeWorkspace({ + root: settings.root, + workspaceId: settings.workspaceId, + maxFiles: settings.maxFiles, + maxFileBytes: settings.maxFileBytes, + metadataOnly: true, + clock: settings.clock + }); + if (current.discoveryIdentity?.ignoreRuleFingerprint !== discovery.discoveryIdentity?.ignoreRuleFingerprint + || current.settings.maxFiles !== settings.maxFiles + || current.settings.maxFileBytes !== settings.maxFileBytes + || current.parserVersion !== AST_CODE_PARSER_VERSION) { + const rebuilt = await buildPersistentSourceGraphIndex(options); + return Object.freeze({ + ...rebuilt, + measurements: Object.freeze({ ...rebuilt.measurements, rebuildReason: 'index_identity_changed' }) + }); + } + + const previousManifest = new Map(current.manifest.map((entry) => [entry.relativePath, entry])); + const nextManifest = new Map(discovery.discoveredFiles.map((entry) => [entry.relativePath, entry])); + const added = []; + const changed = []; + const deleted = [...previousManifest.keys()].filter((relativePath) => !nextManifest.has(relativePath)); + for (const [relativePath, entry] of nextManifest) { + const previous = previousManifest.get(relativePath); + if (!previous) added.push(relativePath); + else if (previous.size !== entry.size || previous.mtimeMs !== entry.mtimeMs) changed.push(relativePath); + } + const parsedPaths = [...added, ...changed].sort(); + if (!parsedPaths.length && !deleted.length) { + return indexResult(current, Object.freeze(current.graph), { + parsedFileCount: 0, + reusedFileCount: current.manifest.length, + addedFileCount: 0, + changedFileCount: 0, + deletedFileCount: 0, + durationMs: Date.now() - startedAt, + rebuildReason: null + }); + } + + const parsedScan = parsedPaths.length + ? await scanAstCodeWorkspace({ + root: settings.root, + workspaceId: settings.workspaceId, + maxFiles: Math.min(settings.maxFiles, parsedPaths.length), + maxFileBytes: settings.maxFileBytes, + onlyIncludes: parsedPaths, + clock: settings.clock + }) + : { chunks: [], fileOutlines: [] }; + const shardsByPath = new Map(current.shards.map((shard) => [relativePathFromLocator(shard.locator), shard])); + for (const relativePath of [...deleted, ...changed]) shardsByPath.delete(relativePath); + for (const shard of shardsFromScan(parsedScan)) shardsByPath.set(relativePathFromLocator(shard.locator), shard); + const shards = [...shardsByPath.values()].sort((left, right) => left.locator.localeCompare(right.locator)); + const sourceIndex = buildJsTsSourceIndexFromShards({ + workspaceId: settings.workspaceId, + shards, + coverage: discovery.coverage, + discoveryIdentity: discovery.discoveryIdentity, + diagnostics: discovery.diagnostics, + clock: settings.clock + }); + const graph = sanitizeSourceGraphPublicOutput(buildSourceGraphFromIndex(sourceIndex, { builtAt: settings.clock() })); + const scan = { + parserVersion: AST_CODE_PARSER_VERSION, + discoveredFiles: discovery.discoveredFiles, + discoveryIdentity: discovery.discoveryIdentity, + coverage: sourceIndex.coverage, + diagnostics: discovery.diagnostics + }; + const document = await indexDocument({ settings, scan, shards, graph }); + await writeIndexDocument(settings.indexPath, document); + return indexResult(document, graph, { + parsedFileCount: parsedPaths.length, + reusedFileCount: Math.max(0, shards.length - parsedPaths.length), + addedFileCount: added.length, + changedFileCount: changed.length, + deletedFileCount: deleted.length, + durationMs: Date.now() - startedAt, + rebuildReason: null + }); +} + +export async function loadPersistentSourceGraphIndex(options = {}) { + const settings = normalizedSettings(options); + const document = await readIndexDocument(settings); + const freshness = await indexFreshness(settings, document); + const graph = sanitizeSourceGraphPublicOutput(document.graph); + if (!graph.envelopeValid) throw indexInvalid(); + return Object.freeze({ + graph, + source: Object.freeze({ + kind: 'persistent-index', + freshness, + persisted: true, + indexLocator: workspaceIndexLocator(settings.relativePath), + generatedAt: document.generatedAt + }) + }); +} + +export async function readPersistentSourceGraphIndexStatus(options = {}) { + const settings = normalizedSettings(options); + try { + const details = await stat(settings.indexPath); + const document = await readIndexDocument(settings); + const freshness = await indexFreshness(settings, document); + return Object.freeze({ + schemaVersion: INDEX_SCHEMA_VERSION, + status: freshness === 'current' ? 'ready' : 'stale', + indexLocator: workspaceIndexLocator(settings.relativePath), + persisted: true, + bytes: details.size, + fileCount: document.manifest.length, + nodeCount: document.graph.nodes.length, + edgeCount: document.graph.edges.length, + generatedAt: document.generatedAt, + parserVersion: document.parserVersion + }); + } catch (error) { + if (error?.code === 'ENOENT') { + return Object.freeze({ schemaVersion: INDEX_SCHEMA_VERSION, status: 'not-built', indexLocator: workspaceIndexLocator(settings.relativePath), persisted: false, bytes: 0, fileCount: 0, nodeCount: 0, edgeCount: 0 }); + } + return Object.freeze({ schemaVersion: INDEX_SCHEMA_VERSION, status: 'invalid', indexLocator: workspaceIndexLocator(settings.relativePath), persisted: false, bytes: 0, fileCount: 0, nodeCount: 0, edgeCount: 0, reasonCode: 'source_graph_index_invalid' }); + } +} + +async function indexFreshness(settings, document) { + const discovery = await scanAstCodeWorkspace({ + root: settings.root, + workspaceId: settings.workspaceId, + maxFiles: settings.maxFiles, + maxFileBytes: settings.maxFileBytes, + metadataOnly: true, + clock: settings.clock + }); + if (document.discoveryIdentity?.ignoreRuleFingerprint !== discovery.discoveryIdentity?.ignoreRuleFingerprint) return 'stale'; + if (document.manifest.length !== discovery.discoveredFiles.length) return 'stale'; + const previous = new Map(document.manifest.map((entry) => [entry.relativePath, entry])); + for (const entry of discovery.discoveredFiles) { + const stored = previous.get(entry.relativePath); + if (!stored || stored.size !== entry.size || stored.mtimeMs !== entry.mtimeMs) return 'stale'; + } + return 'current'; +} + +function normalizedSettings({ + root, + workspaceId = 'ws_local', + relativePath = DEFAULT_SOURCE_GRAPH_INDEX_PATH, + maxFiles = 1000, + maxFileBytes = 512 * 1024, + clock = () => new Date().toISOString() +} = {}) { + if (typeof root !== 'string' || !root) throw new Error('source_graph_index_root_required'); + if (typeof relativePath !== 'string' || !relativePath || path.isAbsolute(relativePath) || relativePath.split(/[\\/]/u).includes('..')) throw new Error('source_graph_index_path_invalid'); + if (!Number.isInteger(maxFiles) || maxFiles < 1 || maxFiles > 100_000) throw new Error('source_graph_index_max_files_invalid'); + if (!Number.isInteger(maxFileBytes) || maxFileBytes < 1024 || maxFileBytes > 1024 * 1024) throw new Error('source_graph_index_max_file_bytes_invalid'); + const resolvedRoot = path.resolve(root); + const indexPath = path.resolve(resolvedRoot, relativePath); + if (indexPath !== resolvedRoot && !indexPath.startsWith(`${resolvedRoot}${path.sep}`)) throw new Error('source_graph_index_path_invalid'); + return Object.freeze({ root: resolvedRoot, workspaceId, relativePath: relativePath.replaceAll('\\', '/'), indexPath, maxFiles, maxFileBytes, clock }); +} + +async function indexDocument({ settings, scan, shards, graph }) { + const rootReal = await realpath(settings.root); + const outlineByLocator = new Map(shards.map((shard) => [shard.locator, shard.fileOutline])); + return Object.freeze({ + schemaVersion: INDEX_SCHEMA_VERSION, + formatVersion: INDEX_FORMAT_VERSION, + workspaceId: settings.workspaceId, + parserVersion: scan.parserVersion ?? AST_CODE_PARSER_VERSION, + generatedAt: settings.clock(), + rootIdentityHash: sha256(rootReal), + settings: Object.freeze({ maxFiles: settings.maxFiles, maxFileBytes: settings.maxFileBytes }), + discoveryIdentity: scan.discoveryIdentity, + coverage: scan.coverage, + diagnostics: scan.diagnostics, + manifest: Object.freeze(scan.discoveredFiles.map((entry) => Object.freeze({ + relativePath: entry.relativePath, + locator: entry.locator, + size: entry.size, + mtimeMs: entry.mtimeMs, + contentHash: outlineByLocator.get(entry.locator)?.contentHash ?? null + }))), + shards: Object.freeze(shards), + graph + }); +} + +function shardsFromScan(scan) { + const chunksByFile = new Map(); + for (const chunk of scan.chunks ?? []) { + const locator = fileLocator(chunk.locator); + const values = chunksByFile.get(locator) ?? []; + values.push(chunk); + chunksByFile.set(locator, values); + } + return (scan.fileOutlines ?? []).map((fileOutline) => Object.freeze({ + locator: fileOutline.locator, + fileOutline, + chunks: Object.freeze((chunksByFile.get(fileOutline.locator) ?? []).sort((left, right) => left.locator.localeCompare(right.locator))) + })).sort((left, right) => left.locator.localeCompare(right.locator)); +} + +async function readIndexDocument(settings) { + let document; + try { + document = JSON.parse(await readFile(settings.indexPath, 'utf8')); + } catch (error) { + if (error?.code === 'ENOENT') throw error; + throw indexInvalid(); + } + const rootReal = await realpath(settings.root); + if (document?.schemaVersion !== INDEX_SCHEMA_VERSION + || document?.formatVersion !== INDEX_FORMAT_VERSION + || document?.workspaceId !== settings.workspaceId + || document?.parserVersion !== AST_CODE_PARSER_VERSION + || document?.rootIdentityHash !== sha256(rootReal) + || !Array.isArray(document?.manifest) + || !Array.isArray(document?.shards) + || !Array.isArray(document?.graph?.nodes) + || !Array.isArray(document?.graph?.edges) + || document.manifest.some((entry) => !safeRelativePath(entry?.relativePath) || entry.locator !== `workspace://${entry.relativePath}`) + || document.shards.some((shard) => !String(shard?.locator ?? '').startsWith('workspace://'))) { + throw indexInvalid(); + } + return document; +} + +async function writeIndexDocument(indexPath, document) { + await mkdir(path.dirname(indexPath), { recursive: true, mode: 0o700 }); + const temporary = `${indexPath}.${process.pid}.${randomUUID()}.tmp`; + const handle = await open(temporary, 'wx', 0o600); + try { + await handle.writeFile(`${JSON.stringify(document)}\n`, 'utf8'); + await handle.sync(); + } finally { + await handle.close(); + } + try { + await rename(temporary, indexPath); + } catch (error) { + await rm(temporary, { force: true }); + throw error; + } +} + +function indexResult(document, graph, measurements) { + return Object.freeze({ + schemaVersion: INDEX_SCHEMA_VERSION, + status: 'ready', + indexLocator: workspaceIndexLocator(DEFAULT_SOURCE_GRAPH_INDEX_PATH), + graph, + measurements: Object.freeze(measurements), + source: Object.freeze({ kind: 'persistent-index', freshness: 'current', persisted: true, generatedAt: document.generatedAt }) + }); +} + +function workspaceIndexLocator(relativePath) { + return `workspace://${relativePath.replaceAll('\\', '/')}`; +} + +function fileLocator(locator) { + return String(locator ?? '').split('#', 1)[0]; +} + +function relativePathFromLocator(locator) { + return String(locator ?? '').replace(/^workspace:\/\//u, ''); +} + +function safeRelativePath(value) { + return typeof value === 'string' && Boolean(value) && !path.isAbsolute(value) && !value.split(/[\\/]/u).includes('..') && !/[\0\r\n]/u.test(value); +} + +function sha256(value) { + return createHash('sha256').update(String(value)).digest('hex'); +} + +function indexInvalid() { + const error = new Error('source_graph_index_invalid'); + error.code = 'source_graph_index_invalid'; + return error; +} diff --git a/packages/source-graph/src/index.mjs b/packages/source-graph/src/index.mjs index 401b5c74..81eba8a5 100644 --- a/packages/source-graph/src/index.mjs +++ b/packages/source-graph/src/index.mjs @@ -17,6 +17,23 @@ import { buildSourceGraphFocus, buildSourceGraphOrientation } from './orientatio export { buildSourceGraphFocus, buildSourceGraphOrientation }; export { createSourceGraphSnapshotService } from './snapshot-service.mjs'; +export { + DEFAULT_SOURCE_GRAPH_INDEX_PATH, + buildPersistentSourceGraphIndex, + loadPersistentSourceGraphIndex, + readPersistentSourceGraphIndexStatus, + refreshPersistentSourceGraphIndex +} from './index-store.mjs'; +export { + buildArchitectureIntelligence, + buildCodeContextIntelligence, + buildCodeDependenciesIntelligence, + buildCodeRoutesIntelligence, + buildCodeSearchIntelligence, + buildCodeTraceIntelligence, + buildSourceGraphIntelligence, + readSourceGraphIndexStatus +} from './intelligence.mjs'; const PREVIEW_VERSION = 'oaf-source-graph-preview-1.0.0'; export const DEFAULT_SOURCE_GRAPH_PREVIEW_MAX_FILE_BYTES = 512 * 1024; diff --git a/packages/source-graph/src/intelligence.mjs b/packages/source-graph/src/intelligence.mjs new file mode 100644 index 00000000..ab8b5d8f --- /dev/null +++ b/packages/source-graph/src/intelligence.mjs @@ -0,0 +1,234 @@ +import { + buildJsTsSourceGraph, + rankArchitectureNodes, + sanitizeSourceGraphPublicOutput, + searchSourceGraph, + traceSourceGraph +} from '../../../providers/native/context-candidate-ast-code/src/index.mjs'; +import { + loadPersistentSourceGraphIndex, + readPersistentSourceGraphIndexStatus +} from './index-store.mjs'; +import { buildSourceGraphOrientation } from './orientation.mjs'; + +const RELATIONSHIP_KINDS = new Set(['contains', 'defined_in', 'imports', 'exports', 'references', 'calls']); +const HTTP_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD']); + +export async function buildSourceGraphIntelligence({ + root, + workspaceId = 'ws_local', + maxFiles = 1000, + maxFileBytes = 512 * 1024, + clock = () => new Date().toISOString() +} = {}) { + let fallbackReason = 'index_not_built'; + try { + const persisted = await loadPersistentSourceGraphIndex({ root, workspaceId, maxFiles, maxFileBytes, clock }); + if (persisted.source.freshness === 'current') return persisted; + fallbackReason = 'persistent_index_stale'; + } catch (error) { + if (error?.code !== 'ENOENT' && error?.code !== 'source_graph_index_invalid') throw error; + if (error?.code === 'source_graph_index_invalid') fallbackReason = 'persistent_index_invalid'; + } + const graph = await buildJsTsSourceGraph({ root, workspaceId, maxFiles, maxFileBytes, clock }); + return Object.freeze({ + graph: sanitizeSourceGraphPublicOutput(graph), + source: Object.freeze({ kind: 'bounded-scan', freshness: 'fresh', persisted: false, reason: fallbackReason }) + }); +} + +export function buildArchitectureIntelligence(graph, { limit = 20 } = {}) { + const boundedLimit = boundedInteger(limit, 1, 50, 'source_graph_architecture_limit_invalid'); + const ranking = rankArchitectureNodes(graph, { limit: boundedLimit }); + const orientation = buildSourceGraphOrientation(graph, { entryPoints: ranking.entryPoints }); + return Object.freeze({ + schemaVersion: '1.0.0', + retrievalMethod: 'source_graph_architecture', + graphFingerprint: graph.graphFingerprint, + groups: orientation.groups, + relationships: orientation.relations, + entryPoints: ranking.entryPoints, + hotspots: ranking.hotspots, + deprioritized: ranking.deprioritized + }); +} + +export function buildCodeSearchIntelligence(graph, options = {}) { + return searchSourceGraph(graph, options); +} + +export function buildCodeContextIntelligence(graph, { query, limit = 20 } = {}) { + const boundedLimit = boundedInteger(limit, 1, 50, 'source_graph_context_limit_invalid'); + const search = searchSourceGraph(graph, { query, nodeKinds: ['symbol'], limit: boundedLimit }); + const selectedResult = search.results.find((item) => item.resultType === 'node') ?? null; + const selected = selectedResult ? nodeReference(selectedResult) : null; + if (!selected) { + return Object.freeze({ + schemaVersion: '1.0.0', + retrievalMethod: 'source_graph_context', + selected: null, + candidates: search.results, + incoming: Object.freeze([]), + outgoing: Object.freeze([]) + }); + } + const nodeById = new Map(graph.nodes.map((node) => [node.id, node])); + const incoming = graph.edges + .filter((edge) => edge.toNodeId === selected.id) + .map((edge) => relationshipReference(edge, nodeById)) + .filter(Boolean) + .slice(0, boundedLimit); + const outgoing = graph.edges + .filter((edge) => edge.fromNodeId === selected.id) + .map((edge) => relationshipReference(edge, nodeById)) + .filter(Boolean) + .slice(0, boundedLimit); + return Object.freeze({ + schemaVersion: '1.0.0', + retrievalMethod: 'source_graph_context', + selected: Object.freeze(selected), + candidates: Object.freeze(search.results.slice(1, boundedLimit)), + incoming: Object.freeze(incoming), + outgoing: Object.freeze(outgoing) + }); +} + +export function buildCodeTraceIntelligence(graph, { + symbol, + direction = 'outbound', + depth = 2, + limit = 20, + locatorPrefix = null +} = {}) { + return traceSourceGraph(graph, { + startName: symbol, + edgeKinds: ['calls'], + locatorPrefix, + direction, + depth, + limit + }); +} + +export function buildCodeDependenciesIntelligence(graph, { + query, + direction = 'outbound', + depth = 2, + limit = 20 +} = {}) { + const boundedDepth = boundedInteger(depth, 1, 3, 'source_graph_dependencies_depth_invalid'); + const boundedLimit = boundedInteger(limit, 1, 50, 'source_graph_dependencies_limit_invalid'); + const search = searchSourceGraph(graph, { query, limit: Math.min(20, boundedLimit) }); + const firstNode = search.results.find((item) => item.resultType === 'node'); + if (!firstNode) { + return Object.freeze({ + schemaVersion: '1.0.0', + retrievalMethod: 'source_graph_dependencies', + selected: null, + direction, + depth: boundedDepth, + relationships: Object.freeze([]) + }); + } + const nodeById = new Map(graph.nodes.map((node) => [node.id, node])); + const selected = nodeById.get(firstNode.id) ?? firstNode; + const seeds = new Set([selected.id]); + if (selected.kind === 'file') { + for (const edge of graph.edges) { + if (edge.kind === 'contains' && edge.fromNodeId === selected.id) seeds.add(edge.toNodeId); + } + } + const frontier = [...seeds].map((nodeId) => ({ nodeId, depth: 0 })); + const visited = new Set(seeds); + const relationships = []; + const seenEdges = new Set(); + while (frontier.length && relationships.length < boundedLimit) { + const current = frontier.shift(); + if (current.depth >= boundedDepth) continue; + const matches = graph.edges.filter((edge) => ( + (direction !== 'inbound' && edge.fromNodeId === current.nodeId) + || (direction !== 'outbound' && edge.toNodeId === current.nodeId) + )); + for (const edge of matches) { + if (!RELATIONSHIP_KINDS.has(edge.kind) || seenEdges.has(edge.id)) continue; + seenEdges.add(edge.id); + const projected = relationshipReference(edge, nodeById); + if (projected) relationships.push(projected); + if (relationships.length >= boundedLimit) break; + const nextId = edge.fromNodeId === current.nodeId ? edge.toNodeId : edge.fromNodeId; + if (!visited.has(nextId)) { + visited.add(nextId); + frontier.push({ nodeId: nextId, depth: current.depth + 1 }); + } + } + } + return Object.freeze({ + schemaVersion: '1.0.0', + retrievalMethod: 'source_graph_dependencies', + selected: Object.freeze(nodeReference(selected)), + direction, + depth: boundedDepth, + relationships: Object.freeze(relationships) + }); +} + +export function buildCodeRoutesIntelligence(graph, { query = '', limit = 20 } = {}) { + const boundedLimit = boundedInteger(limit, 1, 50, 'source_graph_routes_limit_invalid'); + const terms = String(query ?? '').trim().toLowerCase().split(/\s+/u).filter(Boolean); + const routes = graph.nodes + .filter((node) => node.kind === 'symbol' && HTTP_METHODS.has(node.label)) + .filter((node) => routeLikeLocator(node.locator)) + .map((node) => Object.freeze({ + method: node.label, + label: node.qualifiedLabel ?? node.label, + locator: node.locator, + symbolKind: node.symbolKind, + evidence: Object.freeze(['http_method_export', 'route_locator']) + })) + .filter((route) => !terms.length || terms.every((term) => `${route.method} ${route.label} ${route.locator}`.toLowerCase().includes(term))) + .sort((left, right) => left.locator.localeCompare(right.locator) || left.method.localeCompare(right.method)) + .slice(0, boundedLimit); + return Object.freeze({ + schemaVersion: '1.0.0', + retrievalMethod: 'source_graph_route_discovery', + routes: Object.freeze(routes), + omittedCount: Math.max(0, graph.nodes.filter((node) => node.kind === 'symbol' && HTTP_METHODS.has(node.label) && routeLikeLocator(node.locator)).length - routes.length) + }); +} + +export async function readSourceGraphIndexStatus({ root, relativePath = '.local/source-graph/index.v1.json' } = {}) { + return readPersistentSourceGraphIndexStatus({ root, relativePath }); +} + +function relationshipReference(edge, nodeById) { + const from = nodeById.get(edge.fromNodeId); + const to = nodeById.get(edge.toNodeId); + if (!from || !to) return null; + return Object.freeze({ + id: edge.id, + kind: edge.kind, + locator: edge.locator, + from: Object.freeze(nodeReference(from)), + to: Object.freeze(nodeReference(to)) + }); +} + +function nodeReference(node) { + return Object.fromEntries(Object.entries({ + id: node.id, + kind: node.kind, + label: node.label, + qualifiedLabel: node.qualifiedLabel, + locator: node.locator, + symbolKind: node.symbolKind + }).filter(([, value]) => value !== undefined && value !== null)); +} + +function routeLikeLocator(locator) { + return /(?:^|\/)(?:routes?|api)(?:\/|\.|$)/iu.test(String(locator ?? '')); +} + +function boundedInteger(value, minimum, maximum, code) { + if (!Number.isInteger(value) || value < minimum || value > maximum) throw new Error(code); + return value; +} diff --git a/providers/native/context-candidate-ast-code/src/index.mjs b/providers/native/context-candidate-ast-code/src/index.mjs index a056f1b0..0592bd2f 100644 --- a/providers/native/context-candidate-ast-code/src/index.mjs +++ b/providers/native/context-candidate-ast-code/src/index.mjs @@ -484,15 +484,19 @@ export async function scanAstCodeWorkspace({ maxFileBytes = DEFAULT_MAX_FILE_BYTES, maxFiles = DEFAULT_MAX_FILES, explicitIncludes = [], + onlyIncludes = [], + metadataOnly = false, clock = () => new Date().toISOString() } = {}) { if (typeof root !== 'string' || !root) throw new Error('root is required'); const normalizedExplicitIncludes = explicitIncludes.map(normalizeIgnoreRelativePath); + const normalizedOnlyIncludes = onlyIncludes.map(normalizeIgnoreRelativePath); const rootReal = await realpath(root); const rootRecallRules = await loadRootRecallIgnore(rootReal); const diagnostics = []; const chunks = []; const fileOutlines = []; + const discoveredFiles = []; const skippedLocators = new Set(); const oversizedLocators = new Set(); const excludedDirectoryLocators = new Set(); @@ -588,6 +592,12 @@ export async function scanAstCodeWorkspace({ } for (const entry of entries) { const relativePath = normalizeRelative(path.join(relativeDirectory, entry.name)); + if (normalizedOnlyIncludes.length) { + const selected = entry.isDirectory() + ? normalizedOnlyIncludes.some((item) => item.startsWith(`${relativePath}/`)) + : normalizedOnlyIncludes.includes(relativePath); + if (!selected) continue; + } const absolutePath = path.join(rootReal, relativePath); const locator = safeWorkspaceLocatorFor(relativePath); if (!locator) { @@ -659,6 +669,16 @@ export async function scanAstCodeWorkspace({ diagnostics.push(diagnostic(locator, 'file_too_large')); continue; } + discoveredFiles.push(Object.freeze({ + relativePath, + locator, + size, + mtimeMs: Number.isFinite(info.mtimeMs) ? Number(info.mtimeMs.toFixed(3)) : 0 + })); + if (metadataOnly) { + visitedFiles += 1; + continue; + } let body; try { body = await readFile(fileReal, 'utf8'); @@ -710,7 +730,8 @@ export async function scanAstCodeWorkspace({ ignoreFileLocators: [...ignoreFileLocators].sort(), ignoreRuleFingerprint: contentFingerprint({ rules: normalizedIgnoreRules.map(({ base, pattern, negated, directoryOnly }) => ({ base, pattern, negated, directoryOnly })), - explicitIncludes: [...normalizedExplicitIncludes].sort() + explicitIncludes: [...normalizedExplicitIncludes].sort(), + onlyIncludes: [...normalizedOnlyIncludes].sort() }) }); const result = { @@ -720,6 +741,7 @@ export async function scanAstCodeWorkspace({ fileCount: visitedFiles, chunkCount: sortedChunks.length, chunks: sortedChunks, + discoveredFiles: discoveredFiles.sort((left, right) => left.relativePath.localeCompare(right.relativePath)), fileOutlines: sortedFileOutlines, repositoryOutline: repositoryOutlineFor({ workspaceId, fileOutlines: sortedFileOutlines, symbolIndex }), contentJournal: sortedFileOutlines.map((file) => ({ @@ -849,6 +871,58 @@ export async function buildJsTsSourceIndex(options = {}) { }); } +export function buildJsTsSourceIndexFromShards({ + workspaceId = 'ws_local', + parserVersion = AST_CODE_PARSER_VERSION, + shards = [], + coverage = {}, + discoveryIdentity = {}, + diagnostics = [], + clock = () => new Date().toISOString() +} = {}) { + const sortedShards = [...shards].sort((left, right) => left.locator.localeCompare(right.locator)); + const chunks = sortedShards.flatMap((shard) => shard.chunks ?? []).sort((left, right) => left.locator.localeCompare(right.locator)); + const fileOutlines = sortedShards.map((shard) => shard.fileOutline).filter(Boolean).sort((left, right) => left.locator.localeCompare(right.locator)); + const indexedAt = clock(); + const symbolIndex = buildSymbolIndex({ workspaceId, chunks, fileOutlines, indexedAt }); + const normalizedCoverage = sourceGraphCoverage({ + ...coverage, + representedJsTsLocators: fileOutlines.map((file) => file.locator), + diagnosticCodes: diagnostics.map((item) => item.code) + }); + const repositoryOutline = repositoryOutlineFor({ workspaceId, fileOutlines, symbolIndex }); + const contentJournal = fileOutlines.map((file) => ({ + locator: file.locator, + contentHash: file.contentHash, + symbolFingerprint: file.symbolFingerprint, + collectedAt: file.collectedAt + })); + const scanFingerprint = contentFingerprint({ + workspaceId, + parserVersion, + contentJournal, + discoveryIdentity, + diagnostics + }); + const result = { + schemaVersion: '1.0.0', + workspaceId, + parserVersion, + scanFingerprint, + repositoryOutline, + fileOutlines, + contentJournal, + symbolIndex, + coverage: normalizedCoverage, + discoveryIdentity, + diagnostics + }; + return Object.freeze({ + ...result, + sourceIndexFingerprint: contentFingerprint(result) + }); +} + export function querySourceIndex(index, { operation, name = null, module = null, locator = null } = {}) { if (!index?.symbolIndex) throw new Error('source index is required'); const symbols = index.symbolIndex.symbols ?? []; diff --git a/tests/cli-graph-index.test.mjs b/tests/cli-graph-index.test.mjs new file mode 100644 index 00000000..79a9df0c --- /dev/null +++ b/tests/cli-graph-index.test.mjs @@ -0,0 +1,86 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn, spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const cli = path.resolve('apps/cli/oaf.mjs'); + +test('graph index CLI builds, reports, and incrementally refreshes a local persistent index', () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'memory-recall-cli-index-')); + mkdirSync(path.join(root, 'src'), { recursive: true }); + writeFileSync(path.join(root, 'src', 'one.ts'), 'export function one() { return 1; }\n'); + writeFileSync(path.join(root, 'src', 'stable.ts'), 'export const stable = true;\n'); + const run = (...args) => spawnSync(process.execPath, [cli, 'graph', 'index', ...args, '--root', root, '--format', 'json'], { encoding: 'utf8' }); + + const missing = run('--status'); + assert.equal(missing.status, 0, missing.stderr); + assert.equal(JSON.parse(missing.stdout).status, 'not-built'); + assert.equal(existsSync(path.join(root, '.local', 'source-graph', 'index.v1.json')), false); + + const implicit = run(); + assert.equal(implicit.status, 2); + assert.match(implicit.stderr, /requires --status, --write, or --refresh/u); + + const built = run('--write'); + assert.equal(built.status, 0, built.stderr); + const builtReport = JSON.parse(built.stdout); + assert.equal(builtReport.command, 'graph index'); + assert.equal(builtReport.status, 'ready'); + assert.equal(builtReport.measurements.parsedFileCount, 2); + assert.equal(builtReport.safeguards.rawSourceBodiesIncluded, false); + + const status = run('--status'); + assert.equal(status.status, 0, status.stderr); + assert.equal(JSON.parse(status.stdout).fileCount, 2); + + writeFileSync(path.join(root, 'src', 'one.ts'), 'export function one() { return 2; }\n'); + const refreshed = run('--refresh'); + assert.equal(refreshed.status, 0, refreshed.stderr); + const refreshReport = JSON.parse(refreshed.stdout); + assert.equal(refreshReport.measurements.parsedFileCount, 1); + assert.equal(refreshReport.measurements.reusedFileCount, 1); + assert.equal(refreshReport.measurements.changedFileCount, 1); + + const unsafe = spawnSync(process.execPath, [cli, 'graph', 'index', '--status', '--root', root, '--out', '../outside.json', '--format', 'json'], { encoding: 'utf8' }); + assert.equal(unsafe.status, 2); + assert.equal(unsafe.stderr.includes('../outside.json'), false); +}); + +test('graph help documents the explicit persistent index lifecycle', () => { + const result = spawnSync(process.execPath, [cli, 'help', 'graph'], { encoding: 'utf8' }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /graph index --status/u); + assert.match(result.stdout, /graph index --write/u); + assert.match(result.stdout, /graph index --refresh/u); + assert.match(result.stdout, /--watch/u); + assert.match(result.stdout, /MCP reads the index but never builds or refreshes it/u); +}); + +test('graph index watch refreshes after a source file changes and exits cleanly', async () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'memory-recall-cli-index-watch-')); + writeFileSync(path.join(root, 'watch.ts'), 'export const watched = 1;\n'); + const child = spawn(process.execPath, [cli, 'graph', 'index', '--refresh', '--watch', '--root', root, '--format', 'summary'], { encoding: 'utf8' }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { stdout += chunk; }); + child.stderr.on('data', (chunk) => { stderr += chunk; }); + const waitFor = (predicate, timeoutMs = 5000) => new Promise((resolve, reject) => { + const startedAt = Date.now(); + const poll = () => { + if (predicate()) return resolve(); + if (Date.now() - startedAt > timeoutMs) return reject(new Error(`watch timeout: ${stderr}`)); + setTimeout(poll, 25); + }; + poll(); + }); + await waitFor(() => (stdout.match(/# Source Graph Index/gu) ?? []).length >= 1); + writeFileSync(path.join(root, 'watch.ts'), 'export const watched = 2;\n'); + await waitFor(() => (stdout.match(/# Source Graph Index/gu) ?? []).length >= 2); + child.kill('SIGINT'); + const exit = await new Promise((resolve) => child.once('exit', (code, signal) => resolve({ code, signal }))); + assert.equal(exit.signal, null); + assert.equal(exit.code, 0); + assert.match(stdout, /Changed: 1/u); +}); diff --git a/tests/cli.test.mjs b/tests/cli.test.mjs index 978f6ef8..09d5dfa6 100644 --- a/tests/cli.test.mjs +++ b/tests/cli.test.mjs @@ -1705,7 +1705,7 @@ test('mcp server exposes governed memory recall and compressed profile over stdi const lines = result.stdout.trim().split(/\n/u).map((line) => JSON.parse(line)); assert.equal(lines.length, 5); assert.equal(lines[0].result.protocolVersion, '2025-06-18'); - assert.deepEqual(lines[1].result.tools.map((tool) => tool.name).sort(), ['code.impact', 'context.pack', 'context.profile', 'memory.recall', 'repo.map']); + assert.deepEqual(lines[1].result.tools.map((tool) => tool.name).sort(), ['code.context', 'code.dependencies', 'code.impact', 'code.routes', 'code.search', 'code.trace', 'context.pack', 'context.profile', 'memory.recall', 'repo.architecture', 'repo.index_status', 'repo.map']); const recall = JSON.parse(lines[2].result.content[0].text); assert.equal(recall.command, 'memory.recall'); assert.equal(recall.data.available, true); @@ -2030,7 +2030,7 @@ test('mcp server records delivery-token stats and stats command summarizes them' assert.match(summary.stdout, /Recall compaction saving \(memory\.recall\): \d+%/); assert.match(summary.stdout, /Realistic context\.profile saving: \d+%/); }); -test('mcp inspect CLI lists resources and server tools without reading bodies',()=>{const root=mkdtempSync(path.join(os.tmpdir(),'oaf-cli-mcp-inspect-'));mkdirSync(path.join(root,'.local'),{recursive:true});writeFileSync(path.join(root,'PROJECT_STATUS.json'),JSON.stringify({release:'0.2.0-dev',phase:'local-test',nextTask:null,defaults:{network:'deny',externalWrites:false,modelMode:'deterministic',dataResidency:'local-only',adapters:'disabled'}},null,2));writeFileSync(path.join(root,'.local','state.json'),JSON.stringify({schemaVersion:'1.0.0',runs:[{id:'run_cli_mcp_inspect',workspaceId:'ws_local',workflowId:'workflow:content-intelligence',objective:'INSPECT_PRIVATE_OBJECTIVE should not leak',status:'completed',residency:'local-only',createdAt:'2026-06-24T00:00:00.000Z',output:{text:'INSPECT_PRIVATE_OUTPUT should not leak'}}],events:[{id:'evt_cli_mcp_inspect',workspaceId:'ws_local',runId:'run_cli_mcp_inspect',sequence:1,type:'context.compiled',occurredAt:'2026-06-24T00:00:00.000Z',payload:{id:'ctx_cli_mcp_inspect',compilerVersion:'context-compiler@1.0.0',budget:{available:100,used:20},selected:[{id:'doc_cli_mcp_inspect',kind:'instruction',tokens:20,reasonCodes:['explicit_requirement'],source:'workspace://memory/private.md',text:'INSPECT_RAW_BODY should not leak'}],excluded:[]}}],memories:[{id:'mem_cli_mcp_inspect',workspaceId:'ws_local',kind:'decision',status:'proposed',decision:'review',confidence:0.6,text:'INSPECT_PRIVATE_MEMORY should not leak'}],approvals:[],artifacts:[]},null,2));const env={...process.env,OAF_FIXED_NOW:'2026-06-24T00:00:00.000Z'};const result=spawnSync(process.execPath,['apps/cli/oaf.mjs','mcp','inspect','--read-only','--root',root,'--format','json'],{encoding:'utf8',env});assert.equal(result.status,0,result.stderr);const report=JSON.parse(result.stdout);assert.equal(report.command,'mcp inspect');assert.equal(report.mode,'read-only');assert.equal(report.workspaceId,'ws_local');assert.equal(report.summary.resourcesListed,5);assert.equal(report.summary.toolsExposedByResourcesCommand,0);assert.equal(report.summary.toolsExposedByServerCommand,5);assert.equal(report.summary.resourceTemplatesExposed,0);assert.equal(report.summary.promptsExposed,0);assert(report.resources.some(item=>item.uri==='oaf://workspace/ws_local/status'&&item.resourceKind==='status-summary'));assert.deepEqual(report.serverTools.map(item=>item.name).sort(),['code.impact','context.pack','context.profile','memory.recall','repo.map']);assert(report.serverTools.every(item=>item.sideEffectClass==='read-only'));assert.equal(report.safeguards.readOnly,true);assert.equal(report.safeguards.localFilesWritten,0);assert.equal(report.safeguards.resourceBodiesRead,0);assert.equal(report.safeguards.networkCalls,0);assert.equal(report.safeguards.modelCalls,0);assert.equal(report.safeguards.externalWritesEnabled,false);for(const forbidden of ['INSPECT_PRIVATE_OBJECTIVE','INSPECT_PRIVATE_OUTPUT','INSPECT_RAW_BODY','INSPECT_PRIVATE_MEMORY',root,'/Users/rebel'])assert.equal(result.stdout.includes(forbidden),false,forbidden);const summary=spawnSync(process.execPath,['apps/cli/oaf.mjs','mcp','inspect','--read-only','--root',root,'--format','summary'],{encoding:'utf8',env});assert.equal(summary.status,0,summary.stderr);assert.match(summary.stdout,/Resources listed: 5/);assert.match(summary.stdout,/Server tools: 5/);assert.match(summary.stdout,/Resource bodies read: 0/);assert.match(summary.stdout,/memory\.recall \[read-only\]/);assert.equal(summary.stdout.includes('INSPECT_PRIVATE_OBJECTIVE'),false);const missingReadOnly=spawnSync(process.execPath,['apps/cli/oaf.mjs','mcp','inspect','--root',root,'--format','json'],{encoding:'utf8',env});assert.equal(missingReadOnly.status,2);assert.match(missingReadOnly.stderr,/--read-only/);const stdioMode=spawnSync(process.execPath,['apps/cli/oaf.mjs','mcp','inspect','--read-only','--root',root,'--stdio','--format','json'],{encoding:'utf8',env});assert.equal(stdioMode.status,2);assert.match(stdioMode.stderr,/does not write context packs, output files, or start stdio/);}); +test('mcp inspect CLI lists resources and server tools without reading bodies',()=>{const root=mkdtempSync(path.join(os.tmpdir(),'oaf-cli-mcp-inspect-'));mkdirSync(path.join(root,'.local'),{recursive:true});writeFileSync(path.join(root,'PROJECT_STATUS.json'),JSON.stringify({release:'0.2.0-dev',phase:'local-test',nextTask:null,defaults:{network:'deny',externalWrites:false,modelMode:'deterministic',dataResidency:'local-only',adapters:'disabled'}},null,2));writeFileSync(path.join(root,'.local','state.json'),JSON.stringify({schemaVersion:'1.0.0',runs:[{id:'run_cli_mcp_inspect',workspaceId:'ws_local',workflowId:'workflow:content-intelligence',objective:'INSPECT_PRIVATE_OBJECTIVE should not leak',status:'completed',residency:'local-only',createdAt:'2026-06-24T00:00:00.000Z',output:{text:'INSPECT_PRIVATE_OUTPUT should not leak'}}],events:[{id:'evt_cli_mcp_inspect',workspaceId:'ws_local',runId:'run_cli_mcp_inspect',sequence:1,type:'context.compiled',occurredAt:'2026-06-24T00:00:00.000Z',payload:{id:'ctx_cli_mcp_inspect',compilerVersion:'context-compiler@1.0.0',budget:{available:100,used:20},selected:[{id:'doc_cli_mcp_inspect',kind:'instruction',tokens:20,reasonCodes:['explicit_requirement'],source:'workspace://memory/private.md',text:'INSPECT_RAW_BODY should not leak'}],excluded:[]}}],memories:[{id:'mem_cli_mcp_inspect',workspaceId:'ws_local',kind:'decision',status:'proposed',decision:'review',confidence:0.6,text:'INSPECT_PRIVATE_MEMORY should not leak'}],approvals:[],artifacts:[]},null,2));const env={...process.env,OAF_FIXED_NOW:'2026-06-24T00:00:00.000Z'};const result=spawnSync(process.execPath,['apps/cli/oaf.mjs','mcp','inspect','--read-only','--root',root,'--format','json'],{encoding:'utf8',env});assert.equal(result.status,0,result.stderr);const report=JSON.parse(result.stdout);assert.equal(report.command,'mcp inspect');assert.equal(report.mode,'read-only');assert.equal(report.workspaceId,'ws_local');assert.equal(report.summary.resourcesListed,5);assert.equal(report.summary.toolsExposedByResourcesCommand,0);assert.equal(report.summary.toolsExposedByServerCommand,12);assert.equal(report.summary.resourceTemplatesExposed,0);assert.equal(report.summary.promptsExposed,0);assert(report.resources.some(item=>item.uri==='oaf://workspace/ws_local/status'&&item.resourceKind==='status-summary'));assert.deepEqual(report.serverTools.map(item=>item.name).sort(),['code.context','code.dependencies','code.impact','code.routes','code.search','code.trace','context.pack','context.profile','memory.recall','repo.architecture','repo.index_status','repo.map']);assert(report.serverTools.every(item=>item.sideEffectClass==='read-only'));assert.equal(report.safeguards.readOnly,true);assert.equal(report.safeguards.localFilesWritten,0);assert.equal(report.safeguards.resourceBodiesRead,0);assert.equal(report.safeguards.networkCalls,0);assert.equal(report.safeguards.modelCalls,0);assert.equal(report.safeguards.externalWritesEnabled,false);for(const forbidden of ['INSPECT_PRIVATE_OBJECTIVE','INSPECT_PRIVATE_OUTPUT','INSPECT_RAW_BODY','INSPECT_PRIVATE_MEMORY',root,'/Users/rebel'])assert.equal(result.stdout.includes(forbidden),false,forbidden);const summary=spawnSync(process.execPath,['apps/cli/oaf.mjs','mcp','inspect','--read-only','--root',root,'--format','summary'],{encoding:'utf8',env});assert.equal(summary.status,0,summary.stderr);assert.match(summary.stdout,/Resources listed: 5/);assert.match(summary.stdout,/Server tools: 12/);assert.match(summary.stdout,/Resource bodies read: 0/);assert.match(summary.stdout,/memory\.recall \[read-only\]/);assert.equal(summary.stdout.includes('INSPECT_PRIVATE_OBJECTIVE'),false);const missingReadOnly=spawnSync(process.execPath,['apps/cli/oaf.mjs','mcp','inspect','--root',root,'--format','json'],{encoding:'utf8',env});assert.equal(missingReadOnly.status,2);assert.match(missingReadOnly.stderr,/--read-only/);const stdioMode=spawnSync(process.execPath,['apps/cli/oaf.mjs','mcp','inspect','--read-only','--root',root,'--stdio','--format','json'],{encoding:'utf8',env});assert.equal(stdioMode.status,2);assert.match(stdioMode.stderr,/does not write context packs, output files, or start stdio/);}); test('mcp inspect classifies read-only resources by context tier', () => { const root = mkdtempSync(path.join(os.tmpdir(), 'oaf-cli-mcp-inspect-tiers-')); mkdirSync(path.join(root, '.local'), { recursive: true }); diff --git a/tests/mcp-code-intelligence.test.mjs b/tests/mcp-code-intelligence.test.mjs new file mode 100644 index 00000000..bcf45920 --- /dev/null +++ b/tests/mcp-code-intelligence.test.mjs @@ -0,0 +1,146 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, statSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const EXPECTED_TOOLS = [ + 'code.context', + 'code.dependencies', + 'code.impact', + 'code.routes', + 'code.search', + 'code.trace', + 'context.pack', + 'context.profile', + 'memory.recall', + 'repo.architecture', + 'repo.index_status', + 'repo.map' +]; + +test('MCP exposes twelve bounded read-only tools with structural code intelligence', () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'memory-recall-mcp-intelligence-')); + mkdirSync(path.join(root, 'src', 'routes'), { recursive: true }); + mkdirSync(path.join(root, 'src', 'data'), { recursive: true }); + writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'mcp-intelligence-fixture', type: 'module' }, null, 2)); + writeFileSync(path.join(root, 'src', 'data', 'users.ts'), [ + 'export function loadUser() {', + " return 'MCP STRUCTURAL RAW SOURCE BODY';", + '}' + ].join('\n')); + writeFileSync(path.join(root, 'src', 'routes', 'users.ts'), [ + "import { loadUser } from '../data/users';", + 'export function GET() {', + ' return loadUser();', + '}' + ].join('\n')); + writeFileSync(path.join(root, 'src', 'app.ts'), [ + "import { GET } from './routes/users';", + 'export function startServer() {', + ' return GET();', + '}' + ].join('\n')); + + const input = [ + { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }, + { jsonrpc: '2.0', id: 2, method: 'tools/list' }, + { jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'repo.architecture', arguments: { limit: 10 } } }, + { jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: 'repo.index_status', arguments: {} } }, + { jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: 'code.search', arguments: { query: 'load user', nodeKinds: ['symbol'], limit: 10 } } }, + { jsonrpc: '2.0', id: 6, method: 'tools/call', params: { name: 'code.context', arguments: { query: 'loadUser', limit: 10 } } }, + { jsonrpc: '2.0', id: 7, method: 'tools/call', params: { name: 'code.trace', arguments: { symbol: 'GET', direction: 'outbound', depth: 2, limit: 10 } } }, + { jsonrpc: '2.0', id: 8, method: 'tools/call', params: { name: 'code.dependencies', arguments: { query: 'src/routes/users.ts', direction: 'outbound', depth: 2, limit: 10 } } }, + { jsonrpc: '2.0', id: 9, method: 'tools/call', params: { name: 'code.routes', arguments: { limit: 10 } } } + ].map((message) => JSON.stringify(message)).join('\n'); + const result = spawnSync(process.execPath, ['apps/cli/oaf.mjs', 'mcp', 'server', '--read-only', '--root', root, '--stdio'], { + encoding: 'utf8', + env: { ...process.env, OAF_FIXED_NOW: '2026-07-16T08:00:00.000Z' }, + input + }); + assert.equal(result.status, 0, result.stderr); + const responses = result.stdout.trim().split(/\n/u).map((line) => JSON.parse(line)); + const tools = responses.find((entry) => entry.id === 2).result.tools; + assert.deepEqual(tools.map((tool) => tool.name).sort(), EXPECTED_TOOLS); + assert(tools.every((tool) => tool.annotations?.sideEffectClass === 'read-only')); + + const payload = (id) => JSON.parse(responses.find((entry) => entry.id === id).result.content[0].text); + const architecture = payload(3); + assert.equal(architecture.command, 'repo.architecture'); + assert(architecture.data.entryPoints.some((item) => item.label === 'startServer')); + assert(architecture.data.groups.length > 0); + + const indexStatus = payload(4); + assert.equal(indexStatus.command, 'repo.index_status'); + assert.equal(indexStatus.data.status, 'not-built'); + + const search = payload(5); + assert.equal(search.command, 'code.search'); + assert(search.data.results.some((item) => item.label === 'loadUser')); + + const context = payload(6); + assert.equal(context.command, 'code.context'); + assert.equal(context.data.selected.label, 'loadUser'); + assert(context.data.incoming.some((item) => item.kind === 'calls')); + + const trace = payload(7); + assert.equal(trace.command, 'code.trace'); + assert(trace.data.paths.some((item) => item.terminalLabel === 'loadUser')); + + const dependencies = payload(8); + assert.equal(dependencies.command, 'code.dependencies'); + assert(dependencies.data.relationships.some((item) => item.kind === 'imports')); + + const routes = payload(9); + assert.equal(routes.command, 'code.routes'); + assert(routes.data.routes.some((item) => item.method === 'GET' && item.locator.includes('src/routes/users.ts'))); + + for (const id of [3, 4, 5, 6, 7, 8, 9]) { + const response = payload(id); + assert.equal(response.safeguards.readOnly, true); + assert.equal(response.safeguards.localFilesWritten, 0); + assert.equal(response.safeguards.rawSourceBodiesIncluded, false); + } + for (const forbidden of ['MCP STRUCTURAL RAW SOURCE BODY', root, '/Users/rebel']) { + assert.equal(result.stdout.includes(forbidden), false, forbidden); + } +}); + +test('structural MCP tools reject unsafe and unbounded arguments', () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'memory-recall-mcp-intelligence-invalid-')); + writeFileSync(path.join(root, 'index.ts'), 'export function entry() { return true; }'); + const input = [ + { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }, + { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'code.trace', arguments: { symbol: 'entry', direction: 'sideways', depth: 9 } } }, + { jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'code.search', arguments: { query: 'entry', locatorPrefix: '../outside', limit: 500 } } } + ].map((message) => JSON.stringify(message)).join('\n'); + const result = spawnSync(process.execPath, ['apps/cli/oaf.mjs', 'mcp', 'server', '--read-only', '--root', root, '--stdio'], { encoding: 'utf8', input }); + assert.equal(result.status, 0, result.stderr); + const responses = result.stdout.trim().split(/\n/u).map((line) => JSON.parse(line)); + assert(responses.find((entry) => entry.id === 2).error); + assert(responses.find((entry) => entry.id === 3).error); + assert.equal(result.stdout.includes('../outside'), false); +}); + +test('MCP structural tools reuse a persistent index without mutating it', () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'memory-recall-mcp-persisted-')); + writeFileSync(path.join(root, 'index.ts'), 'export function persistedEntry() { return true; }\n'); + const built = spawnSync(process.execPath, ['apps/cli/oaf.mjs', 'graph', 'index', '--write', '--root', root, '--format', 'json'], { encoding: 'utf8' }); + assert.equal(built.status, 0, built.stderr); + const indexPath = path.join(root, '.local', 'source-graph', 'index.v1.json'); + const before = statSync(indexPath).mtimeMs; + const input = [ + { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }, + { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'repo.index_status', arguments: {} } }, + { jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'code.search', arguments: { query: 'persistedEntry' } } } + ].map((message) => JSON.stringify(message)).join('\n'); + const result = spawnSync(process.execPath, ['apps/cli/oaf.mjs', 'mcp', 'server', '--read-only', '--root', root, '--stdio'], { encoding: 'utf8', input }); + assert.equal(result.status, 0, result.stderr); + const responses = result.stdout.trim().split(/\n/u).map((line) => JSON.parse(line)); + const payload = (id) => JSON.parse(responses.find((entry) => entry.id === id).result.content[0].text); + assert.equal(payload(2).data.status, 'ready'); + assert.equal(payload(3).data.source.kind, 'persistent-index'); + assert(payload(3).data.results.some((item) => item.label === 'persistedEntry')); + assert.equal(statSync(indexPath).mtimeMs, before); +}); diff --git a/tests/skill-catalog.test.mjs b/tests/skill-catalog.test.mjs index 72fe17a9..98c39a69 100644 --- a/tests/skill-catalog.test.mjs +++ b/tests/skill-catalog.test.mjs @@ -269,7 +269,7 @@ test('skill catalog CLI exposes governed manifests without raw skill bodies', () assert.equal(inspectReport.summary.toolContextTierCounts['tool-capability'], 2); assert(inspectReport.resources.some((resource) => resource.uri === 'oaf://workspace/ws_local/skills/oaf-memory/load-plan' && resource.contextTier === 'procedural-skill')); assert(inspectReport.serverTools.some((tool) => tool.name === 'memory.recall' && tool.contextTier === 'governed-memory')); - assert.equal(inspectReport.summary.toolsExposedByServerCommand, 5); + assert.equal(inspectReport.summary.toolsExposedByServerCommand, 12); assert.equal(inspectReport.safeguards.resourceBodiesRead, 0); assert.equal(inspect.stdout.includes('Never auto-approve'), false); assert.equal(inspect.stdout.includes('/Users/rebel'), false); diff --git a/tests/source-graph-index-store.test.mjs b/tests/source-graph-index-store.test.mjs new file mode 100644 index 00000000..97a0ff3b --- /dev/null +++ b/tests/source-graph-index-store.test.mjs @@ -0,0 +1,94 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { + buildJsTsSourceGraph, + sanitizeSourceGraphPublicOutput +} from '../providers/native/context-candidate-ast-code/src/index.mjs'; +import { + buildPersistentSourceGraphIndex, + loadPersistentSourceGraphIndex, + readPersistentSourceGraphIndexStatus, + refreshPersistentSourceGraphIndex +} from '../packages/source-graph/src/index-store.mjs'; + +async function fixture() { + const root = await mkdtemp(path.join(os.tmpdir(), 'memory-recall-index-store-')); + await mkdir(path.join(root, 'src'), { recursive: true }); + await writeFile(path.join(root, 'src', 'alpha.ts'), [ + 'export function alpha() {', + " return 'INDEX RAW ALPHA';", + '}' + ].join('\n')); + await writeFile(path.join(root, 'src', 'beta.ts'), [ + "import { alpha } from './alpha';", + 'export function beta() { return alpha(); }' + ].join('\n')); + await writeFile(path.join(root, 'src', 'stable.ts'), 'export const stable = true;\n'); + return root; +} + +test('persistent source index stores sanitized structural shards and survives reload', async () => { + const root = await fixture(); + const built = await buildPersistentSourceGraphIndex({ root, workspaceId: 'ws_local', clock: () => '2026-07-16T10:00:00.000Z' }); + assert.equal(built.status, 'ready'); + assert.equal(built.measurements.parsedFileCount, 3); + assert.equal(built.measurements.reusedFileCount, 0); + const raw = await readFile(path.join(root, '.local', 'source-graph', 'index.v1.json'), 'utf8'); + assert.equal(raw.includes('INDEX RAW ALPHA'), false); + assert.equal(raw.includes(root), false); + assert.equal(raw.includes('/Users/'), false); + const loaded = await loadPersistentSourceGraphIndex({ root, workspaceId: 'ws_local' }); + assert.equal(loaded.graph.graphFingerprint, built.graph.graphFingerprint); + assert.equal(loaded.source.kind, 'persistent-index'); + assert.equal(loaded.source.freshness, 'current'); + const status = await readPersistentSourceGraphIndexStatus({ root, workspaceId: 'ws_local' }); + assert.equal(status.status, 'ready'); + assert.equal(status.fileCount, 3); +}); + +test('incremental refresh parses changed and added files, reuses unchanged shards, and removes deleted files', async () => { + const root = await fixture(); + await buildPersistentSourceGraphIndex({ root, workspaceId: 'ws_local', clock: () => '2026-07-16T10:00:00.000Z' }); + await new Promise((resolve) => setTimeout(resolve, 12)); + await writeFile(path.join(root, 'src', 'alpha.ts'), 'export function alpha() { return 2; }\n'); + await writeFile(path.join(root, 'src', 'gamma.ts'), "import { alpha } from './alpha';\nexport function gamma() { return alpha(); }\n"); + await rm(path.join(root, 'src', 'beta.ts')); + const refreshed = await refreshPersistentSourceGraphIndex({ root, workspaceId: 'ws_local', clock: () => '2026-07-16T10:01:00.000Z' }); + assert.equal(refreshed.measurements.parsedFileCount, 2); + assert.equal(refreshed.measurements.reusedFileCount, 1); + assert.equal(refreshed.measurements.addedFileCount, 1); + assert.equal(refreshed.measurements.changedFileCount, 1); + assert.equal(refreshed.measurements.deletedFileCount, 1); + const clean = sanitizeSourceGraphPublicOutput(await buildJsTsSourceGraph({ root, workspaceId: 'ws_local', clock: () => '2026-07-16T10:01:00.000Z' })); + assert.deepEqual( + refreshed.graph.nodes.map(({ kind, label, locator }) => ({ kind, label, locator })), + clean.nodes.map(({ kind, label, locator }) => ({ kind, label, locator })) + ); + assert.deepEqual( + refreshed.graph.edges.map(({ kind, fromNodeId, toNodeId, locator }) => ({ kind, fromNodeId, toNodeId, locator })), + clean.edges.map(({ kind, fromNodeId, toNodeId, locator }) => ({ kind, fromNodeId, toNodeId, locator })) + ); +}); + +test('persistent source index fails closed when the file is corrupt', async () => { + const root = await fixture(); + await mkdir(path.join(root, '.local', 'source-graph'), { recursive: true }); + await writeFile(path.join(root, '.local', 'source-graph', 'index.v1.json'), '{not-json'); + const status = await readPersistentSourceGraphIndexStatus({ root, workspaceId: 'ws_local' }); + assert.equal(status.status, 'invalid'); + await assert.rejects(() => loadPersistentSourceGraphIndex({ root, workspaceId: 'ws_local' }), /source_graph_index_invalid/u); +}); + +test('persistent source index reports stale after source changes without a refresh', async () => { + const root = await fixture(); + await buildPersistentSourceGraphIndex({ root, workspaceId: 'ws_local' }); + await new Promise((resolve) => setTimeout(resolve, 12)); + await writeFile(path.join(root, 'src', 'alpha.ts'), 'export function alpha() { return 9; }\n'); + const loaded = await loadPersistentSourceGraphIndex({ root, workspaceId: 'ws_local' }); + assert.equal(loaded.source.freshness, 'stale'); + const status = await readPersistentSourceGraphIndexStatus({ root, workspaceId: 'ws_local' }); + assert.equal(status.status, 'stale'); +}); From a8c1d08dbaee26dc2232b219b1eb68de19514ab2 Mon Sep 17 00:00:00 2001 From: rebel Date: Thu, 16 Jul 2026 12:20:12 +0530 Subject: [PATCH 026/276] docs: align release contract with code intelligence --- .npmignore | 1 + HANDOFF_VERIFICATION.json | 14 +- PROJECT_STATUS.json | 36 +- README.md | 18 +- REPOSITORY_MANIFEST.json | 317 ++++++++++++------ apps/cli/help.mjs | 2 +- apps/cli/oaf.mjs | 2 +- apps/web/app.js | 22 +- docs/benchmarks.md | 7 + docs/product/memory-recall-developer-first.md | 6 +- docs/release/1.0-PROVENANCE.json | 12 +- docs/release/1.0-READINESS-REPORT.md | 8 +- docs/release/1.0-REPRODUCIBILITY.md | 6 +- docs/usage/mcp-server-reference.md | 41 +++ docs/usage/recall-map.md | 8 +- docs/usage/support-matrix.md | 17 +- docs/usage/troubleshooting.md | 2 +- package.json | 1 + packages/source-graph/src/index-store.mjs | 19 +- scripts/consumer-smoke.mjs | 2 +- scripts/source-graph-large-repo-smoke.mjs | 73 +++- tests/cli-graph-index.test.mjs | 14 +- tests/cli.test.mjs | 2 +- tests/skill-catalog.test.mjs | 2 +- tests/usage-docs.test.mjs | 4 +- tests/web-shell.test.mjs | 11 + 26 files changed, 480 insertions(+), 167 deletions(-) diff --git a/.npmignore b/.npmignore index 2de09647..bc46b9fa 100644 --- a/.npmignore +++ b/.npmignore @@ -19,3 +19,4 @@ adapters/**/UPSTREAM.lock *.zip .DS_Store REPOSITORY_MANIFEST.json.tmp +REPOSITORY_MANIFEST.json diff --git a/HANDOFF_VERIFICATION.json b/HANDOFF_VERIFICATION.json index 3b5c1f2d..e4ec49ab 100644 --- a/HANDOFF_VERIFICATION.json +++ b/HANDOFF_VERIFICATION.json @@ -2,7 +2,7 @@ "schemaVersion": "1.0.0", "project": "memory-recall", "version": "1.1.1", - "verifiedAt": "2026-07-15T16:01:28.188Z", + "verifiedAt": "2026-07-16T06:49:34.491Z", "environment": { "platform": "darwin", "release": "25.5.0", @@ -18,32 +18,32 @@ { "command": "npm run check", "exitCode": 0, - "durationMs": 289 + "durationMs": 313 }, { "command": "npm run protocol:validate", "exitCode": 0, - "durationMs": 176 + "durationMs": 221 }, { "command": "npm test", "exitCode": 0, - "durationMs": 141473 + "durationMs": 43513 }, { "command": "npm run eval", "exitCode": 0, - "durationMs": 328 + "durationMs": 313 }, { "command": "npm run demo", "exitCode": 0, - "durationMs": 170 + "durationMs": 185 }, { "command": "npm run native:smoke", "exitCode": 0, - "durationMs": 243 + "durationMs": 257 } ], "result": "passed" diff --git a/PROJECT_STATUS.json b/PROJECT_STATUS.json index 50999ee4..eff9d028 100644 --- a/PROJECT_STATUS.json +++ b/PROJECT_STATUS.json @@ -126,6 +126,9 @@ "tests/native-memory-profile-context.test.mjs", "tests/ast-code-candidate-source.test.mjs", "tests/source-graph-preview.test.mjs", + "tests/source-graph-index-store.test.mjs", + "tests/cli-graph-index.test.mjs", + "tests/mcp-code-intelligence.test.mjs", "tests/recall-map-ranking.test.mjs", "evals/recall-map/architecture-ranking.v1.json", "tests/cli.test.mjs", @@ -137,6 +140,8 @@ "packages/protocol/schemas/source-graph-preview.schema.json", "packages/protocol/src/source-graph-locator.mjs", "packages/source-graph/src/index.mjs", + "packages/source-graph/src/index-store.mjs", + "packages/source-graph/src/intelligence.mjs", "packages/protocol/schemas/context-candidate.schema.json", "packages/protocol/schemas/context-selection-policy.schema.json", "packages/protocol/schemas/context-selection-result.schema.json", @@ -151,11 +156,34 @@ "Context views are opt-in deterministic local representations for noisy selected records and tool output; they preserve original/view hashes and token accounting but do not replace canonical source or memory state", "No model reranking, embeddings, vector database, graph database, external search, browser automation, outbound network, or adapter activation", "Compressed memory profile context and oaf measure savings use bounded local before/after delivery-token estimates over a realistic workspace candidate-file and git-history baseline, surfaced in the memory cockpit and MCP stats; MCP stats separates context.profile compression-path savings from memory.recall compaction savings and does not claim provider billing-token savings", - "Native source graph is read-only derived metadata; graph candidate records are locator-only and it is not canonical source state, not a graph database, and not persisted as memory", + "Native source graph is read-only derived metadata; graph candidate records are locator-only and it is not canonical source state, a graph database, or persisted memory; an optional explicit local structural index can cache it across processes", "Token estimate is approximate", "Manifest persistence is local native or repository-layer only; no production orchestration is implied" ] }, + { + "id": "source.graph-index", + "name": "Persistent incremental JS/TS source index", + "status": "implemented", + "evidence": [ + "apps/cli/oaf.mjs", + "packages/source-graph/src/index-store.mjs", + "packages/source-graph/src/intelligence.mjs", + "providers/native/context-candidate-ast-code/src/index.mjs", + "tests/source-graph-index-store.test.mjs", + "tests/cli-graph-index.test.mjs", + "tests/mcp-code-intelligence.test.mjs", + "docs/usage/mcp-server-reference.md", + "docs/usage/support-matrix.md" + ], + "limitations": [ + "The default index is bounded to 1,000 supported JS/TS-family files, 512 KiB per file, 20,000 graph nodes, and 50,000 graph edges; larger limits do not imply million-node support", + "Index writes require explicit graph index --write or --refresh; MCP only reads a current index and falls back to a bounded scan when it is stale", + "Incremental refresh reuses unchanged per-file structural shards and reparses changed or added files; ignore-rule, parser-version, or scan-setting changes trigger a full rebuild", + "The index contains hashes, locators, structural parse metadata, and a sanitized graph; it contains no raw source bodies or absolute workspace paths", + "No non-JS/TS, semantic embedding, cross-repository, remote, or graph-database index is claimed" + ] + }, { "id": "context.candidate-sources", "name": "Provider-neutral context candidate-source generation", @@ -884,6 +912,7 @@ "packages/harness-context/src/index.mjs", "tests/protocol-bridges.test.mjs", "tests/cli.test.mjs", + "tests/mcp-code-intelligence.test.mjs", "docs/architecture/protocol-bridges.md", "docs/implementation/OAF-028-protocol-bridges-note.md" ], @@ -892,6 +921,7 @@ "In-process dependency-free MCP-shaped bridge plus local read-only CLI stdio compositions and the preview-confirm mcp install planner using npm --silent; no HTTP, SSE, hosted bridge, or network listener is started", "Read-only resources expose sanitized summaries for status, latest context manifest, latest run, memory proposals, handoff/artifact review, opt-in current context packs, auto-discovered pinned context-pack use plans, pinned context-pack registry verification, skill catalog, per-skill load plans, and the reviewed tool catalog; they do not expose write tools, source bodies, skill bodies, tool manifest bodies, prompts, model results, credentials, provider URLs, absolute local paths, external egress, or canonical state mutation", "MCP memory.recall and context.profile serve active temporal facts first and pending proposal facts separately, include active/proposal count summaries, support since cursors for deltas, and persist last-delivered cursors locally per client/scope", + "The read-only MCP server exposes twelve tools: governed memory and compact context plus repository map, architecture, index status, code search, symbol context, call trace, dependencies, routes, and changed-file impact; all structural results are bounded and locator-only", "The read-only bridge never approves proposals or activates memory", "The MCP token-saver install command writes only the selected local client MCP config entry after --apply plus a matching dry-run fingerprint; default dry-run prints exact config only, rejects --write, and does not print raw home config bodies or absolute paths", "The stdio bridge enforces bounded stdin, JSON-RPC line size, message count, child runtime, child stdout/stderr, JSON-RPC id, method, resource URI, and tool name limits; JSON-RPC batch arrays are rejected and request-metadata failures are redacted", @@ -1036,7 +1066,7 @@ } ], "qualitySnapshot": { - "asOf": "2026-07-15", + "asOf": "2026-07-16", "commands": [ "npm ci --ignore-scripts --no-audit --no-fund", "npm run local:run", @@ -1058,7 +1088,7 @@ "npm run consumer:smoke", "npm run consumer:browser-smoke" ], - "testsExpected": 705, + "testsExpected": 721, "protocolFixturesExpected": 182, "evaluationsExpected": 144, "note": "Counts are a dated snapshot; command results and HANDOFF_VERIFICATION.json are authoritative." diff --git a/README.md b/README.md index 60eccf1e..242897b2 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ for preserved legacy names and URIs. | New agent session | A compact handoff with required local reads, changed-file coverage, hashes, and MCP proof. | | Repo memory | SQLite/FTS5 facts that start as proposals and become ACTIVE only after review. | | Fast local code intelligence | Implemented JS/TS static graph; experimental Rust ingest and graph/search require a local build. | +| Larger JS/TS repositories | Explicit local index with incremental refresh and watch mode; MCP reads it without writing. | | First look at a repository | Recall Map shows bounded source coverage, entry points, changed impact, and separate memory status without writing. | | Long context pressure | Repeat MCP pulls use cursors and deltas instead of resending the same profile. | | Trust | Dry-run first, confirm-gated writes, local-only storage, and no automatic transcript import. | @@ -113,7 +114,7 @@ approval. Manual MCP install flow: run the `mcp install --client claude-code --dry-run --format json` preview, review it, then run the printed `--apply --confirm ` -command only when the fingerprint matches. That path installs the five-tool +command only when the fingerprint matches. That path installs the twelve-tool read-only MCP server. `recall connect` is separate: it installs a resource bridge plus hooks for Codex or Claude Code, and its MCP `tools/list` is empty. The support matrix names the difference and reversal path. @@ -177,9 +178,12 @@ cross-product benchmark leaderboard. harness; strict result import; pending-only proposals; and source-rechecked named approval. Optional direct API execution is experimental and explicitly consented. -- Read-only `recall mcp server` exposing `memory.recall`, `context.profile`, - `context.pack`, `repo.map`, and `code.impact` over local stdio with active - facts separated from proposals. +- Read-only `recall mcp server` exposing twelve local tools for governed memory, + compact context, repository maps, architecture, code search, symbol context, + call traces, dependencies, routes, changed-file impact, and index status. +- Explicit persistent JS/TS source index with atomic writes, per-file structural + shards, incremental refresh, stale detection, and watch mode. MCP can read a + current index but never creates or refreshes one. - Persisted MCP cursors and `since` deltas so repeated reads send only changed current truth, including after restart. - Preview-then-confirm install paths for Claude Code, Cursor, and Codex with @@ -214,7 +218,8 @@ for the deeper boundary rules. Production PostgreSQL repositories, production authentication, hosted embeddings, vector databases, hosted memory sync, write-capable MCP tools, automatic harness history import, real social connectors, hardened sandboxes, signed Agent Pack -distribution, and a production frontend framework are not claimed. +distribution, non-JS/TS static analysis, cross-repository analysis, and +million-node index support are not claimed. `PROJECT_STATUS.json` is the machine-readable source for current capability status and limitations. @@ -234,6 +239,9 @@ recall token-saver recall graph stats --root . --format summary recall graph search --root . --query "auth workflow" --format summary recall graph trace --root . --symbol runAuthWorkflow --format summary +recall graph index --status --root . --format summary +recall graph index --write --root . --format json +recall graph index --refresh --watch --root . --format summary recall context handoff --read-only --from codex --root . --objective "Prepare handoff" --step "select next agent context" --target codex --format summary recall handoff ``` diff --git a/REPOSITORY_MANIFEST.json b/REPOSITORY_MANIFEST.json index f7c77305..0f78db07 100644 --- a/REPOSITORY_MANIFEST.json +++ b/REPOSITORY_MANIFEST.json @@ -1,9 +1,9 @@ { "schemaVersion": "1.0.0", "project": "memory-recall", - "generatedAt": "2026-07-15T16:07:36.139Z", - "fileCount": 924, - "totalBytes": 6447558, + "generatedAt": "2026-07-16T06:49:34.730Z", + "fileCount": 947, + "totalBytes": 6707493, "exclusions": [ ".git/**", ".local/**", @@ -40,6 +40,11 @@ "bytes": 591, "sha256": "033047b9cfbe8de9c932558ee3c9c3ec622b1413ff52fc23628a86bd28de57b4" }, + { + "path": ".git", + "bytes": 91, + "sha256": "a57aeef226106fbcf7f1463d808725de7c032443bbecfad8c5997a9d17da77dc" + }, { "path": ".gitignore", "bytes": 175, @@ -57,8 +62,8 @@ }, { "path": ".npmignore", - "bytes": 266, - "sha256": "bd684e155960e61975c6bf9e83d4dabd8e0125f77c19e699cc62a50d2c0672ff" + "bytes": 291, + "sha256": "727d4a7ea446f5696cdb926cf9bf900646f319dc648dba3826d40f1671671a65" }, { "path": ".npmrc", @@ -452,48 +457,88 @@ }, { "path": "apps/cli/help.mjs", - "bytes": 25248, - "sha256": "0c0117d86106ec7e5f9cd65a3481ef92b06d5daf63fab94cb26517a9d7ac766d" + "bytes": 25762, + "sha256": "8e61234f6647b0ee9093726e83449e832e34fa5c5b01327c751078483259e510" }, { "path": "apps/cli/oaf.mjs", - "bytes": 463181, - "sha256": "45c46ca2b9da9c9d1f9eb34b56ca51a8da6d325bbb6c34d6068d749b1d15bd48" + "bytes": 480454, + "sha256": "5b6a5539bf7e9016584f499e68b2033d0520e844194e3649cf7a3731bc821ead" }, { "path": "apps/web/AGENTS.md", "bytes": 202, "sha256": "abb5d47e0b5056b0bdbc4fa67f85b9cf658b53cce0c7f2f21e561f6a349283d1" }, + { + "path": "apps/web/api.js", + "bytes": 1839, + "sha256": "368921a08446f8b24e6ba514f5178af084a070d52f96a0eaa92d6218828eeb4f" + }, { "path": "apps/web/app.js", - "bytes": 260078, - "sha256": "fff751153ac96b67bc6d2df262fe262764283a533c29c65ce1f90131893c7819" + "bytes": 226179, + "sha256": "658d66660824354f9bb58ca212a5cc7d65b606e7da770da3b7c03bc975ed079b" }, { "path": "apps/web/favicon.svg", "bytes": 495, "sha256": "b1774942e350f0c007b10e9e82077e2442f1484e88eb79d1e90efc1e38afa1c5" }, + { + "path": "apps/web/graph-layout-worker.js", + "bytes": 2825, + "sha256": "0c9330628c65c52c557d812829339dde9c4e3d9c85fbb4557134d345055b5145" + }, + { + "path": "apps/web/graph-viewport.js", + "bytes": 12417, + "sha256": "42e2a55943277d68ac07db31291911a53dc63271ce0d1cb51c2b3fbe0e11ebf8" + }, { "path": "apps/web/index.html", - "bytes": 2807, - "sha256": "f0ce53a83c84bb79e23acc12855c6dc322dade75d98c8c3f6a01799d695255f5" + "bytes": 3345, + "sha256": "265fa59c60cab744ea57cfa8856db30d73b9ada7791455db5d3e8a6c077ebb03" + }, + { + "path": "apps/web/memory-graph-view.js", + "bytes": 13299, + "sha256": "86db53e576aabaac5e44575ea25159f2bd95258988807a16d0f391c63679cb05" + }, + { + "path": "apps/web/orientation-model.js", + "bytes": 25439, + "sha256": "26a07d039bf51c85219dccdfcbb31d915ad72eecb25dd39d35f9637ad633f011" + }, + { + "path": "apps/web/orientation-view.js", + "bytes": 7905, + "sha256": "77edb911d7971c97e1711cd83cf4978890afd678fca708bc7db2d0a02f091af9" }, { "path": "apps/web/shell-model.js", - "bytes": 2049, - "sha256": "723e987aa0296a8df2707ca6779fc1d734e6958a73f4b62b6051dc96651aee5d" + "bytes": 2187, + "sha256": "a18db7bf8e798993bc03394336bd75b904e2ecc3497d0a13a46eeab250178123" + }, + { + "path": "apps/web/source-map-view.js", + "bytes": 15843, + "sha256": "6e44c56469e4d9a245c08ec4d123c3b253f4a766af9c0d626470760d5fdff414" }, { "path": "apps/web/styles.css", - "bytes": 44685, - "sha256": "dd95e6ebaca5e659f47776cc6fd6cc7d6d9fa565ac3e9281753a9329740598a3" + "bytes": 59004, + "sha256": "4bae6a2bb361acce6bd7b978f95264209ac3333f6b2ef86da1384f73afe57726" }, { "path": "apps/web/tokens.css", - "bytes": 2351, - "sha256": "18b19c1168909f956be55ccca78adbb12ce5db74041f17eefc445a747a11a54e" + "bytes": 2350, + "sha256": "3ed7617e934302dac3f43142167270984f126d226027a9039f6563257fba00cb" + }, + { + "path": "apps/web/ui-primitives.js", + "bytes": 5261, + "sha256": "ff826063df128ce473a7aac672901f0f35e3fb7fca9cc8eafd97e9e98159bd79" }, { "path": "assets/brand/logo.svg", @@ -607,8 +652,8 @@ }, { "path": "DESIGN.md", - "bytes": 4800, - "sha256": "be5c21fb3ac78de26d0bffa7cf0e955d421de0d42d5c1aa8e5aa6aa24243ad57" + "bytes": 5459, + "sha256": "1d9b98a5634f517efd8a2a85276f25dfee6c39a6daa8145ab89cd1498ae6e0f1" }, { "path": "Dockerfile", @@ -747,8 +792,8 @@ }, { "path": "docs/api/openapi.yaml", - "bytes": 60235, - "sha256": "c7f95a629fc72f146614c92db1d2a58147a675b441a893ae531f771b23a1b4d3" + "bytes": 63365, + "sha256": "fd2b91061da40597d28c11f91141c01f28c3495274c138f4b8f61c081d3a4e06" }, { "path": "docs/api/README.md", @@ -837,8 +882,8 @@ }, { "path": "docs/benchmarks.md", - "bytes": 8750, - "sha256": "d486819287f1f97c1f51efded6dd7bf6c8491dc8e7a135c9cfaba9ed8f14b212" + "bytes": 9188, + "sha256": "2514d366ffa277307f7bb8f503ef195e223a55cbe3e7578ee9c4327af5dea938" }, { "path": "docs/implementation/AGENT_EXECUTION_PLAYBOOK.md", @@ -1047,8 +1092,8 @@ }, { "path": "docs/product/memory-recall-developer-first.md", - "bytes": 2055, - "sha256": "0ba4cd0631230ee575cd09cba47470619e4eea1ccbb9b61ea489369b1f75e81e" + "bytes": 2164, + "sha256": "a17d7e7faf242312cf2966c70e160727057ddb52930dfa8b4ad338f41dcc220a" }, { "path": "docs/product/oaf-rust-supertool-spec.md", @@ -1093,12 +1138,12 @@ { "path": "docs/release/1.0-PROVENANCE.json", "bytes": 2414, - "sha256": "de1da2e3a2d45a5042d72ead1e348f0b9fc657ab36ce18a43ab276c68af06cdd" + "sha256": "4cf072b3cbe81b472fabbe508395947e96d88209d4ab75e6dc036c2d0fbd54a9" }, { "path": "docs/release/1.0-READINESS-REPORT.md", "bytes": 3381, - "sha256": "65d15d1d57bf05a07d9d25f46eeb110c45d9347e8384bcba1f7f2edd8eb1775c" + "sha256": "861ccbb7632d40eb2327c0348651088b5d358d84c7a3429c258a84a87b1063f0" }, { "path": "docs/release/1.0-RELEASE-CHECKLIST.md", @@ -1108,7 +1153,7 @@ { "path": "docs/release/1.0-REPRODUCIBILITY.md", "bytes": 1723, - "sha256": "e9daf1a1196a09c62474d968e83e00ae14de6e822f543f7c9d401ec3848508d7" + "sha256": "adea80cf7e9075fb43daff0b549c0d4b5f6533b8003e65c6df586de9025e8310" }, { "path": "docs/release/1.0-SBOM.json", @@ -1212,8 +1257,8 @@ }, { "path": "docs/usage/mcp-server-reference.md", - "bytes": 2616, - "sha256": "be31b255e4d79565fb713ef9a75ffc56ef42e438f874b36957b99c92bc6265f1" + "bytes": 4840, + "sha256": "a55cdec405a05573c0070e8ee7ad4f90fef45d24edcc4a81f42491caa8b807d7" }, { "path": "docs/usage/memory-lifecycle.md", @@ -1232,8 +1277,8 @@ }, { "path": "docs/usage/recall-map.md", - "bytes": 2866, - "sha256": "abeaf7d2a788d9301fea56ea207103cffb418ca32ab3d5a2f91b06bd1b6e5f1f" + "bytes": 4599, + "sha256": "2ad802a6575e6632c53a9ad28a8035ae43c98849457c5a5b310f250c3f2b189d" }, { "path": "docs/usage/rust-acceleration.md", @@ -1252,8 +1297,8 @@ }, { "path": "docs/usage/support-matrix.md", - "bytes": 4490, - "sha256": "dd4108828b742e8d06112e6abdfa4119f90a8b034b417b9e7e41f6924dcf26a6" + "bytes": 5064, + "sha256": "94d3dc7b026944568912f775b85e86109f13dc022cfd1e889a00a13bead70e42" }, { "path": "docs/usage/token-savings.md", @@ -1262,8 +1307,8 @@ }, { "path": "docs/usage/troubleshooting.md", - "bytes": 2446, - "sha256": "317f19dfec8cc3656981460e2bd914a77c193915935d3f36a51683f7719970ec" + "bytes": 2448, + "sha256": "21e8f9acf0a78d8a2218ef4818ae9258af708e99767e4fe65d2df1dff3699eb2" }, { "path": "docs/usage/uninstall.md", @@ -1887,8 +1932,8 @@ }, { "path": "examples/protocol/compatibility/invalid/source-graph-local-path.json", - "bytes": 914, - "sha256": "d477221f96e7c4253afa9938e07f0ca75e867bb1c0cee979bb820c18bb4a5a9d" + "bytes": 919, + "sha256": "973960cacdb84e196fd6c50130cfda67c1f6d5b5cf059dfb145afb3ae2960f9b" }, { "path": "examples/protocol/compatibility/invalid/source-graph-preview-double-encoded-locator.json", @@ -1897,8 +1942,8 @@ }, { "path": "examples/protocol/compatibility/invalid/source-graph-preview-local-path.json", - "bytes": 2036, - "sha256": "0650e9b217ce60bacd037961d811b92bafe1d5809ae7cddb4a3451069e220bbb" + "bytes": 2041, + "sha256": "80e030fcb5af063dd907d8d92aacb33069e38324b1387fef8eac322137aeb8ef" }, { "path": "examples/protocol/compatibility/invalid/source-graph-preview-raw-body.json", @@ -2292,8 +2337,8 @@ }, { "path": "examples/protocol/recall-map.json", - "bytes": 2852, - "sha256": "cdaafe7b410e15c349fa149b67fc6b624cca3cc1e3851a41d366762a5904b7f4" + "bytes": 3587, + "sha256": "dfd39493742143504358274b23c0234c1549a34d4f48a6ee9936f8892b05677e" }, { "path": "examples/protocol/research-ingestion-result.json", @@ -2327,13 +2372,13 @@ }, { "path": "examples/protocol/source-graph-preview.json", - "bytes": 4991, - "sha256": "e7a0d65f5fdc5082b1f90044b901b60451d11058986929a03a03402843d4a0d5" + "bytes": 7998, + "sha256": "13c5d3a2050f74e07003e5a521c8ae5744b79e3816f58ffd0f87bee112bdeafa" }, { "path": "examples/protocol/source-graph.json", - "bytes": 6272, - "sha256": "08fd3be564e1869bc5e92f24d1afd206b1ba3e68adf47c4dc550e5f2e843d257" + "bytes": 7276, + "sha256": "7937c7754fe41def7ef7a2c2ea7c96a3dfb935231ba76f9efc4e5860a19d8222" }, { "path": "examples/protocol/source-snapshot.json", @@ -2372,8 +2417,8 @@ }, { "path": "HANDOFF_VERIFICATION.json", - "bytes": 968, - "sha256": "eb2d810e244a03d9df572f3eea7fc1c15142ad4ccc7cb509a25264d746ccfe9f" + "bytes": 967, + "sha256": "fe8c252c4d1099f4f99124e8580870a9d9ccadf048c4e0380dbfcceb0bf933f1" }, { "path": "LICENSE", @@ -2407,8 +2452,8 @@ }, { "path": "package.json", - "bytes": 4476, - "sha256": "69062661e8988c9d18d38201caf0e4d62547c5e2b1d86de4a57d9582e2ffaf19" + "bytes": 4591, + "sha256": "c0723009a5ad22c016b9acfc06eefd44ade5e7bd6caf05d2c9d45000e60ed831" }, { "path": "packages/adapter-contracts/package.json", @@ -3152,8 +3197,8 @@ }, { "path": "packages/protocol/schemas/recall-map.schema.json", - "bytes": 13170, - "sha256": "6626b9c93a3b446c1db8b24a5344967f9876d60a8087054b9d532e2dff78928f" + "bytes": 17296, + "sha256": "53144ca5a4ed0b9c69d6d1642be58ed91bdeea9cdb7e409825b65193078ffd7b" }, { "path": "packages/protocol/schemas/replay-request.schema.json", @@ -3197,13 +3242,13 @@ }, { "path": "packages/protocol/schemas/source-graph-preview.schema.json", - "bytes": 19619, - "sha256": "469a3247a99319f6e13f10ae797cc42700cd2c6f2de5abc94540f8c2bb27ce19" + "bytes": 25990, + "sha256": "1d256ed8dd697b4a24d710134351ffdf468a1fc5a09df55c635291e0ad57d93e" }, { "path": "packages/protocol/schemas/source-graph.schema.json", - "bytes": 9998, - "sha256": "4624b7fe439c8fb6c41a9c1b6f074f8a319db242df1f158f2a26c5795a71161b" + "bytes": 12413, + "sha256": "3ba52ff8bdd56be530c337d38bf6b9f6cd35922e771d7b2bd5a9a9492a5af776" }, { "path": "packages/protocol/schemas/source-snapshot.schema.json", @@ -3287,8 +3332,8 @@ }, { "path": "packages/protocol/src/index.mjs", - "bytes": 2710, - "sha256": "5e92e31561443d946a5d0750ed06bf5c2042aa1a853d7d257136170df53d459f" + "bytes": 2743, + "sha256": "daf8d1223456970d9b13c6fdcaeddb34b734c21ade4f79c4b3ed7acc6243c711" }, { "path": "packages/protocol/src/schema-validator.mjs", @@ -3297,8 +3342,8 @@ }, { "path": "packages/protocol/src/source-graph-locator.mjs", - "bytes": 3909, - "sha256": "0be7b97d58aa5d871418eb2ecb49ae6235edb35efc9272844fac71815430452b" + "bytes": 3778, + "sha256": "25b32c7497d99485a40039355072dd1d049d6b548c1a7204ffb6cbc0f50c3929" }, { "path": "packages/README.md", @@ -3312,8 +3357,8 @@ }, { "path": "packages/recall-map/src/index.mjs", - "bytes": 19093, - "sha256": "6005d3e3262ae27868ba6ae6684ad3536e4c609d004779b99fb27bbe3ad9cf1a" + "bytes": 22154, + "sha256": "051e7cfadd54a6d2490c93c2c6d8ded2e4ef31dd084ebe3c7ab700523eb497cb" }, { "path": "packages/release-readiness/package.json", @@ -3360,10 +3405,30 @@ "bytes": 172, "sha256": "383b1a911674cd80e4a30034e0d7ad2d46afcecda544b3fd0a6eadb82b238733" }, + { + "path": "packages/source-graph/src/index-store.mjs", + "bytes": 14473, + "sha256": "71f9809d6256fde6d6805ded745c176745b8fe25614c4da67a0764c1df7994ef" + }, { "path": "packages/source-graph/src/index.mjs", - "bytes": 13169, - "sha256": "69c20de2bab3f0fcc4bf32141457c0a89284bb60a36c57d67cfe7902a270c3b8" + "bytes": 19371, + "sha256": "a618c4c8cd3b8e6b85c244b3a5630bd49826e30f154ff2a6b60351ec086e68e2" + }, + { + "path": "packages/source-graph/src/intelligence.mjs", + "bytes": 8954, + "sha256": "14d4b2bb735a4ec638d3a775c9d13274daa0479fba2c7e710af5aeaa2bf65f04" + }, + { + "path": "packages/source-graph/src/orientation.mjs", + "bytes": 8630, + "sha256": "fd1fb12bc176daae7ebc2d4d17a7b76c44b31b557003bc010750eea18ff8a261" + }, + { + "path": "packages/source-graph/src/snapshot-service.mjs", + "bytes": 11541, + "sha256": "2bdc17d07c1306bf00d230b7f7c1c3e133d0c30f9148a6673480d38a413d399c" }, { "path": "packages/storage/package.json", @@ -3412,8 +3477,8 @@ }, { "path": "packages/ui/tokens.json", - "bytes": 1847, - "sha256": "a62bc9b393cd468d071cdb1a032492195d018281e2e115c0ebef17f5e0bcac86" + "bytes": 1846, + "sha256": "0efd0b96083a059a68907ebc860775e8229f93171f4fbc9d3d09f84438cc8d4d" }, { "path": "packages/workflow-runtime/AGENTS.md", @@ -3447,8 +3512,8 @@ }, { "path": "PROJECT_STATUS.json", - "bytes": 60872, - "sha256": "c45a2069a55fe9d917ba7f1d463726670f5c91fe00aa6dbc48b9f60e7b75b151" + "bytes": 63352, + "sha256": "bfc32b5b4db8bb9edbf63b313367eec830df39a0875f1a1ac87dfbfe5c16052d" }, { "path": "providers/AGENTS.md", @@ -3485,10 +3550,15 @@ "bytes": 1185, "sha256": "436a2c8cda4f1e333b79aa9b6838dec4ada2836250da5b9f3bd6388aa4a70af6" }, + { + "path": "providers/native/context-candidate-ast-code/src/ignore-rules.mjs", + "bytes": 3218, + "sha256": "4560e3e881b40f2abdddde5c644eb20ff686a21f85b36c64714c0ecdfd6d9a7c" + }, { "path": "providers/native/context-candidate-ast-code/src/index.mjs", - "bytes": 145608, - "sha256": "0124c48325443dd0ef8bb03159531a0793ea80acd2da6f2cfdb84c5daf889753" + "bytes": 158035, + "sha256": "93a4862dd9ae97546550c15f932ac4bc727b2f692a706b816fc349977098ae69" }, { "path": "providers/native/context-candidate-exact/package.json", @@ -3662,8 +3732,8 @@ }, { "path": "README.md", - "bytes": 14960, - "sha256": "e594862598e86418574f909056059e3c30014f14d56bbbbcd6ede477c9c48a6b" + "bytes": 15562, + "sha256": "270ae1b54d6017a7979bf40918a1125435fb1c9b2224b2d793df2c50742ce197" }, { "path": "REPOSITORY_MAP.md", @@ -3672,8 +3742,8 @@ }, { "path": "rfcs/0001-protocol-contracts.md", - "bytes": 4999, - "sha256": "f7967d8858a7e492f3b6a116005cc87c6817bd78ae66218cc9f21071ef041821" + "bytes": 5457, + "sha256": "8c3b9d4427cb3ac3689e9d35477ba100208bee176734cd03f2fafaed48ab2512" }, { "path": "ROADMAP.md", @@ -3757,13 +3827,13 @@ }, { "path": "scripts/consumer-browser-smoke.mjs", - "bytes": 24218, - "sha256": "dc17608ad6af8d0c1afd4670410b6611d68dfb2c47a85917954a23fc5955f6e9" + "bytes": 23882, + "sha256": "537d28c6b7951e9b8f88187e409088f0a4f7a4b113214ded89f235144c1c5bc7" }, { "path": "scripts/consumer-smoke.mjs", - "bytes": 15345, - "sha256": "0934591890fdda7dea862d6d3655ccd0c05ce649c983ea516275bf9be575f762" + "bytes": 15365, + "sha256": "465d2265c16aa88cd8123074549b25f0a3238c0afed0320418a266d3c8f43276" }, { "path": "scripts/context-recall-eval.mjs", @@ -3930,6 +4000,11 @@ "bytes": 6956, "sha256": "c774ae87417b5b7fd420a9bf2ce60d554813eb046b2aa53691699f1d0af9b928" }, + { + "path": "scripts/source-graph-large-repo-smoke.mjs", + "bytes": 9422, + "sha256": "1458c1ce69931cb1f3dd9a4bb6d096db6e3333b7232427969eb88a0678ac0b75" + }, { "path": "scripts/status.mjs", "bytes": 2995, @@ -3992,13 +4067,13 @@ }, { "path": "services/control-api/src/route-contracts.mjs", - "bytes": 85191, - "sha256": "d56878cde8db320aa8b8d1000a2ae3be9024964f4d59e70c12ccc4a8a2c57341" + "bytes": 85266, + "sha256": "09cf097d01d089f11285dea13934dce27883c08e96f4b6342cb8300af4179854" }, { "path": "services/control-api/src/server.mjs", - "bytes": 77454, - "sha256": "1a4a8efae64d68668e26295234c6db413f32bc1841da24f4237a5b37ef0c4806" + "bytes": 78034, + "sha256": "f72644c4467923736ccee1a53386a6a1f0387147fc2a3031790645c0c58e3890" }, { "path": "services/README.md", @@ -4185,6 +4260,11 @@ "bytes": 13663, "sha256": "eda7b42e5a2b1f52e648e886cf0e403eec748b319f134d31a5bb8e9113b234b6" }, + { + "path": "tests/cli-graph-index.test.mjs", + "bytes": 5026, + "sha256": "bdc12b7626832ed4358e06536148e0af92e35b05b5189df3db9ebcf8043c2b24" + }, { "path": "tests/cli-measure-context-pack.test.mjs", "bytes": 13781, @@ -4192,8 +4272,8 @@ }, { "path": "tests/cli.test.mjs", - "bytes": 307634, - "sha256": "308e9a00e6e07b882b14318fc5de57060ce1f1cde71c6115bcee6f32f930b55d" + "bytes": 307875, + "sha256": "e6afa43b85e848b0b63afdc2ca02850a2b27a21f4d336275b93ce36d701416d9" }, { "path": "tests/content-intelligence.test.mjs", @@ -4247,8 +4327,8 @@ }, { "path": "tests/control-api-boundary.test.mjs", - "bytes": 74195, - "sha256": "77376826e22d0565f5feff2ae4586ae85501d46cc9444e23482c9d90be7b879b" + "bytes": 77424, + "sha256": "e5e31e2980e981eadee6ef979f95c733f4cf1fe4671496899b611eace5823882" }, { "path": "tests/control-api.test.mjs", @@ -4330,6 +4410,11 @@ "bytes": 3729, "sha256": "ee81df87cc9f0df4c4df41cc5b7d421791e2c56fe53cb60853202a339f970f3e" }, + { + "path": "tests/mcp-code-intelligence.test.mjs", + "bytes": 7861, + "sha256": "cbfe5b1163d20e65466bd96524eeda54915a2dacc2487650884c888145fc6128" + }, { "path": "tests/memory-core.test.mjs", "bytes": 6706, @@ -4452,18 +4537,18 @@ }, { "path": "tests/recall-map-ranking.test.mjs", - "bytes": 27311, - "sha256": "0412de5c334debd2e859220f14ae10bba8afd48197d093e1d3dc5ecc0f9afa92" + "bytes": 27478, + "sha256": "9c89ab224fe50980cd1ca844752bfa99c27d1c6ffcfa4e3bed8adc4f298f89fe" }, { "path": "tests/recall-map.test.mjs", - "bytes": 17197, - "sha256": "fe1e3a731bdea70805ebfb77193816d6d16475813362b30d64c0b30eafd69999" + "bytes": 18394, + "sha256": "f3a8c5c252a89934c26ecbee8802210e1f9fa7ccccec3b7d4bd6ce0ada9e074a" }, { "path": "tests/release-readiness.test.mjs", "bytes": 21729, - "sha256": "56124e792cccb107c8528e50342dd81106235944828e17f37aecc4b270559af7" + "sha256": "9d88523f3a3d7e784fb456f91dabec9423710772d2fdca9e2bf6da82a1edf26f" }, { "path": "tests/release-regressions.test.mjs", @@ -4492,13 +4577,28 @@ }, { "path": "tests/skill-catalog.test.mjs", - "bytes": 30945, - "sha256": "6ea779227d1dce5c84c55812cc0834df8c5ce8a969aaf736d6313387ae9eae03" + "bytes": 30946, + "sha256": "64ec4c506e92b8f228c4954cf051f4dedc6b23dac66843fba768adf4c4f9246b" + }, + { + "path": "tests/source-graph-discovery.test.mjs", + "bytes": 4892, + "sha256": "5c84b09dc6c3dcd2ebd6d5f173aa920fb05011a0f588c301547f86ef664cdfce" + }, + { + "path": "tests/source-graph-index-store.test.mjs", + "bytes": 5103, + "sha256": "3515902faec3bab341c00d1b331e961e67bb283caaf559862618f6552ff87886" }, { "path": "tests/source-graph-preview.test.mjs", - "bytes": 82027, - "sha256": "b7469c2e3ae26345950968c678e5e4484ee4fe9443765e288028dd85bc89b92d" + "bytes": 90220, + "sha256": "2d0f58a6842f92ba34dc232bb150b7950fe68cd68a6806bd85c1c5be3a0f68ea" + }, + { + "path": "tests/source-graph-snapshot-service.test.mjs", + "bytes": 5365, + "sha256": "3960d8bfdafdcab81e455e313a35560aa31847cab1ae674a31130e0dc2e6f220" }, { "path": "tests/tool-durable-integration.test.mjs", @@ -4532,13 +4632,28 @@ }, { "path": "tests/usage-docs.test.mjs", - "bytes": 17630, - "sha256": "071dca81f164e734308b88186c37b721350896fa2fbfddeda9288c4ecd814e0f" + "bytes": 17739, + "sha256": "f0163f3d5900062f57c4495b18e2d4a2549a68f3cb6e1ba34f9ce1a31115f1b8" + }, + { + "path": "tests/web-memory-graph.test.mjs", + "bytes": 5701, + "sha256": "486feed038e139cb98c38bf02be6287094a687212fde8b2a60f3628bc2b2ce37" + }, + { + "path": "tests/web-orientation.test.mjs", + "bytes": 8604, + "sha256": "38e7803020ff60d8e08fc2faa97a0a178d4850138c29088b65d59d1d38f859a1" }, { "path": "tests/web-shell.test.mjs", - "bytes": 99856, - "sha256": "3749602b34492de746d712ae0fe56fe88da56c4ff8a088cc8ebe45d111e46fd5" + "bytes": 103200, + "sha256": "be73e313d514015827b59b8f42b16fde19505404e2ac9247367ec173c4f57e7d" + }, + { + "path": "tests/web-source-map.test.mjs", + "bytes": 8717, + "sha256": "4d5d75457ba59e83e345b53805be6d510f43ddda4532ec1632a1cf7e5b0e5cab" }, { "path": "tests/workflow-durable-crash.test.mjs", diff --git a/apps/cli/help.mjs b/apps/cli/help.mjs index 8894652e..5f826a4b 100644 --- a/apps/cli/help.mjs +++ b/apps/cli/help.mjs @@ -252,7 +252,7 @@ Graph commands build a bounded local JS/TS source graph and return locator-only stats, search, trace, or changed-file impact reports. Index writes are explicit. The index stores structural metadata under .local/source-graph by default and never stores raw source bodies. MCP reads the index but never builds or refreshes it. -No graph command makes model or network calls.`], +raw source bodies are not included. No graph command makes model or network calls.`], ['context handoff', `Memory Recall CLI: context handoff Usage: diff --git a/apps/cli/oaf.mjs b/apps/cli/oaf.mjs index de964a01..4732cd4a 100755 --- a/apps/cli/oaf.mjs +++ b/apps/cli/oaf.mjs @@ -2965,7 +2965,7 @@ function compactGraphIndexReport(result) { edgeCount: result.graph.edges.length }, measurements: result.measurements, - safeguards: graphIndexSafeguards({ localFilesWritten: 1 }) + safeguards: graphIndexSafeguards({ localFilesWritten: result.measurements.localFilesWritten ?? 1 }) }; } diff --git a/apps/web/app.js b/apps/web/app.js index 8c30584f..471cdae2 100644 --- a/apps/web/app.js +++ b/apps/web/app.js @@ -1276,23 +1276,27 @@ function renderRoute(route) { if (shellState.kind === 'denied') return renderSetupScreen('login', 'Use the local owner account for this workspace.'); if (shellState.kind === 'error') return statePanel('error','Could not load local state', shellState.message, true); if (route.id === 'home') return renderHome(); - if (route.id === 'runs') return activeRunDetail ? renderRunDetail(activeRunDetail) : renderRuns(); - if (route.id === 'workflows') return renderWorkflows(); - if (route.id === 'loop-workbench') return renderLoopWorkbench(); - if (route.id === 'fabric-map') return renderFabricMap(); - if (route.id === 'context') return renderContext(); + if (route.id === 'runs') return renderSecondaryRoute(route, activeRunDetail ? renderRunDetail(activeRunDetail) : renderRuns()); + if (route.id === 'workflows') return renderSecondaryRoute(route, renderWorkflows()); + if (route.id === 'loop-workbench') return renderSecondaryRoute(route, renderLoopWorkbench()); + if (route.id === 'fabric-map') return renderSecondaryRoute(route, renderFabricMap()); + if (route.id === 'context') return renderSecondaryRoute(route, renderContext()); if (route.id === 'context-pack') return renderContextPack(); if (route.id === 'source-graph') return renderSourceGraph(); if (route.id === 'memory') return renderMemory(); if (route.id === 'memory-graph') return renderMemoryGraphView(buildMemoryGraphViewModel(memoryGraph,memoryGraphOptions,memoryGraphError)); - if (route.id === 'evidence') return renderEvidence(); - if (route.id === 'approvals') return renderApprovals(); - if (route.id === 'content') return renderContentLab(); - if (route.id === 'agents') return renderAgentsTools(); + if (route.id === 'evidence') return renderSecondaryRoute(route, renderEvidence()); + if (route.id === 'approvals') return renderSecondaryRoute(route, renderApprovals()); + if (route.id === 'content') return renderSecondaryRoute(route, renderContentLab()); + if (route.id === 'agents') return renderSecondaryRoute(route, renderAgentsTools()); if (route.id === 'settings') return renderSettings(); return renderHome(); } +export function renderSecondaryRoute(route, content) { + return `

${esc(route?.title ?? 'Advanced view')}

${esc(route?.description ?? '')}

Advanced view
${content}
`; +} + function renderHome() { orientationModel=buildOrientationModel({ report: recallMap, diff --git a/docs/benchmarks.md b/docs/benchmarks.md index 468858de..8412da9f 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -121,6 +121,13 @@ local measurements, not fixed provider-billing or cross-repository claims. ## Methods without a headline number +`npm run source-graph:large-smoke` creates a temporary 1,100-file JS fixture, +exercises the 1,000-file default scan bound, builds and reloads the persistent +index, changes one represented file, and verifies that refresh parses one file +while reusing 999 shards. The JSON includes cold, warm, and one-file refresh +times plus graph and index sizes. Those values are machine-specific and are not +a production latency or million-node claim. + `recall bench locomo --read-only --root . --dataset evals/locomo/smoke.v1.json --budget 512 --limit 4 --format json` is a model-free retrieval-coverage method. The committed smoke fixture contains one conversation and three questions. Its report explicitly says it does not diff --git a/docs/product/memory-recall-developer-first.md b/docs/product/memory-recall-developer-first.md index 75be5b63..9c124869 100644 --- a/docs/product/memory-recall-developer-first.md +++ b/docs/product/memory-recall-developer-first.md @@ -22,11 +22,13 @@ a read-only handoff for the next coding-agent session. ## Support contract -- Implemented: local JS/TS static graph, reviewed SQLite memory, read-only MCP. +- Implemented: local JS/TS static graph, optional persistent incremental index, + reviewed SQLite memory, and twelve read-only MCP tools. - Implemented: bounded semantic plan and task packets, strict result import, pending proposals, and source-rechecked named approval. - Experimental: Rust acceleration paths require a local build before explicit invocation. - Experimental: explicit one-shot Gemini and OpenAI-compatible semantic API execution. -- Unsupported: automatic transcript capture, write-capable MCP, hosted sync, and non-JS/TS source graph analysis. +- Unsupported: automatic transcript capture, write-capable MCP, hosted sync, + non-JS/TS source graph analysis, cross-repository analysis, and million-node indexes. - Unsupported: automatic harness invocation, arbitrary semantic providers, semantic retrieval, raw source-code upload, background semantic sync, and automatic semantic memory activation. diff --git a/docs/release/1.0-PROVENANCE.json b/docs/release/1.0-PROVENANCE.json index 44c8fc96..3dcc6c0c 100644 --- a/docs/release/1.0-PROVENANCE.json +++ b/docs/release/1.0-PROVENANCE.json @@ -21,13 +21,13 @@ "mergePerformedByThisTask": false }, "sourceHashes": { - "package.json": "sha256:24bce104b8c5dce7d4dc2163b4d688955cdf96b6a66c787766348ba4c261cf11", + "package.json": "sha256:c0723009a5ad22c016b9acfc06eefd44ade5e7bd6caf05d2c9d45000e60ed831", "package-lock.json": "sha256:e7af858186750dbd7386fce41dd5e37303dca712915d31bc41fd22f7ecb0a368", - "PROJECT_STATUS.json": "sha256:ffb76f268fc59d0a1e883907c26ed9fd1fd57442d8c1ef3a8dd5770db75ec0d7", + "PROJECT_STATUS.json": "sha256:bfc32b5b4db8bb9edbf63b313367eec830df39a0875f1a1ac87dfbfe5c16052d", "planning/backlog.json": "sha256:a404e5704ba3bf3be635a655d2d73d84eff7eba3ce43ab511b83967cceab6a5f", "adapters/catalog.json": "sha256:11c22a150f847a560be3e64c8280c7757368d813b5f70304938ab7915694c283", - "docs/usage/support-matrix.md": "sha256:dd4108828b742e8d06112e6abdfa4119f90a8b034b417b9e7e41f6924dcf26a6", - "docs/benchmarks.md": "sha256:d486819287f1f97c1f51efded6dd7bf6c8491dc8e7a135c9cfaba9ed8f14b212", + "docs/usage/support-matrix.md": "sha256:94d3dc7b026944568912f775b85e86109f13dc022cfd1e889a00a13bead70e42", + "docs/benchmarks.md": "sha256:2514d366ffa277307f7bb8f503ef195e223a55cbe3e7578ee9c4327af5dea938", "THIRD_PARTY.md": "sha256:adbbffba498346ac25ff8f064db76ae93543d3c1990d37f1137d9122f0742dd7", "SECURITY.md": "sha256:af8c910a7f2092f3adc8a9be30f79618d040101c6eda0fda705fdd8f9327b472", "GOVERNANCE.md": "sha256:479e1fe15e56b58bd33b41cab67244de87b6f62b491bea0761337a5b87cd7e52" @@ -35,11 +35,11 @@ "publicEvidence": [ { "path": "docs/usage/support-matrix.md", - "sha256": "sha256:dd4108828b742e8d06112e6abdfa4119f90a8b034b417b9e7e41f6924dcf26a6" + "sha256": "sha256:94d3dc7b026944568912f775b85e86109f13dc022cfd1e889a00a13bead70e42" }, { "path": "docs/benchmarks.md", - "sha256": "sha256:d486819287f1f97c1f51efded6dd7bf6c8491dc8e7a135c9cfaba9ed8f14b212" + "sha256": "sha256:2514d366ffa277307f7bb8f503ef195e223a55cbe3e7578ee9c4327af5dea938" } ], "generatedFiles": [ diff --git a/docs/release/1.0-READINESS-REPORT.md b/docs/release/1.0-READINESS-REPORT.md index 8bd84bb5..56a118c6 100644 --- a/docs/release/1.0-READINESS-REPORT.md +++ b/docs/release/1.0-READINESS-REPORT.md @@ -23,8 +23,8 @@ Publication state: npm-published. This evidence is prepared for a maintainer mer | Network default | deny | | External writes | false | | Model mode | deterministic | -| Quality snapshot date | 2026-07-15 | -| Recorded tests | 705 | +| Quality snapshot date | 2026-07-16 | +| Recorded tests | 721 | | Recorded protocol fixtures | 182 | | Recorded evaluation assertions | 144 | | Quality snapshot note | Counts are a dated snapshot; command results and HANDOFF_VERIFICATION.json are authoritative. | @@ -33,8 +33,8 @@ Publication state: npm-published. This evidence is prepared for a maintainer mer | Page | Scope | Revision fingerprint | | --- | --- | --- | -| [Support matrix](../usage/support-matrix.md) | `docs/usage/support-matrix.md`: Implemented, experimental, and unsupported client paths with reversal boundaries. | `sha256:dd4108828b742e8d06112e6abdfa4119f90a8b034b417b9e7e41f6924dcf26a6` | -| [Benchmark proof](../benchmarks.md) | `docs/benchmarks.md`: Public local claims, datasets, baselines, commands, artifacts, and limitations. | `sha256:d486819287f1f97c1f51efded6dd7bf6c8491dc8e7a135c9cfaba9ed8f14b212` | +| [Support matrix](../usage/support-matrix.md) | `docs/usage/support-matrix.md`: Implemented, experimental, and unsupported client paths with reversal boundaries. | `sha256:94d3dc7b026944568912f775b85e86109f13dc022cfd1e889a00a13bead70e42` | +| [Benchmark proof](../benchmarks.md) | `docs/benchmarks.md`: Public local claims, datasets, baselines, commands, artifacts, and limitations. | `sha256:2514d366ffa277307f7bb8f503ef195e223a55cbe3e7578ee9c4327af5dea938` | The support matrix is the public contract for install and reversal paths. The benchmark page is the public source of truth for local measurement claims and diff --git a/docs/release/1.0-REPRODUCIBILITY.md b/docs/release/1.0-REPRODUCIBILITY.md index 8aff6974..d6149329 100644 --- a/docs/release/1.0-REPRODUCIBILITY.md +++ b/docs/release/1.0-REPRODUCIBILITY.md @@ -24,7 +24,7 @@ Counts are a dated snapshot; command results and HANDOFF_VERIFICATION.json are a | Gate | Expected Count | | --- | --- | -| Tests | 705 | +| Tests | 721 | | Protocol fixtures | 182 | | Evaluation assertions | 144 | @@ -32,8 +32,8 @@ Counts are a dated snapshot; command results and HANDOFF_VERIFICATION.json are a | Page | Scope | SHA-256 | | --- | --- | --- | -| `docs/usage/support-matrix.md` | Implemented, experimental, and unsupported client paths with reversal boundaries. | `sha256:dd4108828b742e8d06112e6abdfa4119f90a8b034b417b9e7e41f6924dcf26a6` | -| `docs/benchmarks.md` | Public local claims, datasets, baselines, commands, artifacts, and limitations. | `sha256:d486819287f1f97c1f51efded6dd7bf6c8491dc8e7a135c9cfaba9ed8f14b212` | +| `docs/usage/support-matrix.md` | Implemented, experimental, and unsupported client paths with reversal boundaries. | `sha256:94d3dc7b026944568912f775b85e86109f13dc022cfd1e889a00a13bead70e42` | +| `docs/benchmarks.md` | Public local claims, datasets, baselines, commands, artifacts, and limitations. | `sha256:2514d366ffa277307f7bb8f503ef195e223a55cbe3e7578ee9c4327af5dea938` | Regenerate these release artifacts after changing either public page so support boundaries and benchmark limitations remain tied to the same reviewed source. diff --git a/docs/usage/mcp-server-reference.md b/docs/usage/mcp-server-reference.md index 6153a289..d69783ab 100644 --- a/docs/usage/mcp-server-reference.md +++ b/docs/usage/mcp-server-reference.md @@ -22,11 +22,23 @@ npm run recall -- mcp server --read-only --root . --sqlite .local/memory.sqlite | `context.profile` | Return a compact profile selected under a context budget. | No | | `context.pack` | Return a safe locator handoff for the current task. | No | | `repo.map` | Return a bounded Recall Map of local source coverage, governed memory, and handoff readiness. | No | +| `repo.architecture` | Return bounded architecture groups, entry points, and structural hotspots. | No | +| `repo.index_status` | Report whether the optional persistent source index is missing, ready, stale, or invalid. | No | +| `code.search` | Search symbols, files, modules, and relationships. | No | +| `code.context` | Return one symbol with bounded incoming and outgoing relationships. | No | +| `code.trace` | Trace bounded inbound or outbound call paths from a symbol. | No | +| `code.dependencies` | Walk a bounded dependency neighborhood for a file, module, or symbol. | No | +| `code.routes` | Discover HTTP method exports in route-like JS/TS files. | No | | `code.impact` | Return bounded locator-safe impact for changed local source files. | No | The server does not expose memory approval, config mutation, shell, or external write tools. +All structural tools return safe labels, relationship metadata, and +`workspace://` locators. They do not return source bodies or absolute local +paths. Results are bounded to at most 50 requested rows and trace or dependency +depth is capped at 3. + `repo.map` accepts optional `changed`, `query`, and `limit` inputs. `changed` uses safe workspace-relative source locators and `limit` is bounded to `1..50`. `code.impact` requires a non-empty `changed` list and accepts `depth` `1`, `2`, @@ -35,6 +47,34 @@ or `3` plus a `limit` of `1..50`. Both return safe labels and graph state, or memory state. The established Recall Map v1 response envelope remains capped at 20 listed architecture, search, and impact items. +`code.search` accepts a query plus optional node kinds, edge kinds, locator +prefix, limit, and offset. `code.context` selects a symbol and reports its direct +incoming and outgoing relationships. `code.trace` follows call edges. +`code.dependencies` walks imports and related structural edges. `code.routes` +uses static HTTP-method exports in route-like paths; it does not execute a +framework or claim runtime route coverage. + +## Persistent Source Index + +The source index is optional and local: + +```bash +recall graph index --status --root . --format summary +recall graph index --write --root . --format json +recall graph index --refresh --root . --format json +recall graph index --refresh --watch --root . --format summary +``` + +The default file is `.local/source-graph/index.v1.json`. It contains hashes, +locators, symbol metadata, per-file parse shards, and a bounded graph. It does +not contain source bodies or absolute paths. Writes are atomic and require the +explicit CLI command. Refresh reparses changed and added files, reuses unchanged +shards, and removes deleted files. + +MCP checks index freshness but never writes the index. A current index is reused +across processes. A stale index is reported as stale and structural tools fall +back to a fresh bounded scan. + ## Resource Catalog ```bash @@ -68,6 +108,7 @@ recall mcp install --client claude-code --apply --confirm sha256: