@remnic/core 9.6.28 → 9.6.30

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 (106) hide show
  1. package/dist/access-admin-ops-surface.d.ts +2 -2
  2. package/dist/access-admin-ops-surface.js +2 -1
  3. package/dist/access-boundary.d.ts +2 -2
  4. package/dist/access-boundary.js +3 -2
  5. package/dist/access-cli.js +16 -15
  6. package/dist/access-cli.js.map +1 -1
  7. package/dist/access-http.d.ts +2 -2
  8. package/dist/access-http.js +7 -6
  9. package/dist/access-identity-continuity-surface.d.ts +1 -1
  10. package/dist/access-identity-continuity-surface.js +2 -1
  11. package/dist/access-lcm-surface.d.ts +2 -2
  12. package/dist/access-lcm-surface.js +2 -1
  13. package/dist/access-mcp.d.ts +2 -2
  14. package/dist/access-mcp.js +6 -5
  15. package/dist/access-observe-write-surface.d.ts +2 -2
  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 +2 -2
  19. package/dist/access-operations.js +5 -4
  20. package/dist/access-recall-surface.d.ts +4 -3
  21. package/dist/access-recall-surface.js +2 -1
  22. package/dist/{access-service-DK7s3wMj.d.ts → access-service-BofDzaPT.d.ts} +3 -2
  23. package/dist/access-service.d.ts +2 -2
  24. package/dist/access-service.js +2 -1
  25. package/dist/access-surface-catalog.d.ts +2 -2
  26. package/dist/bootstrap.d.ts +1 -1
  27. package/dist/{chunk-4273YO6I.js → chunk-3VSTTB74.js} +111 -11
  28. package/dist/chunk-3VSTTB74.js.map +1 -0
  29. package/dist/{chunk-4463LUIE.js → chunk-77UPM7P3.js} +22 -11
  30. package/dist/chunk-77UPM7P3.js.map +1 -0
  31. package/dist/{chunk-FV5FVO2J.js → chunk-7O2JL5RY.js} +9 -5
  32. package/dist/chunk-7O2JL5RY.js.map +1 -0
  33. package/dist/{chunk-LZTFCAKE.js → chunk-BFU7H32D.js} +28 -15
  34. package/dist/chunk-BFU7H32D.js.map +1 -0
  35. package/dist/chunk-C37ZRXX7.js +36 -0
  36. package/dist/chunk-C37ZRXX7.js.map +1 -0
  37. package/dist/{chunk-ARDRBJHD.js → chunk-C6TKSI35.js} +3 -3
  38. package/dist/{chunk-N6VIXDT7.js → chunk-DAAJUA6Q.js} +4 -4
  39. package/dist/{chunk-MRQN5Q3I.js → chunk-GQURC4B7.js} +2 -2
  40. package/dist/{chunk-4SBW47WK.js → chunk-IRMLYBMU.js} +28 -15
  41. package/dist/chunk-IRMLYBMU.js.map +1 -0
  42. package/dist/{chunk-QLYLTKUG.js → chunk-MXAGJHTA.js} +3 -3
  43. package/dist/{chunk-YDF4HL3W.js → chunk-OXVKR5OU.js} +35 -39
  44. package/dist/chunk-OXVKR5OU.js.map +1 -0
  45. package/dist/{chunk-GXLFDLX2.js → chunk-PUQZIS6A.js} +3 -3
  46. package/dist/{chunk-PYPOFEMK.js → chunk-R26WO46Z.js} +1 -1
  47. package/dist/chunk-R26WO46Z.js.map +1 -0
  48. package/dist/chunk-VDC6RZI4.js +1 -0
  49. package/dist/{chunk-43VNWTBB.js → chunk-VJRJTRYF.js} +4 -4
  50. package/dist/chunk-VJRJTRYF.js.map +1 -0
  51. package/dist/{chunk-L6W77GWW.js → chunk-XGSOXFD4.js} +25 -13
  52. package/dist/chunk-XGSOXFD4.js.map +1 -0
  53. package/dist/{cli-CLSdmOVU.d.ts → cli-3_x5sJTL.d.ts} +2 -2
  54. package/dist/cli.d.ts +3 -3
  55. package/dist/cli.js +8 -7
  56. package/dist/event-order-recall.js +3 -2
  57. package/dist/evidence-pack.d.ts +2 -0
  58. package/dist/evidence-pack.js +1 -1
  59. package/dist/explicit-capture.d.ts +1 -1
  60. package/dist/explicit-cue-recall.d.ts +3 -0
  61. package/dist/explicit-cue-recall.js +3 -2
  62. package/dist/focused-list-recall.js +3 -2
  63. package/dist/index.d.ts +4 -4
  64. package/dist/index.js +16 -15
  65. package/dist/lcm/index.d.ts +22 -0
  66. package/dist/lcm/index.js +9 -1
  67. package/dist/mcp-memory-inspector-app.d.ts +2 -2
  68. package/dist/{orchestrator-BzXGNHco.d.ts → orchestrator-CAH5-PKN.d.ts} +20 -3
  69. package/dist/orchestrator.d.ts +1 -1
  70. package/dist/orchestrator.js +16 -15
  71. package/dist/response-guidance-recall.js +3 -2
  72. package/dist/targeted-fact-recall.js +3 -2
  73. package/package.json +2 -2
  74. package/src/access-lcm-surface.ts +20 -5
  75. package/src/access-recall-surface.ts +48 -50
  76. package/src/access-service.ts +2 -7
  77. package/src/cli.ts +4 -10
  78. package/src/event-order-recall.ts +5 -3
  79. package/src/evidence-pack.ts +2 -0
  80. package/src/explicit-cue-recall.ts +67 -66
  81. package/src/focused-list-recall.ts +25 -16
  82. package/src/lcm/evidence-identity.ts +59 -0
  83. package/src/lcm/index.ts +7 -0
  84. package/src/lcm-retrieval-lineage.test.ts +236 -0
  85. package/src/orchestration/xray-capture-queue.test.ts +42 -0
  86. package/src/orchestration/xray-capture-queue.ts +78 -0
  87. package/src/orchestrator-xray-capture.test.ts +319 -0
  88. package/src/orchestrator.ts +76 -8
  89. package/src/response-guidance-recall.ts +22 -14
  90. package/src/targeted-fact-recall.ts +25 -16
  91. package/dist/chunk-4273YO6I.js.map +0 -1
  92. package/dist/chunk-43VNWTBB.js.map +0 -1
  93. package/dist/chunk-4463LUIE.js.map +0 -1
  94. package/dist/chunk-4SBW47WK.js.map +0 -1
  95. package/dist/chunk-FV5FVO2J.js.map +0 -1
  96. package/dist/chunk-HHLLAQGZ.js +0 -1
  97. package/dist/chunk-L6W77GWW.js.map +0 -1
  98. package/dist/chunk-LZTFCAKE.js.map +0 -1
  99. package/dist/chunk-PYPOFEMK.js.map +0 -1
  100. package/dist/chunk-YDF4HL3W.js.map +0 -1
  101. /package/dist/{chunk-ARDRBJHD.js.map → chunk-C6TKSI35.js.map} +0 -0
  102. /package/dist/{chunk-N6VIXDT7.js.map → chunk-DAAJUA6Q.js.map} +0 -0
  103. /package/dist/{chunk-MRQN5Q3I.js.map → chunk-GQURC4B7.js.map} +0 -0
  104. /package/dist/{chunk-QLYLTKUG.js.map → chunk-MXAGJHTA.js.map} +0 -0
  105. /package/dist/{chunk-GXLFDLX2.js.map → chunk-PUQZIS6A.js.map} +0 -0
  106. /package/dist/{chunk-HHLLAQGZ.js.map → chunk-VDC6RZI4.js.map} +0 -0
@@ -0,0 +1,319 @@
1
+ import assert from "node:assert/strict";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+ import test from "node:test";
5
+
6
+ import { parseConfig } from "./config.js";
7
+ import { Orchestrator } from "./orchestrator.js";
8
+ import { buildXraySnapshot, type RecallXraySnapshot } from "./recall-xray.js";
9
+
10
+ let orchestratorSequence = 0;
11
+
12
+ function makeOrchestrator(): Orchestrator {
13
+ orchestratorSequence += 1;
14
+ const memoryDir = path.join(
15
+ tmpdir(),
16
+ `remnic-xray-capture-unit-${process.pid}-${orchestratorSequence}`,
17
+ );
18
+ return new Orchestrator(parseConfig({
19
+ memoryDir,
20
+ workspaceDir: memoryDir,
21
+ qmdEnabled: false,
22
+ embeddingFallbackEnabled: false,
23
+ }));
24
+ }
25
+
26
+ function snapshot(query: string, snapshotId: string): RecallXraySnapshot {
27
+ return buildXraySnapshot({
28
+ query,
29
+ snapshotIdGenerator: () => snapshotId,
30
+ now: () => 1,
31
+ });
32
+ }
33
+
34
+ function setSnapshot(orchestrator: Orchestrator, value: RecallXraySnapshot): void {
35
+ (orchestrator as unknown as { lastXraySnapshot: RecallXraySnapshot | null })
36
+ .lastXraySnapshot = value;
37
+ }
38
+
39
+ function stubRecall(
40
+ orchestrator: Orchestrator,
41
+ invoke: (
42
+ prompt: string,
43
+ sessionKey: string | undefined,
44
+ options: Record<string, unknown>,
45
+ ) => Promise<string>,
46
+ ): void {
47
+ (orchestrator as unknown as { invokeRecall: typeof invoke }).invokeRecall = invoke;
48
+ }
49
+
50
+ function deferred<T>(): {
51
+ promise: Promise<T>;
52
+ resolve: (value: T) => void;
53
+ reject: (reason?: unknown) => void;
54
+ } {
55
+ let resolve!: (value: T) => void;
56
+ let reject!: (reason?: unknown) => void;
57
+ const promise = new Promise<T>((res, rej) => {
58
+ resolve = res;
59
+ reject = rej;
60
+ });
61
+ return { promise, resolve, reject };
62
+ }
63
+
64
+ test("recallWithXrayCapture clears stale state and returns an owned nested clone", async () => {
65
+ const orchestrator = makeOrchestrator();
66
+ setSnapshot(orchestrator, snapshot("stale", "stale-id"));
67
+ const captured = buildXraySnapshot({
68
+ query: "normalized query",
69
+ snapshotIdGenerator: () => "fresh-id",
70
+ now: () => 1,
71
+ results: [{
72
+ memoryId: "memory-1",
73
+ path: "memories/memory-1.md",
74
+ servedBy: "hybrid",
75
+ scoreDecomposition: { final: 1 },
76
+ admittedBy: ["retrieval"],
77
+ graphPath: ["a", "b"],
78
+ }],
79
+ filters: [{ name: "retrieval", considered: 1, admitted: 1 }],
80
+ });
81
+ stubRecall(orchestrator, async (_prompt, _sessionKey, options) => {
82
+ assert.equal(options.xrayCapture, true);
83
+ assert.equal(orchestrator.getLastXraySnapshot(), null);
84
+ setSnapshot(orchestrator, captured);
85
+ return "recall-result";
86
+ });
87
+
88
+ const result = await orchestrator.recallWithXrayCapture("raw query", "session");
89
+ assert.equal(result.result, "recall-result");
90
+ assert.deepEqual(result.snapshot, captured);
91
+ assert.notEqual(result.snapshot, captured);
92
+
93
+ result.snapshot!.results[0]!.admittedBy.push("tampered");
94
+ result.snapshot!.results[0]!.graphPath!.push("tampered");
95
+ result.snapshot!.filters[0]!.reason = "tampered";
96
+ const stored = orchestrator.getLastXraySnapshot();
97
+ assert.deepEqual(stored?.results[0]?.admittedBy, ["retrieval"]);
98
+ assert.deepEqual(stored?.results[0]?.graphPath, ["a", "b"]);
99
+ assert.equal(stored?.filters[0]?.reason, undefined);
100
+ });
101
+
102
+ test("recallWithXrayCapture serializes every consumer of one orchestrator slot", async () => {
103
+ const orchestrator = makeOrchestrator();
104
+ const firstGate = deferred<void>();
105
+ const firstStarted = deferred<void>();
106
+ const order: string[] = [];
107
+ stubRecall(orchestrator, async (prompt) => {
108
+ order.push(`${prompt}:start`);
109
+ if (prompt === "first") {
110
+ firstStarted.resolve();
111
+ await firstGate.promise;
112
+ }
113
+ setSnapshot(orchestrator, snapshot(prompt, `${prompt}-id`));
114
+ order.push(`${prompt}:end`);
115
+ return prompt;
116
+ });
117
+
118
+ const first = orchestrator.recallWithXrayCapture("first");
119
+ const second = orchestrator.recallWithXrayCapture("second");
120
+ await firstStarted.promise;
121
+ assert.deepEqual(order, ["first:start"]);
122
+ firstGate.resolve();
123
+
124
+ const [firstResult, secondResult] = await Promise.all([first, second]);
125
+ assert.deepEqual(order, ["first:start", "first:end", "second:start", "second:end"]);
126
+ assert.equal(firstResult.snapshot?.snapshotId, "first-id");
127
+ assert.equal(secondResult.snapshot?.snapshotId, "second-id");
128
+ });
129
+
130
+ test("recallWithXrayCapture queues are independent across orchestrators", async () => {
131
+ const firstOrchestrator = makeOrchestrator();
132
+ const secondOrchestrator = makeOrchestrator();
133
+ const firstGate = deferred<void>();
134
+ let secondStarted = false;
135
+ stubRecall(firstOrchestrator, async () => {
136
+ await firstGate.promise;
137
+ return "first";
138
+ });
139
+ stubRecall(secondOrchestrator, async () => {
140
+ secondStarted = true;
141
+ return "second";
142
+ });
143
+
144
+ const first = firstOrchestrator.recallWithXrayCapture("first");
145
+ await secondOrchestrator.recallWithXrayCapture("second");
146
+ assert.equal(secondStarted, true);
147
+ firstGate.resolve();
148
+ await first;
149
+ });
150
+
151
+ test("recallWithXrayCapture rejects a pre-aborted signal without clearing or invoking", async () => {
152
+ const orchestrator = makeOrchestrator();
153
+ const stale = snapshot("stale", "stale-id");
154
+ setSnapshot(orchestrator, stale);
155
+ const abortController = new AbortController();
156
+ abortController.abort();
157
+ let invoked = false;
158
+ stubRecall(orchestrator, async () => {
159
+ invoked = true;
160
+ return "unexpected";
161
+ });
162
+
163
+ await assert.rejects(
164
+ orchestrator.recallWithXrayCapture("aborted", undefined, {
165
+ abortSignal: abortController.signal,
166
+ }),
167
+ { name: "AbortError" },
168
+ );
169
+ assert.equal(invoked, false);
170
+ assert.deepEqual(orchestrator.getLastXraySnapshot(), stale);
171
+ });
172
+
173
+ test("queued abort rejects promptly while its barrier prevents a third capture overtaking", async () => {
174
+ const orchestrator = makeOrchestrator();
175
+ const firstGate = deferred<void>();
176
+ const abortController = new AbortController();
177
+ const order: string[] = [];
178
+ stubRecall(orchestrator, async (prompt) => {
179
+ order.push(`${prompt}:start`);
180
+ if (prompt === "first") await firstGate.promise;
181
+ setSnapshot(orchestrator, snapshot(prompt, `${prompt}-id`));
182
+ order.push(`${prompt}:end`);
183
+ return prompt;
184
+ });
185
+
186
+ const first = orchestrator.recallWithXrayCapture("first");
187
+ await Promise.resolve();
188
+ const queued = orchestrator.recallWithXrayCapture("aborted", undefined, {
189
+ abortSignal: abortController.signal,
190
+ });
191
+ const third = orchestrator.recallWithXrayCapture("third");
192
+ abortController.abort();
193
+
194
+ await assert.rejects(queued, { name: "AbortError" });
195
+ assert.deepEqual(order, ["first:start"]);
196
+ firstGate.resolve();
197
+
198
+ const [firstResult, thirdResult] = await Promise.all([first, third]);
199
+ assert.deepEqual(order, ["first:start", "first:end", "third:start", "third:end"]);
200
+ assert.equal(firstResult.snapshot?.snapshotId, "first-id");
201
+ assert.equal(thirdResult.snapshot?.snapshotId, "third-id");
202
+ });
203
+
204
+ test("started abort retains ownership until recall settles", async () => {
205
+ const orchestrator = makeOrchestrator();
206
+ const activeGate = deferred<void>();
207
+ const activeStarted = deferred<void>();
208
+ const abortController = new AbortController();
209
+ let nextStarted = false;
210
+ stubRecall(orchestrator, async (prompt) => {
211
+ if (prompt === "active") {
212
+ activeStarted.resolve();
213
+ await activeGate.promise;
214
+ setSnapshot(orchestrator, snapshot("active", "active-id"));
215
+ } else {
216
+ nextStarted = true;
217
+ setSnapshot(orchestrator, snapshot("next", "next-id"));
218
+ }
219
+ return prompt;
220
+ });
221
+
222
+ const active = orchestrator.recallWithXrayCapture("active", undefined, {
223
+ abortSignal: abortController.signal,
224
+ });
225
+ await activeStarted.promise;
226
+ abortController.abort();
227
+ const next = orchestrator.recallWithXrayCapture("next");
228
+ await Promise.resolve();
229
+ assert.equal(nextStarted, false);
230
+
231
+ activeGate.resolve();
232
+ const activeResult = await active;
233
+ const nextResult = await next;
234
+ assert.equal(activeResult.snapshot?.snapshotId, "active-id");
235
+ assert.equal(nextResult.snapshot?.snapshotId, "next-id");
236
+ });
237
+
238
+ test("failed and empty captures restore prior shared snapshot but return no stale snapshot", async () => {
239
+ const orchestrator = makeOrchestrator();
240
+ const prior = snapshot("prior", "prior-id");
241
+ setSnapshot(orchestrator, prior);
242
+ stubRecall(orchestrator, async (prompt) => {
243
+ if (prompt === "failure") throw new Error("recall failed");
244
+ return "empty";
245
+ });
246
+
247
+ await assert.rejects(
248
+ orchestrator.recallWithXrayCapture("failure"),
249
+ /recall failed/,
250
+ );
251
+ assert.equal(orchestrator.getLastXraySnapshot()?.snapshotId, "prior-id");
252
+
253
+ const empty = await orchestrator.recallWithXrayCapture("empty");
254
+ assert.equal(empty.snapshot, null);
255
+ assert.equal(orchestrator.getLastXraySnapshot()?.snapshotId, "prior-id");
256
+ });
257
+
258
+ test("legacy capturing recall shares the queue and preserves its string return", async () => {
259
+ const orchestrator = makeOrchestrator();
260
+ const firstGate = deferred<void>();
261
+ const order: string[] = [];
262
+ stubRecall(orchestrator, async (prompt) => {
263
+ order.push(`${prompt}:start`);
264
+ if (prompt === "atomic") await firstGate.promise;
265
+ setSnapshot(orchestrator, snapshot(prompt, `${prompt}-id`));
266
+ order.push(`${prompt}:end`);
267
+ return `${prompt}-result`;
268
+ });
269
+
270
+ const atomic = orchestrator.recallWithXrayCapture("atomic");
271
+ await Promise.resolve();
272
+ const legacy = orchestrator.recall("legacy", undefined, { xrayCapture: true });
273
+ await Promise.resolve();
274
+ assert.deepEqual(order, ["atomic:start"]);
275
+ firstGate.resolve();
276
+
277
+ const [atomicResult, legacyResult] = await Promise.all([atomic, legacy]);
278
+ assert.equal(atomicResult.result, "atomic-result");
279
+ assert.equal(legacyResult, "legacy-result");
280
+ assert.deepEqual(order, ["atomic:start", "atomic:end", "legacy:start", "legacy:end"]);
281
+ });
282
+
283
+ test("ordinary recall remains independent and does not overwrite a captured snapshot", async () => {
284
+ const orchestrator = makeOrchestrator();
285
+ const captureGate = deferred<void>();
286
+ const captureStarted = deferred<void>();
287
+ const order: string[] = [];
288
+ stubRecall(orchestrator, async (prompt, _sessionKey, options) => {
289
+ order.push(prompt);
290
+ if (prompt === "capture") {
291
+ captureStarted.resolve();
292
+ await captureGate.promise;
293
+ setSnapshot(orchestrator, snapshot("capture", "capture-id"));
294
+ }
295
+ assert.equal(options.xrayCapture === true, prompt === "capture");
296
+ return `${prompt}-result`;
297
+ });
298
+
299
+ const capture = orchestrator.recallWithXrayCapture("capture");
300
+ await captureStarted.promise;
301
+ const ordinary = await orchestrator.recall("ordinary");
302
+ assert.equal(ordinary, "ordinary-result");
303
+ assert.deepEqual(order, ["capture", "ordinary"]);
304
+ captureGate.resolve();
305
+ await capture;
306
+ await orchestrator.recall("ordinary-after");
307
+ assert.equal(orchestrator.getLastXraySnapshot()?.snapshotId, "capture-id");
308
+ });
309
+
310
+ test("recallWithXrayCapture accepts normalized snapshot queries without equality checks", async () => {
311
+ const orchestrator = makeOrchestrator();
312
+ stubRecall(orchestrator, async () => {
313
+ setSnapshot(orchestrator, snapshot("daily briefing", "normalized-id"));
314
+ return "context";
315
+ });
316
+
317
+ const result = await orchestrator.recallWithXrayCapture(":cron: daily briefing");
318
+ assert.equal(result.snapshot?.query, "daily briefing");
319
+ });
@@ -121,6 +121,7 @@ import { selfDeps } from "./orchestration/self-deps.js";
121
121
  import { RecallEntryCoordinator } from "./orchestration/recall-entry.js";
122
122
  import { SessionContextCoordinator } from "./orchestration/session-context.js";
123
123
  import { drainRecallWrites, trackRecallWrite } from "./orchestration/recall-background-writes.js";
124
+ import { XrayCaptureQueue } from "./orchestration/xray-capture-queue.js";
124
125
  import {
125
126
  abortRecallError,
126
127
  buildCompressionGuidelinesMarkdown,
@@ -258,10 +259,6 @@ import {
258
259
  type EvalShadowRecallRecord,
259
260
  } from "./evals.js";
260
261
  import { SessionObserverState } from "./session-observer-state.js";
261
- import {
262
- abortError as sharedAbortError,
263
- throwIfAborted as sharedThrowIfAborted,
264
- } from "./abort-error.js";
265
262
  import { CODEX_THREAD_KEY_PREFIX } from "./thread-key.js";
266
263
  import { isDisagreementPrompt } from "./signal.js";
267
264
  import { lintWorkspaceFiles, rotateMarkdownFileToArchive } from "./hygiene.js";
@@ -633,6 +630,8 @@ export class Orchestrator {
633
630
  * capturing caller can still read their snapshot back.
634
631
  */
635
632
  private lastXraySnapshot: RecallXraySnapshot | null = null;
633
+ /** Per-instance ordering domain for every writer of `lastXraySnapshot`. */
634
+ private readonly xrayCaptureQueue = new XrayCaptureQueue();
636
635
  readonly embeddingFallback: EmbeddingFallback;
637
636
  private readonly conversationIndexDir: string;
638
637
  private readonly extraction: ExtractionEngine;
@@ -2182,7 +2181,7 @@ export class Orchestrator {
2182
2181
  );
2183
2182
  }
2184
2183
 
2185
- async recall(
2184
+ private invokeRecall(
2186
2185
  prompt: string,
2187
2186
  sessionKey?: string,
2188
2187
  options: RecallInvocationOptions = {},
@@ -2196,23 +2195,92 @@ export class Orchestrator {
2196
2195
  );
2197
2196
  }
2198
2197
 
2198
+ async recall(
2199
+ prompt: string,
2200
+ sessionKey?: string,
2201
+ options: RecallInvocationOptions = {},
2202
+ ): Promise<string> {
2203
+ if (options.xrayCapture === true) {
2204
+ // Preserve the legacy string-returning surface and its soft-abort
2205
+ // behavior while still placing every X-ray writer in the same ordering
2206
+ // domain. In particular, a pre-aborted legacy recall continues to
2207
+ // resolve with an empty string rather than rejecting.
2208
+ const { result } = await this.runRecallWithXrayCapture(
2209
+ prompt,
2210
+ sessionKey,
2211
+ options,
2212
+ );
2213
+ return result;
2214
+ }
2215
+ return this.invokeRecall(prompt, sessionKey, options);
2216
+ }
2217
+
2199
2218
  /**
2200
- * Return the most recent X-ray snapshot captured during a
2201
- * `recall()` call that passed `xrayCapture: true` (issue #570 PR 1).
2219
+ * Return the most recent X-ray snapshot captured during a recall.
2202
2220
  * Returns `null` when no such capture has occurred on this
2203
2221
  * orchestrator instance. Returned snapshot is a deep copy so
2204
2222
  * caller mutation cannot tear the stored value.
2223
+ *
2224
+ * @deprecated Reading this after a separate `recall()` is not atomic. Use
2225
+ * `recallWithXrayCapture()` when the snapshot must belong to that call.
2205
2226
  */
2206
2227
  getLastXraySnapshot(): RecallXraySnapshot | null {
2207
2228
  if (!this.lastXraySnapshot) return null;
2208
2229
  return structuredClone(this.lastXraySnapshot);
2209
2230
  }
2210
2231
 
2211
- /** Clear the captured X-ray snapshot. Exposed for tests / explicit reset. */
2232
+ /**
2233
+ * Clear the captured X-ray snapshot. Exposed for tests / explicit reset.
2234
+ *
2235
+ * @deprecated A separate clear → recall → get sequence is not atomic. Use
2236
+ * `recallWithXrayCapture()` for capture operations.
2237
+ */
2212
2238
  clearLastXraySnapshot(): void {
2213
2239
  this.lastXraySnapshot = null;
2214
2240
  }
2215
2241
 
2242
+ /** Atomically run recall and return the cloned X-ray snapshot it published. */
2243
+ async recallWithXrayCapture(
2244
+ prompt: string,
2245
+ sessionKey?: string,
2246
+ options: Omit<RecallInvocationOptions, "xrayCapture"> = {},
2247
+ ): Promise<{
2248
+ result: string;
2249
+ snapshot: RecallXraySnapshot | null;
2250
+ recallStartedAt: number;
2251
+ }> {
2252
+ return this.runRecallWithXrayCapture(
2253
+ prompt,
2254
+ sessionKey,
2255
+ options,
2256
+ options.abortSignal,
2257
+ );
2258
+ }
2259
+
2260
+ private async runRecallWithXrayCapture(
2261
+ prompt: string,
2262
+ sessionKey: string | undefined,
2263
+ options: Omit<RecallInvocationOptions, "xrayCapture"> | RecallInvocationOptions,
2264
+ queueAbortSignal?: AbortSignal,
2265
+ ): Promise<{
2266
+ result: string;
2267
+ snapshot: RecallXraySnapshot | null;
2268
+ recallStartedAt: number;
2269
+ }> {
2270
+ return this.xrayCaptureQueue.run(
2271
+ () => this.invokeRecall(prompt, sessionKey, {
2272
+ ...options,
2273
+ xrayCapture: true,
2274
+ }),
2275
+ {
2276
+ read: () => this.getLastXraySnapshot(),
2277
+ clear: () => this.clearLastXraySnapshot(),
2278
+ restore: (snapshot) => { this.lastXraySnapshot = snapshot; },
2279
+ },
2280
+ queueAbortSignal,
2281
+ );
2282
+ }
2283
+
2216
2284
  async waitForDirectAnswerObservationIdle(
2217
2285
  timeoutMs: number = 60_000,
2218
2286
  ): Promise<boolean> {
@@ -1,5 +1,6 @@
1
1
  import { buildEvidencePack, type EvidencePackItem } from "./evidence-pack.js";
2
2
  import type { ExplicitCueRecallEngine } from "./explicit-cue-recall.js";
3
+ import { isSameLcmRow, lcmEvidenceIdentity } from "./lcm/evidence-identity.js";
3
4
  import {
4
5
  gatherAcrossReadSessions,
5
6
  resolveLcmReadSessionIds,
@@ -252,19 +253,26 @@ async function collectGuidanceItems(
252
253
  searchWindowTokens,
253
254
  );
254
255
  const candidates = expanded.length > 0
255
- ? expanded.map((message) => ({
256
- id: `${result.session_id}:${message.turn_index}`,
257
- sessionId: result.session_id,
258
- turnIndex: message.turn_index,
259
- role: message.role,
260
- content: message.content,
261
- ...(message.turn_index === result.turn_index &&
262
- typeof result.score === "number"
263
- ? { score: result.score }
264
- : {}),
265
- }))
256
+ ? expanded.map((message) => {
257
+ const matchesSearchHit = isSameLcmRow(
258
+ message,
259
+ result.session_id,
260
+ result,
261
+ result.session_id,
262
+ );
263
+ return {
264
+ ...lcmEvidenceIdentity(message, result.session_id),
265
+ sessionId: message.session_id ?? result.session_id,
266
+ turnIndex: message.turn_index,
267
+ role: message.role,
268
+ content: message.content,
269
+ ...(matchesSearchHit && typeof result.score === "number"
270
+ ? { score: result.score }
271
+ : {}),
272
+ };
273
+ })
266
274
  : [{
267
- id: `${result.session_id}:${result.turn_index}`,
275
+ ...lcmEvidenceIdentity(result, result.session_id),
268
276
  sessionId: result.session_id,
269
277
  turnIndex: result.turn_index,
270
278
  role: result.role,
@@ -331,8 +339,8 @@ async function collectGuidanceScanItems(
331
339
  for (const message of messages) {
332
340
  if (!isGuidanceEvidence(message.content, options.query, intents, options.forceGeneric === true)) continue;
333
341
  items.push({
334
- id: `${options.sessionId}:${message.turn_index}`,
335
- sessionId: options.sessionId,
342
+ ...lcmEvidenceIdentity(message, options.sessionId),
343
+ sessionId: message.session_id ?? options.sessionId,
336
344
  turnIndex: message.turn_index,
337
345
  role: message.role,
338
346
  content: message.content,
@@ -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
  import {
13
14
  unifiedDedupeAndRank,
@@ -124,27 +125,35 @@ async function collectTargetedFactSearchItems(
124
125
  result.turn_index + searchWindowAfter,
125
126
  searchWindowTokens,
126
127
  );
128
+ const searchIdentity = lcmEvidenceIdentity(result, result.session_id);
127
129
  const searchHit: EvidencePackItem = {
128
- id: `${result.session_id}:${result.turn_index}`,
130
+ ...searchIdentity,
129
131
  sessionId: result.session_id,
130
132
  turnIndex: result.turn_index,
131
133
  role: result.role,
132
134
  content: result.content,
133
135
  ...(typeof result.score === "number" ? { score: result.score } : {}),
134
136
  };
135
- const candidates: EvidencePackItem[] = expanded.map((message) => ({
136
- id: `${result.session_id}:${message.turn_index}`,
137
- sessionId: result.session_id,
138
- turnIndex: message.turn_index,
139
- role: message.role,
140
- content: message.content,
141
- ...(message.turn_index === result.turn_index &&
142
- typeof result.score === "number"
143
- ? { score: result.score }
144
- : {}),
145
- }));
146
- const hitIndex = candidates.findIndex((candidate) =>
147
- candidate.turnIndex === result.turn_index
137
+ const candidates: EvidencePackItem[] = expanded.map((message) => {
138
+ const matchesSearchHit = isSameLcmRow(
139
+ message,
140
+ result.session_id,
141
+ result,
142
+ result.session_id,
143
+ );
144
+ return {
145
+ ...lcmEvidenceIdentity(message, result.session_id),
146
+ sessionId: message.session_id ?? result.session_id,
147
+ turnIndex: message.turn_index,
148
+ role: message.role,
149
+ content: message.content,
150
+ ...(matchesSearchHit && typeof result.score === "number"
151
+ ? { score: result.score }
152
+ : {}),
153
+ };
154
+ });
155
+ const hitIndex = expanded.findIndex((message) =>
156
+ isSameLcmRow(message, result.session_id, result, result.session_id)
148
157
  );
149
158
  if (hitIndex >= 0) {
150
159
  candidates[hitIndex] = searchHit;
@@ -200,8 +209,8 @@ async function collectTargetedFactScanItems(
200
209
  for (const message of messages) {
201
210
  if (!isTargetedFactEvidence(message.content, options.query)) continue;
202
211
  items.push({
203
- id: `${options.sessionId}:${message.turn_index}`,
204
- sessionId: options.sessionId,
212
+ ...lcmEvidenceIdentity(message, options.sessionId),
213
+ sessionId: message.session_id ?? options.sessionId,
205
214
  turnIndex: message.turn_index,
206
215
  role: message.role,
207
216
  content: message.content,