@remnic/core 9.6.29 → 9.6.31

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 (118) hide show
  1. package/dist/access-admin-ops-surface.d.ts +1 -0
  2. package/dist/access-admin-ops-surface.js +2 -1
  3. package/dist/access-boundary.d.ts +1 -0
  4. package/dist/access-boundary.js +3 -2
  5. package/dist/access-cli.js +18 -17
  6. package/dist/access-cli.js.map +1 -1
  7. package/dist/access-http.d.ts +1 -0
  8. package/dist/access-http.js +7 -6
  9. package/dist/access-identity-continuity-surface.d.ts +1 -0
  10. package/dist/access-identity-continuity-surface.js +2 -1
  11. package/dist/access-lcm-surface.d.ts +1 -0
  12. package/dist/access-lcm-surface.js +2 -1
  13. package/dist/access-mcp.d.ts +1 -0
  14. package/dist/access-mcp.js +6 -5
  15. package/dist/access-observe-write-surface.d.ts +1 -0
  16. package/dist/access-observe-write-surface.js +2 -1
  17. package/dist/access-operations-batch.js +4 -3
  18. package/dist/access-operations.d.ts +1 -0
  19. package/dist/access-operations.js +5 -4
  20. package/dist/access-recall-surface.d.ts +1 -0
  21. package/dist/access-recall-surface.js +2 -1
  22. package/dist/access-service.d.ts +1 -0
  23. package/dist/access-service.js +2 -1
  24. package/dist/access-surface-catalog.d.ts +1 -0
  25. package/dist/bootstrap.d.ts +1 -0
  26. package/dist/causal-behavior.d.ts +1 -0
  27. package/dist/{chunk-L6W77GWW.js → chunk-6TCXIRP5.js} +25 -13
  28. package/dist/chunk-6TCXIRP5.js.map +1 -0
  29. package/dist/chunk-C37ZRXX7.js +36 -0
  30. package/dist/chunk-C37ZRXX7.js.map +1 -0
  31. package/dist/{chunk-SGYTQKC2.js → chunk-C6TKSI35.js} +3 -3
  32. package/dist/{chunk-C2SEH754.js → chunk-DAAJUA6Q.js} +4 -4
  33. package/dist/{chunk-FV5FVO2J.js → chunk-DEOYTFA6.js} +9 -5
  34. package/dist/chunk-DEOYTFA6.js.map +1 -0
  35. package/dist/{chunk-PYPOFEMK.js → chunk-DGFU5MSX.js} +16 -1
  36. package/dist/chunk-DGFU5MSX.js.map +1 -0
  37. package/dist/{chunk-EAREADWV.js → chunk-GQURC4B7.js} +2 -2
  38. package/dist/{chunk-XA4PWKBW.js → chunk-MXAGJHTA.js} +3 -3
  39. package/dist/{chunk-LZTFCAKE.js → chunk-OH7QLI6W.js} +28 -15
  40. package/dist/chunk-OH7QLI6W.js.map +1 -0
  41. package/dist/{chunk-5BHIGBHQ.js → chunk-OXVKR5OU.js} +6 -3
  42. package/dist/{chunk-5BHIGBHQ.js.map → chunk-OXVKR5OU.js.map} +1 -1
  43. package/dist/{chunk-IF7THCGJ.js → chunk-PUQZIS6A.js} +3 -3
  44. package/dist/{chunk-4SBW47WK.js → chunk-T67LUYCR.js} +28 -15
  45. package/dist/chunk-T67LUYCR.js.map +1 -0
  46. package/dist/{chunk-4463LUIE.js → chunk-TVIVY7KU.js} +180 -70
  47. package/dist/chunk-TVIVY7KU.js.map +1 -0
  48. package/dist/{chunk-KCYE2MZM.js → chunk-UXPUUL3G.js} +29 -8
  49. package/dist/chunk-UXPUUL3G.js.map +1 -0
  50. package/dist/chunk-VDC6RZI4.js +1 -0
  51. package/dist/{chunk-3RROR5KV.js → chunk-VJRJTRYF.js} +4 -4
  52. package/dist/{chunk-5WJ4LBFR.js → chunk-WSOEYOHN.js} +8 -8
  53. package/dist/{chunk-5AJIDEFT.js → chunk-XFTPUXCE.js} +11 -7
  54. package/dist/chunk-XFTPUXCE.js.map +1 -0
  55. package/dist/cli.d.ts +1 -0
  56. package/dist/cli.js +8 -7
  57. package/dist/compounding/preference-consolidator.d.ts +1 -0
  58. package/dist/event-order-recall.d.ts +1 -0
  59. package/dist/event-order-recall.js +3 -2
  60. package/dist/evidence-pack.d.ts +18 -1
  61. package/dist/evidence-pack.js +1 -1
  62. package/dist/explicit-capture.d.ts +1 -0
  63. package/dist/explicit-cue-recall.d.ts +19 -1
  64. package/dist/explicit-cue-recall.js +3 -2
  65. package/dist/focused-list-recall.d.ts +1 -0
  66. package/dist/focused-list-recall.js +3 -2
  67. package/dist/index.d.ts +3 -2
  68. package/dist/index.js +18 -17
  69. package/dist/lcm/engine.d.ts +3 -0
  70. package/dist/lcm/engine.js +2 -2
  71. package/dist/lcm/index.d.ts +23 -1
  72. package/dist/lcm/index.js +14 -4
  73. package/dist/lcm/recall.d.ts +15 -1
  74. package/dist/lcm/recall.js +5 -3
  75. package/dist/lcm/tools.d.ts +1 -0
  76. package/dist/mcp-memory-inspector-app.d.ts +1 -0
  77. package/dist/orchestrator.d.ts +1 -0
  78. package/dist/orchestrator.js +18 -17
  79. package/dist/response-guidance-recall.d.ts +1 -0
  80. package/dist/response-guidance-recall.js +3 -2
  81. package/dist/targeted-fact-recall.d.ts +1 -0
  82. package/dist/targeted-fact-recall.js +3 -2
  83. package/package.json +2 -2
  84. package/src/access-lcm-surface.ts +20 -5
  85. package/src/access-recall-surface.ts +2 -1
  86. package/src/event-order-recall.ts +5 -3
  87. package/src/evidence-pack.test.ts +66 -1
  88. package/src/evidence-pack.ts +41 -0
  89. package/src/explicit-cue-recall.ts +291 -133
  90. package/src/focused-list-recall.ts +25 -16
  91. package/src/index.ts +3 -0
  92. package/src/lcm/engine.ts +17 -5
  93. package/src/lcm/evidence-identity.ts +59 -0
  94. package/src/lcm/index.ts +13 -1
  95. package/src/lcm/recall.ts +48 -5
  96. package/src/lcm-engine.test.ts +46 -0
  97. package/src/lcm-recall.test.ts +48 -1
  98. package/src/lcm-retrieval-lineage.test.ts +236 -0
  99. package/src/recall-trace-receipts.test.ts +380 -0
  100. package/src/response-guidance-recall.ts +22 -14
  101. package/src/targeted-fact-recall.ts +25 -16
  102. package/dist/chunk-4463LUIE.js.map +0 -1
  103. package/dist/chunk-4SBW47WK.js.map +0 -1
  104. package/dist/chunk-5AJIDEFT.js.map +0 -1
  105. package/dist/chunk-FV5FVO2J.js.map +0 -1
  106. package/dist/chunk-HHLLAQGZ.js +0 -1
  107. package/dist/chunk-KCYE2MZM.js.map +0 -1
  108. package/dist/chunk-L6W77GWW.js.map +0 -1
  109. package/dist/chunk-LZTFCAKE.js.map +0 -1
  110. package/dist/chunk-PYPOFEMK.js.map +0 -1
  111. /package/dist/{chunk-SGYTQKC2.js.map → chunk-C6TKSI35.js.map} +0 -0
  112. /package/dist/{chunk-C2SEH754.js.map → chunk-DAAJUA6Q.js.map} +0 -0
  113. /package/dist/{chunk-EAREADWV.js.map → chunk-GQURC4B7.js.map} +0 -0
  114. /package/dist/{chunk-XA4PWKBW.js.map → chunk-MXAGJHTA.js.map} +0 -0
  115. /package/dist/{chunk-IF7THCGJ.js.map → chunk-PUQZIS6A.js.map} +0 -0
  116. /package/dist/{chunk-HHLLAQGZ.js.map → chunk-VDC6RZI4.js.map} +0 -0
  117. /package/dist/{chunk-3RROR5KV.js.map → chunk-VJRJTRYF.js.map} +0 -0
  118. /package/dist/{chunk-5WJ4LBFR.js.map → chunk-WSOEYOHN.js.map} +0 -0
@@ -8,6 +8,7 @@ import {
8
8
  gatherAcrossReadSessions,
9
9
  resolveLcmReadSessionIds,
10
10
  } from "./lcm-fallback-read.js";
11
+ import { isSameLcmRow, lcmEvidenceIdentity } from "./lcm/evidence-identity.js";
11
12
 
12
13
  export interface FocusedListRecallOptions {
13
14
  engine: ExplicitCueRecallEngine | null | undefined;
@@ -134,27 +135,35 @@ async function collectFocusedListItems(
134
135
  result.turn_index + searchWindowAfter,
135
136
  searchWindowTokens,
136
137
  );
138
+ const searchIdentity = lcmEvidenceIdentity(result, result.session_id);
137
139
  const searchHit: EvidencePackItem = {
138
- id: `${result.session_id}:${result.turn_index}`,
140
+ ...searchIdentity,
139
141
  sessionId: result.session_id,
140
142
  turnIndex: result.turn_index,
141
143
  role: result.role,
142
144
  content: result.content,
143
145
  ...(typeof result.score === "number" ? { score: result.score } : {}),
144
146
  };
145
- const candidates: EvidencePackItem[] = expanded.map((message) => ({
146
- id: `${result.session_id}:${message.turn_index}`,
147
- sessionId: result.session_id,
148
- turnIndex: message.turn_index,
149
- role: message.role,
150
- content: message.content,
151
- ...(message.turn_index === result.turn_index &&
152
- typeof result.score === "number"
153
- ? { score: result.score }
154
- : {}),
155
- }));
156
- const hitIndex = candidates.findIndex((candidate) =>
157
- candidate.turnIndex === result.turn_index
147
+ const candidates: EvidencePackItem[] = expanded.map((message) => {
148
+ const matchesSearchHit = isSameLcmRow(
149
+ message,
150
+ result.session_id,
151
+ result,
152
+ result.session_id,
153
+ );
154
+ return {
155
+ ...lcmEvidenceIdentity(message, result.session_id),
156
+ sessionId: message.session_id ?? result.session_id,
157
+ turnIndex: message.turn_index,
158
+ role: message.role,
159
+ content: message.content,
160
+ ...(matchesSearchHit && typeof result.score === "number"
161
+ ? { score: result.score }
162
+ : {}),
163
+ };
164
+ });
165
+ const hitIndex = expanded.findIndex((message) =>
166
+ isSameLcmRow(message, result.session_id, result, result.session_id)
158
167
  );
159
168
  if (hitIndex >= 0) {
160
169
  candidates[hitIndex] = searchHit;
@@ -221,8 +230,8 @@ async function collectFocusedListScanItems(
221
230
  );
222
231
  for (const message of messages) {
223
232
  const candidate = {
224
- id: `${options.sessionId}:${message.turn_index}`,
225
- sessionId: options.sessionId,
233
+ ...lcmEvidenceIdentity(message, options.sessionId),
234
+ sessionId: message.session_id ?? options.sessionId,
226
235
  turnIndex: message.turn_index,
227
236
  role: message.role,
228
237
  content: message.content,
package/src/index.ts CHANGED
@@ -58,6 +58,8 @@ export {
58
58
  buildEvidencePack,
59
59
  type EvidencePackItem,
60
60
  type EvidencePackOptions,
61
+ type EvidencePackSelectedItem,
62
+ type EvidencePackSelectionReceipt,
61
63
  } from "./evidence-pack.js";
62
64
  export {
63
65
  buildExplicitCueRecallSection,
@@ -74,6 +76,7 @@ export {
74
76
  type ExplicitCueRecallOptions,
75
77
  type ExplicitTurnReference,
76
78
  type TrajectoryAnalysisRecallOptions,
79
+ type TrajectoryAnalysisLineReceipt,
77
80
  } from "./explicit-cue-recall.js";
78
81
  export {
79
82
  buildTargetedFactRecallSection,
package/src/lcm/engine.ts CHANGED
@@ -3,7 +3,11 @@ import { openLcmDatabase, ensureLcmStateDir } from "./schema.js";
3
3
  import { LcmArchive, type LcmStructuredRecallMatch } from "./archive.js";
4
4
  import { LcmDag } from "./dag.js";
5
5
  import { LcmSummarizer, type SummarizeFn } from "./summarizer.js";
6
- import { assembleCompressedHistory, type LcmRecallConfig } from "./recall.js";
6
+ import {
7
+ assembleCompressedHistoryWithTrace,
8
+ type LcmRecallConfig,
9
+ type LcmRecallWithTraceResult,
10
+ } from "./recall.js";
7
11
  import { LcmWorkQueue, type LcmObserveMessage } from "./queue.js";
8
12
  import type { PluginConfig } from "../types.js";
9
13
  import { log } from "../logger.js";
@@ -336,17 +340,25 @@ export class LcmEngine {
336
340
  sessionId: string,
337
341
  budgetChars: number,
338
342
  ): Promise<string> {
339
- if (!this.config.enabled) return "";
343
+ return (await this.assembleRecallWithTrace(sessionId, budgetChars)).text;
344
+ }
345
+
346
+ /** Build compressed recall plus content-free selected-summary receipts. */
347
+ async assembleRecallWithTrace(
348
+ sessionId: string,
349
+ budgetChars: number,
350
+ ): Promise<LcmRecallWithTraceResult> {
351
+ if (!this.config.enabled) return { text: "", selectedSummaries: [] };
340
352
  const normalizedSessionId = normalizeLcmSessionId(sessionId);
341
- if (!normalizedSessionId) return "";
353
+ if (!normalizedSessionId) return { text: "", selectedSummaries: [] };
342
354
  await this.ensureInitialized();
343
355
 
344
356
  const effectiveBudget = Math.ceil(
345
357
  budgetChars * this.config.recallBudgetShare,
346
358
  );
347
- if (effectiveBudget <= 0) return "";
359
+ if (effectiveBudget <= 0) return { text: "", selectedSummaries: [] };
348
360
 
349
- return assembleCompressedHistory(this.dag!, this.archive!, normalizedSessionId, {
361
+ return assembleCompressedHistoryWithTrace(this.dag!, this.archive!, normalizedSessionId, {
350
362
  freshTailTurns: this.config.freshTailTurns,
351
363
  budgetChars: effectiveBudget,
352
364
  });
@@ -0,0 +1,59 @@
1
+ export interface LcmRowIdentity {
2
+ id?: number;
3
+ session_id?: string;
4
+ turn_index: number;
5
+ }
6
+
7
+ export interface LcmEvidenceIdentity {
8
+ id: string;
9
+ archiveRowId?: number;
10
+ }
11
+
12
+ export function lcmArchiveRowId(row: Pick<LcmRowIdentity, "id">): number | undefined {
13
+ const value = row.id;
14
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0
15
+ ? value
16
+ : undefined;
17
+ }
18
+
19
+ function resolveSessionId(row: LcmRowIdentity, fallbackSessionId: string): string {
20
+ return row.session_id ?? fallbackSessionId;
21
+ }
22
+
23
+ /**
24
+ * Build the non-rendered evidence identity for an LCM archive row.
25
+ *
26
+ * Older adapters and test doubles may omit the SQLite row id. Those callers
27
+ * retain the historical session+turn identity until they adopt row lineage.
28
+ */
29
+ export function lcmEvidenceIdentity(
30
+ row: LcmRowIdentity,
31
+ fallbackSessionId: string,
32
+ ): LcmEvidenceIdentity {
33
+ const sessionId = resolveSessionId(row, fallbackSessionId);
34
+ const archiveRowId = lcmArchiveRowId(row);
35
+ if (archiveRowId === undefined) {
36
+ return { id: `${sessionId}:${row.turn_index}` };
37
+ }
38
+ return {
39
+ id: JSON.stringify(["lcm-row", sessionId, archiveRowId]),
40
+ archiveRowId,
41
+ };
42
+ }
43
+
44
+ /** Match a search hit to its expanded archive row without conflating siblings. */
45
+ export function isSameLcmRow(
46
+ left: LcmRowIdentity,
47
+ leftFallbackSessionId: string,
48
+ right: LcmRowIdentity,
49
+ rightFallbackSessionId: string,
50
+ ): boolean {
51
+ const leftRowId = lcmArchiveRowId(left);
52
+ const rightRowId = lcmArchiveRowId(right);
53
+ const leftSessionId = resolveSessionId(left, leftFallbackSessionId);
54
+ const rightSessionId = resolveSessionId(right, rightFallbackSessionId);
55
+ if (leftRowId !== undefined && rightRowId !== undefined) {
56
+ return leftRowId === rightRowId && leftSessionId === rightSessionId;
57
+ }
58
+ return leftSessionId === rightSessionId && left.turn_index === right.turn_index;
59
+ }
package/src/lcm/index.ts CHANGED
@@ -4,9 +4,21 @@ export {
4
4
  type LcmEngineConfig,
5
5
  type LcmExpandedMessage,
6
6
  } from "./engine.js";
7
+ export {
8
+ isSameLcmRow,
9
+ lcmArchiveRowId,
10
+ lcmEvidenceIdentity,
11
+ type LcmEvidenceIdentity,
12
+ type LcmRowIdentity,
13
+ } from "./evidence-identity.js";
7
14
  export { LcmArchive, estimateTokens } from "./archive.js";
8
15
  export { LcmDag, type SummaryNode } from "./dag.js";
9
16
  export { LcmSummarizer, type SummarizeFn } from "./summarizer.js";
10
- export { assembleCompressedHistory } from "./recall.js";
17
+ export {
18
+ assembleCompressedHistory,
19
+ assembleCompressedHistoryWithTrace,
20
+ type LcmRecallWithTraceResult,
21
+ type LcmSummarySelectionReceipt,
22
+ } from "./recall.js";
11
23
  export { registerLcmTools } from "./tools.js";
12
24
  export { openLcmDatabase, ensureLcmStateDir } from "./schema.js";
package/src/lcm/recall.ts CHANGED
@@ -7,6 +7,21 @@ export interface LcmRecallConfig {
7
7
  budgetChars: number;
8
8
  }
9
9
 
10
+ export interface LcmSummarySelectionReceipt {
11
+ id: string;
12
+ depth: number;
13
+ msgStart: number;
14
+ msgEnd: number;
15
+ /** Half-open offsets within the returned compressed-history string. */
16
+ entryStart: number;
17
+ entryEnd: number;
18
+ }
19
+
20
+ export interface LcmRecallWithTraceResult {
21
+ text: string;
22
+ selectedSummaries: LcmSummarySelectionReceipt[];
23
+ }
24
+
10
25
  /**
11
26
  * Assemble a compressed session history section from the summary DAG.
12
27
  *
@@ -21,14 +36,24 @@ export function assembleCompressedHistory(
21
36
  sessionId: string,
22
37
  config: LcmRecallConfig,
23
38
  ): string {
39
+ return assembleCompressedHistoryWithTrace(dag, archive, sessionId, config).text;
40
+ }
41
+
42
+ export function assembleCompressedHistoryWithTrace(
43
+ dag: LcmDag,
44
+ archive: LcmArchive,
45
+ sessionId: string,
46
+ config: LcmRecallConfig,
47
+ ): LcmRecallWithTraceResult {
24
48
  const maxTurn = archive.getMaxTurnIndex(sessionId);
25
- if (maxTurn < 0) return "";
49
+ if (maxTurn < 0) return { text: "", selectedSummaries: [] };
26
50
 
27
51
  const allNodes = dag.getAllNodes(sessionId);
28
- if (allNodes.length === 0) return "";
52
+ if (allNodes.length === 0) return { text: "", selectedSummaries: [] };
29
53
 
30
54
  const freshTailStart = Math.max(0, maxTurn - config.freshTailTurns + 1);
31
55
  const sections: string[] = [];
56
+ const selectedNodes: SummaryNode[] = [];
32
57
  let usedChars = 0;
33
58
 
34
59
  // Collect nodes covering the "old" portion (before fresh tail)
@@ -39,6 +64,7 @@ export function assembleCompressedHistory(
39
64
  const entry = `**${label}** (depth ${node.depth}):\n${node.summary_text}`;
40
65
  if (usedChars + entry.length > config.budgetChars) break;
41
66
  sections.push(entry);
67
+ selectedNodes.push(node);
42
68
  usedChars += entry.length;
43
69
  }
44
70
  }
@@ -58,12 +84,29 @@ export function assembleCompressedHistory(
58
84
  const entry = `**${label}**:\n${node.summary_text}`;
59
85
  if (usedChars + entry.length > config.budgetChars) break;
60
86
  sections.push(entry);
87
+ selectedNodes.push(node);
61
88
  usedChars += entry.length;
62
89
  }
63
90
 
64
- if (sections.length === 0) return "";
65
-
66
- return `## Session History (Compressed)\n\n${sections.join("\n\n")}`;
91
+ if (sections.length === 0) return { text: "", selectedSummaries: [] };
92
+
93
+ const prefix = "## Session History (Compressed)\n\n";
94
+ const text = `${prefix}${sections.join("\n\n")}`;
95
+ let entryStart = prefix.length;
96
+ const selectedSummaries = selectedNodes.map((node, index) => {
97
+ const entryEnd = entryStart + sections[index]!.length;
98
+ const receipt: LcmSummarySelectionReceipt = {
99
+ id: node.id,
100
+ depth: node.depth,
101
+ msgStart: node.msg_start,
102
+ msgEnd: node.msg_end,
103
+ entryStart,
104
+ entryEnd,
105
+ };
106
+ entryStart = entryEnd + 2;
107
+ return receipt;
108
+ });
109
+ return { text, selectedSummaries };
67
110
  }
68
111
 
69
112
  /**
@@ -676,6 +676,52 @@ test("expandContext preserves first, middle, and last row identity when truncate
676
676
  }
677
677
  });
678
678
 
679
+ test("assembleRecallWithTrace preserves text and reports selected summary metadata", async () => {
680
+ const memoryDir = await mkdtemp(
681
+ path.join(os.tmpdir(), "engram-lcm-summary-receipts-"),
682
+ );
683
+
684
+ try {
685
+ const engine = new LcmEngine(
686
+ createPluginConfig(memoryDir),
687
+ async () => "private summary body",
688
+ );
689
+ await engine.observeMessages(
690
+ "private-session",
691
+ Array.from({ length: 8 }, (_, index) => ({
692
+ role: index % 2 === 0 ? "user" : "assistant",
693
+ content: `private message ${index}`,
694
+ })),
695
+ );
696
+ await engine.waitForSessionObserveIdle("private-session");
697
+
698
+ const baseline = await engine.assembleRecall("private-session", 10_000);
699
+ const traced = await engine.assembleRecallWithTrace(
700
+ "private-session",
701
+ 10_000,
702
+ );
703
+
704
+ assert.equal(traced.text, baseline);
705
+ assert.ok(traced.selectedSummaries.length > 0);
706
+ assert.ok(
707
+ traced.selectedSummaries.every((receipt) =>
708
+ receipt.entryStart < receipt.entryEnd &&
709
+ receipt.msgStart <= receipt.msgEnd
710
+ ),
711
+ );
712
+ assert.equal(
713
+ JSON.stringify(traced.selectedSummaries).includes("private summary body"),
714
+ false,
715
+ );
716
+ assert.equal(
717
+ JSON.stringify(traced.selectedSummaries).includes("private-session"),
718
+ false,
719
+ );
720
+ } finally {
721
+ await rm(memoryDir, { recursive: true, force: true });
722
+ }
723
+ });
724
+
679
725
  test("close prevents deferred observe work from reinitializing the engine", async () => {
680
726
  const memoryDir = await mkdtemp(
681
727
  path.join(os.tmpdir(), "engram-lcm-engine-close-"),
@@ -6,7 +6,10 @@ import test from "node:test";
6
6
 
7
7
  import { LcmArchive } from "./lcm/archive.js";
8
8
  import { LcmDag } from "./lcm/dag.js";
9
- import { assembleCompressedHistory } from "./lcm/recall.js";
9
+ import {
10
+ assembleCompressedHistory,
11
+ assembleCompressedHistoryWithTrace,
12
+ } from "./lcm/recall.js";
10
13
  import { ensureLcmStateDir, openLcmDatabase } from "./lcm/schema.js";
11
14
 
12
15
  test("assembleCompressedHistory excludes old summaries that straddle the fresh tail", async () => {
@@ -58,12 +61,56 @@ test("assembleCompressedHistory excludes old summaries that straddle the fresh t
58
61
  freshTailTurns: 16,
59
62
  budgetChars: 10_000,
60
63
  });
64
+ const traced = assembleCompressedHistoryWithTrace(
65
+ dag,
66
+ archive,
67
+ sessionId,
68
+ { freshTailTurns: 16, budgetChars: 10_000 },
69
+ );
61
70
 
71
+ assert.equal(traced.text, section);
62
72
  assert.doesNotMatch(section, /parent summary 0-31/);
63
73
  assert.match(section, /leaf summary 0-7/);
64
74
  assert.match(section, /leaf summary 8-15/);
65
75
  assert.match(section, /leaf summary 16-23/);
66
76
  assert.match(section, /leaf summary 24-31/);
77
+ assert.deepEqual(
78
+ traced.selectedSummaries.map(({ id, depth, msgStart, msgEnd }) => ({
79
+ id,
80
+ depth,
81
+ msgStart,
82
+ msgEnd,
83
+ })),
84
+ [
85
+ { id: "leaf-0-7", depth: 0, msgStart: 0, msgEnd: 7 },
86
+ { id: "leaf-8-15", depth: 0, msgStart: 8, msgEnd: 15 },
87
+ { id: "leaf-16-23", depth: 0, msgStart: 16, msgEnd: 23 },
88
+ { id: "leaf-24-31", depth: 0, msgStart: 24, msgEnd: 31 },
89
+ ],
90
+ );
91
+ for (const receipt of traced.selectedSummaries) {
92
+ assert.ok(receipt.entryStart < receipt.entryEnd);
93
+ const expectedSummary = receipt.id.replace("leaf-", "leaf summary ");
94
+ assert.equal(
95
+ traced.text.slice(receipt.entryStart, receipt.entryEnd).includes(expectedSummary),
96
+ true,
97
+ );
98
+ assert.equal("summary" in receipt, false);
99
+ assert.equal("sessionId" in receipt, false);
100
+ }
101
+
102
+ const first = traced.selectedSummaries[0]!;
103
+ const firstEntryBudget = first.entryEnd - first.entryStart;
104
+ const limited = assembleCompressedHistoryWithTrace(
105
+ dag,
106
+ archive,
107
+ sessionId,
108
+ { freshTailTurns: 16, budgetChars: firstEntryBudget },
109
+ );
110
+ assert.deepEqual(
111
+ limited.selectedSummaries.map((receipt) => receipt.id),
112
+ ["leaf-0-7"],
113
+ );
67
114
  } finally {
68
115
  db.close();
69
116
  await rm(memoryDir, { recursive: true, force: true });
@@ -0,0 +1,236 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ import { AccessLcmSurface, type AccessLcmSurfaceDeps } from "./access-lcm-surface.js";
5
+ import {
6
+ AccessRecallSurface,
7
+ type AccessRecallSurfaceDeps,
8
+ } from "./access-recall-surface.js";
9
+ import { buildEventOrderRecallSection } from "./event-order-recall.js";
10
+ import { buildExplicitCueRecallSection, type ExplicitCueRecallEngine } from "./explicit-cue-recall.js";
11
+ import { buildFocusedListRecallSection } from "./focused-list-recall.js";
12
+ import {
13
+ isSameLcmRow,
14
+ lcmEvidenceIdentity,
15
+ } from "./lcm/evidence-identity.js";
16
+ import { buildResponseGuidanceRecallSection } from "./response-guidance-recall.js";
17
+ import { buildTargetedFactRecallSection } from "./targeted-fact-recall.js";
18
+
19
+ interface TestRow {
20
+ id?: number;
21
+ session_id: string;
22
+ turn_index: number;
23
+ role: string;
24
+ content: string;
25
+ }
26
+
27
+ class LineageEngine implements ExplicitCueRecallEngine {
28
+ constructor(
29
+ private readonly rows: TestRow[],
30
+ private readonly searchRowId?: number,
31
+ private readonly searchContent?: string,
32
+ ) {}
33
+
34
+ async expandContext(
35
+ sessionId: string,
36
+ fromTurn: number,
37
+ toTurn: number,
38
+ _maxTokens: number,
39
+ ): Promise<TestRow[]> {
40
+ return this.rows.filter((row) =>
41
+ row.session_id === sessionId &&
42
+ row.turn_index >= fromTurn &&
43
+ row.turn_index <= toTurn
44
+ );
45
+ }
46
+
47
+ async searchContextFull(): Promise<
48
+ Array<TestRow & { score: number }>
49
+ > {
50
+ const row = this.rows.find((candidate) => candidate.id === this.searchRowId);
51
+ return row
52
+ ? [{
53
+ ...row,
54
+ content: this.searchContent ?? row.content,
55
+ score: 99,
56
+ }]
57
+ : [];
58
+ }
59
+
60
+ async getStats(sessionId?: string): Promise<{
61
+ totalMessages: number;
62
+ maxTurnIndex?: number;
63
+ }> {
64
+ const rows = sessionId
65
+ ? this.rows.filter((row) => row.session_id === sessionId)
66
+ : this.rows;
67
+ return {
68
+ totalMessages: rows.length,
69
+ maxTurnIndex: rows.length > 0
70
+ ? Math.max(...rows.map((row) => row.turn_index))
71
+ : undefined,
72
+ };
73
+ }
74
+ }
75
+
76
+ test("LCM evidence identity distinguishes sibling rows and preserves the legacy fallback", () => {
77
+ const first = { id: 41, session_id: "session", turn_index: 7 };
78
+ const second = { id: 42, session_id: "session", turn_index: 7 };
79
+ assert.notEqual(
80
+ lcmEvidenceIdentity(first, "session").id,
81
+ lcmEvidenceIdentity(second, "session").id,
82
+ );
83
+ assert.equal(isSameLcmRow(first, "session", second, "session"), false);
84
+ assert.equal(
85
+ lcmEvidenceIdentity({ turn_index: 7 }, "session").id,
86
+ "session:7",
87
+ );
88
+ assert.equal(
89
+ isSameLcmRow(
90
+ { turn_index: 7 },
91
+ "session",
92
+ { turn_index: 7 },
93
+ "session",
94
+ ),
95
+ true,
96
+ );
97
+ });
98
+
99
+ test("explicit-cue recall keeps distinct archive rows that share a turn", async () => {
100
+ const engine = new LineageEngine([
101
+ { id: 101, session_id: "s", turn_index: 7, role: "user", content: "First row at the referenced turn." },
102
+ { id: 102, session_id: "s", turn_index: 7, role: "assistant", content: "Second row at the referenced turn." },
103
+ ]);
104
+ const recalled = await buildExplicitCueRecallSection({
105
+ engine,
106
+ sessionId: "s",
107
+ query: "Review turn 7",
108
+ maxChars: 4_000,
109
+ });
110
+ assert.match(recalled, /First row at the referenced turn/);
111
+ assert.match(recalled, /Second row at the referenced turn/);
112
+ });
113
+
114
+ test("targeted recall replaces and scores only the matching same-turn row", async () => {
115
+ const engine = new LineageEngine([
116
+ { id: 101, session_id: "s", turn_index: 7, role: "user", content: "I earn approximately $100,000 CAD annually as a senior engineer." },
117
+ { id: 102, session_id: "s", turn_index: 7, role: "user", content: "My recent raise to $110,000 CAD as a senior engineer at Saint Pierre Manufacturing Ltd is now effective." },
118
+ ], 102, "The matching row records my recent raise to $110,000 CAD as a senior engineer at Saint Pierre Manufacturing Ltd.");
119
+ const recalled = await buildTargetedFactRecallSection({
120
+ engine,
121
+ sessionId: "s",
122
+ query: "What is my annual salary as a senior engineer at Saint Pierre Manufacturing Ltd?",
123
+ maxChars: 6_000,
124
+ });
125
+ assert.match(recalled, /earn approximately \$100,000 CAD annually/);
126
+ assert.match(recalled, /matching row records my recent raise to \$110,000 CAD/);
127
+ });
128
+
129
+ test("focused-list recall keeps distinct same-turn calculations while matching the search row", async () => {
130
+ const engine = new LineageEngine([
131
+ { id: 101, session_id: "s", turn_index: 7, role: "user", content: "Can you help me calculate P(both heads) = 1/2 x 1/2 = 1/4 so I can make sure I get it right?" },
132
+ { id: 102, session_id: "s", turn_index: 7, role: "user", content: "I am trying to verify if P(rolling a number greater than 4) = 2/6 = 1/3 is correct." },
133
+ ], 102, "I confirmed P(rolling a number greater than 4) = 2/6 = 1/3.");
134
+ const recalled = await buildFocusedListRecallSection({
135
+ engine,
136
+ sessionId: "s",
137
+ query: "In my questions about tossing coins and rolling dice, how many different probability calculations did I try to confirm?",
138
+ maxChars: 6_000,
139
+ });
140
+ assert.match(recalled, /P\(both heads\)/);
141
+ assert.match(recalled, /P\(rolling a number greater than 4\)/);
142
+ });
143
+
144
+ test("response guidance keeps distinct same-turn rows while preserving its existing filters", async () => {
145
+ const engine = new LineageEngine([
146
+ { id: 101, session_id: "s", turn_index: 7, role: "user", content: "I prefer a structured daily routine with a written priority list each morning." },
147
+ { id: 102, session_id: "s", turn_index: 7, role: "user", content: "My structured daily routine schedules focused work blocks and buffer time between meetings." },
148
+ ], 102, "My structured daily routine schedules focused work blocks and buffer time between meetings.");
149
+ const recalled = await buildResponseGuidanceRecallSection({
150
+ engine,
151
+ sessionId: "s",
152
+ query: "How should I organize my day to stay on track?",
153
+ maxChars: 6_000,
154
+ });
155
+ assert.match(recalled, /written priority list each morning/);
156
+ assert.match(recalled, /focused work blocks/);
157
+ });
158
+
159
+ test("event-order recall keeps distinct same-turn chronological rows", async () => {
160
+ const engine = new LineageEngine([
161
+ { id: 101, session_id: "s", turn_index: 7, role: "user", content: "Patrick suggested a March workshop on workflow optimization." },
162
+ { id: 102, session_id: "s", turn_index: 7, role: "user", content: "Patrick and I planned an interview tips meeting for tomorrow." },
163
+ ]);
164
+ const recalled = await buildEventOrderRecallSection({
165
+ engine,
166
+ sessionId: "s",
167
+ query: "Walk me through my interactions with Patrick in order.",
168
+ maxChars: 6_000,
169
+ });
170
+ assert.match(recalled, /workshop suggestion/);
171
+ assert.match(recalled, /interview tips meeting/);
172
+ });
173
+
174
+ function accessSearchDeps(rows: TestRow[]): AccessLcmSurfaceDeps {
175
+ const orchestrator = {
176
+ config: {
177
+ defaultNamespace: "default",
178
+ namespaces: false,
179
+ },
180
+ lcmEngine: {
181
+ enabled: true,
182
+ searchContextFull: async () => rows,
183
+ },
184
+ };
185
+ return {
186
+ orchestrator,
187
+ lcmSessionIdsForNamespaces: () => ["primary", "fallback"],
188
+ resolveImplicitLcmReadFallbackNamespace: () => "default",
189
+ resolveLcmReadNamespace: () => "default",
190
+ resolveLcmReadSessionIds: () => ["primary", "fallback"],
191
+ resolveLcmReadSessionKey: () => "primary",
192
+ resolveReadableNamespace: () => "default",
193
+ resolveRequestPrincipal: () => "default",
194
+ resolveScopeProfileLcmReadNamespaces: () => null,
195
+ } as unknown as AccessLcmSurfaceDeps;
196
+ }
197
+
198
+ test("access LCM search dedupes repeated rows but retains same-turn siblings", async () => {
199
+ const rows: TestRow[] = [
200
+ { id: 101, session_id: "s", turn_index: 7, role: "user", content: "first sibling" },
201
+ { id: 102, session_id: "s", turn_index: 7, role: "assistant", content: "second sibling" },
202
+ ];
203
+ const response = await new AccessLcmSurface(accessSearchDeps(rows)).lcmSearch({
204
+ query: "sibling",
205
+ sessionKey: "s",
206
+ });
207
+ assert.deepEqual(response.results.map((row) => row.content), [
208
+ "first sibling",
209
+ "second sibling",
210
+ ]);
211
+ });
212
+
213
+ test("raw excerpts dedupe repeated rows but retain same-turn siblings", async () => {
214
+ const rows: TestRow[] = [
215
+ { id: 101, session_id: "s", turn_index: 7, role: "user", content: "first sibling" },
216
+ { id: 102, session_id: "s", turn_index: 7, role: "assistant", content: "second sibling" },
217
+ ];
218
+ const deps = {
219
+ orchestrator: {
220
+ config: { defaultNamespace: "default" },
221
+ lcmEngine: {
222
+ enabled: true,
223
+ searchContextFull: async () => rows,
224
+ },
225
+ },
226
+ } as unknown as AccessRecallSurfaceDeps;
227
+ const excerpts = await new AccessRecallSurface(deps).fetchRawExcerpts("raw", {
228
+ query: "sibling",
229
+ sessionKey: "s",
230
+ lcmSessionIds: ["primary", "fallback"],
231
+ });
232
+ assert.deepEqual(excerpts?.map((row) => row.content), [
233
+ "first sibling",
234
+ "second sibling",
235
+ ]);
236
+ });