@remnic/core 9.3.761 → 9.3.763

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 (134) hide show
  1. package/dist/access-admin-ops-surface.js +10 -9
  2. package/dist/access-boundary.js +11 -10
  3. package/dist/access-cli.js +45 -44
  4. package/dist/access-cli.js.map +1 -1
  5. package/dist/access-http.js +17 -16
  6. package/dist/access-identity-continuity-surface.js +10 -9
  7. package/dist/access-lcm-surface.js +10 -9
  8. package/dist/access-mcp.js +14 -13
  9. package/dist/access-observe-write-surface.js +10 -9
  10. package/dist/access-operations-batch.js +12 -11
  11. package/dist/access-operations.js +13 -12
  12. package/dist/access-recall-surface.js +10 -9
  13. package/dist/access-service.js +10 -9
  14. package/dist/adapters/index.js +4 -4
  15. package/dist/adapters/registry.js +2 -2
  16. package/dist/briefing.js +5 -4
  17. package/dist/causal-consolidation.js +6 -5
  18. package/dist/causal-consolidation.js.map +1 -1
  19. package/dist/{chunk-742MWWVL.js → chunk-2RLECZ6J.js} +2 -2
  20. package/dist/{chunk-CFHDI5CV.js → chunk-6YEENRE3.js} +4 -4
  21. package/dist/{chunk-JYH3XYWM.js → chunk-6ZFE2TPO.js} +3 -3
  22. package/dist/{chunk-LG2MCUBC.js → chunk-A4FMQIST.js} +2 -2
  23. package/dist/{chunk-APJQ6UEA.js → chunk-AGNBY3VG.js} +4 -4
  24. package/dist/{chunk-OCPVA4TH.js → chunk-AS2NRB45.js} +2 -2
  25. package/dist/{chunk-W4ADKEIY.js → chunk-BAF7MBEH.js} +22 -22
  26. package/dist/{chunk-ICVNVSKN.js → chunk-BRYPUVJU.js} +2 -2
  27. package/dist/{chunk-IV4INTAU.js → chunk-CVINPTKG.js} +2 -2
  28. package/dist/{chunk-IRFHFRHP.js → chunk-D6YTILJO.js} +25 -25
  29. package/dist/chunk-DQC3ZZYM.js +35 -0
  30. package/dist/chunk-DQC3ZZYM.js.map +1 -0
  31. package/dist/{chunk-3DAIQSVS.js → chunk-DTTVX6EO.js} +23 -8
  32. package/dist/chunk-DTTVX6EO.js.map +1 -0
  33. package/dist/{chunk-P7UA66OG.js → chunk-IVFIYAIP.js} +2 -2
  34. package/dist/{chunk-RKNJBZ55.js → chunk-JBPKEARU.js} +4 -4
  35. package/dist/{chunk-Y3XEN5TL.js → chunk-K4EDULCP.js} +3 -3
  36. package/dist/{chunk-P7N2DQLC.js → chunk-KXG7HHIQ.js} +3 -3
  37. package/dist/{chunk-4WOXD56B.js → chunk-LDCFFJ67.js} +5 -5
  38. package/dist/{chunk-5AZQZFJG.js → chunk-N3QDLE2N.js} +5 -5
  39. package/dist/{chunk-2B3BTQN4.js → chunk-N4ZA2I7M.js} +2 -2
  40. package/dist/chunk-NDXJJKT2.js +118 -0
  41. package/dist/chunk-NDXJJKT2.js.map +1 -0
  42. package/dist/{chunk-HP23UD6W.js → chunk-NFWTMM2M.js} +2 -2
  43. package/dist/{chunk-3QLPBCFG.js → chunk-NKP6II6G.js} +4 -4
  44. package/dist/{chunk-HHCMZEUC.js → chunk-OX272PBW.js} +2 -2
  45. package/dist/{chunk-YU4CLTZY.js → chunk-QPTP6SKC.js} +2 -2
  46. package/dist/{chunk-6S2C5FQ4.js → chunk-R3K4CXPR.js} +2 -2
  47. package/dist/{chunk-XDFWMY4X.js → chunk-SFNQDXXL.js} +108 -14
  48. package/dist/chunk-SFNQDXXL.js.map +1 -0
  49. package/dist/{chunk-MB5RSUW6.js → chunk-SXN3LCUT.js} +114 -28
  50. package/dist/chunk-SXN3LCUT.js.map +1 -0
  51. package/dist/{chunk-3CBSKDRL.js → chunk-TIOXQPD7.js} +9 -9
  52. package/dist/{chunk-CXW6Y7SI.js → chunk-V5RVMULT.js} +4 -4
  53. package/dist/{chunk-DUU4VLOW.js → chunk-VKHZFF5P.js} +2 -2
  54. package/dist/{chunk-NPZTEHR7.js → chunk-X7HTU4HO.js} +2 -2
  55. package/dist/{chunk-ZAIU7ZEA.js → chunk-YDYC6PIS.js} +2 -2
  56. package/dist/cli.js +34 -33
  57. package/dist/compounding/engine.js +5 -4
  58. package/dist/connectors/codex-materialize-runner.js +5 -4
  59. package/dist/connectors/index.js +5 -4
  60. package/dist/entity-retrieval.js +5 -4
  61. package/dist/{forget-OEE5OELK.js → forget-WYLR4ZFV.js} +2 -2
  62. package/dist/index.js +47 -46
  63. package/dist/lcm/engine.js +2 -2
  64. package/dist/lcm/index.js +4 -4
  65. package/dist/maintenance/memory-governance.js +5 -4
  66. package/dist/maintenance/rebuild-memory-lifecycle-ledger.d.ts +11 -1
  67. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +5 -4
  68. package/dist/maintenance/rebuild-memory-projection.d.ts +22 -1
  69. package/dist/maintenance/rebuild-memory-projection.js +6 -5
  70. package/dist/memory-projection-store.d.ts +6 -2
  71. package/dist/memory-projection-store.js +9 -3
  72. package/dist/namespaces/migrate.js +9 -8
  73. package/dist/namespaces/search.js +3 -3
  74. package/dist/namespaces/storage.js +5 -4
  75. package/dist/operator-toolkit.js +12 -11
  76. package/dist/orchestration/maintenance.js +7 -6
  77. package/dist/orchestrator.js +45 -44
  78. package/dist/{purge-6ATBGT77.js → purge-IHRX4Z7X.js} +7 -1
  79. package/dist/purge-IHRX4Z7X.js.map +1 -0
  80. package/dist/schemas.d.ts +22 -22
  81. package/dist/search/factory.js +2 -2
  82. package/dist/search/index.js +4 -4
  83. package/dist/semantic-consolidation.js +6 -5
  84. package/dist/semantic-rule-promotion.js +5 -4
  85. package/dist/semantic-rule-verifier.js +5 -4
  86. package/dist/storage-guards.d.ts +9 -0
  87. package/dist/storage-guards.js +11 -0
  88. package/dist/storage-guards.js.map +1 -0
  89. package/dist/storage.js +4 -3
  90. package/dist/transfer/import-sqlite.js +2 -2
  91. package/dist/transfer/types.d.ts +12 -12
  92. package/dist/verified-recall.js +5 -4
  93. package/package.json +2 -2
  94. package/src/maintenance/purge.test.ts +76 -0
  95. package/src/maintenance/purge.ts +3 -0
  96. package/src/maintenance/rebuild-memory-lifecycle-ledger.ts +59 -8
  97. package/src/maintenance/rebuild-memory-projection.ts +154 -10
  98. package/src/memory-projection-store.ts +143 -35
  99. package/src/storage-guards.ts +30 -0
  100. package/src/storage.ts +16 -16
  101. package/dist/chunk-3DAIQSVS.js.map +0 -1
  102. package/dist/chunk-GYJESQS5.js +0 -77
  103. package/dist/chunk-GYJESQS5.js.map +0 -1
  104. package/dist/chunk-MB5RSUW6.js.map +0 -1
  105. package/dist/chunk-XDFWMY4X.js.map +0 -1
  106. package/dist/purge-6ATBGT77.js.map +0 -1
  107. /package/dist/{chunk-742MWWVL.js.map → chunk-2RLECZ6J.js.map} +0 -0
  108. /package/dist/{chunk-CFHDI5CV.js.map → chunk-6YEENRE3.js.map} +0 -0
  109. /package/dist/{chunk-JYH3XYWM.js.map → chunk-6ZFE2TPO.js.map} +0 -0
  110. /package/dist/{chunk-LG2MCUBC.js.map → chunk-A4FMQIST.js.map} +0 -0
  111. /package/dist/{chunk-APJQ6UEA.js.map → chunk-AGNBY3VG.js.map} +0 -0
  112. /package/dist/{chunk-OCPVA4TH.js.map → chunk-AS2NRB45.js.map} +0 -0
  113. /package/dist/{chunk-W4ADKEIY.js.map → chunk-BAF7MBEH.js.map} +0 -0
  114. /package/dist/{chunk-ICVNVSKN.js.map → chunk-BRYPUVJU.js.map} +0 -0
  115. /package/dist/{chunk-IV4INTAU.js.map → chunk-CVINPTKG.js.map} +0 -0
  116. /package/dist/{chunk-IRFHFRHP.js.map → chunk-D6YTILJO.js.map} +0 -0
  117. /package/dist/{chunk-P7UA66OG.js.map → chunk-IVFIYAIP.js.map} +0 -0
  118. /package/dist/{chunk-RKNJBZ55.js.map → chunk-JBPKEARU.js.map} +0 -0
  119. /package/dist/{chunk-Y3XEN5TL.js.map → chunk-K4EDULCP.js.map} +0 -0
  120. /package/dist/{chunk-P7N2DQLC.js.map → chunk-KXG7HHIQ.js.map} +0 -0
  121. /package/dist/{chunk-4WOXD56B.js.map → chunk-LDCFFJ67.js.map} +0 -0
  122. /package/dist/{chunk-5AZQZFJG.js.map → chunk-N3QDLE2N.js.map} +0 -0
  123. /package/dist/{chunk-2B3BTQN4.js.map → chunk-N4ZA2I7M.js.map} +0 -0
  124. /package/dist/{chunk-HP23UD6W.js.map → chunk-NFWTMM2M.js.map} +0 -0
  125. /package/dist/{chunk-3QLPBCFG.js.map → chunk-NKP6II6G.js.map} +0 -0
  126. /package/dist/{chunk-HHCMZEUC.js.map → chunk-OX272PBW.js.map} +0 -0
  127. /package/dist/{chunk-YU4CLTZY.js.map → chunk-QPTP6SKC.js.map} +0 -0
  128. /package/dist/{chunk-6S2C5FQ4.js.map → chunk-R3K4CXPR.js.map} +0 -0
  129. /package/dist/{chunk-3CBSKDRL.js.map → chunk-TIOXQPD7.js.map} +0 -0
  130. /package/dist/{chunk-CXW6Y7SI.js.map → chunk-V5RVMULT.js.map} +0 -0
  131. /package/dist/{chunk-DUU4VLOW.js.map → chunk-VKHZFF5P.js.map} +0 -0
  132. /package/dist/{chunk-NPZTEHR7.js.map → chunk-X7HTU4HO.js.map} +0 -0
  133. /package/dist/{chunk-ZAIU7ZEA.js.map → chunk-YDYC6PIS.js.map} +0 -0
  134. /package/dist/{forget-OEE5OELK.js.map → forget-WYLR4ZFV.js.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remnic/core",
3
- "version": "9.3.761",
3
+ "version": "9.3.763",
4
4
  "description": "Framework-agnostic Remnic memory engine — orchestrator, storage, extraction, search, trust zones",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -3000,7 +3000,7 @@
3000
3000
  "core"
3001
3001
  ],
3002
3002
  "peerDependencies": {
3003
- "@remnic/coding-graph": "^9.3.761"
3003
+ "@remnic/coding-graph": "^9.3.763"
3004
3004
  },
3005
3005
  "peerDependenciesMeta": {
3006
3006
  "@remnic/coding-graph": {
@@ -5,6 +5,9 @@ import path from "node:path";
5
5
  import test from "node:test";
6
6
 
7
7
  import { purgeMemories } from "./purge.js";
8
+ import { rebuildMemoryProjection } from "./rebuild-memory-projection.js";
9
+ import { readProjectedMemoryBrowse } from "../memory-projection-store.js";
10
+ import { StorageManager } from "../storage.js";
8
11
  import type { MemoryFile } from "../types.js";
9
12
 
10
13
  test("purgeMemories records audit errors without blocking hard delete", async () => {
@@ -54,6 +57,79 @@ test("purgeMemories records audit errors without blocking hard delete", async ()
54
57
  }
55
58
  });
56
59
 
60
+ test("purgeMemories invalidates projection rows for hard-deleted memories", async () => {
61
+ const dir = await mkdtemp(path.join(os.tmpdir(), "remnic-purge-projection-"));
62
+ try {
63
+ const storage = new StorageManager(dir);
64
+ const { id } = await storage.writeMemory("fact", "purge projection target", {
65
+ source: "test",
66
+ });
67
+ const memory = await storage.getMemoryById(id);
68
+ assert.ok(memory);
69
+ await rebuildMemoryProjection({ memoryDir: dir, dryRun: false });
70
+ assert.equal(readProjectedMemoryBrowse(dir, { limit: 5, offset: 0 })?.total, 1);
71
+
72
+ const result = await purgeMemories({
73
+ storage,
74
+ olderThanMs: 1,
75
+ tier: "all",
76
+ dryRun: false,
77
+ now: () => new Date(Date.now() + 86_400_000),
78
+ });
79
+
80
+ assert.equal(result.purgedCount, 1);
81
+ assert.equal(readProjectedMemoryBrowse(dir, { limit: 5, offset: 0 })?.total, 0);
82
+ } finally {
83
+ await rm(dir, { recursive: true, force: true });
84
+ }
85
+ });
86
+
87
+ test("purgeMemories invalidates projection rows for already-absent files but not failed deletes, and dry-run never mutates", async () => {
88
+ const dir = await mkdtemp(path.join(os.tmpdir(), "remnic-purge-projection-branches-"));
89
+ try {
90
+ // Real projection with two rows; candidates come from a mock storage so the
91
+ // unlink outcomes are deterministic: one path is already absent (ENOENT),
92
+ // the other is a non-empty directory (unlink fails, not ENOENT).
93
+ const realStorage = new StorageManager(dir);
94
+ const first = await realStorage.writeMemory("fact", "purge branch enoent", { source: "test" });
95
+ const second = await realStorage.writeMemory("fact", "purge branch failure", { source: "test" });
96
+ await rebuildMemoryProjection({ memoryDir: dir, dryRun: false });
97
+ assert.equal(readProjectedMemoryBrowse(dir, { limit: 5, offset: 0 })?.total, 2);
98
+
99
+ const firstMemory = await realStorage.getMemoryById(first.id);
100
+ const secondMemory = await realStorage.getMemoryById(second.id);
101
+ assert.ok(firstMemory && secondMemory);
102
+ await rm(firstMemory.path);
103
+ await rm(secondMemory.path);
104
+ await mkdir(secondMemory.path);
105
+ await writeFile(path.join(secondMemory.path, "block.txt"), "x", "utf8");
106
+
107
+ const mockStorage = {
108
+ dir,
109
+ readAllMemories: async () => [firstMemory, secondMemory],
110
+ readAllColdMemories: async () => [],
111
+ readArchivedMemories: async () => [],
112
+ };
113
+ const future = () => new Date(Date.now() + 86_400_000);
114
+
115
+ // Dry-run: no projection mutation.
116
+ await purgeMemories({ storage: mockStorage as never, olderThanMs: 1, tier: "all", dryRun: true, now: future });
117
+ assert.equal(readProjectedMemoryBrowse(dir, { limit: 5, offset: 0 })?.total, 2);
118
+
119
+ const result = await purgeMemories({ storage: mockStorage as never, olderThanMs: 1, tier: "all", dryRun: false, now: future });
120
+
121
+ assert.equal(result.alreadyAbsentCount, 1);
122
+ assert.equal(result.purgedCount, 0);
123
+ assert.equal(result.errors.filter((e) => e.id === second.id).length, 1);
124
+ const page = readProjectedMemoryBrowse(dir, { limit: 5, offset: 0 });
125
+ assert.ok(page);
126
+ // ENOENT row invalidated; failed-delete row keeps its projection entry.
127
+ assert.equal(page.total, 1);
128
+ } finally {
129
+ await rm(dir, { recursive: true, force: true });
130
+ }
131
+ });
132
+
57
133
  async function fileExists(filePath: string): Promise<boolean> {
58
134
  try {
59
135
  await access(filePath);
@@ -23,6 +23,7 @@ import path from "node:path";
23
23
  import { appendFile, mkdir, unlink } from "node:fs/promises";
24
24
  import type { StorageManager } from "../storage.js";
25
25
  import type { MemoryFile } from "../types.js";
26
+ import { markProjectedMemoryPathInvalid } from "../memory-projection-store.js";
26
27
  import type { SearchBackend } from "../search/port.js";
27
28
 
28
29
  export type PurgeTierFilter = "cold" | "all";
@@ -236,12 +237,14 @@ export async function purgeMemories(
236
237
  await unlink(candidate.path);
237
238
  actuallyPurged.push(candidate);
238
239
  addCollectionForCandidate(candidate);
240
+ markProjectedMemoryPathInvalid(storage.dir, candidate.id);
239
241
  } catch (err) {
240
242
  const message = err instanceof Error ? err.message : String(err);
241
243
  // ENOENT is fine — already gone
242
244
  if (hasErrorCode(err, "ENOENT")) {
243
245
  alreadyAbsent.push(candidate);
244
246
  addCollectionForCandidate(candidate);
247
+ markProjectedMemoryPathInvalid(storage.dir, candidate.id);
245
248
  } else {
246
249
  errors.push({ id: candidate.id, path: candidate.path, error: message });
247
250
  }
@@ -1,6 +1,7 @@
1
1
  import path from "node:path";
2
2
  import { stat } from "node:fs/promises";
3
3
  import { StorageManager } from "../storage.js";
4
+ import { log } from "../logger.js";
4
5
  import type { MemoryLifecycleEvent } from "../types.js";
5
6
  import { toBackupStamp } from "./backup-stamp.js";
6
7
  import { writeFileAtomically } from "./atomic-file.js";
@@ -15,12 +16,24 @@ export interface RebuildMemoryLifecycleLedgerOptions {
15
16
  now?: Date;
16
17
  }
17
18
 
19
+ export interface SkippedLifecycleBlankIdMemory {
20
+ path: string;
21
+ }
22
+
23
+ export interface SkippedDuplicateLifecycleEvent {
24
+ eventId: string;
25
+ keptPath: string;
26
+ skippedPath: string;
27
+ }
28
+
18
29
  export interface RebuildMemoryLifecycleLedgerResult {
19
30
  dryRun: boolean;
20
31
  scannedMemories: number;
21
32
  rebuiltRows: number;
22
33
  outputPath: string;
23
34
  backupPath?: string;
35
+ skippedBlankIdMemories: SkippedLifecycleBlankIdMemory[];
36
+ skippedDuplicateEvents: SkippedDuplicateLifecycleEvent[];
24
37
  }
25
38
 
26
39
  function isNodeError(err: unknown): err is NodeJS.ErrnoException {
@@ -59,16 +72,52 @@ export async function rebuildMemoryLifecycleLedger(
59
72
  const now = options.now ?? new Date();
60
73
  const outputPath = path.join(options.memoryDir, "state", "memory-lifecycle-ledger.jsonl");
61
74
  const storage = new StorageManager(options.memoryDir);
62
- const allMemories = [
63
- ...await storage.readAllMemories(),
64
- ...await storage.readAllColdMemories(),
65
- ...await storage.readArchivedMemories(),
66
- ]
67
- .sort((a, b) => a.frontmatter.id.localeCompare(b.frontmatter.id));
75
+ const tiers = [
76
+ await storage.readAllMemories(),
77
+ await storage.readAllColdMemories(),
78
+ await storage.readArchivedMemories(),
79
+ ];
80
+ const allMemories = tiers.flat();
81
+ const skippedBlankIdMemories: SkippedLifecycleBlankIdMemory[] = [];
82
+ const eventCandidates: Array<{ event: MemoryLifecycleEvent; path: string }> = [];
83
+ for (const tier of tiers) {
84
+ for (const memory of [...tier].sort((left, right) => left.path.localeCompare(right.path))) {
85
+ if (!memory.frontmatter.id.trim()) {
86
+ skippedBlankIdMemories.push({ path: memory.path });
87
+ log.warn(`lifecycle ledger rebuild skipped blank memory id: path=${memory.path}`);
88
+ continue;
89
+ }
90
+ for (const event of buildLifecycleEventsForMemory(memory)) {
91
+ eventCandidates.push({ event, path: memory.path });
92
+ }
93
+ }
94
+ }
68
95
 
69
- const events: MemoryLifecycleEvent[] = sortMemoryLifecycleEvents(
70
- allMemories.flatMap((memory) => buildLifecycleEventsForMemory(memory)),
96
+ const orderedEvents = sortMemoryLifecycleEvents(eventCandidates.map((candidate) => candidate.event));
97
+ const candidateByEvent = new Map(
98
+ eventCandidates.map((candidate) => [candidate.event, candidate]),
71
99
  );
100
+ const keptByEventId = new Map<string, { event: MemoryLifecycleEvent; path: string }>();
101
+ const events: MemoryLifecycleEvent[] = [];
102
+ const skippedDuplicateEvents: SkippedDuplicateLifecycleEvent[] = [];
103
+ for (const event of orderedEvents) {
104
+ const candidate = candidateByEvent.get(event)!;
105
+ const kept = keptByEventId.get(event.eventId);
106
+ if (kept) {
107
+ skippedDuplicateEvents.push({
108
+ eventId: event.eventId,
109
+ keptPath: kept.path,
110
+ skippedPath: candidate.path,
111
+ });
112
+ log.warn(
113
+ `lifecycle ledger rebuild skipped duplicate event_id=${event.eventId}: `
114
+ + `kept=${kept.path} skipped=${candidate.path}`,
115
+ );
116
+ continue;
117
+ }
118
+ keptByEventId.set(event.eventId, candidate);
119
+ events.push(event);
120
+ }
72
121
 
73
122
  let backupPath: string | undefined;
74
123
  if (!dryRun) {
@@ -87,5 +136,7 @@ export async function rebuildMemoryLifecycleLedger(
87
136
  rebuiltRows: events.length,
88
137
  outputPath,
89
138
  backupPath,
139
+ skippedBlankIdMemories,
140
+ skippedDuplicateEvents,
90
141
  };
91
142
  }
@@ -1,6 +1,7 @@
1
1
  import path from "node:path";
2
2
  import { mkdir, rm, stat } from "node:fs/promises";
3
3
  import { StorageManager } from "../storage.js";
4
+ import { log } from "../logger.js";
4
5
  import {
5
6
  listMemoryGovernanceRuns,
6
7
  readMemoryGovernanceRunArtifact,
@@ -23,6 +24,7 @@ import {
23
24
  import {
24
25
  getMemoryProjectionPath,
25
26
  initializeMemoryProjectionDb,
27
+ isProjectedMemoryPathValid,
26
28
  type ProjectedEntityMentionRow,
27
29
  type MemoryProjectionGovernanceAppliedActionRow,
28
30
  type MemoryProjectionGovernanceReviewQueueRow,
@@ -51,6 +53,27 @@ export interface RebuildMemoryProjectionOptions {
51
53
  updatedBefore?: string;
52
54
  }
53
55
 
56
+ export interface SkippedDuplicateMemory {
57
+ memoryId: string;
58
+ keptPath: string;
59
+ skippedPath: string;
60
+ }
61
+
62
+ export interface SkippedBlankIdMemory {
63
+ path: string;
64
+ }
65
+
66
+ export interface SkippedDuplicateTimelineEvent {
67
+ eventId: string;
68
+ keptPath: string;
69
+ skippedPath: string;
70
+ }
71
+
72
+ export interface SkippedBlankIdTimelineEvent {
73
+ eventId: string;
74
+ path: string;
75
+ }
76
+
54
77
  export interface RebuildMemoryProjectionResult {
55
78
  dryRun: boolean;
56
79
  scannedMemories: number;
@@ -62,6 +85,10 @@ export interface RebuildMemoryProjectionResult {
62
85
  outputPath: string;
63
86
  backupPath?: string;
64
87
  usedLifecycleLedger: boolean;
88
+ skippedDuplicateMemories: SkippedDuplicateMemory[];
89
+ skippedBlankIdMemories: SkippedBlankIdMemory[];
90
+ skippedDuplicateTimelineEvents: SkippedDuplicateTimelineEvent[];
91
+ skippedBlankIdTimelineEvents: SkippedBlankIdTimelineEvent[];
65
92
  scope: {
66
93
  updatedAfter: string | null;
67
94
  updatedBefore: string | null;
@@ -435,6 +462,91 @@ function serializeGovernanceReviewQueueRow(
435
462
  });
436
463
  }
437
464
 
465
+ function selectProjectionMemories(
466
+ tiers: MemoryFile[][],
467
+ ): {
468
+ allMemories: MemoryFile[];
469
+ memories: MemoryFile[];
470
+ skippedDuplicateMemories: SkippedDuplicateMemory[];
471
+ skippedBlankIdMemories: SkippedBlankIdMemory[];
472
+ } {
473
+ const allMemories = tiers.flat();
474
+ const memories: MemoryFile[] = [];
475
+ const keptById = new Map<string, MemoryFile>();
476
+ const skippedDuplicateMemories: SkippedDuplicateMemory[] = [];
477
+ const skippedBlankIdMemories: SkippedBlankIdMemory[] = [];
478
+
479
+ // Tier order is authoritative: hot wins over cold, and cold wins over archive.
480
+ // Paths break ties within a tier so the winner does not depend on readdir order.
481
+ for (const tier of tiers) {
482
+ for (const memory of [...tier].sort((left, right) => left.path.localeCompare(right.path))) {
483
+ const memoryId = memory.frontmatter.id.trim();
484
+ if (!memoryId) {
485
+ skippedBlankIdMemories.push({ path: memory.path });
486
+ log.warn(`projection rebuild skipped blank memory id: path=${memory.path}`);
487
+ continue;
488
+ }
489
+ const kept = keptById.get(memoryId);
490
+ if (kept) {
491
+ skippedDuplicateMemories.push({
492
+ memoryId,
493
+ keptPath: kept.path,
494
+ skippedPath: memory.path,
495
+ });
496
+ log.warn(
497
+ `projection rebuild skipped duplicate memory id=${memoryId}: kept=${kept.path} skipped=${memory.path}`,
498
+ );
499
+ continue;
500
+ }
501
+ keptById.set(memoryId, memory);
502
+ memories.push(memory);
503
+ }
504
+ }
505
+
506
+ return { allMemories, memories, skippedDuplicateMemories, skippedBlankIdMemories };
507
+ }
508
+
509
+ function deduplicateTimelineEvents(
510
+ events: MemoryLifecycleEvent[],
511
+ sourcePathFor: (event: MemoryLifecycleEvent) => string,
512
+ ): {
513
+ events: MemoryLifecycleEvent[];
514
+ skipped: SkippedDuplicateTimelineEvent[];
515
+ skippedBlankIds: SkippedBlankIdTimelineEvent[];
516
+ } {
517
+ const keptById = new Map<string, { event: MemoryLifecycleEvent; path: string }>();
518
+ const unique: MemoryLifecycleEvent[] = [];
519
+ const skipped: SkippedDuplicateTimelineEvent[] = [];
520
+ const skippedBlankIds: SkippedBlankIdTimelineEvent[] = [];
521
+ for (const event of sortMemoryLifecycleEvents(events)) {
522
+ const sourcePath = sourcePathFor(event);
523
+ if (!event.memoryId.trim()) {
524
+ skippedBlankIds.push({ eventId: event.eventId, path: sourcePath });
525
+ log.warn(
526
+ `projection rebuild skipped timeline event with blank memory id: `
527
+ + `event_id=${event.eventId} path=${sourcePath}`,
528
+ );
529
+ continue;
530
+ }
531
+ const kept = keptById.get(event.eventId);
532
+ if (kept) {
533
+ skipped.push({
534
+ eventId: event.eventId,
535
+ keptPath: kept.path,
536
+ skippedPath: sourcePath,
537
+ });
538
+ log.warn(
539
+ `projection rebuild skipped duplicate timeline event_id=${event.eventId}: `
540
+ + `kept=${kept.path} skipped=${sourcePath}`,
541
+ );
542
+ continue;
543
+ }
544
+ keptById.set(event.eventId, { event, path: sourcePath });
545
+ unique.push(event);
546
+ }
547
+ return { events: unique, skipped, skippedBlankIds };
548
+ }
549
+
438
550
  async function loadAuthoritativeProjectionSnapshot(options: {
439
551
  memoryDir: string;
440
552
  defaultNamespace?: string;
@@ -464,22 +576,43 @@ async function loadAuthoritativeProjectionSnapshot(options: {
464
576
  updatedAfter: string | null;
465
577
  updatedBefore: string | null;
466
578
  };
579
+ skippedDuplicateMemories: SkippedDuplicateMemory[];
580
+ skippedBlankIdMemories: SkippedBlankIdMemory[];
581
+ skippedDuplicateTimelineEvents: SkippedDuplicateTimelineEvent[];
582
+ skippedBlankIdTimelineEvents: SkippedBlankIdTimelineEvent[];
467
583
  }> {
468
584
  const storage = new StorageManager(options.memoryDir);
469
585
  // Force a fresh disk read — projection verify/rebuild must see the true
470
586
  // on-disk state, not a potentially stale in-process cache.
471
587
  storage.invalidateAllMemoriesCacheForDir();
472
- const allMemories = [
473
- ...await storage.readAllMemories(),
474
- ...await storage.readAllColdMemories(),
475
- ...await storage.readArchivedMemories(),
476
- ]
477
- .sort((a, b) => a.frontmatter.id.localeCompare(b.frontmatter.id));
588
+ const selected = selectProjectionMemories([
589
+ await storage.readAllMemories(),
590
+ await storage.readAllColdMemories(),
591
+ await storage.readArchivedMemories(),
592
+ ]);
593
+ const allMemories = selected.allMemories;
594
+ const projectionMemories = selected.memories;
478
595
  const lifecycleEvents = await storage.readAllMemoryLifecycleEvents();
479
- const { events, usedLifecycleLedger } = loadTimelineEvents(allMemories, lifecycleEvents);
480
- const currentRows = allMemories.map((memory) => toCurrentStateRow(options.memoryDir, memory));
596
+ const timeline = loadTimelineEvents(projectionMemories, lifecycleEvents);
597
+ const lifecycleLedgerPath = path.join(
598
+ options.memoryDir,
599
+ "state",
600
+ "memory-lifecycle-ledger.jsonl",
601
+ );
602
+ const memoryPathById = new Map(
603
+ projectionMemories.map((memory) => [memory.frontmatter.id, memory.path]),
604
+ );
605
+ const deduplicatedTimeline = deduplicateTimelineEvents(
606
+ timeline.events,
607
+ (event) => timeline.usedLifecycleLedger
608
+ ? lifecycleLedgerPath
609
+ : memoryPathById.get(event.memoryId) ?? lifecycleLedgerPath,
610
+ );
611
+ const events = deduplicatedTimeline.events;
612
+ const usedLifecycleLedger = timeline.usedLifecycleLedger;
613
+ const currentRows = projectionMemories.map((memory) => toCurrentStateRow(options.memoryDir, memory));
481
614
  const scope = normalizeProjectionScope(options);
482
- const scopedMemories = filterMemoriesForProjectionScope(allMemories, scope);
615
+ const scopedMemories = filterMemoriesForProjectionScope(projectionMemories, scope);
483
616
  const scopedMemoryIds = new Set(scopedMemories.map((memory) => memory.frontmatter.id));
484
617
  const nativeKnowledgeRows = await loadPersistedNativeKnowledgeChunks({
485
618
  memoryDir: options.memoryDir,
@@ -516,6 +649,10 @@ async function loadAuthoritativeProjectionSnapshot(options: {
516
649
  governance,
517
650
  usedLifecycleLedger,
518
651
  scope,
652
+ skippedDuplicateMemories: selected.skippedDuplicateMemories,
653
+ skippedBlankIdMemories: selected.skippedBlankIdMemories,
654
+ skippedDuplicateTimelineEvents: deduplicatedTimeline.skipped,
655
+ skippedBlankIdTimelineEvents: deduplicatedTimeline.skippedBlankIds,
519
656
  };
520
657
  }
521
658
 
@@ -658,6 +795,7 @@ function writeProjectionDb(
658
795
  usedLifecycleLedger: boolean,
659
796
  ): void {
660
797
  const db = openBetterSqlite3(dbPath);
798
+ const memoryDir = path.dirname(path.dirname(dbPath));
661
799
  try {
662
800
  initializeMemoryProjectionDb(db);
663
801
 
@@ -674,6 +812,7 @@ function writeProjectionDb(
674
812
  status,
675
813
  lifecycle_state,
676
814
  path_rel,
815
+ path_valid,
677
816
  created_at,
678
817
  updated_at,
679
818
  archived_at,
@@ -687,7 +826,7 @@ function writeProjectionDb(
687
826
  last_accessed,
688
827
  tags_json,
689
828
  preview_text
690
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
829
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
691
830
  `);
692
831
 
693
832
  const insertTimeline = db.prepare(`
@@ -785,6 +924,7 @@ function writeProjectionDb(
785
924
  row.status,
786
925
  row.lifecycleState ?? null,
787
926
  row.pathRel,
927
+ isProjectedMemoryPathValid(memoryDir, row.pathRel) ? 1 : 0,
788
928
  row.created,
789
929
  row.updated,
790
930
  row.archivedAt ?? null,
@@ -1040,6 +1180,10 @@ export async function rebuildMemoryProjection(
1040
1180
  outputPath,
1041
1181
  backupPath,
1042
1182
  usedLifecycleLedger: snapshot.usedLifecycleLedger,
1183
+ skippedDuplicateMemories: snapshot.skippedDuplicateMemories,
1184
+ skippedBlankIdMemories: snapshot.skippedBlankIdMemories,
1185
+ skippedDuplicateTimelineEvents: snapshot.skippedDuplicateTimelineEvents,
1186
+ skippedBlankIdTimelineEvents: snapshot.skippedBlankIdTimelineEvents,
1043
1187
  scope: snapshot.scope,
1044
1188
  };
1045
1189
  }