@remnic/core 9.3.688 → 9.3.690

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 (163) hide show
  1. package/dist/access-boundary.d.ts +5 -4
  2. package/dist/access-boundary.js +12 -11
  3. package/dist/access-cli.js +90 -26
  4. package/dist/access-cli.js.map +1 -1
  5. package/dist/access-http.d.ts +4 -3
  6. package/dist/access-http.js +15 -14
  7. package/dist/access-mcp.d.ts +11 -3
  8. package/dist/access-mcp.js +14 -13
  9. package/dist/access-operations.d.ts +11 -5
  10. package/dist/access-operations.js +15 -12
  11. package/dist/{access-service-DmCHJ4cH.d.ts → access-service-Dujr3MTm.d.ts} +62 -2
  12. package/dist/access-service.d.ts +4 -3
  13. package/dist/access-service.js +11 -10
  14. package/dist/access-surface-catalog.d.ts +4 -3
  15. package/dist/access-surface-catalog.js +2 -0
  16. package/dist/access-surface-catalog.js.map +1 -1
  17. package/dist/bootstrap.d.ts +3 -2
  18. package/dist/briefing.d.ts +1 -0
  19. package/dist/briefing.js +3 -2
  20. package/dist/buffer.d.ts +1 -0
  21. package/dist/{catalog-COqWZlZ6.d.ts → catalog-CKPtJ114.d.ts} +1 -1
  22. package/dist/causal-consolidation.js +4 -3
  23. package/dist/causal-consolidation.js.map +1 -1
  24. package/dist/{chunk-B4XVLHJA.js → chunk-2N6UNJSX.js} +2 -2
  25. package/dist/{chunk-NN7QYW5W.js → chunk-46URPRE6.js} +2 -2
  26. package/dist/chunk-46URPRE6.js.map +1 -0
  27. package/dist/{chunk-4N3TFFPH.js → chunk-54PVJDO5.js} +2 -2
  28. package/dist/{chunk-PH3HOKYW.js → chunk-67MMWC74.js} +2 -2
  29. package/dist/{chunk-STOEE37X.js → chunk-BKAMHZYR.js} +2 -2
  30. package/dist/{chunk-B43NZNMG.js → chunk-CE6CBRCV.js} +432 -2240
  31. package/dist/chunk-CE6CBRCV.js.map +1 -0
  32. package/dist/{chunk-RVYD6LR3.js → chunk-CP2NZQLT.js} +15 -4
  33. package/dist/chunk-CP2NZQLT.js.map +1 -0
  34. package/dist/{chunk-BLIWOONZ.js → chunk-CUNTLEJP.js} +4 -12
  35. package/dist/chunk-CUNTLEJP.js.map +1 -0
  36. package/dist/{chunk-2SJCWLQD.js → chunk-CWE74HRG.js} +3 -3
  37. package/dist/{chunk-VX6OBUDW.js → chunk-GR77Z2BM.js} +2 -2
  38. package/dist/{chunk-CTOQEZSN.js → chunk-LXIEXSHU.js} +2 -2
  39. package/dist/{chunk-KFBOZYME.js → chunk-NSVXK7M5.js} +33 -4
  40. package/dist/chunk-NSVXK7M5.js.map +1 -0
  41. package/dist/{chunk-473JIN2U.js → chunk-OMKUJTVJ.js} +37 -5
  42. package/dist/chunk-OMKUJTVJ.js.map +1 -0
  43. package/dist/{chunk-GS55WYRL.js → chunk-PHZKALOE.js} +2 -2
  44. package/dist/{chunk-6O6A6YUO.js → chunk-RGNEARXW.js} +3 -3
  45. package/dist/{chunk-4FE2K57M.js → chunk-RTFAZOIR.js} +2 -2
  46. package/dist/{chunk-UTYBJR7M.js → chunk-SB6CQTKP.js} +2 -2
  47. package/dist/chunk-SVOZFLIQ.js +16 -0
  48. package/dist/chunk-SVOZFLIQ.js.map +1 -0
  49. package/dist/{chunk-2KAYTPPT.js → chunk-TYF3D4MS.js} +5 -5
  50. package/dist/{chunk-WIHPNY65.js → chunk-UD5OKH4J.js} +2 -2
  51. package/dist/{chunk-QANVLERJ.js → chunk-UPIBE2DK.js} +2 -2
  52. package/dist/{chunk-CCWHPGT4.js → chunk-WROKD3XC.js} +45 -30
  53. package/dist/chunk-WROKD3XC.js.map +1 -0
  54. package/dist/{chunk-ZPTISBQU.js → chunk-X5J3JZR3.js} +7 -7
  55. package/dist/{chunk-OV4D5T7V.js → chunk-X7RLU5CR.js} +2 -2
  56. package/dist/{chunk-S2OU5DZY.js → chunk-XKUKJIOY.js} +30 -7
  57. package/dist/chunk-XKUKJIOY.js.map +1 -0
  58. package/dist/{chunk-FUCUR2OZ.js → chunk-Z7KILAOU.js} +603 -9
  59. package/dist/chunk-Z7KILAOU.js.map +1 -0
  60. package/dist/chunk-ZU7N3S6V.js +2190 -0
  61. package/dist/chunk-ZU7N3S6V.js.map +1 -0
  62. package/dist/{chunk-DCWIQFNA.js → chunk-ZUDM75KG.js} +4 -4
  63. package/dist/{cli-D8nZ2MPH.d.ts → cli-BkDp6WNi.d.ts} +2 -2
  64. package/dist/cli.d.ts +5 -4
  65. package/dist/cli.js +32 -30
  66. package/dist/compounding/engine.d.ts +1 -0
  67. package/dist/compounding/engine.js +3 -2
  68. package/dist/connectors/codex-materialize-runner.js +3 -2
  69. package/dist/connectors/index.js +3 -2
  70. package/dist/consolidation-provenance-check.d.ts +1 -0
  71. package/dist/consolidation-undo.d.ts +1 -0
  72. package/dist/contradiction/index.d.ts +1 -0
  73. package/dist/conversation-index/backend.js +2 -2
  74. package/dist/entity-retrieval.d.ts +1 -0
  75. package/dist/entity-retrieval.js +3 -2
  76. package/dist/explicit-capture.d.ts +3 -2
  77. package/dist/index.d.ts +6 -5
  78. package/dist/index.js +47 -44
  79. package/dist/index.js.map +1 -1
  80. package/dist/lcm/engine.js +3 -3
  81. package/dist/lcm/index.js +5 -5
  82. package/dist/maintenance/memory-governance.js +3 -2
  83. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +3 -2
  84. package/dist/maintenance/rebuild-memory-projection.js +4 -3
  85. package/dist/mcp-memory-inspector-app.d.ts +4 -3
  86. package/dist/memory-worth-outcomes.d.ts +1 -0
  87. package/dist/namespaces/migrate.d.ts +2 -1
  88. package/dist/namespaces/migrate.js +10 -9
  89. package/dist/namespaces/search.js +6 -6
  90. package/dist/namespaces/storage.d.ts +2 -1
  91. package/dist/namespaces/storage.js +3 -2
  92. package/dist/operator-toolkit.d.ts +1 -0
  93. package/dist/operator-toolkit.js +13 -11
  94. package/dist/{orchestrator-CA6ouzBn.d.ts → orchestrator-B7ixmUkP.d.ts} +145 -1
  95. package/dist/orchestrator.d.ts +3 -2
  96. package/dist/orchestrator.js +22 -20
  97. package/dist/recall-pipeline-stages.d.ts +167 -0
  98. package/dist/recall-pipeline-stages.js +61 -0
  99. package/dist/recall-pipeline-stages.js.map +1 -0
  100. package/dist/schemas.d.ts +10 -10
  101. package/dist/search/factory.js +5 -5
  102. package/dist/search/index.js +9 -9
  103. package/dist/semantic-consolidation.js +4 -3
  104. package/dist/semantic-rule-promotion.js +3 -2
  105. package/dist/semantic-rule-verifier.js +3 -2
  106. package/dist/storage.d.ts +3 -27
  107. package/dist/storage.js +5 -3
  108. package/dist/structured-attributes.d.ts +29 -0
  109. package/dist/structured-attributes.js +8 -0
  110. package/dist/structured-attributes.js.map +1 -0
  111. package/dist/temporal-supersession.d.ts +1 -0
  112. package/dist/tier-migration.d.ts +1 -0
  113. package/dist/verified-recall.js +3 -2
  114. package/package.json +2 -2
  115. package/src/access-boundary.ts +2 -1
  116. package/src/access-cli.test.ts +40 -0
  117. package/src/access-cli.ts +90 -2
  118. package/src/access-http.ts +35 -6
  119. package/src/access-mcp.ts +34 -0
  120. package/src/access-operations.ts +45 -0
  121. package/src/access-service.ts +60 -0
  122. package/src/access-surface-catalog.test.ts +1 -1
  123. package/src/access-surface-catalog.ts +2 -0
  124. package/src/cli.ts +18 -0
  125. package/src/coding/architecture-card.test.ts +544 -0
  126. package/src/coding/architecture-card.ts +687 -0
  127. package/src/coding/architecture-surfaces.test.ts +579 -0
  128. package/src/coding/architecture-surfaces.ts +457 -0
  129. package/src/maintenance/namespace-maintenance-fanout.test.ts +595 -0
  130. package/src/maintenance/namespace-maintenance-fanout.ts +318 -0
  131. package/src/maintenance/namespace-planner.ts +74 -16
  132. package/src/operator-toolkit.ts +25 -0
  133. package/src/orchestrator.ts +144 -0
  134. package/src/recall-pipeline-stages.test.ts +269 -0
  135. package/src/recall-pipeline-stages.ts +294 -0
  136. package/src/storage.ts +6 -20
  137. package/src/structured-attributes.ts +39 -0
  138. package/dist/chunk-473JIN2U.js.map +0 -1
  139. package/dist/chunk-B43NZNMG.js.map +0 -1
  140. package/dist/chunk-BLIWOONZ.js.map +0 -1
  141. package/dist/chunk-CCWHPGT4.js.map +0 -1
  142. package/dist/chunk-FUCUR2OZ.js.map +0 -1
  143. package/dist/chunk-KFBOZYME.js.map +0 -1
  144. package/dist/chunk-NN7QYW5W.js.map +0 -1
  145. package/dist/chunk-RVYD6LR3.js.map +0 -1
  146. package/dist/chunk-S2OU5DZY.js.map +0 -1
  147. /package/dist/{chunk-B4XVLHJA.js.map → chunk-2N6UNJSX.js.map} +0 -0
  148. /package/dist/{chunk-4N3TFFPH.js.map → chunk-54PVJDO5.js.map} +0 -0
  149. /package/dist/{chunk-PH3HOKYW.js.map → chunk-67MMWC74.js.map} +0 -0
  150. /package/dist/{chunk-STOEE37X.js.map → chunk-BKAMHZYR.js.map} +0 -0
  151. /package/dist/{chunk-2SJCWLQD.js.map → chunk-CWE74HRG.js.map} +0 -0
  152. /package/dist/{chunk-VX6OBUDW.js.map → chunk-GR77Z2BM.js.map} +0 -0
  153. /package/dist/{chunk-CTOQEZSN.js.map → chunk-LXIEXSHU.js.map} +0 -0
  154. /package/dist/{chunk-GS55WYRL.js.map → chunk-PHZKALOE.js.map} +0 -0
  155. /package/dist/{chunk-6O6A6YUO.js.map → chunk-RGNEARXW.js.map} +0 -0
  156. /package/dist/{chunk-4FE2K57M.js.map → chunk-RTFAZOIR.js.map} +0 -0
  157. /package/dist/{chunk-UTYBJR7M.js.map → chunk-SB6CQTKP.js.map} +0 -0
  158. /package/dist/{chunk-2KAYTPPT.js.map → chunk-TYF3D4MS.js.map} +0 -0
  159. /package/dist/{chunk-WIHPNY65.js.map → chunk-UD5OKH4J.js.map} +0 -0
  160. /package/dist/{chunk-QANVLERJ.js.map → chunk-UPIBE2DK.js.map} +0 -0
  161. /package/dist/{chunk-ZPTISBQU.js.map → chunk-X5J3JZR3.js.map} +0 -0
  162. /package/dist/{chunk-OV4D5T7V.js.map → chunk-X7RLU5CR.js.map} +0 -0
  163. /package/dist/{chunk-DCWIQFNA.js.map → chunk-ZUDM75KG.js.map} +0 -0
@@ -0,0 +1,269 @@
1
+ // Issue #1539 PR2 — unit tests for the recall pipeline spine module.
2
+ //
3
+ // These tests verify `unifiedDedupeAndRank` produces the correct results for
4
+ // each declared divergence dimension, WITHOUT changing any existing pipeline.
5
+ // PRs 3–6 will migrate each pipeline to call this function; at that point the
6
+ // characterization snapshots (tests/recall-pipeline-unified.test.ts) verify
7
+ // end-to-end byte-for-byte parity.
8
+
9
+ import assert from "node:assert/strict";
10
+ import test from "node:test";
11
+
12
+ import type { EvidencePackItem } from "./evidence-pack.js";
13
+ import {
14
+ unifiedDedupeAndRank,
15
+ type RankedEvidenceItem,
16
+ } from "./recall-pipeline-stages.js";
17
+
18
+ const NO_INTENTS: never[] = [];
19
+ const constantScorer = (_item: EvidencePackItem): number => 10;
20
+
21
+ function item(
22
+ turnIndex: number,
23
+ content: string,
24
+ overrides: Partial<EvidencePackItem> = {},
25
+ ): EvidencePackItem {
26
+ return {
27
+ id: `s1:${turnIndex}`,
28
+ sessionId: "s1",
29
+ turnIndex,
30
+ role: "user",
31
+ content,
32
+ ...overrides,
33
+ };
34
+ }
35
+
36
+ test("unifiedDedupeAndRank: dedup collapses identical ids", () => {
37
+ const items = [
38
+ item(3, "Content A"),
39
+ item(3, "Content A"), // same id → deduped
40
+ ];
41
+ const result = unifiedDedupeAndRank(items, {
42
+ query: "q",
43
+ intents: NO_INTENTS,
44
+ scoreEvidence: constantScorer,
45
+ });
46
+ assert.equal(result.length, 1);
47
+ assert.equal(result[0]?.turnIndex, 3);
48
+ });
49
+
50
+ test("unifiedDedupeAndRank: dedup collapses identical normalized content under different ids", () => {
51
+ const items = [
52
+ item(10, "My monthly expenses are $2,400."),
53
+ item(11, "MY MONTHLY EXPENSES ARE $2,400."), // same normalized content
54
+ ];
55
+ const result = unifiedDedupeAndRank(items, {
56
+ query: "q",
57
+ intents: NO_INTENTS,
58
+ scoreEvidence: constantScorer,
59
+ });
60
+ assert.equal(result.length, 1, "expected content dedup to collapse the two items");
61
+ assert.equal(result[0]?.turnIndex, 10, "first-seen wins");
62
+ });
63
+
64
+ test("unifiedDedupeAndRank: DESC sort (default) orders turnIndex descending on score ties", () => {
65
+ const items = [
66
+ item(2, "Oldest"),
67
+ item(5, "Middle"),
68
+ item(9, "Newest"),
69
+ ];
70
+ const result = unifiedDedupeAndRank(items, {
71
+ query: "q",
72
+ intents: NO_INTENTS,
73
+ scoreEvidence: constantScorer, // all tied → turnIndex DESC
74
+ });
75
+ const turns = result.map((r) => r.turnIndex);
76
+ assert.deepEqual(turns, [9, 5, 2]);
77
+ });
78
+
79
+ test("unifiedDedupeAndRank: ASC sort orders turnIndex ascending on score ties", () => {
80
+ const items = [
81
+ item(30, "Latest"),
82
+ item(10, "Earliest"),
83
+ item(20, "Middle"),
84
+ ];
85
+ const result = unifiedDedupeAndRank(items, {
86
+ query: "q",
87
+ intents: NO_INTENTS,
88
+ scoreEvidence: constantScorer,
89
+ turnIndexSortDirection: "asc",
90
+ });
91
+ const turns = result.map((r) => r.turnIndex);
92
+ assert.deepEqual(turns, [10, 20, 30]);
93
+ });
94
+
95
+ test("unifiedDedupeAndRank: rank primary key is always DESC regardless of turnIndex direction", () => {
96
+ const items = [
97
+ item(1, "Low rank", { score: 0 }),
98
+ item(2, "High rank", { score: 0 }),
99
+ ];
100
+ const result = unifiedDedupeAndRank(items, {
101
+ query: "q",
102
+ intents: NO_INTENTS,
103
+ scoreEvidence: (i) => (i.turnIndex === 2 ? 100 : 1),
104
+ turnIndexSortDirection: "asc",
105
+ });
106
+ // rank DESC wins over turnIndex ASC: turn 2 (rank 100) comes first despite ASC.
107
+ assert.equal(result[0]?.turnIndex, 2);
108
+ assert.equal(result[1]?.turnIndex, 1);
109
+ });
110
+
111
+ test("unifiedDedupeAndRank: rankThreshold drops items below the declared threshold", () => {
112
+ const items = [
113
+ item(1, "Weak", { score: 0 }),
114
+ item(2, "Strong", { score: 0 }),
115
+ item(3, "Medium", { score: 0 }),
116
+ ];
117
+ const result = unifiedDedupeAndRank(items, {
118
+ query: "q",
119
+ intents: NO_INTENTS,
120
+ scoreEvidence: (i) => {
121
+ if (i.turnIndex === 1) return 3;
122
+ if (i.turnIndex === 2) return 10;
123
+ return 6;
124
+ },
125
+ rankThreshold: 6,
126
+ });
127
+ const turns = result.map((r) => r.turnIndex).sort((a, b) => (a ?? 0) - (b ?? 0));
128
+ assert.deepEqual(turns, [2, 3], "turn 1 (rank 3) is below threshold 6 and dropped");
129
+ });
130
+
131
+ test("unifiedDedupeAndRank: transformContent is applied to output but NOT to scorer input", () => {
132
+ const items = [item(5, "original content")];
133
+ let scorerSawTransformed = false;
134
+ const result = unifiedDedupeAndRank(items, {
135
+ query: "q",
136
+ intents: NO_INTENTS,
137
+ scoreEvidence: (i) => {
138
+ if (i.content.includes("APPENDED CUE")) scorerSawTransformed = true;
139
+ return 5;
140
+ },
141
+ transformContent: (content) => `${content}\nAPPENDED CUE`,
142
+ });
143
+ assert.equal(scorerSawTransformed, false, "scorer must see ORIGINAL content");
144
+ assert.ok(result[0]?.content.includes("APPENDED CUE"), "output must have transformed content");
145
+ });
146
+
147
+ test("unifiedDedupeAndRank: undefined turnIndex sorts to the bottom of DESC (-1 sentinel)", () => {
148
+ const items = [
149
+ item(5, "Has turn"),
150
+ { id: "s1:x", sessionId: "s1", role: "user", content: "No turn" }, // no turnIndex
151
+ ];
152
+ const result = unifiedDedupeAndRank(items, {
153
+ query: "q",
154
+ intents: NO_INTENTS,
155
+ scoreEvidence: constantScorer,
156
+ // default DESC
157
+ });
158
+ assert.equal(result[1]?.id, "s1:x", "undefined-turnIndex item sorts last in DESC");
159
+ });
160
+
161
+ test("unifiedDedupeAndRank: undefined turnIndex sorts to the bottom of ASC (MAX sentinel)", () => {
162
+ const items = [
163
+ item(5, "Has turn"),
164
+ { id: "s1:x", sessionId: "s1", role: "user", content: "No turn" },
165
+ ];
166
+ const result = unifiedDedupeAndRank(items, {
167
+ query: "q",
168
+ intents: NO_INTENTS,
169
+ scoreEvidence: constantScorer,
170
+ turnIndexSortDirection: "asc",
171
+ });
172
+ assert.equal(result[1]?.id, "s1:x", "undefined-turnIndex item sorts last in ASC");
173
+ });
174
+
175
+ test("unifiedDedupeAndRank: DESC tertiary tiebreaker is score DESC", () => {
176
+ // Same rank (all tied via constantScorer) AND same turnIndex → score breaks the tie.
177
+ const items = [
178
+ item(5, "Low score", { score: 10 }),
179
+ item(5, "High score", { score: 90 }), // same turn, deduped by content? No — different content
180
+ ];
181
+ // Wait — both have turn 5 so same id "s1:5" → second is deduped. Use different sessions.
182
+ const itemsDistinct: EvidencePackItem[] = [
183
+ { id: "s1:5", sessionId: "s1", turnIndex: 5, role: "user", content: "A", score: 10 },
184
+ { id: "s2:5", sessionId: "s2", turnIndex: 5, role: "user", content: "B", score: 90 },
185
+ ];
186
+ const result = unifiedDedupeAndRank(itemsDistinct, {
187
+ query: "q",
188
+ intents: NO_INTENTS,
189
+ scoreEvidence: constantScorer, // rank tied → turnIndex tied → score DESC
190
+ });
191
+ assert.equal(result[0]?.id, "s2:5", "higher score (90) ranks first on DESC tertiary");
192
+ assert.equal(result[1]?.id, "s1:5");
193
+ });
194
+
195
+ test("unifiedDedupeAndRank: ASC tertiary tiebreaker is content.localeCompare", () => {
196
+ const items: EvidencePackItem[] = [
197
+ { id: "s2:5", sessionId: "s2", turnIndex: 5, role: "user", content: "Banana" },
198
+ { id: "s1:5", sessionId: "s1", turnIndex: 5, role: "user", content: "Apple" },
199
+ ];
200
+ const result = unifiedDedupeAndRank(items, {
201
+ query: "q",
202
+ intents: NO_INTENTS,
203
+ scoreEvidence: constantScorer, // rank tied → turnIndex tied → content localeCompare
204
+ turnIndexSortDirection: "asc",
205
+ });
206
+ assert.equal(result[0]?.content, "Apple", "localeCompare ASC: Apple < Banana");
207
+ assert.equal(result[1]?.content, "Banana");
208
+ });
209
+
210
+ test("unifiedDedupeAndRank: RankedEvidenceItem carries the computed rank", () => {
211
+ const items = [item(1, "content")];
212
+ const result: RankedEvidenceItem[] = unifiedDedupeAndRank(items, {
213
+ query: "q",
214
+ intents: NO_INTENTS,
215
+ scoreEvidence: () => 42,
216
+ });
217
+ assert.equal(result[0]?.rank, 42);
218
+ });
219
+
220
+ test("unifiedDedupeAndRank: fallback id uses sessionId:turnIndex when id is absent", () => {
221
+ const items: EvidencePackItem[] = [
222
+ { sessionId: "s1", turnIndex: 7, role: "user", content: "No explicit id" },
223
+ { sessionId: "s1", turnIndex: 7, role: "user", content: "Same fallback id → deduped" },
224
+ ];
225
+ const result = unifiedDedupeAndRank(items, {
226
+ query: "q",
227
+ intents: NO_INTENTS,
228
+ scoreEvidence: constantScorer,
229
+ });
230
+ assert.equal(result.length, 1, "fallback id dedup must collapse the two items");
231
+ });
232
+
233
+ test("unifiedDedupeAndRank: dedupByContent false keeps distinct ids with identical content (event-order shape)", () => {
234
+ // Event-order's rankAndSelectEventOrderItems deduplicates by turn id only and
235
+ // keeps distinct turns even when two turns share the same cue-appended body.
236
+ // The spine must express that as a declared config field so PR 6's migration
237
+ // does not silently drop valid turns (cursor bugbot a4299851).
238
+ const items: EvidencePackItem[] = [
239
+ item(1, "What time is it?", { id: "s1:1" }),
240
+ item(5, "What time is it?", { id: "s1:5" }),
241
+ ];
242
+ const result = unifiedDedupeAndRank(items, {
243
+ query: "q",
244
+ intents: NO_INTENTS,
245
+ scoreEvidence: constantScorer,
246
+ dedupByContent: false,
247
+ });
248
+ assert.equal(result.length, 2, "distinct ids with identical content must both survive when dedupByContent is false");
249
+ });
250
+
251
+ test("unifiedDedupeAndRank: dedupByContent true (default) still collapses identical content under different ids", () => {
252
+ const items: EvidencePackItem[] = [
253
+ item(1, "Duplicate body", { id: "s1:1" }),
254
+ item(2, "Duplicate body", { id: "s1:2" }),
255
+ ];
256
+ const resultDefault = unifiedDedupeAndRank(items, {
257
+ query: "q",
258
+ intents: NO_INTENTS,
259
+ scoreEvidence: constantScorer,
260
+ });
261
+ assert.equal(resultDefault.length, 1, "default dedupByContent collapses identical content");
262
+ const resultExplicit = unifiedDedupeAndRank(items, {
263
+ query: "q",
264
+ intents: NO_INTENTS,
265
+ scoreEvidence: constantScorer,
266
+ dedupByContent: true,
267
+ });
268
+ assert.equal(resultExplicit.length, 1, "explicit dedupByContent: true collapses identical content");
269
+ });
@@ -0,0 +1,294 @@
1
+ /**
2
+ * Issue #1539 PR2 — the staged recall pipeline spine.
3
+ *
4
+ * The four recall pipelines (`targeted-fact-recall.ts`, `response-guidance-
5
+ * recall.ts`, `explicit-cue-recall.ts`, `event-order-recall.ts`) implement
6
+ * the same stage sequence — intent → candidate collection → dedup → rank →
7
+ * filter → slice → metadata insertion → token budgeting — with ~70%
8
+ * near-duplicate code. The most dangerous divergence is the sort
9
+ * comparator: guidance/targeted-fact sort `turnIndex` DESC (recency) while
10
+ * event-order sorts `turnIndex` ASC (chronological). The comparators are
11
+ * byte-identical except for this direction — copy-pasting into a new
12
+ * chronological pipeline silently inverts the ordering.
13
+ *
14
+ * This module centralizes the **dedup + score + threshold-filter + sort**
15
+ * stages behind one declared config object. Per-tier divergence (threshold
16
+ * value, sort direction, content transformation) becomes a declared config
17
+ * field instead of embedded code.
18
+ *
19
+ * **PR 2 scope:** extract the module with NO pipeline changes. PRs 3–6
20
+ * migrate each pipeline to call `unifiedDedupeAndRank` one at a time,
21
+ * re-running the characterization snapshots
22
+ * (`tests/recall-pipeline-unified.test.ts`) to verify byte-for-byte parity.
23
+ *
24
+ * Future PRs will add the remaining stages from the issue's Solution
25
+ * (`mergeAcrossLcmKeys`, `insertMetadata`) once the per-tier hooks are
26
+ * factored out of each pipeline.
27
+ *
28
+ * @see https://github.com/joshuaswarren/remnic/issues/1539
29
+ */
30
+
31
+ import type { EvidencePackItem } from "./evidence-pack.js";
32
+
33
+ /** A ranked evidence item: an `EvidencePackItem` with a computed `rank`. */
34
+ export interface RankedEvidenceItem extends EvidencePackItem {
35
+ rank: number;
36
+ }
37
+
38
+ /**
39
+ * Sort direction for the turn-index secondary key. This is the divergence
40
+ * issue #1539 identifies: relevance-ranked pipelines (targeted-fact,
41
+ * response-guidance) sort `turnIndex` DESC (recency — latest first); the
42
+ * chronological pipeline (event-order) sorts `turnIndex` ASC (earliest
43
+ * first). The rank primary key is ALWAYS DESC (higher rank first); only
44
+ * the `turnIndex` tiebreaker flips direction.
45
+ */
46
+ export type TurnIndexSortDirection = "desc" | "asc";
47
+
48
+ /**
49
+ * Configuration for the unified dedup + score + threshold-filter + sort
50
+ * pass. Each pipeline declares its divergences as fields here instead of
51
+ * embedding them in pipeline-specific code.
52
+ *
53
+ * The issue's Solution defines the intended full config object
54
+ * (`RecallPipelineConfig`); this interface is extracted incrementally:
55
+ * - PR 2 (this PR): dedup/score/threshold/sort only
56
+ * - PRs 3–6: migrate each pipeline to consume the spine
57
+ * - future: add `mergeAcrossLcmKeys`, `insertMetadata` once the per-tier
58
+ * hooks are factored out of each pipeline
59
+ */
60
+ export interface UnifiedRankConfig<TIntent> {
61
+ /** The user query — passed to the scorer. */
62
+ query: string;
63
+ /**
64
+ * Per-tier intent classification result (already computed by the caller;
65
+ * intent classification stays per-tier because it is genuinely
66
+ * per-tier per issue #1539 Pitfall 1).
67
+ */
68
+ intents: TIntent[];
69
+ /**
70
+ * Score an item using its ORIGINAL (pre-transform) content. Higher = more
71
+ * relevant. The return value becomes the item's `rank`. For pipelines
72
+ * that don't score (explicit-cue), pass a constant scorer — the config
73
+ * makes the no-scoring policy explicit.
74
+ */
75
+ scoreEvidence: (
76
+ item: EvidencePackItem,
77
+ query: string,
78
+ intents: TIntent[],
79
+ ) => number;
80
+ /**
81
+ * Optional content transformation applied to each surviving item's
82
+ * OUTPUT content (NOT to the content the scorer sees). Per-tier
83
+ * cue-appenders go here:
84
+ * - targeted-fact: `appendNormalizedNumericCues`
85
+ * - response-guidance: `appendGuidanceCues`
86
+ * - event-order: `appendChronologicalCues`
87
+ *
88
+ * The dedup key uses the TRANSFORMED content. This is equivalent to
89
+ * deduping on original content for all existing pipelines because every
90
+ * transform is a deterministic append (same original → same transformed →
91
+ * same key; different originals → different transformed → different key).
92
+ */
93
+ transformContent?: (content: string, intents: TIntent[]) => string;
94
+ /**
95
+ * Whether to deduplicate by normalized (transformed) content in addition to
96
+ * id. Default `true` — matches targeted-fact, response-guidance, and
97
+ * explicit-cue, which all collapse later items sharing a normalized content
98
+ * key. Event-order sets this to `false`: its rank pass
99
+ * (`rankAndSelectEventOrderItems`) deduplicates by turn id only and keeps
100
+ * distinct turns even when two turns share the same cue-appended body
101
+ * (legitimate repeated turns in a chronological transcript). Making this a
102
+ * declared field prevents PR 6's migration from silently dropping valid
103
+ * turns (cursor bugbot a4299851).
104
+ */
105
+ dedupByContent?: boolean;
106
+ /**
107
+ * Items with `rank` below this threshold are dropped. `undefined` = no
108
+ * filter. Event-order declares `rankThreshold: 6` here instead of
109
+ * inlining an undocumented `.filter((item) => item.rank >= 6)` (the
110
+ * "hardcoded rank threshold that exists in no config and no other
111
+ * pipeline" from issue #1539's audit).
112
+ */
113
+ rankThreshold?: number;
114
+ /**
115
+ * Sort direction for the `turnIndex` secondary key.
116
+ *
117
+ * - `"desc"` (default): relevance-ranked pipelines (targeted-fact,
118
+ * response-guidance) sort `turnIndex` DESC. Undefined `turnIndex`
119
+ * falls to `-1` (bottom of a DESC list). Tertiary tiebreaker:
120
+ * `score DESC`.
121
+ * - `"asc"`: the chronological pipeline (event-order) sorts `turnIndex`
122
+ * ASC. Undefined `turnIndex` falls to `Number.MAX_SAFE_INTEGER`
123
+ * (bottom of an ASC list). Tertiary tiebreaker: `content.localeCompare`.
124
+ *
125
+ * The rank primary key is ALWAYS DESC regardless of this setting.
126
+ */
127
+ turnIndexSortDirection?: TurnIndexSortDirection;
128
+ }
129
+
130
+ /**
131
+ * Sentinel for undefined `turnIndex` when sorting DESC. `-1` sorts below
132
+ * every real turn index (which are `>= 0`), so missing `turn_index` always
133
+ * lands at the bottom of a DESC-ordered list — never wins ordering over a
134
+ * real turn.
135
+ */
136
+ const UNDEFINED_TURN_INDEX_DESC_SENTINEL = -1;
137
+
138
+ /**
139
+ * Sentinel for undefined `turnIndex` when sorting ASC.
140
+ * `Number.MAX_SAFE_INTEGER` sorts above every real turn index, so missing
141
+ * `turn_index` always lands at the bottom of an ASC-ordered list.
142
+ */
143
+ const UNDEFINED_TURN_INDEX_ASC_SENTINEL = Number.MAX_SAFE_INTEGER;
144
+
145
+ /**
146
+ * Deduplicate, score, threshold-filter, and sort evidence items under one
147
+ * unified policy.
148
+ *
149
+ * **Stage order** (matches every existing pipeline's rank/dedupe function):
150
+ * 1. **dedup** by `id` + normalized content (first-seen wins). The dedup
151
+ * key uses transformed content if `transformContent` is declared.
152
+ * 2. **score** each surviving item on its ORIGINAL content (the transform
153
+ * does not influence the score).
154
+ * 3. **threshold-filter**: drop items with `rank < rankThreshold` (if
155
+ * declared).
156
+ * 4. **sort**: `rank DESC` → `turnIndex` (direction-configurable) →
157
+ * tertiary tiebreaker (`score DESC` for relevance pipelines,
158
+ * `content.localeCompare` for chronological pipelines).
159
+ *
160
+ * This function does NOT slice (`maxResults`), budget, or format — those
161
+ * stages remain per-tier because they interact with per-tier metadata
162
+ * insertion. The issue's Solution describes a future `insertMetadata` hook
163
+ * that will make budget-adjustment uniform; that lands in the per-tier
164
+ * migration PRs.
165
+ *
166
+ * @example
167
+ * // Relevance-ranked pipeline (targeted-fact shape):
168
+ * unifiedDedupeAndRank(items, {
169
+ * query,
170
+ * intents: [],
171
+ * scoreEvidence: (item, q) => scoreTargetedFact(item, q),
172
+ * transformContent: (content) => appendNormalizedNumericCues(content),
173
+ * // turnIndexSortDirection defaults to "desc"
174
+ * });
175
+ *
176
+ * @example
177
+ * // Chronological pipeline (event-order shape):
178
+ * unifiedDedupeAndRank(items, {
179
+ * query,
180
+ * intents: [],
181
+ * scoreEvidence: (item, q) => scoreEventOrder(item, q),
182
+ * transformContent: (content) => appendChronologicalCues(content, query),
183
+ * rankThreshold: 6, // declared, not inlined
184
+ * turnIndexSortDirection: "asc",
185
+ * });
186
+ */
187
+ export function unifiedDedupeAndRank<TIntent>(
188
+ items: readonly EvidencePackItem[],
189
+ config: UnifiedRankConfig<TIntent>,
190
+ ): RankedEvidenceItem[] {
191
+ const transformContent = config.transformContent ?? ((content: string) => content);
192
+ const direction: TurnIndexSortDirection = config.turnIndexSortDirection ?? "desc";
193
+ const dedupByContent = config.dedupByContent !== false;
194
+
195
+ // Stage 1: dedup by id (+ normalized transformed content when enabled).
196
+ // First-seen wins, matching every existing pipeline. Event-order opts out
197
+ // of content dedup (dedupByContent: false) because it keeps distinct turns
198
+ // even when two turns share the same cue-appended body.
199
+ const seenIds = new Set<string>();
200
+ const seenContent = new Set<string>();
201
+ const survivors: Array<{ original: EvidencePackItem; transformedContent: string }> = [];
202
+
203
+ for (const item of items) {
204
+ const id = resolveItemId(item);
205
+ if (id && seenIds.has(id)) continue;
206
+
207
+ const transformedContent = transformContent(item.content, config.intents);
208
+ if (dedupByContent) {
209
+ const contentKey = transformedContent.toLowerCase().replace(/\s+/g, " ").trim();
210
+ if (seenContent.has(contentKey)) continue;
211
+ seenContent.add(contentKey);
212
+ }
213
+ if (id) seenIds.add(id);
214
+ survivors.push({ original: item, transformedContent });
215
+ }
216
+
217
+ // Stage 2: score on ORIGINAL content (transforms don't influence the score).
218
+ const scored: RankedEvidenceItem[] = survivors.map(({ original, transformedContent }) => ({
219
+ ...original,
220
+ content: transformedContent,
221
+ rank: config.scoreEvidence(original, config.query, config.intents),
222
+ }));
223
+
224
+ // Stage 3: threshold-filter (declared, not inlined).
225
+ const filtered =
226
+ typeof config.rankThreshold === "number"
227
+ ? scored.filter((item) => item.rank >= (config.rankThreshold as number))
228
+ : scored;
229
+
230
+ // Stage 4: sort.
231
+ // rank is ALWAYS DESC (higher rank first).
232
+ // turnIndex direction is configurable: DESC (relevance) or ASC (chronology).
233
+ // The tertiary tiebreaker follows the direction:
234
+ // DESC → score DESC; ASC → content.localeCompare.
235
+ return filtered.sort(makeComparator(direction));
236
+ }
237
+
238
+ /**
239
+ * Build the sort comparator for the configured direction. Extracted so the
240
+ * comparator's two shapes (DESC / ASC) can be tested independently and so
241
+ * the direction divergence is visible in ONE place.
242
+ */
243
+ function makeComparator(
244
+ direction: TurnIndexSortDirection,
245
+ ): (left: RankedEvidenceItem, right: RankedEvidenceItem) => number {
246
+ if (direction === "asc") {
247
+ // Chronological (event-order): rank DESC → turnIndex ASC → content localeCompare.
248
+ // Matches event-order-recall.ts:159-164 (rankedByScore sort) byte-for-byte.
249
+ return (left, right) => {
250
+ if (right.rank !== left.rank) return right.rank - left.rank;
251
+ const leftTurn =
252
+ typeof left.turnIndex === "number"
253
+ ? left.turnIndex
254
+ : UNDEFINED_TURN_INDEX_ASC_SENTINEL;
255
+ const rightTurn =
256
+ typeof right.turnIndex === "number"
257
+ ? right.turnIndex
258
+ : UNDEFINED_TURN_INDEX_ASC_SENTINEL;
259
+ if (leftTurn !== rightTurn) return leftTurn - rightTurn;
260
+ return left.content.localeCompare(right.content);
261
+ };
262
+ }
263
+ // Relevance-ranked (targeted-fact, response-guidance):
264
+ // rank DESC → turnIndex DESC → score DESC.
265
+ // Matches targeted-fact-recall.ts:239-245 and response-guidance-recall.ts:374-380
266
+ // byte-for-byte.
267
+ return (left, right) => {
268
+ if (right.rank !== left.rank) return right.rank - left.rank;
269
+ const leftTurn =
270
+ typeof left.turnIndex === "number"
271
+ ? left.turnIndex
272
+ : UNDEFINED_TURN_INDEX_DESC_SENTINEL;
273
+ const rightTurn =
274
+ typeof right.turnIndex === "number"
275
+ ? right.turnIndex
276
+ : UNDEFINED_TURN_INDEX_DESC_SENTINEL;
277
+ if (rightTurn !== leftTurn) return rightTurn - leftTurn;
278
+ return (right.score ?? 0) - (left.score ?? 0);
279
+ };
280
+ }
281
+
282
+
283
+ /**
284
+ * Resolve an evidence item's id. Falls back to `sessionId:turnIndex` when
285
+ * `id` is absent — mirrors every existing pipeline's fallback and
286
+ * `evidence-pack.ts`'s `evidenceItemFallbackId`.
287
+ */
288
+ function resolveItemId(item: EvidencePackItem): string | undefined {
289
+ if (item.id) return item.id;
290
+ if (item.sessionId && typeof item.turnIndex === "number") {
291
+ return `${item.sessionId}:${item.turnIndex}`;
292
+ }
293
+ return undefined;
294
+ }
package/src/storage.ts CHANGED
@@ -1175,26 +1175,12 @@ export class ContentHashIndex {
1175
1175
  * normalizeAttributePairs({ foo: "bar", BAZ: "qux" })
1176
1176
  * // → "baz: qux; foo: bar"
1177
1177
  */
1178
- /**
1179
- * Remove the "[Attributes: ...]" suffix `writeMemory` appends to the
1180
- * stored body when structuredAttributes are present, yielding the raw
1181
- * fact text for content comparison. Inverse companion of
1182
- * `normalizeAttributePairs` enrichment. String operations, not regex —
1183
- * CodeQL js/polynomial-redos on a suffix-anchored pattern over
1184
- * library-supplied content.
1185
- */
1186
- export function stripAttributesSuffix(content: string): string {
1187
- const trimmed = content.trimEnd();
1188
- if (!trimmed.endsWith("]")) return content.trim();
1189
- const marker = "\n[Attributes: ";
1190
- const markerIndex = trimmed.lastIndexOf(marker);
1191
- if (markerIndex === -1) return content.trim();
1192
- // The block must be the FINAL line and contain no "]" before the
1193
- // closing bracket (mirrors the shape normalizeAttributePairs emits).
1194
- const inner = trimmed.slice(markerIndex + marker.length, -1);
1195
- if (inner.includes("]") || inner.includes("\n")) return content.trim();
1196
- return trimmed.slice(0, markerIndex).trim();
1197
- }
1178
+ // `stripAttributesSuffix` now lives in ./structured-attributes.ts (shared with
1179
+ // the coding surfaces + wearable service). Imported here so internal callers
1180
+ // (snapshotBeforeWrite, snapshotForProvenance) resolve, and re-exported to
1181
+ // keep the public storage API stable for existing callers (wearables, dist).
1182
+ import { stripAttributesSuffix } from "./structured-attributes.js";
1183
+ export { stripAttributesSuffix };
1198
1184
 
1199
1185
  export function normalizeAttributePairs(pairs: Record<string, string>): string {
1200
1186
  return Object.entries(pairs)
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Shared helpers for the `\n[Attributes: …]` enrichment suffix that
3
+ * `writeMemory` appends to a stored body when `structuredAttributes` are
4
+ * present.
5
+ *
6
+ * Extracted here (out of `storage.ts`) so every reader — the storage
7
+ * manager, the wearable service, and the coding surfaces — strips the suffix
8
+ * with ONE definition. Previously `coding/architecture-surfaces.ts` carried a
9
+ * weaker local copy that only checked the trailing `]`, which could truncate
10
+ * card markdown that legitimately ends in a `[Attributes: …]` line (cursor
11
+ * review: duplicate attribute suffix stripper).
12
+ *
13
+ * String operations, not regex — CodeQL `js/polynomial-redos` flags
14
+ * suffix-anchored patterns over library-supplied content.
15
+ */
16
+
17
+ /**
18
+ * Remove the `[Attributes: …]` suffix `writeMemory` appends to the stored
19
+ * body when structuredAttributes are present, yielding the raw fact/card text
20
+ * for content comparison or client display. Inverse companion of
21
+ * `normalizeAttributePairs` enrichment in `storage.ts`.
22
+ *
23
+ * Returns the stripped (trimmed) body when the marker opens the final
24
+ * non-empty line and its inner payload contains no premature `]` or newline
25
+ * (mirrors the exact shape `normalizeAttributePairs` emits). Otherwise
26
+ * returns the trimmed input unchanged — never truncates legitimate content.
27
+ */
28
+ export function stripAttributesSuffix(content: string): string {
29
+ const trimmed = content.trimEnd();
30
+ if (!trimmed.endsWith("]")) return content.trim();
31
+ const marker = "\n[Attributes: ";
32
+ const markerIndex = trimmed.lastIndexOf(marker);
33
+ if (markerIndex === -1) return content.trim();
34
+ // The block must be the FINAL line and contain no "]" before the
35
+ // closing bracket (mirrors the shape normalizeAttributePairs emits).
36
+ const inner = trimmed.slice(markerIndex + marker.length, -1);
37
+ if (inner.includes("]") || inner.includes("\n")) return content.trim();
38
+ return trimmed.slice(0, markerIndex).trim();
39
+ }