@remnic/core 9.6.30 → 9.6.32

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 (74) hide show
  1. package/dist/access-admin-ops-surface.d.ts +1 -0
  2. package/dist/access-boundary.d.ts +1 -0
  3. package/dist/access-cli.js +9 -9
  4. package/dist/access-http.d.ts +1 -0
  5. package/dist/access-identity-continuity-surface.d.ts +1 -0
  6. package/dist/access-lcm-surface.d.ts +1 -0
  7. package/dist/access-mcp.d.ts +1 -0
  8. package/dist/access-observe-write-surface.d.ts +1 -0
  9. package/dist/access-operations.d.ts +1 -0
  10. package/dist/access-recall-surface.d.ts +1 -0
  11. package/dist/access-service.d.ts +1 -0
  12. package/dist/access-surface-catalog.d.ts +1 -0
  13. package/dist/bootstrap.d.ts +1 -0
  14. package/dist/causal-behavior.d.ts +1 -0
  15. package/dist/{chunk-XGSOXFD4.js → chunk-6TCXIRP5.js} +2 -2
  16. package/dist/{chunk-7O2JL5RY.js → chunk-DEOYTFA6.js} +2 -2
  17. package/dist/{chunk-R26WO46Z.js → chunk-DGFU5MSX.js} +16 -1
  18. package/dist/chunk-DGFU5MSX.js.map +1 -0
  19. package/dist/{chunk-BFU7H32D.js → chunk-OH7QLI6W.js} +2 -2
  20. package/dist/{chunk-IRMLYBMU.js → chunk-T67LUYCR.js} +2 -2
  21. package/dist/{chunk-77UPM7P3.js → chunk-TVIVY7KU.js} +160 -61
  22. package/dist/chunk-TVIVY7KU.js.map +1 -0
  23. package/dist/{chunk-KCYE2MZM.js → chunk-UXPUUL3G.js} +29 -8
  24. package/dist/chunk-UXPUUL3G.js.map +1 -0
  25. package/dist/{chunk-3VSTTB74.js → chunk-WSOEYOHN.js} +7 -7
  26. package/dist/{chunk-5AJIDEFT.js → chunk-XFTPUXCE.js} +11 -7
  27. package/dist/chunk-XFTPUXCE.js.map +1 -0
  28. package/dist/cli.d.ts +1 -0
  29. package/dist/compounding/preference-consolidator.d.ts +1 -0
  30. package/dist/event-order-recall.d.ts +1 -0
  31. package/dist/event-order-recall.js +2 -2
  32. package/dist/evidence-pack.d.ts +16 -1
  33. package/dist/evidence-pack.js +1 -1
  34. package/dist/explicit-capture.d.ts +1 -0
  35. package/dist/explicit-cue-recall.d.ts +16 -1
  36. package/dist/explicit-cue-recall.js +2 -2
  37. package/dist/focused-list-recall.d.ts +1 -0
  38. package/dist/focused-list-recall.js +2 -2
  39. package/dist/index.d.ts +3 -2
  40. package/dist/index.js +9 -9
  41. package/dist/lcm/engine.d.ts +3 -0
  42. package/dist/lcm/engine.js +2 -2
  43. package/dist/lcm/index.d.ts +1 -1
  44. package/dist/lcm/index.js +5 -3
  45. package/dist/lcm/recall.d.ts +15 -1
  46. package/dist/lcm/recall.js +5 -3
  47. package/dist/lcm/tools.d.ts +1 -0
  48. package/dist/mcp-memory-inspector-app.d.ts +1 -0
  49. package/dist/orchestrator.d.ts +1 -0
  50. package/dist/orchestrator.js +9 -9
  51. package/dist/response-guidance-recall.d.ts +1 -0
  52. package/dist/response-guidance-recall.js +2 -2
  53. package/dist/targeted-fact-recall.d.ts +1 -0
  54. package/dist/targeted-fact-recall.js +2 -2
  55. package/package.json +2 -2
  56. package/src/evidence-pack.test.ts +66 -1
  57. package/src/evidence-pack.ts +39 -0
  58. package/src/explicit-cue-recall.ts +224 -67
  59. package/src/index.ts +3 -0
  60. package/src/lcm/engine.ts +17 -5
  61. package/src/lcm/index.ts +6 -1
  62. package/src/lcm/recall.ts +48 -5
  63. package/src/lcm-engine.test.ts +46 -0
  64. package/src/lcm-recall.test.ts +48 -1
  65. package/src/recall-trace-receipts.test.ts +380 -0
  66. package/dist/chunk-5AJIDEFT.js.map +0 -1
  67. package/dist/chunk-77UPM7P3.js.map +0 -1
  68. package/dist/chunk-KCYE2MZM.js.map +0 -1
  69. package/dist/chunk-R26WO46Z.js.map +0 -1
  70. /package/dist/{chunk-XGSOXFD4.js.map → chunk-6TCXIRP5.js.map} +0 -0
  71. /package/dist/{chunk-7O2JL5RY.js.map → chunk-DEOYTFA6.js.map} +0 -0
  72. /package/dist/{chunk-BFU7H32D.js.map → chunk-OH7QLI6W.js.map} +0 -0
  73. /package/dist/{chunk-IRMLYBMU.js.map → chunk-T67LUYCR.js.map} +0 -0
  74. /package/dist/{chunk-3VSTTB74.js.map → chunk-WSOEYOHN.js.map} +0 -0
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
  });
package/src/lcm/index.ts CHANGED
@@ -14,6 +14,11 @@ export {
14
14
  export { LcmArchive, estimateTokens } from "./archive.js";
15
15
  export { LcmDag, type SummaryNode } from "./dag.js";
16
16
  export { LcmSummarizer, type SummarizeFn } from "./summarizer.js";
17
- export { assembleCompressedHistory } from "./recall.js";
17
+ export {
18
+ assembleCompressedHistory,
19
+ assembleCompressedHistoryWithTrace,
20
+ type LcmRecallWithTraceResult,
21
+ type LcmSummarySelectionReceipt,
22
+ } from "./recall.js";
18
23
  export { registerLcmTools } from "./tools.js";
19
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,380 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ import {
5
+ buildExplicitCueRecallSection,
6
+ buildTrajectoryAnalysisRecallSection,
7
+ type ExplicitCueRecallEngine,
8
+ type TrajectoryAnalysisLineReceipt,
9
+ } from "./explicit-cue-recall.js";
10
+ import type { EvidencePackSelectionReceipt } from "./evidence-pack.js";
11
+
12
+ interface ReceiptRow {
13
+ id?: number;
14
+ session_id: string;
15
+ turn_index: number;
16
+ role: string;
17
+ content: string;
18
+ }
19
+
20
+ class ReceiptEngine implements ExplicitCueRecallEngine {
21
+ constructor(private readonly rows: ReceiptRow[]) {}
22
+
23
+ async expandContext(
24
+ sessionId: string,
25
+ fromTurn: number,
26
+ toTurn: number,
27
+ ): Promise<ReceiptRow[]> {
28
+ return this.rows.filter((row) =>
29
+ row.session_id === sessionId &&
30
+ row.turn_index >= fromTurn &&
31
+ row.turn_index <= toTurn
32
+ );
33
+ }
34
+
35
+ async searchContextFull(): Promise<Array<ReceiptRow & { score: number }>> {
36
+ return [];
37
+ }
38
+
39
+ async getStats(): Promise<{ totalMessages: number; maxTurnIndex?: number }> {
40
+ return {
41
+ totalMessages: this.rows.length,
42
+ maxTurnIndex: this.rows.length > 0
43
+ ? Math.max(...this.rows.map((row) => row.turn_index))
44
+ : undefined,
45
+ };
46
+ }
47
+ }
48
+
49
+ test("explicit-cue receipts identify only blocks that survive selection", async () => {
50
+ const engine = new ReceiptEngine([
51
+ {
52
+ id: 101,
53
+ session_id: "s",
54
+ turn_index: 7,
55
+ role: "user",
56
+ content: "first selected row",
57
+ },
58
+ {
59
+ id: 102,
60
+ session_id: "s",
61
+ turn_index: 7,
62
+ role: "assistant",
63
+ content: "second row that the deliberately narrow section budget rejects",
64
+ },
65
+ ]);
66
+ const receipts: EvidencePackSelectionReceipt[] = [];
67
+ const options = {
68
+ engine,
69
+ sessionId: "s",
70
+ query: "Review turn 7",
71
+ maxChars:
72
+ "## Explicit Cue Evidence".length +
73
+ 2 +
74
+ "[s, turn 7, user]: first selected row".length,
75
+ maxItemChars: 200,
76
+ };
77
+
78
+ const baseline = await buildExplicitCueRecallSection(options);
79
+ const traced = await buildExplicitCueRecallSection({
80
+ ...options,
81
+ onEvidenceSelected: (receipt) => receipts.push(receipt),
82
+ });
83
+
84
+ assert.equal(traced, baseline);
85
+ assert.deepEqual(receipts.map((receipt) => receipt.item.archiveRowId), [101]);
86
+ assert.equal(
87
+ traced.slice(receipts[0]!.blockStart, receipts[0]!.blockEnd).includes(
88
+ "first selected row",
89
+ ),
90
+ true,
91
+ );
92
+ assert.equal(JSON.stringify(receipts).includes("first selected row"), false);
93
+ assert.equal(JSON.stringify(receipts).includes("second row"), false);
94
+ });
95
+
96
+ test("trajectory receipts retain row lineage only for emitted clipped lines", async () => {
97
+ const engine = new ReceiptEngine([
98
+ {
99
+ id: 201,
100
+ session_id: "ama",
101
+ turn_index: 1,
102
+ role: "user",
103
+ content: "[Action 1]: move right",
104
+ },
105
+ {
106
+ id: 202,
107
+ session_id: "ama",
108
+ turn_index: 1,
109
+ role: "assistant",
110
+ content: "[Observation 1]: reached the wall",
111
+ },
112
+ {
113
+ id: 203,
114
+ session_id: "ama",
115
+ turn_index: 2,
116
+ role: "user",
117
+ content: "[Action 2]: move left",
118
+ },
119
+ {
120
+ id: 204,
121
+ session_id: "ama",
122
+ turn_index: 2,
123
+ role: "assistant",
124
+ content: "[Observation 2]: reached the door",
125
+ },
126
+ ]);
127
+ const receipts: TrajectoryAnalysisLineReceipt[] = [];
128
+ const options = {
129
+ engine,
130
+ sessionId: "ama",
131
+ query:
132
+ "What will be the resulting state at action 1? Provide the full observation.",
133
+ maxChars: 4_000,
134
+ };
135
+
136
+ const baseline = await buildTrajectoryAnalysisRecallSection(options);
137
+ const traced = await buildTrajectoryAnalysisRecallSection({
138
+ ...options,
139
+ onLineSelected: (receipt) => receipts.push(receipt),
140
+ });
141
+
142
+ assert.equal(traced, baseline);
143
+ const actionOne = receipts.find((receipt) =>
144
+ traced.slice(receipt.lineStart, receipt.lineEnd).startsWith("[Action 1]")
145
+ );
146
+ const observationOne = receipts.find((receipt) =>
147
+ traced.slice(receipt.lineStart, receipt.lineEnd).startsWith("[Observation 1]")
148
+ );
149
+ assert.ok(actionOne);
150
+ assert.ok(observationOne);
151
+ assert.equal(actionOne.lineageStatus, "exact");
152
+ assert.equal(observationOne.lineageStatus, "exact");
153
+ assert.deepEqual(actionOne.actionArchiveRowIds, [201]);
154
+ assert.deepEqual(actionOne.observationArchiveRowIds, []);
155
+ assert.deepEqual(observationOne.actionArchiveRowIds, []);
156
+ assert.deepEqual(observationOne.observationArchiveRowIds, [202]);
157
+ assert.equal(actionOne.actionArchiveRowIds.includes(203), false);
158
+ assert.equal(observationOne.observationArchiveRowIds.includes(204), false);
159
+
160
+ const clippedReceipts: TrajectoryAnalysisLineReceipt[] = [];
161
+ const clipped = await buildTrajectoryAnalysisRecallSection({
162
+ ...options,
163
+ maxChars: actionOne.lineStart + 8,
164
+ onLineSelected: (receipt) => clippedReceipts.push(receipt),
165
+ });
166
+ const partialAction = clippedReceipts.at(-1)!;
167
+ assert.equal(partialAction.lineStart, actionOne.lineStart);
168
+ assert.equal(partialAction.lineEnd, clipped.length);
169
+ assert.equal(partialAction.lineageStatus, "exact");
170
+ assert.deepEqual(partialAction.actionArchiveRowIds, [201]);
171
+ assert.equal(partialAction.actionArchiveRowIds.includes(203), false);
172
+ assert.equal(JSON.stringify(receipts).includes("move right"), false);
173
+ assert.equal(JSON.stringify(receipts).includes("reached the wall"), false);
174
+ });
175
+
176
+ test("spatial derived-line receipts mark lineage unavailable without guessing siblings", async () => {
177
+ const engine = new ReceiptEngine([
178
+ {
179
+ id: 301,
180
+ session_id: "ama",
181
+ turn_index: 20,
182
+ role: "user",
183
+ content: "[Action 20]: right",
184
+ },
185
+ {
186
+ id: 302,
187
+ session_id: "ama",
188
+ turn_index: 20,
189
+ role: "assistant",
190
+ content: [
191
+ "[Observation 20]: Active rules:",
192
+ "baba is you",
193
+ "",
194
+ "Objects on the map:",
195
+ "rule `win` 3 step to the left",
196
+ ].join("\n"),
197
+ },
198
+ {
199
+ id: 303,
200
+ session_id: "ama",
201
+ turn_index: 21,
202
+ role: "user",
203
+ content: "[Action 21]: left",
204
+ },
205
+ {
206
+ id: 304,
207
+ session_id: "ama",
208
+ turn_index: 21,
209
+ role: "assistant",
210
+ content: [
211
+ "[Observation 21]: Active rules:",
212
+ "baba is you",
213
+ "",
214
+ "Objects on the map:",
215
+ "rule `win` 2 step to the left",
216
+ ].join("\n"),
217
+ },
218
+ ]);
219
+ const receipts: TrajectoryAnalysisLineReceipt[] = [];
220
+ const options = {
221
+ engine,
222
+ sessionId: "ama",
223
+ query:
224
+ "Between steps 20 and 21, the rule 'win' block's relative position changed from 3 step to the left to 2 step to the left. What was the actual movement?",
225
+ maxChars: 4_000,
226
+ };
227
+
228
+ const baseline = await buildTrajectoryAnalysisRecallSection(options);
229
+ const traced = await buildTrajectoryAnalysisRecallSection({
230
+ ...options,
231
+ onLineSelected: (receipt) => receipts.push(receipt),
232
+ });
233
+
234
+ assert.equal(traced, baseline);
235
+ const heading = receipts.find((receipt) =>
236
+ traced.slice(receipt.lineStart, receipt.lineEnd) === "Relative-position movement cues:"
237
+ );
238
+ const movement = receipts.find((receipt) =>
239
+ traced.slice(receipt.lineStart, receipt.lineEnd).startsWith("Observation 20->21:")
240
+ );
241
+ assert.ok(heading);
242
+ assert.ok(movement);
243
+ assert.equal(heading.lineageStatus, "exact");
244
+ assert.deepEqual(heading.actionArchiveRowIds, []);
245
+ assert.deepEqual(heading.observationArchiveRowIds, []);
246
+ assert.equal(movement.lineageStatus, "unavailable");
247
+ assert.deepEqual(movement.actionArchiveRowIds, []);
248
+ assert.deepEqual(movement.observationArchiveRowIds, []);
249
+ assert.equal(JSON.stringify(movement).includes("rule win"), false);
250
+ });
251
+
252
+ test("trajectory receipts mark id-less and partially id-less direct sources unavailable", async () => {
253
+ const engine = new ReceiptEngine([
254
+ {
255
+ session_id: "ama",
256
+ turn_index: 1,
257
+ role: "user",
258
+ content: "[Action 1]: move right",
259
+ },
260
+ {
261
+ id: 402,
262
+ session_id: "ama",
263
+ turn_index: 1,
264
+ role: "assistant",
265
+ content: "[Observation 1]: reached the wall",
266
+ },
267
+ {
268
+ session_id: "ama",
269
+ turn_index: 2,
270
+ role: "user",
271
+ content: "[Action 2]: move left",
272
+ },
273
+ {
274
+ session_id: "ama",
275
+ turn_index: 2,
276
+ role: "assistant",
277
+ content: "[Observation 2]: reached the door",
278
+ },
279
+ ]);
280
+ const receipts: TrajectoryAnalysisLineReceipt[] = [];
281
+ const text = await buildTrajectoryAnalysisRecallSection({
282
+ engine,
283
+ sessionId: "ama",
284
+ query: "Compare steps 1 and 2 and provide the full observations.",
285
+ maxChars: 4_000,
286
+ onLineSelected: (receipt) => receipts.push(receipt),
287
+ });
288
+ const findLine = (prefix: string) => receipts.find((receipt) =>
289
+ text.slice(receipt.lineStart, receipt.lineEnd).startsWith(prefix)
290
+ );
291
+
292
+ const idLessAction = findLine("[Action 1]");
293
+ const identifiedObservation = findLine("[Observation 1]");
294
+ const idLessObservation = findLine("[Observation 2]");
295
+ assert.ok(idLessAction);
296
+ assert.ok(identifiedObservation);
297
+ assert.ok(idLessObservation);
298
+ assert.equal(idLessAction.lineageStatus, "unavailable");
299
+ assert.deepEqual(idLessAction.actionArchiveRowIds, []);
300
+ assert.equal(identifiedObservation.lineageStatus, "exact");
301
+ assert.deepEqual(identifiedObservation.observationArchiveRowIds, [402]);
302
+ assert.equal(idLessObservation.lineageStatus, "unavailable");
303
+ assert.deepEqual(idLessObservation.observationArchiveRowIds, []);
304
+ });
305
+
306
+ test("inferred-location receipts identify only the action that establishes the final location", async () => {
307
+ const engine = new ReceiptEngine([
308
+ {
309
+ id: 501,
310
+ session_id: "ama",
311
+ turn_index: 1,
312
+ role: "user",
313
+ content: "[Action 1]: open cabinet 1",
314
+ },
315
+ {
316
+ id: 502,
317
+ session_id: "ama",
318
+ turn_index: 2,
319
+ role: "user",
320
+ content: "[Action 2]: move cabinet 1 to kitchen",
321
+ },
322
+ ]);
323
+ const receipts: TrajectoryAnalysisLineReceipt[] = [];
324
+ const text = await buildTrajectoryAnalysisRecallSection({
325
+ engine,
326
+ sessionId: "ama",
327
+ query:
328
+ "What is the state of cabinet 1 at step 2 and what was its prior whole changes history?",
329
+ maxChars: 4_000,
330
+ onLineSelected: (receipt) => receipts.push(receipt),
331
+ });
332
+ const location = receipts.find((receipt) =>
333
+ text.slice(receipt.lineStart, receipt.lineEnd).startsWith(
334
+ "Inferred cabinet 1 location at step 2: kitchen",
335
+ )
336
+ );
337
+
338
+ assert.ok(location);
339
+ assert.equal(location.lineageStatus, "exact");
340
+ assert.deepEqual(location.actionArchiveRowIds, [502]);
341
+ assert.equal(location.actionArchiveRowIds.includes(501), false);
342
+ assert.deepEqual(location.observationArchiveRowIds, []);
343
+ });
344
+
345
+ test("stateful inventory-change lines do not publish partial current-row lineage", async () => {
346
+ const engine = new ReceiptEngine([
347
+ {
348
+ id: 601,
349
+ session_id: "ama",
350
+ turn_index: 1,
351
+ role: "user",
352
+ content: "[Action 1]: take apple 1 from shelf 1",
353
+ },
354
+ {
355
+ id: 602,
356
+ session_id: "ama",
357
+ turn_index: 2,
358
+ role: "user",
359
+ content: "[Action 2]: place apple 1 into bowl 1",
360
+ },
361
+ ]);
362
+ const receipts: TrajectoryAnalysisLineReceipt[] = [];
363
+ const text = await buildTrajectoryAnalysisRecallSection({
364
+ engine,
365
+ sessionId: "ama",
366
+ query: "What changes occurred to the inventory throughout the trajectory?",
367
+ maxChars: 4_000,
368
+ onLineSelected: (receipt) => receipts.push(receipt),
369
+ });
370
+ const placed = receipts.find((receipt) =>
371
+ text.slice(receipt.lineStart, receipt.lineEnd).startsWith(
372
+ "[Action 2]: place apple 1 into bowl 1 => inventory removed apple 1",
373
+ )
374
+ );
375
+
376
+ assert.ok(placed);
377
+ assert.equal(placed.lineageStatus, "unavailable");
378
+ assert.deepEqual(placed.actionArchiveRowIds, []);
379
+ assert.deepEqual(placed.observationArchiveRowIds, []);
380
+ });