@fgv/ts-agent-memory 5.1.0-45 → 5.1.0-47

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 (120) hide show
  1. package/.rush/temp/{bc5b0f2098f7df2a5c498ea77b0de13316349b96.tar.log → 5667e36f1c0cc503a5c20a3ebff76e7b9a702d41.tar.log} +26 -2
  2. package/.rush/temp/chunked-rush-logs/ts-agent-memory.build.chunks.jsonl +2 -2
  3. package/.rush/temp/operation/build/all.log +2 -2
  4. package/.rush/temp/operation/build/log-chunks.jsonl +2 -2
  5. package/.rush/temp/operation/build/state.json +1 -1
  6. package/README.md +134 -4
  7. package/dist/packlets/ingest/orchestrator.js +104 -19
  8. package/dist/packlets/ingest/orchestrator.js.map +1 -1
  9. package/dist/packlets/retrieve/fragmentSemanticRetriever.js +10 -7
  10. package/dist/packlets/retrieve/fragmentSemanticRetriever.js.map +1 -1
  11. package/dist/packlets/store/fileTreeMemoryStore.js +31 -8
  12. package/dist/packlets/store/fileTreeMemoryStore.js.map +1 -1
  13. package/dist/packlets/types/writePolicy.js +10 -2
  14. package/dist/packlets/types/writePolicy.js.map +1 -1
  15. package/dist/packlets/vector/fragmentConverters.js +41 -0
  16. package/dist/packlets/vector/fragmentConverters.js.map +1 -0
  17. package/dist/packlets/vector/inMemoryFragmentCosineIndex.js +17 -9
  18. package/dist/packlets/vector/inMemoryFragmentCosineIndex.js.map +1 -1
  19. package/dist/packlets/vector/index.js +1 -0
  20. package/dist/packlets/vector/index.js.map +1 -1
  21. package/dist/packlets/vector/vectorIndex.js.map +1 -1
  22. package/dist/test/unit/ingest/dedupScope.test.js +434 -0
  23. package/dist/test/unit/ingest/dedupScope.test.js.map +1 -0
  24. package/dist/test/unit/ingest/orchestrator.test.js +7 -4
  25. package/dist/test/unit/ingest/orchestrator.test.js.map +1 -1
  26. package/dist/test/unit/store/fileTreeMemoryStore.test.js +64 -0
  27. package/dist/test/unit/store/fileTreeMemoryStore.test.js.map +1 -1
  28. package/dist/test/unit/store/indexInjection.test.js +432 -0
  29. package/dist/test/unit/store/indexInjection.test.js.map +1 -0
  30. package/dist/test/unit/tools/memoryTools.test.js +1 -0
  31. package/dist/test/unit/tools/memoryTools.test.js.map +1 -1
  32. package/dist/test/unit/types/temporalPolicy.test.js +21 -0
  33. package/dist/test/unit/types/temporalPolicy.test.js.map +1 -1
  34. package/dist/test/unit/types/writePolicy.test.js +83 -0
  35. package/dist/test/unit/types/writePolicy.test.js.map +1 -1
  36. package/dist/test/unit/vector/fragmentConverters.test.js +58 -0
  37. package/dist/test/unit/vector/fragmentConverters.test.js.map +1 -0
  38. package/dist/test/unit/vector/inMemoryFragmentCosineIndex.test.js +65 -0
  39. package/dist/test/unit/vector/inMemoryFragmentCosineIndex.test.js.map +1 -1
  40. package/dist/ts-agent-memory.d.ts +332 -42
  41. package/etc/ts-agent-memory.api.md +12 -1
  42. package/lib/packlets/ingest/orchestrator.d.ts +62 -5
  43. package/lib/packlets/ingest/orchestrator.d.ts.map +1 -1
  44. package/lib/packlets/ingest/orchestrator.js +104 -19
  45. package/lib/packlets/ingest/orchestrator.js.map +1 -1
  46. package/lib/packlets/retrieve/fragmentSemanticRetriever.d.ts +10 -7
  47. package/lib/packlets/retrieve/fragmentSemanticRetriever.d.ts.map +1 -1
  48. package/lib/packlets/retrieve/fragmentSemanticRetriever.js +10 -7
  49. package/lib/packlets/retrieve/fragmentSemanticRetriever.js.map +1 -1
  50. package/lib/packlets/store/fileTreeMemoryStore.d.ts +96 -4
  51. package/lib/packlets/store/fileTreeMemoryStore.d.ts.map +1 -1
  52. package/lib/packlets/store/fileTreeMemoryStore.js +31 -8
  53. package/lib/packlets/store/fileTreeMemoryStore.js.map +1 -1
  54. package/lib/packlets/types/writePolicy.d.ts +40 -2
  55. package/lib/packlets/types/writePolicy.d.ts.map +1 -1
  56. package/lib/packlets/types/writePolicy.js +10 -2
  57. package/lib/packlets/types/writePolicy.js.map +1 -1
  58. package/lib/packlets/vector/fragmentConverters.d.ts +25 -0
  59. package/lib/packlets/vector/fragmentConverters.d.ts.map +1 -0
  60. package/lib/packlets/vector/fragmentConverters.js +44 -0
  61. package/lib/packlets/vector/fragmentConverters.js.map +1 -0
  62. package/lib/packlets/vector/inMemoryFragmentCosineIndex.d.ts +5 -3
  63. package/lib/packlets/vector/inMemoryFragmentCosineIndex.d.ts.map +1 -1
  64. package/lib/packlets/vector/inMemoryFragmentCosineIndex.js +17 -9
  65. package/lib/packlets/vector/inMemoryFragmentCosineIndex.js.map +1 -1
  66. package/lib/packlets/vector/index.d.ts +1 -0
  67. package/lib/packlets/vector/index.d.ts.map +1 -1
  68. package/lib/packlets/vector/index.js +1 -0
  69. package/lib/packlets/vector/index.js.map +1 -1
  70. package/lib/packlets/vector/vectorIndex.d.ts +97 -22
  71. package/lib/packlets/vector/vectorIndex.d.ts.map +1 -1
  72. package/lib/packlets/vector/vectorIndex.js.map +1 -1
  73. package/lib/test/unit/ingest/dedupScope.test.d.ts +2 -0
  74. package/lib/test/unit/ingest/dedupScope.test.d.ts.map +1 -0
  75. package/lib/test/unit/ingest/dedupScope.test.js +436 -0
  76. package/lib/test/unit/ingest/dedupScope.test.js.map +1 -0
  77. package/lib/test/unit/ingest/orchestrator.test.js +7 -4
  78. package/lib/test/unit/ingest/orchestrator.test.js.map +1 -1
  79. package/lib/test/unit/store/fileTreeMemoryStore.test.js +64 -0
  80. package/lib/test/unit/store/fileTreeMemoryStore.test.js.map +1 -1
  81. package/lib/test/unit/store/indexInjection.test.d.ts +2 -0
  82. package/lib/test/unit/store/indexInjection.test.d.ts.map +1 -0
  83. package/lib/test/unit/store/indexInjection.test.js +434 -0
  84. package/lib/test/unit/store/indexInjection.test.js.map +1 -0
  85. package/lib/test/unit/tools/memoryTools.test.js +1 -0
  86. package/lib/test/unit/tools/memoryTools.test.js.map +1 -1
  87. package/lib/test/unit/types/temporalPolicy.test.js +21 -0
  88. package/lib/test/unit/types/temporalPolicy.test.js.map +1 -1
  89. package/lib/test/unit/types/writePolicy.test.js +83 -0
  90. package/lib/test/unit/types/writePolicy.test.js.map +1 -1
  91. package/lib/test/unit/vector/fragmentConverters.test.d.ts +2 -0
  92. package/lib/test/unit/vector/fragmentConverters.test.d.ts.map +1 -0
  93. package/lib/test/unit/vector/fragmentConverters.test.js +60 -0
  94. package/lib/test/unit/vector/fragmentConverters.test.js.map +1 -0
  95. package/lib/test/unit/vector/inMemoryFragmentCosineIndex.test.js +65 -0
  96. package/lib/test/unit/vector/inMemoryFragmentCosineIndex.test.js.map +1 -1
  97. package/package.json +7 -7
  98. package/rush-logs/ts-agent-memory.build.cache.log +1 -1
  99. package/rush-logs/ts-agent-memory.build.log +2 -2
  100. package/src/packlets/ingest/orchestrator.ts +116 -21
  101. package/src/packlets/retrieve/fragmentSemanticRetriever.ts +10 -7
  102. package/src/packlets/store/fileTreeMemoryStore.ts +114 -6
  103. package/src/packlets/types/writePolicy.ts +40 -2
  104. package/src/packlets/vector/fragmentConverters.ts +55 -0
  105. package/src/packlets/vector/inMemoryFragmentCosineIndex.ts +35 -8
  106. package/src/packlets/vector/index.ts +1 -0
  107. package/src/packlets/vector/vectorIndex.ts +97 -22
  108. package/src/test/unit/ingest/dedupScope.test.ts +542 -0
  109. package/src/test/unit/ingest/orchestrator.test.ts +4 -0
  110. package/src/test/unit/store/fileTreeMemoryStore.test.ts +75 -0
  111. package/src/test/unit/store/indexInjection.test.ts +550 -0
  112. package/src/test/unit/tools/memoryTools.test.ts +1 -0
  113. package/src/test/unit/types/temporalPolicy.test.ts +28 -0
  114. package/src/test/unit/types/writePolicy.test.ts +112 -0
  115. package/src/test/unit/vector/fragmentConverters.test.ts +80 -0
  116. package/src/test/unit/vector/inMemoryFragmentCosineIndex.test.ts +88 -0
  117. package/temp/build/lint/_eslint-5eVG3S6w.json +30 -14
  118. package/temp/build/typescript/ts_8nwakTlr.json +1 -1
  119. package/temp/ts-agent-memory.api.json +274 -21
  120. package/temp/ts-agent-memory.api.md +12 -1
@@ -1,5 +1,5 @@
1
- Start time: Tue Jul 28 2026 04:22:12 GMT+0000 (Coordinated Universal Time)
2
- Invoking "/usr/bin/tar -c -f /home/runner/work/fgv/fgv/common/temp/build-cache/bc5b0f2098f7df2a5c498ea77b0de13316349b96-673fc6539337f6a1.temp -z --files-from=-"
1
+ Start time: Fri Aug 07 2026 01:16:23 GMT+0000 (Coordinated Universal Time)
2
+ Invoking "/usr/bin/tar -c -f /home/runner/work/fgv/fgv/common/temp/build-cache/5667e36f1c0cc503a5c20a3ebff76e7b9a702d41-f9816615898c604e.temp -z --files-from=-"
3
3
 
4
4
  ======= BEGIN PROCESS INPUT ======
5
5
  .rush/temp/operation/build/all.log
@@ -77,6 +77,8 @@ dist/packlets/types/temporal.js
77
77
  dist/packlets/types/temporal.js.map
78
78
  dist/packlets/types/writePolicy.js
79
79
  dist/packlets/types/writePolicy.js.map
80
+ dist/packlets/vector/fragmentConverters.js
81
+ dist/packlets/vector/fragmentConverters.js.map
80
82
  dist/packlets/vector/inMemoryCosineIndex.js
81
83
  dist/packlets/vector/inMemoryCosineIndex.js.map
82
84
  dist/packlets/vector/inMemoryFragmentCosineIndex.js
@@ -97,6 +99,8 @@ dist/test/unit/ingest/antagonistCycleAndParity.test.js
97
99
  dist/test/unit/ingest/antagonistCycleAndParity.test.js.map
98
100
  dist/test/unit/ingest/cycleGuard.test.js
99
101
  dist/test/unit/ingest/cycleGuard.test.js.map
102
+ dist/test/unit/ingest/dedupScope.test.js
103
+ dist/test/unit/ingest/dedupScope.test.js.map
100
104
  dist/test/unit/ingest/orchestrator.test.js
101
105
  dist/test/unit/ingest/orchestrator.test.js.map
102
106
  dist/test/unit/observe/memoryObservationStore.test.js
@@ -117,6 +121,8 @@ dist/test/unit/store/fileTreeMemoryStore.test.js
117
121
  dist/test/unit/store/fileTreeMemoryStore.test.js.map
118
122
  dist/test/unit/store/fragmentEmbedOnWrite.test.js
119
123
  dist/test/unit/store/fragmentEmbedOnWrite.test.js.map
124
+ dist/test/unit/store/indexInjection.test.js
125
+ dist/test/unit/store/indexInjection.test.js.map
120
126
  dist/test/unit/store/lenientOpen.test.js
121
127
  dist/test/unit/store/lenientOpen.test.js.map
122
128
  dist/test/unit/store/listScoped.test.js
@@ -141,6 +147,8 @@ dist/test/unit/types/temporalPolicy.test.js
141
147
  dist/test/unit/types/temporalPolicy.test.js.map
142
148
  dist/test/unit/types/writePolicy.test.js
143
149
  dist/test/unit/types/writePolicy.test.js.map
150
+ dist/test/unit/vector/fragmentConverters.test.js
151
+ dist/test/unit/vector/fragmentConverters.test.js.map
144
152
  dist/test/unit/vector/inMemoryCosineIndex.test.js
145
153
  dist/test/unit/vector/inMemoryCosineIndex.test.js.map
146
154
  dist/test/unit/vector/inMemoryFragmentCosineIndex.test.js
@@ -293,6 +301,10 @@ lib/packlets/types/writePolicy.d.ts
293
301
  lib/packlets/types/writePolicy.d.ts.map
294
302
  lib/packlets/types/writePolicy.js
295
303
  lib/packlets/types/writePolicy.js.map
304
+ lib/packlets/vector/fragmentConverters.d.ts
305
+ lib/packlets/vector/fragmentConverters.d.ts.map
306
+ lib/packlets/vector/fragmentConverters.js
307
+ lib/packlets/vector/fragmentConverters.js.map
296
308
  lib/packlets/vector/inMemoryCosineIndex.d.ts
297
309
  lib/packlets/vector/inMemoryCosineIndex.d.ts.map
298
310
  lib/packlets/vector/inMemoryCosineIndex.js
@@ -333,6 +345,10 @@ lib/test/unit/ingest/cycleGuard.test.d.ts
333
345
  lib/test/unit/ingest/cycleGuard.test.d.ts.map
334
346
  lib/test/unit/ingest/cycleGuard.test.js
335
347
  lib/test/unit/ingest/cycleGuard.test.js.map
348
+ lib/test/unit/ingest/dedupScope.test.d.ts
349
+ lib/test/unit/ingest/dedupScope.test.d.ts.map
350
+ lib/test/unit/ingest/dedupScope.test.js
351
+ lib/test/unit/ingest/dedupScope.test.js.map
336
352
  lib/test/unit/ingest/orchestrator.test.d.ts
337
353
  lib/test/unit/ingest/orchestrator.test.d.ts.map
338
354
  lib/test/unit/ingest/orchestrator.test.js
@@ -373,6 +389,10 @@ lib/test/unit/store/fragmentEmbedOnWrite.test.d.ts
373
389
  lib/test/unit/store/fragmentEmbedOnWrite.test.d.ts.map
374
390
  lib/test/unit/store/fragmentEmbedOnWrite.test.js
375
391
  lib/test/unit/store/fragmentEmbedOnWrite.test.js.map
392
+ lib/test/unit/store/indexInjection.test.d.ts
393
+ lib/test/unit/store/indexInjection.test.d.ts.map
394
+ lib/test/unit/store/indexInjection.test.js
395
+ lib/test/unit/store/indexInjection.test.js.map
376
396
  lib/test/unit/store/lenientOpen.test.d.ts
377
397
  lib/test/unit/store/lenientOpen.test.d.ts.map
378
398
  lib/test/unit/store/lenientOpen.test.js
@@ -421,6 +441,10 @@ lib/test/unit/types/writePolicy.test.d.ts
421
441
  lib/test/unit/types/writePolicy.test.d.ts.map
422
442
  lib/test/unit/types/writePolicy.test.js
423
443
  lib/test/unit/types/writePolicy.test.js.map
444
+ lib/test/unit/vector/fragmentConverters.test.d.ts
445
+ lib/test/unit/vector/fragmentConverters.test.d.ts.map
446
+ lib/test/unit/vector/fragmentConverters.test.js
447
+ lib/test/unit/vector/fragmentConverters.test.js.map
424
448
  lib/test/unit/vector/inMemoryCosineIndex.test.d.ts
425
449
  lib/test/unit/vector/inMemoryCosineIndex.test.d.ts.map
426
450
  lib/test/unit/vector/inMemoryCosineIndex.test.js
@@ -5,5 +5,5 @@
5
5
  {"kind":"O","text":"[build:lint] Using ESLint version 9.39.5\n"}
6
6
  {"kind":"O","text":"[build:api-extractor] Using API Extractor version 7.58.9\n"}
7
7
  {"kind":"O","text":"[build:api-extractor] Analysis will use the bundled TypeScript version 5.9.3\n"}
8
- {"kind":"O","text":" ---- build finished (21.343s) ---- \n"}
9
- {"kind":"O","text":"-------------------- Finished (21.348s) --------------------\n"}
8
+ {"kind":"O","text":" ---- build finished (26.047s) ---- \n"}
9
+ {"kind":"O","text":"-------------------- Finished (26.053s) --------------------\n"}
@@ -5,5 +5,5 @@ Invoking: heft build --clean
5
5
  [build:lint] Using ESLint version 9.39.5
6
6
  [build:api-extractor] Using API Extractor version 7.58.9
7
7
  [build:api-extractor] Analysis will use the bundled TypeScript version 5.9.3
8
- ---- build finished (21.343s) ----
9
- -------------------- Finished (21.348s) --------------------
8
+ ---- build finished (26.047s) ----
9
+ -------------------- Finished (26.053s) --------------------
@@ -5,5 +5,5 @@
5
5
  {"kind":"O","text":"[build:lint] Using ESLint version 9.39.5\n"}
6
6
  {"kind":"O","text":"[build:api-extractor] Using API Extractor version 7.58.9\n"}
7
7
  {"kind":"O","text":"[build:api-extractor] Analysis will use the bundled TypeScript version 5.9.3\n"}
8
- {"kind":"O","text":" ---- build finished (21.343s) ---- \n"}
9
- {"kind":"O","text":"-------------------- Finished (21.348s) --------------------\n"}
8
+ {"kind":"O","text":" ---- build finished (26.047s) ---- \n"}
9
+ {"kind":"O","text":"-------------------- Finished (26.053s) --------------------\n"}
@@ -1,3 +1,3 @@
1
1
  {
2
- "nonCachedDurationMs": 22097.002042000007
2
+ "nonCachedDurationMs": 26984.765819000007
3
3
  }
package/README.md CHANGED
@@ -6,11 +6,12 @@ edges, content-hash dedup, and the optional-layer seams (vector / temporal /
6
6
  observe). It owns none of a consumer's processing, transformation, or
7
7
  composition logic.
8
8
 
9
- > **Status:** v0.1, under active development. The package is being built
10
- > knowledge-first. This tier (B0) ships the foundational type model and
11
- > converters only — no store, index, retrieval, observe, or vector code yet.
9
+ > **Status:** under active development. The package is being built
10
+ > knowledge-first. The surface listing below covers the foundational type model
11
+ > and converters; the store, index, retrieval, observe, and vector layers ship
12
+ > alongside them and are not itemized here.
12
13
 
13
- ## B0 surface (this tier)
14
+ ## Foundational surface
14
15
 
15
16
  ### `types` packlet
16
17
 
@@ -34,6 +35,135 @@ composition logic.
34
35
  `joinFrontmatter` / `parseMemoryFile` / `serializeMemoryFile` helpers for
35
36
  the `---\n<yaml>\n---\n<body>` memory-file format.
36
37
 
38
+ ## Record updates — the merge contract
39
+
40
+ When a `put` targets an `entityId` that already exists, the write is an
41
+ **update**, not a replace. The store projects the incoming record's mutable
42
+ fields into a patch and hands it to the kind's `IWritePolicy.applyUpdate`, which
43
+ applies it as an **RFC-7386 JSON Merge Patch** over the policy's declared
44
+ mutable surface. This is a **pinned contract**, not an artifact of the current
45
+ merge configuration — the shipped policies compose `@fgv/ts-json`'s `JsonEditor`
46
+ with `{ nullAsDelete: true, arrayMergeBehavior: 'replace' }` specifically to get
47
+ RFC-7386 semantics, and `applyUpdate` documents them as the interface contract.
48
+
49
+ Within the policy's declared mutable surface:
50
+
51
+ - **Objects merge per key.** Keys you supply overwrite; keys you omit are
52
+ **preserved**, not dropped. An update that sets one provenance field leaves
53
+ every sibling field intact.
54
+ - **An explicit `null` on a sub-key clears that sub-key.** This is the
55
+ sanctioned way to remove a single key from a nested object such as
56
+ `provenance`. The cleared key is gone from the persisted record and does not
57
+ reappear on reload.
58
+ - **Arrays replace wholesale.** `tags` and `links` are not element-merged.
59
+ - **A whole-block `null` on a required field is rejected loudly**, never
60
+ silently accepted. `body`, `tags`, `links`, and `provenance` are required; a
61
+ patch that deletes one fails with
62
+ `... merge patch may not delete required field(s): <names>`. (`embeddingRef`
63
+ is optional and *may* be cleared by a `null`, which restores it to absent
64
+ rather than to `null`.)
65
+ - **Keys outside the mutable surface are ignored.** Identity and
66
+ transaction-time envelope fields (`id`, `entityId`, `kind`, `created`,
67
+ `updated`, `seq`, `contentHash`) are preserved verbatim; the store stamps
68
+ `updated` / `seq` / `contentHash` on write.
69
+
70
+ ### `provenance` is on the pinned surface
71
+
72
+ `KnowledgeLwwPolicy` and `TemporalVersionedPolicy` both hard-code their mutable
73
+ surface as `['body', 'tags', 'links', 'provenance', 'embeddingRef']`. For those
74
+ policies the guarantees above apply to `provenance` specifically: per-key merge,
75
+ sub-key clearing via `null`, and loud rejection of a whole-block delete are all
76
+ part of the contract and are pinned by tests.
77
+
78
+ ```ts
79
+ // existing.envelope.provenance: { source: 'agent', confidence: 0.9, note: 'stale' }
80
+
81
+ // clear one sub-key; siblings survive
82
+ policy.applyUpdate(existing, { provenance: { note: null } });
83
+ // => provenance: { source: 'agent', confidence: 0.9 }
84
+
85
+ // revise one sub-key; siblings survive
86
+ policy.applyUpdate(existing, { provenance: { confidence: 0.5 } });
87
+ // => provenance: { source: 'agent', confidence: 0.5, note: 'stale' }
88
+
89
+ // delete the whole block — rejected, not silently accepted
90
+ policy.applyUpdate(existing, { provenance: null });
91
+ // => Result.fail('knowledge LWW: merge patch may not delete required field(s): provenance')
92
+ ```
93
+
94
+ Going through the store rather than calling a policy directly, the same three
95
+ cases are expressed as the `provenance` value on the record you `put`: include a
96
+ key to set it, set a key to `null` to clear it, and omit a key to leave it
97
+ alone. A whole-block delete is not expressible there at all —
98
+ `IMemoryEnvelope.provenance` is non-nullable and `envelopeConverter` rejects
99
+ `null` — so that case fails at the converter instead of at the policy. Either
100
+ way it fails loudly.
101
+
102
+ ### The surface is policy-dependent
103
+
104
+ `MemoryCapCullPolicy` takes its `mutableFields` from the caller, so **what it
105
+ guarantees depends on what you declared**. A field you did not declare mutable
106
+ is not merged at all: patch keys naming it are dropped before the merge runs, so
107
+ a `null` on it neither clears anything nor raises an error — it is simply inert,
108
+ and the existing value is preserved verbatim. If you want the guarantees above
109
+ for `provenance` under a cap-cull policy, `provenance` must appear in the
110
+ `mutableFields` you pass to `MemoryCapCullPolicy.create`.
111
+
112
+ ## Dedup granularity — `dedupScope`
113
+
114
+ Each kind's `IWritePolicy` declares the granularity at which a write
115
+ deduplicates against the existing vault:
116
+
117
+ - **`'content'`** — scope-wide, cross-id. An identical body anywhere in the
118
+ scope, even under a different entity, is the same record. The knowledge
119
+ family (`KnowledgeLwwPolicy`) uses this.
120
+ - **`'entity'`** — same-entity only. An identical re-put of one entity is a
121
+ no-op, but two *distinct* entities with coincidentally-identical bodies both
122
+ persist. `MemoryCapCullPolicy` and `TemporalVersionedPolicy` declare this, so
123
+ every experience and versioned kind gets it.
124
+
125
+ Read the effective value for a kind through **`IMemoryStore.dedupScopeFor(kind)`**.
126
+ That accessor is the single owner: it resolves the registered policy, the store's
127
+ default policy, the policy's declaration, and finally `DEFAULT_DEDUP_SCOPE` — and
128
+ the store's own write path reads through it too, so nothing can drift out of
129
+ agreement with it.
130
+
131
+ > **Careful:** `DEFAULT_DEDUP_SCOPE` is `'entity'`, but it is only reached when a
132
+ > policy declares no `dedupScope`. A kind with **no registered policy** falls back
133
+ > to the store's default policy — a `KnowledgeLwwPolicy`, which declares
134
+ > `'content'` explicitly. So an unpoliced kind gets `'content'`, not `'entity'`.
135
+
136
+ ### ⚠️ Behavior change: ingest now honors `dedupScope`
137
+
138
+ **This changes results for existing hosts, deliberately, with no opt-in flag.**
139
+
140
+ The ingest orchestrator's stage-4 layer-1 exact match previously ignored
141
+ `dedupScope` entirely and always behaved as `'content'` — the declaration was
142
+ dead code on that path. It now honors the declaration, so:
143
+
144
+ - A kind declaring **`'entity'`** (i.e. anything using `MemoryCapCullPolicy` or
145
+ `TemporalVersionedPolicy`) **no longer collapses two distinct entities that
146
+ share a byte-identical body** during `ingestItem` / `ingestBatch`. Candidates
147
+ that used to come back `deduped` now come back `written`, and the records
148
+ genuinely persist. If your vault silently lost turns whose summaries happened
149
+ to match, it will stop doing that.
150
+ - A kind declaring **`'content'`** is unchanged.
151
+ - A kind with **no registered policy** is unchanged (it resolves to `'content'`,
152
+ as above).
153
+
154
+ This is the intended fix, not a regression: the same declaration already governed
155
+ the direct `put` path, and the two paths disagreeing was the bug. It is not
156
+ flagged, because a flag would preserve the disagreement.
157
+
158
+ Independently of `dedupScope`, a `duplicate-of` collapse no longer orphans
159
+ sibling edges. An edge built against a collapsed candidate is **redirected** to
160
+ the record that candidate collapsed into, instead of failing the entire ingest
161
+ item. This also fixes `'content'` kinds, where the collapse is correct but the
162
+ ingest still should not fail.
163
+
164
+ See `.claude/project/agent-memory-ingest-design.md` §1 and §3 for the full
165
+ treatment.
166
+
37
167
  ## Conventions
38
168
 
39
169
  `Result<T>` on every fallible operation; no `any`; Converters/Validators for
@@ -115,7 +115,7 @@ export class MemoryIngestOrchestrator {
115
115
  }
116
116
  // Stage 5: relate over the writable candidates, validate + cycle-guard.
117
117
  const writablePlans = plans.filter((plan) => plan.verdict.verdict !== 'duplicate-of');
118
- const edgesResult = await this._relate(item, writablePlans, scoped, byKey);
118
+ const edgesResult = await this._relate(item, writablePlans, MemoryIngestOrchestrator._collapseRedirects(plans), scoped, byKey);
119
119
  if (edgesResult.isFailure()) {
120
120
  return fail(edgesResult.message);
121
121
  }
@@ -211,13 +211,15 @@ export class MemoryIngestOrchestrator {
211
211
  }
212
212
  /**
213
213
  * Stage 4 — resolve a dedup verdict. Layer 1: an exact `{ kind, body }` match in
214
- * the candidate's scope is a `duplicate-of` (design note §1). Layer 2 (only when
214
+ * the candidate's scope is a `duplicate-of`, at the granularity the kind's
215
+ * {@link DedupScope} declares — scope-wide for `'content'`, restricted to the
216
+ * candidate's own entity for `'entity'` (design note §1). Layer 2 (only when
215
217
  * a resolver + vector index + embedder are all wired): embed the candidate,
216
218
  * surface over-threshold neighbors, and dispatch to the {@link IEntityResolver}.
217
219
  * Otherwise the verdict is `new` (the exact-only fall-back path).
218
220
  */
219
221
  async _resolveVerdict(candidate, kind, body, addr, snapshot, byKey) {
220
- return this._findExactMatch(kind, body, addr.scope, snapshot).thenOnSuccess(async (matchId) => {
222
+ return this._findExactMatch(kind, body, addr, snapshot).thenOnSuccess(async (matchId) => {
221
223
  if (matchId !== undefined) {
222
224
  // The exact-match cohort is filtered to `addr.scope`, so the match lives
223
225
  // under that scope — its scope-qualified target is `(addr.scope, matchId)`.
@@ -269,19 +271,44 @@ export class MemoryIngestOrchestrator {
269
271
  return this._capture(() => wiring.resolver.resolve(candidate, similar), `ingest '${candidate.envelope.entityId}': resolve`);
270
272
  }
271
273
  /**
272
- * Find an existing record in `scope` whose `{ kind, body }` hash matches the
273
- * candidate's (layer-1 exact dedup). Invalidated temporal versions are excluded
274
- * — only a live (non-temporal or current) record deduplicates a candidate.
274
+ * Find an existing record whose `{ kind, body }` hash matches the candidate's
275
+ * (layer-1 exact dedup, design note §1). Invalidated temporal versions are
276
+ * excluded — only a live (non-temporal or current) record deduplicates a
277
+ * candidate.
278
+ *
279
+ * @remarks
280
+ * The cohort's granularity is the kind's DECLARED {@link DedupScope}, read
281
+ * through {@link IMemoryStore.dedupScopeFor} so this layer and the store's own
282
+ * write path can never disagree:
283
+ *
284
+ * - `'content'` — every live same-kind record in the candidate's scope,
285
+ * regardless of entity. A cross-id body collision IS a duplicate (the
286
+ * knowledge family; unchanged behavior).
287
+ * - `'entity'` — only records at the candidate's OWN entity address. Two
288
+ * distinct entities with byte-identical bodies never collapse into one
289
+ * another, which is what the declaration has always meant on the direct-put
290
+ * path and now means here too.
291
+ *
292
+ * The same-id collapse itself remains the store's job — this layer only decides
293
+ * whether a cross-id body collision is eligible to be a `duplicate-of` at all.
275
294
  */
276
- _findExactMatch(kind, body, scope, snapshot) {
295
+ _findExactMatch(kind, body, addr, snapshot) {
296
+ const dedupScope = this._store.dedupScopeFor(kind);
277
297
  // Same-kind, same-scope, LIVE (non-temporal or current) records are the exact
278
- // cohort. `_resolveAddress(...).map(...).orDefault()` collapses an unresolved
279
- // codec to a non-matching scope with no explicit failure branch.
280
- const cohort = snapshot.filter((record) => record.envelope.kind === kind &&
281
- !(isTemporalRecord(record) && !isVersionCurrent(record)) &&
282
- this._resolveAddress(record.envelope.entityId, record.envelope.kind)
283
- .onSuccess((addr) => succeed(addr.scope))
284
- .orDefault() === scope);
298
+ // cohort, narrowed to the candidate's own entity address under `'entity'`
299
+ // granularity. `_resolveAddress(...).orDefault()` collapses an unresolved
300
+ // codec to `undefined` a non-matching address — with no explicit failure
301
+ // branch, exactly as the scope-only filter did before.
302
+ const cohort = snapshot.filter((record) => {
303
+ if (record.envelope.kind !== kind || (isTemporalRecord(record) && !isVersionCurrent(record))) {
304
+ return false;
305
+ }
306
+ const recordAddr = this._resolveAddress(record.envelope.entityId, record.envelope.kind).orDefault();
307
+ if (recordAddr === undefined || recordAddr.scope !== addr.scope) {
308
+ return false;
309
+ }
310
+ return dedupScope === 'content' || recordAddr.idStem === addr.idStem;
311
+ });
285
312
  return this._exactKey(kind, body).onSuccess((key) => mapResults(cohort.map((record) => MemoryIngestOrchestrator._recordBodyString(record).onSuccess((recordBody) => this._exactKey(record.envelope.kind, recordBody).onSuccess((recordKey) => succeed({ id: record.envelope.id, key: recordKey }))))).onSuccess((keyed) => { var _a; return succeed((_a = keyed.find((entry) => entry.key === key)) === null || _a === void 0 ? void 0 : _a.id); }));
286
313
  }
287
314
  /**
@@ -300,8 +327,8 @@ export class MemoryIngestOrchestrator {
300
327
  _exactKey(kind, body) {
301
328
  return this._hasher.computeHash({ kind, body });
302
329
  }
303
- /** Stage 5 — relate (host), validate edges, and run the write-time cycle guard. */
304
- async _relate(item, writablePlans, scoped, byKey) {
330
+ /** Stage 5 — relate (host), redirect collapsed targets, validate edges, and run the write-time cycle guard. */
331
+ async _relate(item, writablePlans, redirects, scoped, byKey) {
305
332
  const relationCandidates = writablePlans.map((plan) => ({
306
333
  candidate: plan.candidate,
307
334
  id: plan.refTarget
@@ -310,21 +337,79 @@ export class MemoryIngestOrchestrator {
310
337
  if (proposed.isFailure()) {
311
338
  return proposed;
312
339
  }
340
+ // Redirect BEFORE validation and BEFORE the cycle guard: an edge naming a
341
+ // collapsed candidate must be judged — and persisted — against the record it
342
+ // collapsed into, not against the address that no longer gets written.
343
+ const edges = MemoryIngestOrchestrator._redirectEdges(proposed.value, redirects);
313
344
  // refIds and the existing-record view (`byKey`, shared with the verdict path)
314
345
  // both key on the canonical scoped address, so a stem reused across scopes
315
346
  // never aliases.
316
347
  const refIds = new Set(writablePlans.map((plan) => edgeTargetKey(plan.refTarget)));
317
- const validation = this._validateEdges(item, proposed.value, refIds, byKey);
348
+ const validation = this._validateEdges(item, edges, refIds, byKey);
318
349
  if (validation.isFailure()) {
319
350
  return fail(validation.message);
320
351
  }
321
352
  if (this._cycleGuard === 'reject') {
322
- const guard = assertNoCycles(MemoryIngestOrchestrator._existingEdges(scoped), proposed.value.map((e) => ({ source: e.source, target: e.edge.target, type: e.edge.type })));
353
+ const guard = assertNoCycles(MemoryIngestOrchestrator._existingEdges(scoped), edges.map((e) => ({ source: e.source, target: e.edge.target, type: e.edge.type })));
323
354
  if (guard.isFailure()) {
324
355
  return fail(`ingest '${item.id}': ${guard.message}`);
325
356
  }
326
357
  }
327
- return succeed(proposed.value);
358
+ return succeed(edges);
359
+ }
360
+ /**
361
+ * The stage-5 edge-target redirect map for this pass: every `duplicate-of`
362
+ * candidate's OWN scoped address, mapped to the address of the record its
363
+ * verdict collapsed it into (design note §3).
364
+ *
365
+ * @remarks
366
+ * A `duplicate-of` verdict means "this candidate IS that record". The candidate
367
+ * is therefore not written, and its address never becomes a live reference — so
368
+ * a sibling edge built against it in the same pass would resolve to neither a
369
+ * written candidate nor an existing record and would fail the WHOLE ingest item.
370
+ * That is a second-order hazard of collapsing, not a defect in the edge: the
371
+ * host related two candidates it was correctly told about, and one of them
372
+ * turned out to already exist.
373
+ *
374
+ * Redirecting is the honest repair, and it is independent of {@link DedupScope}
375
+ * — it applies equally to a `'content'` kind, where the collapse is exactly
376
+ * right and the ingest still must not fail.
377
+ *
378
+ * The map cannot chain: {@link MemoryIngestOrchestrator._planFromVerdict}
379
+ * requires every target-bearing verdict's target to already exist in the store
380
+ * snapshot, so a redirect destination is always a persisted record and never
381
+ * another collapsed candidate. One pass is sufficient by construction.
382
+ */
383
+ static _collapseRedirects(plans) {
384
+ const redirects = new Map();
385
+ for (const plan of plans) {
386
+ if (plan.verdict.verdict === 'duplicate-of') {
387
+ redirects.set(edgeTargetKey(plan.refTarget), plan.verdict.target);
388
+ }
389
+ }
390
+ return redirects;
391
+ }
392
+ /**
393
+ * Rewrite each edge whose TARGET names a collapsed candidate to point at the
394
+ * record that candidate collapsed into.
395
+ *
396
+ * @remarks
397
+ * Only the target is redirected. An edge whose SOURCE is a collapsed candidate
398
+ * stays untouched and is still rejected by {@link
399
+ * MemoryIngestOrchestrator._validateEdges}: sources are the records an edge is
400
+ * written ONTO, a collapsed candidate is never written, and the relation
401
+ * extractor is only ever offered writable candidates as edge sources in the
402
+ * first place. Silently relocating such an edge onto an existing record would
403
+ * attribute a link the host never asked for — the loud failure is correct there.
404
+ */
405
+ static _redirectEdges(edges, redirects) {
406
+ if (redirects.size === 0) {
407
+ return edges;
408
+ }
409
+ return edges.map((e) => {
410
+ const to = redirects.get(edgeTargetKey(e.edge.target));
411
+ return to === undefined ? e : Object.assign(Object.assign({}, e), { edge: Object.assign(Object.assign({}, e.edge), { target: to }) });
412
+ });
328
413
  }
329
414
  /**
330
415
  * Validate stage-5 edges: each `source` must be a candidate being written; each