memorix 1.4.0 → 1.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/CLAUDE.md +9 -9
  3. package/README.md +7 -2
  4. package/README.zh-CN.md +6 -2
  5. package/dist/cli/index.js +1216 -485
  6. package/dist/index.js +984 -337
  7. package/dist/maintenance-runner.js +564 -180
  8. package/dist/media-video-runner.js +564 -180
  9. package/dist/memcode/index.js +53 -21
  10. package/dist/memcode-runtime/CHANGELOG.md +31 -0
  11. package/dist/sdk.js +983 -336
  12. package/dist/vector-backfill-runner.js +564 -180
  13. package/docs/1.4.2-EVIDENCE-GOVERNED-MEMORY-SPEC.md +434 -0
  14. package/docs/API_REFERENCE.md +1 -1
  15. package/docs/KNOWN_ISSUES_AND_ROADMAP.md +52 -128
  16. package/package.json +5 -2
  17. package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
  18. package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
  19. package/plugins/copilot/memorix/plugin.json +1 -1
  20. package/plugins/gemini/memorix/gemini-extension.json +1 -1
  21. package/plugins/omp/memorix/package.json +1 -1
  22. package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
  23. package/plugins/pi/memorix/package.json +1 -1
  24. package/server.json +2 -2
  25. package/src/cli/commands/codegraph.ts +83 -2
  26. package/src/codegraph/auto-context.ts +20 -0
  27. package/src/codegraph/current-facts.ts +1 -0
  28. package/src/codegraph/external-provider.ts +1 -0
  29. package/src/codegraph/store.ts +216 -5
  30. package/src/codegraph/types.ts +42 -0
  31. package/src/knowledge/context-assembly.ts +25 -0
  32. package/src/knowledge/governor.ts +156 -0
  33. package/src/knowledge/outcome-store.ts +102 -0
  34. package/src/knowledge/outcome-types.ts +49 -0
  35. package/src/knowledge/workflows.ts +32 -1
  36. package/src/knowledge/workset.ts +203 -13
  37. package/src/store/sqlite-db.ts +42 -0
  38. package/docs/dev-log/progress.txt +0 -388
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "memorix",
3
3
  "mcpName": "io.github.AVIDS2/memorix",
4
- "version": "1.4.0",
4
+ "version": "1.4.2",
5
5
  "description": "Local-first shared memory layer for AI coding agents across MCP clients, Git history, reasoning context, and project sessions.",
6
6
  "type": "module",
7
7
  "sideEffects": false,
@@ -44,6 +44,7 @@
44
44
  "README.zh-CN.md",
45
45
  "assets",
46
46
  "docs",
47
+ "!docs/dev-log",
47
48
  "!docs/memcode",
48
49
  "!docs/superpowers",
49
50
  "plugins",
@@ -69,9 +70,11 @@
69
70
  "generate:star-history": "node scripts/generate-star-history.mjs",
70
71
  "sync:mcp-registry": "node scripts/sync-mcp-registry-manifest.mjs",
71
72
  "check:mcp-registry": "node scripts/check-mcp-registry-manifest.mjs",
73
+ "sync:plugin-release": "node scripts/sync-plugin-release-manifests.mjs",
74
+ "check:plugin-release": "node scripts/sync-plugin-release-manifests.mjs --check",
72
75
  "audit:pi-upstream": "node scripts/pi-upstream-audit.mjs",
73
76
  "lint": "tsc --noEmit",
74
- "prepublishOnly": "npm run sync:mcp-registry && npm run check:mcp-registry && npm run build && npm test"
77
+ "prepublishOnly": "npm run sync:mcp-registry && npm run sync:plugin-release && npm run check:mcp-registry && npm run check:plugin-release && npm run build && npm test"
75
78
  },
76
79
  "keywords": [
77
80
  "mcp",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "memorix",
4
- "version": "1.4.0",
4
+ "version": "1.4.2",
5
5
  "description": "Shared workspace memory for Claude Code and other AI coding agents.",
6
6
  "author": {
7
7
  "name": "AVIDS2",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memorix",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
4
4
  "description": "Shared workspace memory for Codex and other AI coding agents.",
5
5
  "author": {
6
6
  "name": "AVIDS2",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memorix",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
4
4
  "description": "Shared local workspace memory for GitHub Copilot CLI and other AI coding agents.",
5
5
  "author": {
6
6
  "name": "AVIDS2",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memorix",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
4
4
  "mcpServers": {
5
5
  "memorix": {
6
6
  "command": "memorix",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memorix-omp-package",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
4
4
  "description": "Memorix shared workspace memory package for Oh-my-Pi.",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memorix",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
4
4
  "description": "Shared workspace memory for OpenClaw and other AI coding agents.",
5
5
  "author": {
6
6
  "name": "AVIDS2",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memorix-pi-package",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
4
4
  "description": "Memorix shared workspace memory package for Pi coding agent.",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [
package/server.json CHANGED
@@ -7,7 +7,7 @@
7
7
  "url": "https://github.com/AVIDS2/memorix",
8
8
  "source": "github"
9
9
  },
10
- "version": "1.4.0",
10
+ "version": "1.4.2",
11
11
  "websiteUrl": "https://github.com/AVIDS2/memorix#readme",
12
12
  "icons": [
13
13
  {
@@ -22,7 +22,7 @@
22
22
  {
23
23
  "registryType": "npm",
24
24
  "identifier": "memorix",
25
- "version": "1.4.0",
25
+ "version": "1.4.2",
26
26
  "runtimeHint": "npx",
27
27
  "packageArguments": [
28
28
  {
@@ -58,12 +58,52 @@ function formatUsageHint(): string {
58
58
  'Usage:',
59
59
  ' memorix codegraph refresh',
60
60
  ' memorix codegraph status --json',
61
+ ' memorix codegraph diff [--from <snapshot>] [--to <snapshot>]',
62
+ ' memorix codegraph impact --path <relative-source-path>',
61
63
  ' memorix codegraph context-pack --task "continue auth bug"',
62
64
  '',
63
65
  'Tip: use `memorix context "..."` for new work or `memorix resume "..."` for prior work.',
64
66
  ].join('\n');
65
67
  }
66
68
 
69
+ function formatDiff(diff: ReturnType<CodeGraphStore['diffSnapshots']>, impact: ReturnType<CodeGraphStore['impactSlice']>): string {
70
+ if (!diff.available) {
71
+ return `CodeGraph diff is unavailable: ${diff.reason ?? 'unknown reason'}. Refresh twice to establish comparable snapshots.`;
72
+ }
73
+ const summary = diff.changes.reduce((counts, change) => {
74
+ counts[change.kind] += 1;
75
+ return counts;
76
+ }, { added: 0, modified: 0, removed: 0 });
77
+ const lines = [
78
+ `CodeGraph diff: ${diff.fromSnapshotId} -> ${diff.toSnapshotId}`,
79
+ `- Changed files: ${diff.changes.length} (${summary.added} added, ${summary.modified} modified, ${summary.removed} removed)`,
80
+ ];
81
+ for (const change of diff.changes.slice(0, 20)) lines.push(`- ${change.kind}: ${change.path}`);
82
+ if (diff.changes.length > 20) lines.push(`- ${diff.changes.length - 20} additional file change(s) omitted`);
83
+ if (impact.directlyConnectedPaths.length > 0) {
84
+ lines.push('', 'Current one-hop structural impact');
85
+ for (const path of impact.directlyConnectedPaths.slice(0, 20)) lines.push(`- ${path}`);
86
+ }
87
+ if (impact.relationCount === 0) lines.push('', 'No current file-edge relation was found for the changed paths.');
88
+ if (impact.truncated) lines.push('- Impact relation limit reached; inspect the graph before assuming coverage.');
89
+ return lines.join('\n');
90
+ }
91
+
92
+ function formatImpact(impact: ReturnType<CodeGraphStore['impactSlice']>): string {
93
+ const lines = [
94
+ `CodeGraph current one-hop impact: ${impact.changedPaths.join(', ')}`,
95
+ `- Relations inspected: ${impact.relationCount}`,
96
+ ];
97
+ if (impact.directlyConnectedPaths.length > 0) {
98
+ lines.push('- Directly connected paths:');
99
+ for (const path of impact.directlyConnectedPaths) lines.push(` - ${path}`);
100
+ } else {
101
+ lines.push('- No current file-edge relation was found for this indexed path.');
102
+ }
103
+ if (impact.truncated) lines.push('- Impact relation limit reached; inspect the graph before assuming coverage.');
104
+ return lines.join('\n');
105
+ }
106
+
67
107
  function compactFacts(project: { rootPath: string }): { facts: string[]; dirty: boolean } {
68
108
  const current = collectCurrentProjectFacts({ project, now: new Date() });
69
109
  const facts: string[] = [];
@@ -82,9 +122,13 @@ export default defineCommand({
82
122
  description: 'Inspect and refresh CodeGraph Memory for the current project',
83
123
  },
84
124
  args: {
85
- action: { type: 'string', description: 'Action: status, refresh, or context-pack' },
125
+ action: { type: 'string', description: 'Action: status, refresh, diff, impact, or context-pack' },
86
126
  task: { type: 'string', description: 'Task text for context-pack' },
127
+ path: { type: 'string', description: 'Indexed relative source path for impact' },
87
128
  limit: { type: 'string', description: 'Max active memories to inspect for context-pack' },
129
+ from: { type: 'string', description: 'Earlier CodeGraph snapshot id for diff' },
130
+ to: { type: 'string', description: 'Later CodeGraph snapshot id for diff' },
131
+ impactLimit: { type: 'string', description: 'Maximum graph relations to inspect for diff impact' },
88
132
  json: { type: 'boolean', description: 'Emit machine-readable JSON output' },
89
133
  },
90
134
  run: async ({ args }) => {
@@ -162,6 +206,43 @@ export default defineCommand({
162
206
  return;
163
207
  }
164
208
 
209
+ case 'diff': {
210
+ const snapshots = store.listSnapshots(project.id, 2);
211
+ const fromSnapshotId = (args.from as string | undefined)?.trim() || snapshots[1]?.id;
212
+ const toSnapshotId = (args.to as string | undefined)?.trim() || snapshots[0]?.id;
213
+ if (!fromSnapshotId || !toSnapshotId) {
214
+ emitError('two completed CodeGraph snapshots are required; run "memorix codegraph refresh" again after a code change.', asJson);
215
+ return;
216
+ }
217
+ const diff = store.diffSnapshots(project.id, fromSnapshotId, toSnapshotId);
218
+ const impact = store.impactSlice(
219
+ project.id,
220
+ diff.changes.map(change => change.path),
221
+ parsePositiveInt(args.impactLimit as string | undefined, 24),
222
+ );
223
+ emitResult({ project, diff, impact }, formatDiff(diff, impact), asJson);
224
+ return;
225
+ }
226
+
227
+ case 'impact': {
228
+ const sourcePath = (args.path as string | undefined)?.trim() || positional.slice(1).join(' ').trim();
229
+ if (!sourcePath) {
230
+ emitError('path is required for "memorix codegraph impact"', asJson);
231
+ return;
232
+ }
233
+ if (!store.getFile(project.id, sourcePath)) {
234
+ emitError(`path is not in the current CodeGraph index: ${sourcePath}. Run "memorix codegraph refresh" and retry.`, asJson);
235
+ return;
236
+ }
237
+ const impact = store.impactSlice(
238
+ project.id,
239
+ [sourcePath],
240
+ parsePositiveInt(args.impactLimit as string | undefined, 24),
241
+ );
242
+ emitResult({ project, impact }, formatImpact(impact), asJson);
243
+ return;
244
+ }
245
+
165
246
  case 'context-pack': {
166
247
  const task = (args.task as string | undefined)?.trim() || positional.slice(1).join(' ').trim();
167
248
  if (!task) {
@@ -224,7 +305,7 @@ export default defineCommand({
224
305
  }
225
306
 
226
307
  default:
227
- emitError(`unknown codegraph action "${action}". Use "status", "refresh", or "context-pack".`, asJson);
308
+ emitError(`unknown codegraph action "${action}". Use "status", "refresh", "diff", "impact", or "context-pack".`, asJson);
228
309
  }
229
310
  } catch (error) {
230
311
  emitError(error instanceof Error ? error.message : String(error), asJson);
@@ -259,6 +259,25 @@ export async function buildAutoProjectContext(input: {
259
259
  const overview = explain.overview;
260
260
  const currentFacts = collectCurrentProjectFacts({ project: input.project, now });
261
261
  const latestSnapshot = overview.code.latestSnapshot;
262
+ let codeEvolution: TaskWorkset['codeEvolution'];
263
+ if (continuationRequested && latestSnapshot?.previousSnapshotId && latestSnapshot.changedPathCount > 0) {
264
+ const diff = store.diffSnapshots(input.project.id, latestSnapshot.previousSnapshotId, latestSnapshot.id);
265
+ if (diff.available && diff.changes.length > 0) {
266
+ const impact = store.impactSlice(input.project.id, diff.changes.map(change => change.path));
267
+ const changedFileIds = new Set([
268
+ ...store.listSnapshotFiles(diff.fromSnapshotId),
269
+ ...store.listSnapshotFiles(diff.toSnapshotId),
270
+ ].filter(file => diff.changes.some(change => change.path === file.path)).map(file => file.fileId));
271
+ codeEvolution = {
272
+ fromSnapshotId: diff.fromSnapshotId,
273
+ toSnapshotId: diff.toSnapshotId,
274
+ changes: diff.changes.slice(0, 5).map(change => ({ path: change.path, kind: change.kind })),
275
+ directlyConnectedPaths: impact.directlyConnectedPaths.slice(0, 3),
276
+ affectedMemoryCount: store.countStaleObservationRefsForFiles(input.project.id, [...changedFileIds]),
277
+ truncated: impact.truncated || diff.changes.length > 5,
278
+ };
279
+ }
280
+ }
262
281
  const sourceSets = lensSourceSets({ task, lens, explain });
263
282
  let externalOutline: ExternalCodeGraphOutline | undefined;
264
283
  let externalCaution: string | undefined;
@@ -348,6 +367,7 @@ export async function buildAutoProjectContext(input: {
348
367
  currentFacts: worksetFactLines(currentFacts),
349
368
  ...(continuation ? { continuation } : {}),
350
369
  codeState: codeStateLine(overview),
370
+ ...(codeEvolution ? { codeEvolution } : {}),
351
371
  reliableMemory: sourceSets.reliableSources
352
372
  .slice(0, lens.sourceLimit)
353
373
  .map(source => ({
@@ -129,6 +129,7 @@ function detectStaleProgressNotes(input: {
129
129
  now: Date;
130
130
  }): ProjectStaleNote[] {
131
131
  const candidates = [
132
+ 'ACTIVE_WORK.md',
132
133
  'progress.txt',
133
134
  'docs/dev-log/progress.txt',
134
135
  'docs/memcode/dev-log/progress.txt',
@@ -302,6 +302,7 @@ function providerQuality(input: {
302
302
  capabilities: {
303
303
  declarations: true,
304
304
  importHints: true,
305
+ localRelativeImportTargets: true,
305
306
  resolvedRelations: false,
306
307
  exactLocations: false,
307
308
  },
@@ -1,9 +1,14 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import { posix as pathPosix } from 'node:path';
2
3
  import { getDatabase } from '../store/sqlite-db.js';
3
4
  import type {
4
5
  CodeEdge,
5
6
  CodeFile,
7
+ CodeGraphImpactSlice,
6
8
  CodeGraphStatus,
9
+ CodeStateDiff,
10
+ CodeStateFileChange,
11
+ CodeStateSnapshotFile,
7
12
  CodeStateScanCompleteness,
8
13
  CodeStateSnapshot,
9
14
  CodeStateSnapshotInput,
@@ -71,6 +76,41 @@ function rowToEdge(row: any): CodeEdge {
71
76
  } as CodeEdge;
72
77
  }
73
78
 
79
+ function resolveRelativeImportTarget(
80
+ sourcePath: string,
81
+ evidence: string | undefined,
82
+ fileByPath: Map<string, CodeFile>,
83
+ ): CodeFile | undefined {
84
+ const target = evidence?.trim().replace(/\\/g, '/');
85
+ if (!target?.startsWith('.')) return undefined;
86
+ const base = normalizeCodePath(pathPosix.normalize(pathPosix.join(pathPosix.dirname(sourcePath), target)));
87
+ if (!base || base === '..' || base.startsWith('../')) return undefined;
88
+ const candidates = [base];
89
+ const extensionMatch = base.match(/(\.[A-Za-z0-9]+)$/);
90
+ const hasExtension = Boolean(extensionMatch);
91
+ if (!hasExtension) {
92
+ const extensions = [...new Set([...fileByPath.keys()]
93
+ .map(path => path.match(/(\.[A-Za-z0-9]+)$/)?.[1])
94
+ .filter((extension): extension is string => Boolean(extension)))];
95
+ for (const extension of extensions) {
96
+ candidates.push(`${base}${extension}`, `${base}/index${extension}`);
97
+ }
98
+ } else {
99
+ // Node ESM projects commonly import the emitted .js path from a .ts source.
100
+ // Keep an exact path first, then resolve only equivalent local source forms.
101
+ const sourceStem = base.slice(0, -extensionMatch![1].length);
102
+ const sourceExtensions = extensionMatch![1] === '.js'
103
+ ? ['.ts', '.tsx', '.js', '.jsx']
104
+ : extensionMatch![1] === '.mjs'
105
+ ? ['.mts', '.mjs']
106
+ : extensionMatch![1] === '.cjs'
107
+ ? ['.cts', '.cjs']
108
+ : [];
109
+ for (const extension of sourceExtensions) candidates.push(`${sourceStem}${extension}`);
110
+ }
111
+ return candidates.map(candidate => fileByPath.get(candidate)).find((file): file is CodeFile => Boolean(file));
112
+ }
113
+
74
114
  function rowToRef(row: any): ObservationCodeRef {
75
115
  return {
76
116
  id: row.id,
@@ -134,6 +174,22 @@ function rowToSnapshot(row: any): CodeStateSnapshot {
134
174
  } as CodeStateSnapshot;
135
175
  }
136
176
 
177
+ function rowToSnapshotFile(row: any): CodeStateSnapshotFile {
178
+ return {
179
+ snapshotId: row.snapshotId,
180
+ projectId: row.projectId,
181
+ fileId: row.fileId,
182
+ path: row.path,
183
+ contentHash: row.contentHash,
184
+ };
185
+ }
186
+
187
+ function snapshotIsIncomplete(snapshot: CodeStateSnapshot): boolean {
188
+ return snapshot.completeness.skippedOversizedFiles > 0
189
+ || (snapshot.completeness.unreadableFiles ?? 0) > 0
190
+ || snapshot.completeness.removalScanDeferred;
191
+ }
192
+
137
193
  export class CodeGraphStore {
138
194
  private db: any = null;
139
195
  private dataDir: string | null = null;
@@ -312,6 +368,7 @@ export class CodeGraphStore {
312
368
  });
313
369
 
314
370
  tx();
371
+ this.reconcileLocalImportTargets(projectId);
315
372
  }
316
373
 
317
374
  /**
@@ -329,7 +386,10 @@ export class CodeGraphStore {
329
386
  const changed = input.changed.filter((delta) => delta.file.projectId === projectId);
330
387
  const metadataOnly = (input.metadataOnly ?? []).filter((file) => file.projectId === projectId);
331
388
  const removedFileIds = input.removedFileIds ?? [];
332
- if (changed.length === 0 && metadataOnly.length === 0 && removedFileIds.length === 0) return;
389
+ if (changed.length === 0 && metadataOnly.length === 0 && removedFileIds.length === 0) {
390
+ this.reconcileLocalImportTargets(projectId);
391
+ return;
392
+ }
333
393
 
334
394
  const staleRefsForFile = this.db.prepare(`
335
395
  UPDATE observation_code_refs
@@ -340,9 +400,18 @@ export class CodeGraphStore {
340
400
  )
341
401
  )
342
402
  `);
343
- const deleteEdgesForFile = this.db.prepare(`
403
+ const deleteEdgesFromFile = this.db.prepare(`
404
+ DELETE FROM code_edges
405
+ WHERE projectId = ? AND fromFileId = ?
406
+ `);
407
+ const clearImportTargetsForFile = this.db.prepare(`
408
+ UPDATE code_edges
409
+ SET toFileId = NULL
410
+ WHERE projectId = ? AND toFileId = ? AND type = 'imports'
411
+ `);
412
+ const deleteOtherIncomingEdgesForFile = this.db.prepare(`
344
413
  DELETE FROM code_edges
345
- WHERE projectId = ? AND (fromFileId = ? OR toFileId = ?)
414
+ WHERE projectId = ? AND toFileId = ? AND type != 'imports'
346
415
  `);
347
416
  const deleteSymbolsForFile = this.db.prepare(`DELETE FROM code_symbols WHERE projectId = ? AND fileId = ?`);
348
417
  const deleteFile = this.db.prepare(`DELETE FROM code_files WHERE projectId = ? AND id = ?`);
@@ -380,7 +449,9 @@ export class CodeGraphStore {
380
449
  const staleAt = new Date().toISOString();
381
450
  for (const fileId of removedFileIds) {
382
451
  staleRefsForFile.run(staleAt, projectId, fileId, projectId, fileId);
383
- deleteEdgesForFile.run(projectId, fileId, fileId);
452
+ deleteEdgesFromFile.run(projectId, fileId);
453
+ clearImportTargetsForFile.run(projectId, fileId);
454
+ deleteOtherIncomingEdgesForFile.run(projectId, fileId);
384
455
  deleteSymbolsForFile.run(projectId, fileId);
385
456
  deleteFile.run(projectId, fileId);
386
457
  }
@@ -388,7 +459,9 @@ export class CodeGraphStore {
388
459
  for (const delta of changed) {
389
460
  const fileId = delta.file.id;
390
461
  staleRefsForFile.run(staleAt, projectId, fileId, projectId, fileId);
391
- deleteEdgesForFile.run(projectId, fileId, fileId);
462
+ deleteEdgesFromFile.run(projectId, fileId);
463
+ clearImportTargetsForFile.run(projectId, fileId);
464
+ deleteOtherIncomingEdgesForFile.run(projectId, fileId);
392
465
  deleteSymbolsForFile.run(projectId, fileId);
393
466
  writeFile(delta.file);
394
467
 
@@ -429,6 +502,7 @@ export class CodeGraphStore {
429
502
  for (const file of metadataOnly) writeFile(file);
430
503
  });
431
504
  tx();
505
+ this.reconcileLocalImportTargets(projectId);
432
506
  }
433
507
 
434
508
  upsertObservationRefs(refs: ObservationCodeRef[]): void {
@@ -493,6 +567,26 @@ export class CodeGraphStore {
493
567
  tx();
494
568
  }
495
569
 
570
+ /**
571
+ * Lite import extraction records the original import text first. Resolve only
572
+ * local relative imports against the current index after each refresh, so an
573
+ * added or removed target cannot leave a fabricated file-to-file relation.
574
+ */
575
+ reconcileLocalImportTargets(projectId: string): void {
576
+ const fileById = new Map(this.listFiles(projectId).map(file => [file.id, file]));
577
+ const fileByPath = new Map([...fileById.values()].map(file => [file.path, file]));
578
+ const update = this.db.prepare('UPDATE code_edges SET toFileId = ? WHERE id = ?');
579
+ const tx = this.db.transaction(() => {
580
+ for (const edge of this.listEdges(projectId)) {
581
+ if (edge.type !== 'imports' || !edge.fromFileId) continue;
582
+ const source = fileById.get(edge.fromFileId);
583
+ const target = source ? resolveRelativeImportTarget(source.path, edge.evidence, fileByPath) : undefined;
584
+ update.run(target?.id ?? null, edge.id);
585
+ }
586
+ });
587
+ tx();
588
+ }
589
+
496
590
  getFile(projectId: string, path: string): CodeFile | null {
497
591
  const row = this.db.prepare(`SELECT * FROM code_files WHERE projectId = ? AND path = ?`).get(projectId, normalizeCodePath(path));
498
592
  return row ? rowToFile(row) : null;
@@ -577,6 +671,19 @@ export class CodeGraphStore {
577
671
  `).all(projectId).map(rowToRef);
578
672
  }
579
673
 
674
+ /** Count distinct observations whose binding to these exact files is stale. */
675
+ countStaleObservationRefsForFiles(projectId: string, fileIds: string[]): number {
676
+ const ids = [...new Set(fileIds.filter(Boolean))];
677
+ if (ids.length === 0) return 0;
678
+ const placeholders = ids.map(() => '?').join(', ');
679
+ const row = this.db.prepare(`
680
+ SELECT COUNT(DISTINCT observationId) AS count
681
+ FROM observation_code_refs
682
+ WHERE projectId = ? AND status = 'stale' AND fileId IN (${placeholders})
683
+ `).get(projectId, ...ids);
684
+ return Number(row?.count ?? 0);
685
+ }
686
+
580
687
  listReferencedSymbols(projectId: string): CodeSymbol[] {
581
688
  return this.db.prepare(`
582
689
  SELECT DISTINCT symbols.*
@@ -601,6 +708,100 @@ export class CodeGraphStore {
601
708
  ).all(projectId, safeLimit).map(rowToSnapshot);
602
709
  }
603
710
 
711
+ listSnapshotFiles(snapshotId: string): CodeStateSnapshotFile[] {
712
+ return this.db.prepare(`
713
+ SELECT snapshotId, projectId, fileId, path, contentHash
714
+ FROM code_state_snapshot_files
715
+ WHERE snapshotId = ?
716
+ ORDER BY path
717
+ `).all(snapshotId).map(rowToSnapshotFile);
718
+ }
719
+
720
+ diffSnapshots(projectId: string, fromSnapshotId: string, toSnapshotId: string): CodeStateDiff {
721
+ const snapshots = this.db.prepare(`
722
+ SELECT * FROM code_state_snapshots WHERE id IN (?, ?)
723
+ `).all(fromSnapshotId, toSnapshotId).map(rowToSnapshot) as CodeStateSnapshot[];
724
+ if (snapshots.length !== 2) {
725
+ return { projectId, fromSnapshotId, toSnapshotId, available: false, reason: 'missing-snapshot', changes: [] };
726
+ }
727
+ if (snapshots.some(snapshot => snapshot.projectId !== projectId)) {
728
+ return { projectId, fromSnapshotId, toSnapshotId, available: false, reason: 'project-mismatch', changes: [] };
729
+ }
730
+ if (snapshots.some(snapshotIsIncomplete)) {
731
+ return { projectId, fromSnapshotId, toSnapshotId, available: false, reason: 'incomplete-snapshot', changes: [] };
732
+ }
733
+ const before = this.listSnapshotFiles(fromSnapshotId);
734
+ const after = this.listSnapshotFiles(toSnapshotId);
735
+ if (before.length === 0 || after.length === 0) {
736
+ return {
737
+ projectId,
738
+ fromSnapshotId,
739
+ toSnapshotId,
740
+ available: false,
741
+ reason: 'legacy-snapshot-without-manifest',
742
+ changes: [],
743
+ };
744
+ }
745
+ const beforeByPath = new Map(before.map(file => [file.path, file]));
746
+ const afterByPath = new Map(after.map(file => [file.path, file]));
747
+ const paths = [...new Set([...beforeByPath.keys(), ...afterByPath.keys()])].sort();
748
+ const changes: CodeStateFileChange[] = [];
749
+ for (const path of paths) {
750
+ const oldFile = beforeByPath.get(path);
751
+ const newFile = afterByPath.get(path);
752
+ if (!oldFile && newFile) {
753
+ changes.push({ path, kind: 'added', afterHash: newFile.contentHash });
754
+ } else if (oldFile && !newFile) {
755
+ changes.push({ path, kind: 'removed', beforeHash: oldFile.contentHash });
756
+ } else if (oldFile && newFile && oldFile.contentHash !== newFile.contentHash) {
757
+ changes.push({ path, kind: 'modified', beforeHash: oldFile.contentHash, afterHash: newFile.contentHash });
758
+ }
759
+ }
760
+ return { projectId, fromSnapshotId, toSnapshotId, available: true, changes };
761
+ }
762
+
763
+ impactSlice(projectId: string, changedPaths: string[], maxRelations = 24): CodeGraphImpactSlice {
764
+ const safeLimit = Number.isFinite(maxRelations) ? Math.max(1, Math.min(100, Math.floor(maxRelations))) : 24;
765
+ const normalizedPaths = [...new Set(changedPaths.map(normalizeCodePath).filter(Boolean))].sort();
766
+ const files = this.listFiles(projectId);
767
+ const fileById = new Map(files.map(file => [file.id, file.path]));
768
+ const fileIdBySymbolId = new Map(this.listSymbols(projectId).map(symbol => [symbol.id, symbol.fileId]));
769
+ const changedIds = new Set(files.filter(file => normalizedPaths.includes(file.path)).map(file => file.id));
770
+ const direct = new Set<string>();
771
+ let relationCount = 0;
772
+ let truncated = false;
773
+ for (const edge of this.listEdges(projectId)) {
774
+ const endpoints = [
775
+ edge.fromFileId,
776
+ edge.toFileId,
777
+ edge.fromSymbolId ? fileIdBySymbolId.get(edge.fromSymbolId) : undefined,
778
+ edge.toSymbolId ? fileIdBySymbolId.get(edge.toSymbolId) : undefined,
779
+ ].filter((id): id is string => Boolean(id));
780
+ if (!endpoints.some(id => changedIds.has(id))) continue;
781
+ const relatedPaths = endpoints
782
+ .filter(id => !changedIds.has(id))
783
+ .map(id => fileById.get(id))
784
+ .filter((path): path is string => Boolean(path));
785
+ if (relatedPaths.length === 0) continue;
786
+ if (relationCount >= safeLimit) {
787
+ truncated = true;
788
+ break;
789
+ }
790
+ relationCount += 1;
791
+ for (const path of relatedPaths) direct.add(path);
792
+ }
793
+ const snapshot = this.latestSnapshot(projectId);
794
+ return {
795
+ projectId,
796
+ ...(snapshot ? { snapshotId: snapshot.id } : {}),
797
+ changedPaths: normalizedPaths,
798
+ directlyConnectedPaths: [...direct].sort(),
799
+ relationCount,
800
+ truncated,
801
+ provider: snapshot?.provider ?? 'lite',
802
+ };
803
+ }
804
+
604
805
  /**
605
806
  * Record a completed scan and mark all current structural facts with its
606
807
  * epoch. The snapshot is written only after refresh reconciliation succeeds,
@@ -642,6 +843,16 @@ export class CodeGraphStore {
642
843
  this.db.prepare(
643
844
  "UPDATE observation_code_refs SET snapshotId = ? WHERE projectId = ? AND status = 'current'",
644
845
  ).run(id, input.projectId);
846
+ const snapshotFileRows = this.db.prepare(`
847
+ SELECT id, projectId, path, contentHash FROM code_files WHERE projectId = ? ORDER BY path
848
+ `).all(input.projectId) as Array<{ id: string; projectId: string; path: string; contentHash: string }>;
849
+ const insertSnapshotFile = this.db.prepare(`
850
+ INSERT INTO code_state_snapshot_files (snapshotId, projectId, fileId, path, contentHash)
851
+ VALUES (?, ?, ?, ?, ?)
852
+ `);
853
+ for (const file of snapshotFileRows) {
854
+ insertSnapshotFile.run(id, file.projectId, file.id, file.path, file.contentHash);
855
+ }
645
856
  snapshot = {
646
857
  ...input,
647
858
  id,
@@ -116,6 +116,46 @@ export interface CodeStateSnapshot extends CodeStateSnapshotInput {
116
116
  previousSnapshotId?: string;
117
117
  }
118
118
 
119
+ /** Hash-only file state retained for an explicit Code State snapshot. */
120
+ export interface CodeStateSnapshotFile {
121
+ snapshotId: string;
122
+ projectId: string;
123
+ fileId: string;
124
+ path: string;
125
+ contentHash: string;
126
+ }
127
+
128
+ export interface CodeStateFileChange {
129
+ path: string;
130
+ kind: 'added' | 'modified' | 'removed';
131
+ beforeHash?: string;
132
+ afterHash?: string;
133
+ }
134
+
135
+ /**
136
+ * An exact file-level comparison when both snapshots have hash manifests.
137
+ * It intentionally makes no claim about source-level semantic equivalence.
138
+ */
139
+ export interface CodeStateDiff {
140
+ projectId: string;
141
+ fromSnapshotId: string;
142
+ toSnapshotId: string;
143
+ available: boolean;
144
+ reason?: 'missing-snapshot' | 'project-mismatch' | 'legacy-snapshot-without-manifest' | 'incomplete-snapshot';
145
+ changes: CodeStateFileChange[];
146
+ }
147
+
148
+ /** Bounded one-hop structural evidence around changed file paths. */
149
+ export interface CodeGraphImpactSlice {
150
+ projectId: string;
151
+ snapshotId?: string;
152
+ changedPaths: string[];
153
+ directlyConnectedPaths: string[];
154
+ relationCount: number;
155
+ truncated: boolean;
156
+ provider: CodeGraphProviderKind;
157
+ }
158
+
119
159
  export interface CodeGraphStatus {
120
160
  provider: CodeGraphProviderKind;
121
161
  files: number;
@@ -164,6 +204,8 @@ export interface CodeGraphProviderQuality {
164
204
  capabilities: {
165
205
  declarations: boolean;
166
206
  importHints: boolean;
207
+ /** Deterministic local-path mapping only; not semantic name resolution. */
208
+ localRelativeImportTargets: boolean;
167
209
  resolvedRelations: false;
168
210
  exactLocations: false;
169
211
  };