memorix 1.1.13 → 1.2.1

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 (97) hide show
  1. package/CHANGELOG.md +33 -1
  2. package/README.md +6 -3
  3. package/README.zh-CN.md +6 -3
  4. package/dist/cli/index.js +36639 -31279
  5. package/dist/cli/index.js.map +1 -1
  6. package/dist/dashboard/static/app.js +50 -30
  7. package/dist/index.js +6636 -1778
  8. package/dist/index.js.map +1 -1
  9. package/dist/maintenance-runner.d.ts +1 -1
  10. package/dist/maintenance-runner.js +3775 -302
  11. package/dist/maintenance-runner.js.map +1 -1
  12. package/dist/memcode-runtime/CHANGELOG.md +33 -1
  13. package/dist/sdk.d.ts +1 -1
  14. package/dist/sdk.js +6634 -1776
  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 +27 -6
  30. package/docs/CONFIGURATION.md +21 -3
  31. package/docs/DEVELOPMENT.md +4 -0
  32. package/docs/README.md +17 -2
  33. package/docs/SETUP.md +7 -1
  34. package/docs/dev-log/progress.txt +120 -40
  35. package/docs/knowledge/workflows/memorix-release.md +57 -0
  36. package/llms-full.txt +16 -2
  37. package/llms.txt +9 -4
  38. package/package.json +3 -2
  39. package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
  40. package/src/cli/capability-map.ts +1 -0
  41. package/src/cli/commands/codegraph.ts +111 -9
  42. package/src/cli/commands/context.ts +2 -0
  43. package/src/cli/commands/doctor.ts +73 -4
  44. package/src/cli/commands/knowledge.ts +322 -0
  45. package/src/cli/commands/serve-http.ts +26 -42
  46. package/src/cli/commands/setup.ts +9 -3
  47. package/src/cli/index.ts +3 -1
  48. package/src/cli/tui/App.tsx +1 -1
  49. package/src/cli/tui/Panels.tsx +8 -8
  50. package/src/cli/tui/theme.ts +1 -1
  51. package/src/cli/tui/views/GraphView.tsx +8 -7
  52. package/src/cli/tui/views/KnowledgeView.tsx +9 -9
  53. package/src/codegraph/auto-context.ts +169 -19
  54. package/src/codegraph/code-state.ts +95 -0
  55. package/src/codegraph/context-pack.ts +82 -1
  56. package/src/codegraph/current-facts.ts +19 -1
  57. package/src/codegraph/external-provider.ts +581 -0
  58. package/src/codegraph/lite-provider.ts +64 -19
  59. package/src/codegraph/project-context.ts +9 -1
  60. package/src/codegraph/store.ts +154 -6
  61. package/src/codegraph/task-lens.ts +49 -5
  62. package/src/codegraph/types.ts +117 -0
  63. package/src/config/resolved-config.ts +28 -0
  64. package/src/config/toml-loader.ts +3 -0
  65. package/src/config/yaml-loader.ts +6 -0
  66. package/src/dashboard/server.ts +30 -47
  67. package/src/evaluation/workset-evaluation.ts +120 -0
  68. package/src/hooks/handler.ts +48 -6
  69. package/src/knowledge/claim-store.ts +267 -0
  70. package/src/knowledge/claims.ts +587 -0
  71. package/src/knowledge/markdown.ts +129 -0
  72. package/src/knowledge/types.ts +158 -0
  73. package/src/knowledge/wiki.ts +524 -0
  74. package/src/knowledge/workflow-store.ts +168 -0
  75. package/src/knowledge/workflow-types.ts +95 -0
  76. package/src/knowledge/workflows.ts +774 -0
  77. package/src/knowledge/workset.ts +515 -0
  78. package/src/knowledge/workspace-store.ts +220 -0
  79. package/src/knowledge/workspace-types.ts +106 -0
  80. package/src/knowledge/workspace.ts +220 -0
  81. package/src/memory/auto-relations.ts +21 -0
  82. package/src/memory/graph-scope.ts +46 -0
  83. package/src/memory/observations.ts +19 -0
  84. package/src/orchestrate/verify-gate.ts +33 -10
  85. package/src/runtime/control-plane-maintenance.ts +5 -0
  86. package/src/runtime/isolated-maintenance.ts +5 -0
  87. package/src/runtime/lifecycle-status.ts +102 -0
  88. package/src/runtime/lifecycle.ts +107 -0
  89. package/src/runtime/maintenance-jobs.ts +5 -0
  90. package/src/runtime/project-maintenance.ts +190 -0
  91. package/src/server/tool-profile.ts +3 -2
  92. package/src/server.ts +424 -22
  93. package/src/store/file-lock.ts +24 -4
  94. package/src/store/sqlite-db.ts +307 -0
  95. package/src/wiki/generator.ts +4 -2
  96. package/src/wiki/knowledge-graph.ts +7 -4
  97. 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
  }
@@ -115,6 +115,7 @@ const KEYWORDS: Record<Exclude<TaskLensId, 'general'>, string[]> = {
115
115
  bugfix: [
116
116
  'bug',
117
117
  'crash',
118
+ 'incident',
118
119
  'debug',
119
120
  'error',
120
121
  'fail',
@@ -125,6 +126,7 @@ const KEYWORDS: Record<Exclude<TaskLensId, 'general'>, string[]> = {
125
126
  'repro',
126
127
  '报错',
127
128
  '崩溃',
129
+ '故障',
128
130
  '失败',
129
131
  '修复',
130
132
  '问题',
@@ -157,11 +159,53 @@ function tokenize(text: string): string[] {
157
159
  .filter(token => token.length > 1 && !STOP_WORDS.has(token));
158
160
  }
159
161
 
160
- function containsKeyword(text: string, keyword: string): boolean {
161
- if (/^[a-z0-9_-]+$/i.test(keyword)) {
162
- return new RegExp(`(^|[^a-z0-9_-])${keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^a-z0-9_-]|$)`, 'i').test(text);
162
+ /**
163
+ * Match a task keyword only when the task states it as an action or intent,
164
+ * rather than prohibiting that action. Workflow selection shares this rule so
165
+ * a safety constraint cannot reintroduce an intent rejected by task routing.
166
+ */
167
+ export function containsTaskKeyword(text: string, keyword: string): boolean {
168
+ const matcher = /^[a-z0-9_-]+$/i.test(keyword)
169
+ ? new RegExp(`(^|[^a-z0-9_-])${keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?=[^a-z0-9_-]|$)`, 'gi')
170
+ : new RegExp(keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
171
+ let match: RegExpExecArray | null;
172
+ while ((match = matcher.exec(text)) !== null) {
173
+ const keywordIndex = match.index + (match[1]?.length ?? 0);
174
+ if (!isNegatedTaskKeyword(text, keywordIndex)) return true;
163
175
  }
164
- return text.includes(keyword);
176
+ return false;
177
+ }
178
+
179
+ /**
180
+ * Task text often carries safety constraints such as "do not publish". Those
181
+ * constraints must not route a debugging task into the release workflow.
182
+ * Keep this deliberately small and local: it only suppresses a keyword inside
183
+ * the same sentence when the sentence explicitly negates an action.
184
+ */
185
+ function isNegatedTaskKeyword(text: string, keywordIndex: number): boolean {
186
+ const boundary = Math.max(
187
+ text.lastIndexOf('.', keywordIndex - 1),
188
+ text.lastIndexOf('!', keywordIndex - 1),
189
+ text.lastIndexOf('?', keywordIndex - 1),
190
+ text.lastIndexOf(';', keywordIndex - 1),
191
+ text.lastIndexOf('\n', keywordIndex - 1),
192
+ text.lastIndexOf('\u3002', keywordIndex - 1),
193
+ text.lastIndexOf('\uff01', keywordIndex - 1),
194
+ text.lastIndexOf('\uff1f', keywordIndex - 1),
195
+ text.lastIndexOf('\uff1b', keywordIndex - 1),
196
+ );
197
+ let prefix = text.slice(boundary + 1, keywordIndex).toLowerCase();
198
+ const contrast = /(?:,|\uff0c)\s*(?:but|however|instead|yet|\u4f46\u662f|\u4f46|\u800c\u662f)\s*/gi;
199
+ let contrastMatch: RegExpExecArray | null;
200
+ let contrastEnd = -1;
201
+ while ((contrastMatch = contrast.exec(prefix)) !== null) contrastEnd = contrast.lastIndex;
202
+ if (contrastEnd >= 0) prefix = prefix.slice(contrastEnd);
203
+ if (/\b(?:do|does|did|should|must|can|could|will|would|may|might)\s+not\b/.test(prefix)) return true;
204
+ if (/\b(?:don't|dont|never|without|avoid|skip)\b/.test(prefix)) return true;
205
+ if (/(?:^|[\s,])no\s+(?:[a-z0-9_-]+\s*){0,4}$/i.test(prefix)) return true;
206
+ const chineseClauseStart = Math.max(prefix.lastIndexOf(','), prefix.lastIndexOf('\uff0c'), prefix.lastIndexOf('\u3001'));
207
+ const chineseClause = prefix.slice(chineseClauseStart + 1);
208
+ return /(?:\u4e0d\u8981|\u4e0d\u5e94|\u4e0d\u53ef|\u4e0d\u80fd|\u4e0d\u4f1a|\u7981\u6b62|\u52ff|\u4e0d)\s*(?:\u7acb\u5373|\u76f4\u63a5|\u518d|\u73b0\u5728|\u64c5\u81ea|\u5148)?\s*$/.test(chineseClause);
165
209
  }
166
210
 
167
211
  export function resolveTaskLens(task?: string): TaskLens {
@@ -175,7 +219,7 @@ export function resolveTaskLens(task?: string): TaskLens {
175
219
  };
176
220
 
177
221
  for (const id of LENS_PRIORITY) {
178
- const score = KEYWORDS[id].reduce((sum, keyword) => sum + (containsKeyword(normalized, keyword) ? 1 : 0), 0);
222
+ const score = KEYWORDS[id].reduce((sum, keyword) => sum + (containsTaskKeyword(normalized, keyword) ? 1 : 0), 0);
179
223
  const priority = LENS_PRIORITY.indexOf(id);
180
224
  if (score > best.score || (score === best.score && score > 0 && priority < best.priority)) {
181
225
  best = { id, score, priority };
@@ -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
  }