memorix 1.1.13 → 1.2.0

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 (88) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +3 -2
  3. package/README.zh-CN.md +3 -2
  4. package/dist/cli/index.js +36313 -31189
  5. package/dist/cli/index.js.map +1 -1
  6. package/dist/dashboard/static/app.js +50 -30
  7. package/dist/index.js +5348 -674
  8. package/dist/index.js.map +1 -1
  9. package/dist/maintenance-runner.d.ts +1 -1
  10. package/dist/maintenance-runner.js +3661 -293
  11. package/dist/maintenance-runner.js.map +1 -1
  12. package/dist/memcode-runtime/CHANGELOG.md +19 -0
  13. package/dist/sdk.d.ts +1 -1
  14. package/dist/sdk.js +5346 -672
  15. package/dist/sdk.js.map +1 -1
  16. package/docs/1.2.0-CLAIM-LEDGER.md +72 -0
  17. package/docs/1.2.0-CODE-STATE.md +61 -0
  18. package/docs/1.2.0-DEVELOPMENT-CHARTER.md +255 -0
  19. package/docs/1.2.0-DYNAMIC-LIFECYCLE.md +71 -0
  20. package/docs/1.2.0-EVALUATION-HARNESS.md +51 -0
  21. package/docs/1.2.0-IMPLEMENTATION-PLAN.md +554 -0
  22. package/docs/1.2.0-KNOWLEDGE-WORKFLOW-RESEARCH.md +205 -0
  23. package/docs/1.2.0-KNOWLEDGE-WORKSPACE.md +68 -0
  24. package/docs/1.2.0-PRODUCT-STORY.md +234 -0
  25. package/docs/1.2.0-PROVIDER-QUALITY.md +189 -0
  26. package/docs/1.2.0-WORKFLOW-INHERITANCE.md +101 -0
  27. package/docs/1.2.0-WORKSET-RETRIEVAL.md +80 -0
  28. package/docs/AGENT_OPERATOR_PLAYBOOK.md +6 -3
  29. package/docs/API_REFERENCE.md +25 -6
  30. package/docs/CONFIGURATION.md +21 -3
  31. package/docs/README.md +17 -2
  32. package/docs/dev-log/progress.txt +120 -40
  33. package/llms-full.txt +16 -2
  34. package/llms.txt +9 -4
  35. package/package.json +3 -2
  36. package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
  37. package/src/cli/capability-map.ts +1 -0
  38. package/src/cli/commands/codegraph.ts +112 -9
  39. package/src/cli/commands/context.ts +2 -0
  40. package/src/cli/commands/doctor.ts +73 -4
  41. package/src/cli/commands/knowledge.ts +282 -0
  42. package/src/cli/commands/serve-http.ts +12 -1
  43. package/src/cli/index.ts +3 -1
  44. package/src/cli/tui/App.tsx +1 -1
  45. package/src/cli/tui/Panels.tsx +8 -8
  46. package/src/cli/tui/theme.ts +1 -1
  47. package/src/cli/tui/views/GraphView.tsx +8 -7
  48. package/src/cli/tui/views/KnowledgeView.tsx +9 -9
  49. package/src/codegraph/auto-context.ts +171 -9
  50. package/src/codegraph/code-state.ts +95 -0
  51. package/src/codegraph/context-pack.ts +82 -1
  52. package/src/codegraph/external-provider.ts +581 -0
  53. package/src/codegraph/lite-provider.ts +64 -19
  54. package/src/codegraph/project-context.ts +9 -1
  55. package/src/codegraph/store.ts +154 -6
  56. package/src/codegraph/types.ts +117 -0
  57. package/src/config/resolved-config.ts +28 -0
  58. package/src/config/toml-loader.ts +3 -0
  59. package/src/config/yaml-loader.ts +6 -0
  60. package/src/dashboard/server.ts +15 -1
  61. package/src/evaluation/workset-evaluation.ts +120 -0
  62. package/src/hooks/handler.ts +48 -6
  63. package/src/knowledge/claim-store.ts +267 -0
  64. package/src/knowledge/claims.ts +537 -0
  65. package/src/knowledge/markdown.ts +129 -0
  66. package/src/knowledge/types.ts +157 -0
  67. package/src/knowledge/wiki.ts +524 -0
  68. package/src/knowledge/workflow-store.ts +168 -0
  69. package/src/knowledge/workflow-types.ts +95 -0
  70. package/src/knowledge/workflows.ts +743 -0
  71. package/src/knowledge/workset.ts +515 -0
  72. package/src/knowledge/workspace-store.ts +220 -0
  73. package/src/knowledge/workspace-types.ts +106 -0
  74. package/src/knowledge/workspace.ts +220 -0
  75. package/src/memory/observations.ts +19 -0
  76. package/src/runtime/control-plane-maintenance.ts +5 -0
  77. package/src/runtime/isolated-maintenance.ts +5 -0
  78. package/src/runtime/lifecycle-status.ts +102 -0
  79. package/src/runtime/lifecycle.ts +107 -0
  80. package/src/runtime/maintenance-jobs.ts +5 -0
  81. package/src/runtime/project-maintenance.ts +190 -0
  82. package/src/server/tool-profile.ts +3 -2
  83. package/src/server.ts +354 -14
  84. package/src/store/file-lock.ts +24 -4
  85. package/src/store/sqlite-db.ts +307 -0
  86. package/src/wiki/generator.ts +4 -2
  87. package/src/wiki/knowledge-graph.ts +7 -4
  88. package/src/wiki/types.ts +16 -4
@@ -1,7 +1,8 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { readdirSync, readFileSync, statSync, type Dirent } from 'node:fs';
3
3
  import { join, relative } from 'node:path';
4
- import type { CodeEdge, CodeFile, CodeSymbol } from './types.js';
4
+ import type { CodeEdge, CodeFile, CodeStateSnapshot, CodeSymbol } from './types.js';
5
+ import { collectCodeStateSnapshot } from './code-state.js';
5
6
  import { makeCodeEdgeId, makeCodeFileId, makeCodeSymbolId, normalizeCodePath } from './ids.js';
6
7
  import { isCodeGraphExcludedPath, normalizeCodeGraphExcludePatterns } from './exclude.js';
7
8
  import { CodeGraphStore, type CodeGraphFileDelta } from './store.js';
@@ -19,6 +20,7 @@ export interface LiteIndexResult {
19
20
  symbols: CodeSymbol[];
20
21
  edges: CodeEdge[];
21
22
  skippedOversizedFiles: number;
23
+ unreadableFiles: number;
22
24
  }
23
25
 
24
26
  export interface LiteRefreshResult {
@@ -30,7 +32,9 @@ export interface LiteRefreshResult {
30
32
  indexedSymbols: number;
31
33
  indexedEdges: number;
32
34
  skippedOversizedFiles: number;
35
+ unreadableFiles: number;
33
36
  removalScanDeferred: boolean;
37
+ snapshot: CodeStateSnapshot;
34
38
  }
35
39
 
36
40
  export const DEFAULT_CODEGRAPH_MAX_FILE_BYTES = 2 * 1024 * 1024;
@@ -299,13 +303,18 @@ function extractImportEdges(projectId: string, file: CodeFile, text: string, ind
299
303
  return edges;
300
304
  }
301
305
 
306
+ interface LiteFileIndexAttempt {
307
+ delta?: CodeGraphFileDelta;
308
+ unreadable: boolean;
309
+ }
310
+
302
311
  function indexFileLite(
303
312
  projectId: string,
304
313
  projectRoot: string,
305
314
  abs: string,
306
315
  indexedAt: string,
307
316
  fileStat?: ReturnType<typeof statSync>,
308
- ): CodeGraphFileDelta | undefined {
317
+ ): LiteFileIndexAttempt {
309
318
  const rel = normalizeCodePath(relative(projectRoot, abs));
310
319
  try {
311
320
  const text = readFileSync(abs, 'utf-8');
@@ -316,17 +325,22 @@ function indexFileLite(
316
325
  path: rel,
317
326
  language: languageForPath(rel),
318
327
  contentHash: hashText(text),
319
- mtimeMs: Math.round(Number(stat.mtimeMs)),
328
+ // Preserve filesystem precision. Rounding here made same-size edits in
329
+ // a short Windows timestamp window indistinguishable to refresh.
330
+ mtimeMs: Number(stat.mtimeMs),
320
331
  sizeBytes: Number(stat.size),
321
332
  indexedAt,
322
333
  };
323
334
  return {
324
- file,
325
- symbols: extractSymbols(projectId, file, text, indexedAt),
326
- edges: extractImportEdges(projectId, file, text, indexedAt),
335
+ delta: {
336
+ file,
337
+ symbols: extractSymbols(projectId, file, text, indexedAt),
338
+ edges: extractImportEdges(projectId, file, text, indexedAt),
339
+ },
340
+ unreadable: false,
327
341
  };
328
342
  } catch {
329
- return undefined;
343
+ return { unreadable: true };
330
344
  }
331
345
  }
332
346
 
@@ -340,26 +354,31 @@ export async function indexProjectLite(options: LiteIndexOptions): Promise<LiteI
340
354
  const symbols: CodeSymbol[] = [];
341
355
  const edges: CodeEdge[] = [];
342
356
  let skippedOversizedFiles = 0;
357
+ let unreadableFiles = 0;
343
358
 
344
359
  for (const abs of paths) {
345
360
  let stat: ReturnType<typeof statSync>;
346
361
  try {
347
362
  stat = statSync(abs);
348
363
  } catch {
364
+ unreadableFiles++;
349
365
  continue;
350
366
  }
351
367
  if (Number(stat.size) > maxFileBytes) {
352
368
  skippedOversizedFiles++;
353
369
  continue;
354
370
  }
355
- const delta = indexFileLite(options.projectId, options.projectRoot, abs, indexedAt, stat);
356
- if (!delta) continue;
357
- files.push(delta.file);
358
- symbols.push(...delta.symbols);
359
- edges.push(...delta.edges);
371
+ const attempt = indexFileLite(options.projectId, options.projectRoot, abs, indexedAt, stat);
372
+ if (!attempt.delta) {
373
+ if (attempt.unreadable) unreadableFiles++;
374
+ continue;
375
+ }
376
+ files.push(attempt.delta.file);
377
+ symbols.push(...attempt.delta.symbols);
378
+ edges.push(...attempt.delta.edges);
360
379
  }
361
380
 
362
- return { files, symbols, edges, skippedOversizedFiles };
381
+ return { files, symbols, edges, skippedOversizedFiles, unreadableFiles };
363
382
  }
364
383
 
365
384
  /**
@@ -382,31 +401,38 @@ export async function refreshProjectLite(
382
401
  const oversizedExistingFileIds = new Set<string>();
383
402
  let unchangedFiles = 0;
384
403
  let skippedOversizedFiles = 0;
404
+ let unreadableFiles = 0;
385
405
 
386
406
  for (const abs of paths) {
387
407
  const rel = normalizeCodePath(relative(options.projectRoot, abs));
408
+ // A discovered but temporarily unreadable path is not deletion evidence.
409
+ seenPaths.add(rel);
388
410
  let stat: ReturnType<typeof statSync>;
389
411
  try {
390
412
  stat = statSync(abs);
391
413
  } catch {
414
+ unreadableFiles++;
392
415
  continue;
393
416
  }
394
- seenPaths.add(rel);
395
417
  const existing = existingByPath.get(rel);
396
- const roundedMtime = Math.round(Number(stat.mtimeMs));
418
+ const mtimeMs = Number(stat.mtimeMs);
397
419
  const sizeBytes = Number(stat.size);
398
420
  if (sizeBytes > maxFileBytes) {
399
421
  skippedOversizedFiles++;
400
422
  if (existing) oversizedExistingFileIds.add(existing.id);
401
423
  continue;
402
424
  }
403
- if (existing && existing.mtimeMs === roundedMtime && existing.sizeBytes === sizeBytes) {
425
+ if (existing && existing.mtimeMs === mtimeMs && existing.sizeBytes === sizeBytes) {
404
426
  unchangedFiles++;
405
427
  continue;
406
428
  }
407
429
 
408
- const delta = indexFileLite(options.projectId, options.projectRoot, abs, indexedAt, stat);
409
- if (!delta) continue;
430
+ const attempt = indexFileLite(options.projectId, options.projectRoot, abs, indexedAt, stat);
431
+ if (!attempt.delta) {
432
+ if (attempt.unreadable) unreadableFiles++;
433
+ continue;
434
+ }
435
+ const delta = attempt.delta;
410
436
  if (existing?.contentHash === delta.file.contentHash) {
411
437
  metadataOnly.push(delta.file);
412
438
  unchangedFiles++;
@@ -430,7 +456,24 @@ export async function refreshProjectLite(
430
456
  metadataOnly,
431
457
  removedFileIds,
432
458
  });
433
-
459
+ const completeness = {
460
+ scannedFiles: paths.length,
461
+ maxFiles,
462
+ changedFiles: changed.length,
463
+ unchangedFiles,
464
+ metadataOnlyFiles: metadataOnly.length,
465
+ removedFiles: removedFileIds.length,
466
+ skippedOversizedFiles,
467
+ unreadableFiles,
468
+ removalScanDeferred,
469
+ };
470
+ const snapshot = store.recordCodeStateSnapshot(await collectCodeStateSnapshot({
471
+ projectId: options.projectId,
472
+ projectRoot: options.projectRoot,
473
+ provider: 'lite',
474
+ indexedAt,
475
+ completeness,
476
+ }));
434
477
  return {
435
478
  scannedFiles: paths.length,
436
479
  changedFiles: changed.length,
@@ -440,6 +483,8 @@ export async function refreshProjectLite(
440
483
  indexedSymbols: changed.reduce((total, delta) => total + delta.symbols.length, 0),
441
484
  indexedEdges: changed.reduce((total, delta) => total + delta.edges.length, 0),
442
485
  skippedOversizedFiles,
486
+ unreadableFiles,
443
487
  removalScanDeferred,
488
+ snapshot,
444
489
  };
445
490
  }
@@ -1,6 +1,12 @@
1
1
  import type { ProjectInfo } from '../types.js';
2
2
  import { evaluateCodeRefFreshness } from './freshness.js';
3
- import type { CodeFile, CodeRefStatus, CodeSymbol, ObservationCodeRef } from './types.js';
3
+ import type {
4
+ CodeFile,
5
+ CodeRefStatus,
6
+ CodeStateSnapshot,
7
+ CodeSymbol,
8
+ ObservationCodeRef,
9
+ } from './types.js';
4
10
  import type { CodeGraphStore } from './store.js';
5
11
  import { isCodeGraphExcludedPath } from './exclude.js';
6
12
 
@@ -31,6 +37,7 @@ export interface ProjectContextOverview {
31
37
  refs: number;
32
38
  indexedAt?: string;
33
39
  languages: LanguageSummary[];
40
+ latestSnapshot?: CodeStateSnapshot;
34
41
  };
35
42
  memory: {
36
43
  total: number;
@@ -196,6 +203,7 @@ function overviewFromGraph(input: {
196
203
  refs: status.refs,
197
204
  ...(status.indexedAt ? { indexedAt: status.indexedAt } : {}),
198
205
  languages: countLanguages(input.graph.files),
206
+ ...(status.latestSnapshot ? { latestSnapshot: status.latestSnapshot } : {}),
199
207
  },
200
208
  memory: {
201
209
  total: input.observations.filter(obs => obs.projectId === input.project.id).length,
@@ -1,5 +1,15 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import { getDatabase } from '../store/sqlite-db.js';
2
- import type { CodeEdge, CodeFile, CodeGraphStatus, CodeSymbol, ObservationCodeRef } from './types.js';
3
+ import type {
4
+ CodeEdge,
5
+ CodeFile,
6
+ CodeGraphStatus,
7
+ CodeStateScanCompleteness,
8
+ CodeStateSnapshot,
9
+ CodeStateSnapshotInput,
10
+ CodeSymbol,
11
+ ObservationCodeRef,
12
+ } from './types.js';
3
13
  import { normalizeCodePath } from './ids.js';
4
14
 
5
15
  export interface CodeGraphFileDelta {
@@ -19,6 +29,8 @@ function rowToFile(row: any): CodeFile {
19
29
  ...(row.sizeBytes != null ? { sizeBytes: row.sizeBytes } : {}),
20
30
  indexedAt: row.indexedAt,
21
31
  ...(row.gitCommit ? { gitCommit: row.gitCommit } : {}),
32
+ ...(row.snapshotId ? { snapshotId: row.snapshotId } : {}),
33
+ ...(row.sourceEpoch != null ? { sourceEpoch: Number(row.sourceEpoch) } : {}),
22
34
  };
23
35
  }
24
36
 
@@ -37,6 +49,8 @@ function rowToSymbol(row: any): CodeSymbol {
37
49
  ...(row.contentHash ? { contentHash: row.contentHash } : {}),
38
50
  indexedAt: row.indexedAt,
39
51
  stale: !!row.stale,
52
+ ...(row.snapshotId ? { snapshotId: row.snapshotId } : {}),
53
+ ...(row.sourceEpoch != null ? { sourceEpoch: Number(row.sourceEpoch) } : {}),
40
54
  } as CodeSymbol;
41
55
  }
42
56
 
@@ -52,6 +66,8 @@ function rowToEdge(row: any): CodeEdge {
52
66
  confidence: row.confidence,
53
67
  ...(row.evidence ? { evidence: row.evidence } : {}),
54
68
  indexedAt: row.indexedAt,
69
+ ...(row.snapshotId ? { snapshotId: row.snapshotId } : {}),
70
+ ...(row.sourceEpoch != null ? { sourceEpoch: Number(row.sourceEpoch) } : {}),
55
71
  } as CodeEdge;
56
72
  }
57
73
 
@@ -68,16 +84,71 @@ function rowToRef(row: any): ObservationCodeRef {
68
84
  ...(row.reason ? { reason: row.reason } : {}),
69
85
  createdAt: row.createdAt,
70
86
  ...(row.updatedAt ? { updatedAt: row.updatedAt } : {}),
87
+ ...(row.snapshotId ? { snapshotId: row.snapshotId } : {}),
71
88
  } as ObservationCodeRef;
72
89
  }
73
90
 
91
+ function parseCompleteness(raw: unknown): CodeStateScanCompleteness {
92
+ try {
93
+ const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
94
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('invalid completeness');
95
+ return {
96
+ scannedFiles: Number((parsed as any).scannedFiles) || 0,
97
+ maxFiles: Number((parsed as any).maxFiles) || 0,
98
+ changedFiles: Number((parsed as any).changedFiles) || 0,
99
+ unchangedFiles: Number((parsed as any).unchangedFiles) || 0,
100
+ metadataOnlyFiles: Number((parsed as any).metadataOnlyFiles) || 0,
101
+ removedFiles: Number((parsed as any).removedFiles) || 0,
102
+ skippedOversizedFiles: Number((parsed as any).skippedOversizedFiles) || 0,
103
+ unreadableFiles: Number((parsed as any).unreadableFiles) || 0,
104
+ removalScanDeferred: Boolean((parsed as any).removalScanDeferred),
105
+ };
106
+ } catch {
107
+ return {
108
+ scannedFiles: 0,
109
+ maxFiles: 0,
110
+ changedFiles: 0,
111
+ unchangedFiles: 0,
112
+ metadataOnlyFiles: 0,
113
+ removedFiles: 0,
114
+ skippedOversizedFiles: 0,
115
+ unreadableFiles: 0,
116
+ removalScanDeferred: false,
117
+ };
118
+ }
119
+ }
120
+
121
+ function rowToSnapshot(row: any): CodeStateSnapshot {
122
+ return {
123
+ id: row.id,
124
+ projectId: row.projectId,
125
+ provider: row.provider,
126
+ ...(row.baseRevision ? { baseRevision: row.baseRevision } : {}),
127
+ worktreeFingerprint: row.worktreeFingerprint,
128
+ worktreeState: row.worktreeState,
129
+ changedPathCount: Number(row.changedPathCount),
130
+ indexedAt: row.indexedAt,
131
+ sourceEpoch: Number(row.sourceEpoch),
132
+ completeness: parseCompleteness(row.completenessJson),
133
+ ...(row.previousSnapshotId ? { previousSnapshotId: row.previousSnapshotId } : {}),
134
+ } as CodeStateSnapshot;
135
+ }
136
+
74
137
  export class CodeGraphStore {
75
138
  private db: any = null;
139
+ private dataDir: string | null = null;
76
140
 
77
141
  async init(dataDir: string): Promise<void> {
142
+ this.dataDir = dataDir;
78
143
  this.db = getDatabase(dataDir);
79
144
  }
80
145
 
146
+ /** Data directory shared with the rest of the local project evidence stores. */
147
+ getDataDir(): string {
148
+ if (!this.dataDir) throw new Error('CodeGraphStore is not initialized');
149
+ return this.dataDir;
150
+ }
151
+
81
152
  upsertFiles(files: CodeFile[]): void {
82
153
  if (files.length === 0) return;
83
154
  const stmt = this.db.prepare(`
@@ -364,9 +435,9 @@ export class CodeGraphStore {
364
435
  if (refs.length === 0) return;
365
436
  const stmt = this.db.prepare(`
366
437
  INSERT OR REPLACE INTO observation_code_refs
367
- (id, projectId, observationId, fileId, symbolId, capturedFileHash, capturedSymbolHash, status, reason, createdAt, updatedAt)
438
+ (id, projectId, observationId, fileId, symbolId, capturedFileHash, capturedSymbolHash, status, reason, createdAt, updatedAt, snapshotId)
368
439
  VALUES
369
- (@id, @projectId, @observationId, @fileId, @symbolId, @capturedFileHash, @capturedSymbolHash, @status, @reason, @createdAt, @updatedAt)
440
+ (@id, @projectId, @observationId, @fileId, @symbolId, @capturedFileHash, @capturedSymbolHash, @status, @reason, @createdAt, @updatedAt, @snapshotId)
370
441
  `);
371
442
  const tx = this.db.transaction((items: ObservationCodeRef[]) => {
372
443
  for (const ref of items) {
@@ -382,6 +453,7 @@ export class CodeGraphStore {
382
453
  reason: ref.reason ?? null,
383
454
  createdAt: ref.createdAt,
384
455
  updatedAt: ref.updatedAt ?? null,
456
+ snapshotId: ref.snapshotId ?? this.latestSnapshot(ref.projectId)?.id ?? null,
385
457
  });
386
458
  }
387
459
  });
@@ -395,9 +467,9 @@ export class CodeGraphStore {
395
467
  `);
396
468
  const insertRef = this.db.prepare(`
397
469
  INSERT OR REPLACE INTO observation_code_refs
398
- (id, projectId, observationId, fileId, symbolId, capturedFileHash, capturedSymbolHash, status, reason, createdAt, updatedAt)
470
+ (id, projectId, observationId, fileId, symbolId, capturedFileHash, capturedSymbolHash, status, reason, createdAt, updatedAt, snapshotId)
399
471
  VALUES
400
- (@id, @projectId, @observationId, @fileId, @symbolId, @capturedFileHash, @capturedSymbolHash, @status, @reason, @createdAt, @updatedAt)
472
+ (@id, @projectId, @observationId, @fileId, @symbolId, @capturedFileHash, @capturedSymbolHash, @status, @reason, @createdAt, @updatedAt, @snapshotId)
401
473
  `);
402
474
  const tx = this.db.transaction(() => {
403
475
  deleteRefs.run(projectId, observationId);
@@ -414,6 +486,7 @@ export class CodeGraphStore {
414
486
  reason: ref.reason ?? null,
415
487
  createdAt: ref.createdAt,
416
488
  updatedAt: ref.updatedAt ?? null,
489
+ snapshotId: ref.snapshotId ?? this.latestSnapshot(ref.projectId)?.id ?? null,
417
490
  });
418
491
  }
419
492
  });
@@ -514,12 +587,87 @@ export class CodeGraphStore {
514
587
  `).all(projectId, projectId).map(rowToSymbol);
515
588
  }
516
589
 
590
+ latestSnapshot(projectId: string): CodeStateSnapshot | undefined {
591
+ const row = this.db.prepare(
592
+ 'SELECT * FROM code_state_snapshots WHERE projectId = ? ORDER BY sourceEpoch DESC LIMIT 1',
593
+ ).get(projectId);
594
+ return row ? rowToSnapshot(row) : undefined;
595
+ }
596
+
597
+ listSnapshots(projectId: string, limit = 20): CodeStateSnapshot[] {
598
+ const safeLimit = Number.isFinite(limit) ? Math.max(1, Math.min(200, Math.floor(limit))) : 20;
599
+ return this.db.prepare(
600
+ 'SELECT * FROM code_state_snapshots WHERE projectId = ? ORDER BY sourceEpoch DESC LIMIT ?',
601
+ ).all(projectId, safeLimit).map(rowToSnapshot);
602
+ }
603
+
604
+ /**
605
+ * Record a completed scan and mark all current structural facts with its
606
+ * epoch. The snapshot is written only after refresh reconciliation succeeds,
607
+ * so an interrupted scan cannot be advertised as complete.
608
+ */
609
+ recordCodeStateSnapshot(input: CodeStateSnapshotInput): CodeStateSnapshot {
610
+ const id = randomUUID();
611
+ let snapshot: CodeStateSnapshot | undefined;
612
+ const tx = this.db.transaction(() => {
613
+ const previous = this.db.prepare(
614
+ 'SELECT id, sourceEpoch FROM code_state_snapshots WHERE projectId = ? ORDER BY sourceEpoch DESC LIMIT 1',
615
+ ).get(input.projectId);
616
+ const sourceEpoch = Number(previous?.sourceEpoch ?? 0) + 1;
617
+ const previousSnapshotId = previous?.id as string | undefined;
618
+ this.db.prepare(
619
+ 'INSERT INTO code_state_snapshots (id, projectId, provider, baseRevision, worktreeFingerprint, worktreeState, changedPathCount, indexedAt, sourceEpoch, completenessJson, previousSnapshotId) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
620
+ ).run(
621
+ id,
622
+ input.projectId,
623
+ input.provider,
624
+ input.baseRevision ?? null,
625
+ input.worktreeFingerprint,
626
+ input.worktreeState,
627
+ input.changedPathCount,
628
+ input.indexedAt,
629
+ sourceEpoch,
630
+ JSON.stringify(input.completeness),
631
+ previousSnapshotId ?? null,
632
+ );
633
+ this.db.prepare(
634
+ 'UPDATE code_files SET snapshotId = ?, sourceEpoch = ?, gitCommit = COALESCE(?, gitCommit) WHERE projectId = ?',
635
+ ).run(id, sourceEpoch, input.baseRevision ?? null, input.projectId);
636
+ this.db.prepare(
637
+ 'UPDATE code_symbols SET snapshotId = ?, sourceEpoch = ? WHERE projectId = ? AND stale = 0',
638
+ ).run(id, sourceEpoch, input.projectId);
639
+ this.db.prepare(
640
+ 'UPDATE code_edges SET snapshotId = ?, sourceEpoch = ? WHERE projectId = ?',
641
+ ).run(id, sourceEpoch, input.projectId);
642
+ this.db.prepare(
643
+ "UPDATE observation_code_refs SET snapshotId = ? WHERE projectId = ? AND status = 'current'",
644
+ ).run(id, input.projectId);
645
+ snapshot = {
646
+ ...input,
647
+ id,
648
+ sourceEpoch,
649
+ ...(previousSnapshotId ? { previousSnapshotId } : {}),
650
+ };
651
+ });
652
+ tx();
653
+ return snapshot!;
654
+ }
655
+
517
656
  status(projectId: string): CodeGraphStatus {
518
657
  const files = this.db.prepare(`SELECT COUNT(*) AS count FROM code_files WHERE projectId = ?`).get(projectId).count;
519
658
  const symbols = this.db.prepare(`SELECT COUNT(*) AS count FROM code_symbols WHERE projectId = ? AND stale = 0`).get(projectId).count;
520
659
  const edges = this.db.prepare(`SELECT COUNT(*) AS count FROM code_edges WHERE projectId = ?`).get(projectId).count;
521
660
  const refs = this.db.prepare(`SELECT COUNT(*) AS count FROM observation_code_refs WHERE projectId = ?`).get(projectId).count;
522
661
  const latest = this.db.prepare(`SELECT MAX(indexedAt) AS indexedAt FROM code_files WHERE projectId = ?`).get(projectId);
523
- return { provider: 'lite', files, symbols, edges, refs, ...(latest?.indexedAt ? { indexedAt: latest.indexedAt } : {}) };
662
+ const latestSnapshot = this.latestSnapshot(projectId);
663
+ return {
664
+ provider: 'lite',
665
+ files,
666
+ symbols,
667
+ edges,
668
+ refs,
669
+ ...(latest?.indexedAt ? { indexedAt: latest.indexedAt } : {}),
670
+ ...(latestSnapshot ? { latestSnapshot } : {}),
671
+ };
524
672
  }
525
673
  }
@@ -32,6 +32,8 @@ export interface CodeFile {
32
32
  sizeBytes?: number;
33
33
  indexedAt: string;
34
34
  gitCommit?: string;
35
+ snapshotId?: string;
36
+ sourceEpoch?: number;
35
37
  }
36
38
 
37
39
  export interface CodeSymbol {
@@ -48,6 +50,8 @@ export interface CodeSymbol {
48
50
  contentHash?: string;
49
51
  indexedAt: string;
50
52
  stale?: boolean;
53
+ snapshotId?: string;
54
+ sourceEpoch?: number;
51
55
  }
52
56
 
53
57
  export interface CodeEdge {
@@ -61,6 +65,8 @@ export interface CodeEdge {
61
65
  confidence: number;
62
66
  evidence?: string;
63
67
  indexedAt: string;
68
+ snapshotId?: string;
69
+ sourceEpoch?: number;
64
70
  }
65
71
 
66
72
  export interface ObservationCodeRef {
@@ -75,6 +81,39 @@ export interface ObservationCodeRef {
75
81
  reason?: string;
76
82
  createdAt: string;
77
83
  updatedAt?: string;
84
+ snapshotId?: string;
85
+ }
86
+
87
+ export type CodeStateWorktreeState = 'clean' | 'dirty' | 'unavailable';
88
+
89
+ export interface CodeStateScanCompleteness {
90
+ scannedFiles: number;
91
+ maxFiles: number;
92
+ changedFiles: number;
93
+ unchangedFiles: number;
94
+ metadataOnlyFiles: number;
95
+ removedFiles: number;
96
+ skippedOversizedFiles: number;
97
+ /** Source paths discovered but unavailable for stat/read during this scan. */
98
+ unreadableFiles?: number;
99
+ removalScanDeferred: boolean;
100
+ }
101
+
102
+ export interface CodeStateSnapshotInput {
103
+ projectId: string;
104
+ provider: CodeGraphProviderKind;
105
+ baseRevision?: string;
106
+ worktreeFingerprint: string;
107
+ worktreeState: CodeStateWorktreeState;
108
+ changedPathCount: number;
109
+ indexedAt: string;
110
+ completeness: CodeStateScanCompleteness;
111
+ }
112
+
113
+ export interface CodeStateSnapshot extends CodeStateSnapshotInput {
114
+ id: string;
115
+ sourceEpoch: number;
116
+ previousSnapshotId?: string;
78
117
  }
79
118
 
80
119
  export interface CodeGraphStatus {
@@ -84,4 +123,82 @@ export interface CodeGraphStatus {
84
123
  edges: number;
85
124
  refs: number;
86
125
  indexedAt?: string;
126
+ latestSnapshot?: CodeStateSnapshot;
127
+ }
128
+
129
+ /** Policy for using a separately installed, local semantic CodeGraph index. */
130
+ export type CodeGraphExternalMode = 'auto' | 'off';
131
+
132
+ /**
133
+ * The external provider state is deliberately more precise than a boolean.
134
+ * A caller can distinguish an absent optional tool from a stale or invalid
135
+ * result without treating either as a fatal failure for Code Memory.
136
+ */
137
+ export type ExternalCodeGraphState =
138
+ | 'disabled'
139
+ | 'not-detected'
140
+ | 'unavailable'
141
+ | 'not-initialized'
142
+ | 'stale'
143
+ | 'timed-out'
144
+ | 'invalid'
145
+ | 'ready';
146
+
147
+ export interface ExternalCodeGraphHealth {
148
+ state: ExternalCodeGraphState;
149
+ reason?: string;
150
+ indexedFiles?: number;
151
+ indexedNodes?: number;
152
+ indexedEdges?: number;
153
+ languages?: string[];
154
+ }
155
+
156
+ export interface CodeGraphProviderQuality {
157
+ /** Provider that contributed task-scoped code evidence to this response. */
158
+ selected: CodeGraphProviderKind;
159
+ /** Lite is heuristic; external outlines are only semantic after validation. */
160
+ selectedQuality: 'heuristic' | 'semantic';
161
+ mode: CodeGraphExternalMode;
162
+ lite: {
163
+ quality: 'heuristic';
164
+ capabilities: {
165
+ declarations: boolean;
166
+ importHints: boolean;
167
+ resolvedRelations: false;
168
+ exactLocations: false;
169
+ };
170
+ supportedLanguages: string[];
171
+ };
172
+ external: ExternalCodeGraphHealth;
173
+ }
174
+
175
+ export interface ExternalCodeGraphSymbol {
176
+ id: string;
177
+ name: string;
178
+ qualifiedName?: string;
179
+ kind: string;
180
+ path: string;
181
+ startLine?: number;
182
+ endLine?: number;
183
+ language?: string;
184
+ }
185
+
186
+ export interface ExternalCodeGraphRelation {
187
+ from: ExternalCodeGraphSymbol;
188
+ to: ExternalCodeGraphSymbol;
189
+ kind: string;
190
+ line?: number;
191
+ }
192
+
193
+ /** A bounded, non-source-code semantic outline returned for one task. */
194
+ export interface ExternalCodeGraphOutline {
195
+ provider: 'external';
196
+ entryPoints: ExternalCodeGraphSymbol[];
197
+ relations: ExternalCodeGraphRelation[];
198
+ relatedFiles: string[];
199
+ stats: {
200
+ nodes: number;
201
+ edges: number;
202
+ files: number;
203
+ };
87
204
  }
@@ -55,6 +55,9 @@ export interface ResolvedMemorixConfig {
55
55
  codegraph: {
56
56
  excludePatterns?: string[];
57
57
  maxFileBytes?: number;
58
+ externalContext: 'auto' | 'off';
59
+ externalCommand?: string;
60
+ externalTimeoutMs?: number;
58
61
  };
59
62
  server: {
60
63
  transport?: 'stdio' | 'http';
@@ -156,6 +159,22 @@ export function getResolvedConfig(options: ResolvedLaneOptions = {}): ResolvedMe
156
159
  codegraph: {
157
160
  excludePatterns: firstArray(toml.codegraph?.exclude_patterns, yaml.codegraph?.excludePatterns),
158
161
  maxFileBytes: firstNumber(toml.codegraph?.max_file_bytes, yaml.codegraph?.maxFileBytes),
162
+ externalContext: first(
163
+ normalizeExternalContext(process.env.MEMORIX_CODEGRAPH_EXTERNAL_CONTEXT),
164
+ toml.codegraph?.external_context,
165
+ yaml.codegraph?.externalContext,
166
+ 'auto',
167
+ )!,
168
+ externalCommand: first(
169
+ process.env.MEMORIX_CODEGRAPH_EXTERNAL_COMMAND,
170
+ toml.codegraph?.external_command,
171
+ yaml.codegraph?.externalCommand,
172
+ ),
173
+ externalTimeoutMs: firstNumber(
174
+ parseNumber(process.env.MEMORIX_CODEGRAPH_EXTERNAL_TIMEOUT_MS),
175
+ toml.codegraph?.external_timeout_ms,
176
+ yaml.codegraph?.externalTimeoutMs,
177
+ ),
159
178
  },
160
179
  server: {
161
180
  transport: first(toml.server?.transport, yaml.server?.transport),
@@ -254,6 +273,9 @@ function getEnvSourceNames(): string[] {
254
273
  'MEMORIX_EMBEDDING_BASE_URL',
255
274
  'MEMORIX_EMBEDDING_MODEL',
256
275
  'MEMORIX_EMBEDDING_DIMENSIONS',
276
+ 'MEMORIX_CODEGRAPH_EXTERNAL_CONTEXT',
277
+ 'MEMORIX_CODEGRAPH_EXTERNAL_COMMAND',
278
+ 'MEMORIX_CODEGRAPH_EXTERNAL_TIMEOUT_MS',
257
279
  'OPENROUTER_API_KEY',
258
280
  ].filter((name) => process.env[name]);
259
281
  }
@@ -267,3 +289,9 @@ function isOpenRouterUrl(value: string | undefined): boolean {
267
289
  return /(^|\.)openrouter\.ai(?::|\/|$)/i.test(value);
268
290
  }
269
291
  }
292
+
293
+ function normalizeExternalContext(value: string | undefined): 'auto' | 'off' | undefined {
294
+ const normalized = value?.trim().toLowerCase();
295
+ if (normalized === 'auto' || normalized === 'off') return normalized;
296
+ return undefined;
297
+ }
@@ -43,6 +43,9 @@ export interface MemorixTomlConfig {
43
43
  codegraph?: {
44
44
  exclude_patterns?: string[];
45
45
  max_file_bytes?: number;
46
+ external_context?: 'auto' | 'off';
47
+ external_command?: string;
48
+ external_timeout_ms?: number;
46
49
  };
47
50
  server?: {
48
51
  transport?: 'stdio' | 'http';
@@ -70,6 +70,12 @@ export interface MemorixYamlConfig {
70
70
  excludePatterns?: string[];
71
71
  /** Maximum source file size to parse into CodeGraph Memory */
72
72
  maxFileBytes?: number;
73
+ /** Use a healthy pre-existing local CodeGraph index when present (default: auto). */
74
+ externalContext?: 'auto' | 'off';
75
+ /** Optional CodeGraph executable path when it is not available on PATH. */
76
+ externalCommand?: string;
77
+ /** Bound for one local semantic context request. */
78
+ externalTimeoutMs?: number;
73
79
  };
74
80
 
75
81
  /** Behavior settings */