@remnic/core 9.6.27 → 9.6.29
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.
- package/dist/access-admin-ops-surface.d.ts +2 -2
- package/dist/access-admin-ops-surface.js +1 -1
- package/dist/access-boundary.d.ts +2 -2
- package/dist/access-boundary.js +2 -2
- package/dist/access-cli.js +9 -9
- package/dist/access-http.d.ts +2 -2
- package/dist/access-http.js +6 -6
- package/dist/access-identity-continuity-surface.d.ts +1 -1
- package/dist/access-identity-continuity-surface.js +1 -1
- package/dist/access-lcm-surface.d.ts +2 -2
- package/dist/access-lcm-surface.js +1 -1
- package/dist/access-mcp.d.ts +2 -2
- package/dist/access-mcp.js +5 -5
- package/dist/access-observe-write-surface.d.ts +2 -2
- package/dist/access-observe-write-surface.js +1 -1
- package/dist/access-operations-batch.js +3 -3
- package/dist/access-operations.d.ts +2 -2
- package/dist/access-operations.js +4 -4
- package/dist/access-recall-surface.d.ts +4 -3
- package/dist/access-recall-surface.js +1 -1
- package/dist/{access-service-DK7s3wMj.d.ts → access-service-BofDzaPT.d.ts} +3 -2
- package/dist/access-service.d.ts +2 -2
- package/dist/access-service.js +1 -1
- package/dist/access-surface-catalog.d.ts +2 -2
- package/dist/bootstrap.d.ts +1 -1
- package/dist/{chunk-43VNWTBB.js → chunk-3RROR5KV.js} +4 -4
- package/dist/chunk-3RROR5KV.js.map +1 -0
- package/dist/{chunk-Y4RKAQZ7.js → chunk-5AJIDEFT.js} +7 -1
- package/dist/chunk-5AJIDEFT.js.map +1 -0
- package/dist/{chunk-YDF4HL3W.js → chunk-5BHIGBHQ.js} +30 -37
- package/dist/chunk-5BHIGBHQ.js.map +1 -0
- package/dist/{chunk-XMNVHYI6.js → chunk-5WJ4LBFR.js} +107 -7
- package/dist/chunk-5WJ4LBFR.js.map +1 -0
- package/dist/{chunk-N6VIXDT7.js → chunk-C2SEH754.js} +4 -4
- package/dist/{chunk-MRQN5Q3I.js → chunk-EAREADWV.js} +2 -2
- package/dist/{chunk-GXLFDLX2.js → chunk-IF7THCGJ.js} +3 -3
- package/dist/{chunk-ARDRBJHD.js → chunk-SGYTQKC2.js} +3 -3
- package/dist/{chunk-QLYLTKUG.js → chunk-XA4PWKBW.js} +3 -3
- package/dist/{cli-CLSdmOVU.d.ts → cli-3_x5sJTL.d.ts} +2 -2
- package/dist/cli.d.ts +3 -3
- package/dist/cli.js +7 -7
- package/dist/explicit-capture.d.ts +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.js +9 -9
- package/dist/lcm/engine.d.ts +10 -6
- package/dist/lcm/engine.js +1 -1
- package/dist/lcm/index.d.ts +1 -1
- package/dist/lcm/index.js +1 -1
- package/dist/mcp-memory-inspector-app.d.ts +2 -2
- package/dist/{orchestrator-BzXGNHco.d.ts → orchestrator-CAH5-PKN.d.ts} +20 -3
- package/dist/orchestrator.d.ts +1 -1
- package/dist/orchestrator.js +9 -9
- package/package.json +2 -2
- package/src/access-recall-surface.ts +46 -49
- package/src/access-service.ts +2 -7
- package/src/cli.ts +4 -10
- package/src/lcm/engine.ts +17 -3
- package/src/lcm/index.ts +6 -1
- package/src/lcm-engine.test.ts +131 -1
- package/src/orchestration/xray-capture-queue.test.ts +42 -0
- package/src/orchestration/xray-capture-queue.ts +78 -0
- package/src/orchestrator-xray-capture.test.ts +319 -0
- package/src/orchestrator.ts +76 -8
- package/dist/chunk-43VNWTBB.js.map +0 -1
- package/dist/chunk-XMNVHYI6.js.map +0 -1
- package/dist/chunk-Y4RKAQZ7.js.map +0 -1
- package/dist/chunk-YDF4HL3W.js.map +0 -1
- /package/dist/{chunk-N6VIXDT7.js.map → chunk-C2SEH754.js.map} +0 -0
- /package/dist/{chunk-MRQN5Q3I.js.map → chunk-EAREADWV.js.map} +0 -0
- /package/dist/{chunk-GXLFDLX2.js.map → chunk-IF7THCGJ.js.map} +0 -0
- /package/dist/{chunk-ARDRBJHD.js.map → chunk-SGYTQKC2.js.map} +0 -0
- /package/dist/{chunk-QLYLTKUG.js.map → chunk-XA4PWKBW.js.map} +0 -0
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { createHash } from "node:crypto";
|
|
15
15
|
import * as nodePath from "node:path";
|
|
16
16
|
import { AccessAuditAdapter, type AccessAuditResult } from "./access-audit.js";
|
|
17
|
+
import { throwIfAborted } from "./abort-error.js";
|
|
17
18
|
import { resolveNamespaceCapabilities } from "./capabilities.js";
|
|
18
19
|
import { resolveCodingNamespaceOverlay } from "./coding/coding-namespace.js";
|
|
19
20
|
import { type BudgetDecision, CrossNamespaceBudget } from "./cross-namespace-budget.js";
|
|
@@ -173,7 +174,6 @@ export interface AccessRecallSurfaceDeps {
|
|
|
173
174
|
primaryNamespace: string,
|
|
174
175
|
recallNamespaces?: readonly string[],
|
|
175
176
|
): Promise<{ storage: StorageManager; dir: string } | null>;
|
|
176
|
-
xrayQueue: Promise<void>;
|
|
177
177
|
}
|
|
178
178
|
|
|
179
179
|
export class AccessRecallSurface {
|
|
@@ -1117,6 +1117,8 @@ export class AccessRecallSurface {
|
|
|
1117
1117
|
* regular X-ray API/CLI/MCP surfaces keep their existing payload shape.
|
|
1118
1118
|
*/
|
|
1119
1119
|
includeRecall?: boolean;
|
|
1120
|
+
/** Cancel the capture before it starts and propagate cancellation to recall. */
|
|
1121
|
+
abortSignal?: AbortSignal;
|
|
1120
1122
|
}): Promise<{
|
|
1121
1123
|
snapshotFound: boolean;
|
|
1122
1124
|
snapshot?: RecallXraySnapshot;
|
|
@@ -1195,21 +1197,9 @@ export class AccessRecallSurface {
|
|
|
1195
1197
|
const mode = this.deps.normalizeRecallMode(request.mode);
|
|
1196
1198
|
const disclosure = request.disclosure ?? DEFAULT_RECALL_DISCLOSURE;
|
|
1197
1199
|
|
|
1198
|
-
//
|
|
1199
|
-
//
|
|
1200
|
-
|
|
1201
|
-
// Budget and principal are now threaded through
|
|
1202
|
-
// `RecallInvocationOptions`, so global config mutation is gone
|
|
1203
|
-
// (CLAUDE.md rule 47: no shared mutable state across async
|
|
1204
|
-
// boundaries). The mutex stays only for the snapshot-slot
|
|
1205
|
-
// ordering guarantee.
|
|
1206
|
-
const previousQueue = this.deps.xrayQueue;
|
|
1207
|
-
let release: () => void = () => {};
|
|
1208
|
-
this.deps.xrayQueue = new Promise<void>((resolve) => {
|
|
1209
|
-
release = resolve;
|
|
1210
|
-
});
|
|
1211
|
-
await previousQueue;
|
|
1212
|
-
const recallStartedAt = Date.now();
|
|
1200
|
+
// Reset when the orchestrator-owned snapshot lock is actually acquired so
|
|
1201
|
+
// queue wait is not misreported as recall latency.
|
|
1202
|
+
let recallStartedAt = Date.now();
|
|
1213
1203
|
|
|
1214
1204
|
const recallSessionKey = request.sessionKey?.trim() || undefined;
|
|
1215
1205
|
let xrayResponse: {
|
|
@@ -1217,37 +1207,46 @@ export class AccessRecallSurface {
|
|
|
1217
1207
|
snapshot?: RecallXraySnapshot;
|
|
1218
1208
|
} = { snapshotFound: false };
|
|
1219
1209
|
|
|
1220
|
-
|
|
1221
|
-
//
|
|
1222
|
-
//
|
|
1223
|
-
//
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1210
|
+
{
|
|
1211
|
+
// Capture through the orchestrator-owned critical section so every
|
|
1212
|
+
// consumer of its mutable snapshot slot shares the same queue. The
|
|
1213
|
+
// returned clone remains owned by this call after the lock is released.
|
|
1214
|
+
const {
|
|
1215
|
+
snapshot: rawSnapshot,
|
|
1216
|
+
recallStartedAt: capturedRecallStartedAt,
|
|
1217
|
+
} = await this.deps.orchestrator.recallWithXrayCapture(
|
|
1218
|
+
query,
|
|
1219
|
+
recallSessionKey,
|
|
1220
|
+
{
|
|
1221
|
+
...(requestedNamespace ? { namespace: requestedNamespace } : {}),
|
|
1222
|
+
...(budgetOverride !== undefined
|
|
1223
|
+
? { budgetCharsOverride: budgetOverride }
|
|
1224
|
+
: {}),
|
|
1225
|
+
...(mode !== undefined ? { mode } : {}),
|
|
1226
|
+
// When the caller supplies an authenticated principal, forward
|
|
1227
|
+
// it via the dedicated override channel so orchestrator-side
|
|
1228
|
+
// ACL decisions use the SAME principal the access-surface
|
|
1229
|
+
// pre-check above authorized. Threading an
|
|
1230
|
+
// `authenticatedPrincipal` through `sessionKey` would be wrong:
|
|
1231
|
+
// `resolvePrincipal(sessionKey)` only maps configured raw
|
|
1232
|
+
// session keys and otherwise collapses to `"default"`, which
|
|
1233
|
+
// in namespace-enabled deployments produces false denials /
|
|
1234
|
+
// wrong-scope serving despite the pre-check passing
|
|
1235
|
+
// (CLAUDE.md rule 42).
|
|
1236
|
+
...(authenticatedPrincipal
|
|
1237
|
+
? { principalOverride: authenticatedPrincipal }
|
|
1238
|
+
: {}),
|
|
1239
|
+
...(request.currentContextScopes !== undefined
|
|
1240
|
+
? { currentContextScopes: request.currentContextScopes }
|
|
1241
|
+
: {}),
|
|
1242
|
+
...(request.abortSignal ? { abortSignal: request.abortSignal } : {}),
|
|
1243
|
+
},
|
|
1244
|
+
);
|
|
1245
|
+
recallStartedAt = capturedRecallStartedAt;
|
|
1246
|
+
// Cancellation covers queueing + recall/capture. Post-capture shaping is
|
|
1247
|
+
// intentionally not wired for mid-I/O abort in this change, but do not
|
|
1248
|
+
// begin that work after the caller has already canceled.
|
|
1249
|
+
throwIfAborted(request.abortSignal, "recall X-ray aborted before postprocessing");
|
|
1251
1250
|
// Re-check namespace after capture: the recall may have served
|
|
1252
1251
|
// from a different namespace than the caller requested. Drop
|
|
1253
1252
|
// the snapshot rather than leak cross-tenant data (CLAUDE.md
|
|
@@ -1483,8 +1482,6 @@ export class AccessRecallSurface {
|
|
|
1483
1482
|
};
|
|
1484
1483
|
}
|
|
1485
1484
|
}
|
|
1486
|
-
} finally {
|
|
1487
|
-
release();
|
|
1488
1485
|
}
|
|
1489
1486
|
|
|
1490
1487
|
if (
|
package/src/access-service.ts
CHANGED
|
@@ -2917,6 +2917,8 @@ export class EngramAccessService {
|
|
|
2917
2917
|
* regular X-ray API/CLI/MCP surfaces keep their existing payload shape.
|
|
2918
2918
|
*/
|
|
2919
2919
|
includeRecall?: boolean;
|
|
2920
|
+
/** Cancel the capture before it starts and propagate cancellation to recall. */
|
|
2921
|
+
abortSignal?: AbortSignal;
|
|
2920
2922
|
}): Promise<{
|
|
2921
2923
|
snapshotFound: boolean;
|
|
2922
2924
|
snapshot?: RecallXraySnapshot;
|
|
@@ -2926,12 +2928,6 @@ export class EngramAccessService {
|
|
|
2926
2928
|
request,
|
|
2927
2929
|
);
|
|
2928
2930
|
}
|
|
2929
|
-
// Sequence lock for `recallXray` — see comment inside the method.
|
|
2930
|
-
// Lives on the instance so every x-ray call on the same service
|
|
2931
|
-
// shares it, and so separate services in the same process (e.g.
|
|
2932
|
-
// per-tenant) do not block each other.
|
|
2933
|
-
private xrayQueue: Promise<void> = Promise.resolve();
|
|
2934
|
-
|
|
2935
2931
|
async memoryStore(
|
|
2936
2932
|
request: EngramAccessMemoryStoreRequest,
|
|
2937
2933
|
hooks?: { enforceWriteQuota?: () => void | Promise<void> },
|
|
@@ -5906,4 +5902,3 @@ export class EngramAccessService {
|
|
|
5906
5902
|
);
|
|
5907
5903
|
}
|
|
5908
5904
|
}
|
|
5909
|
-
|
package/src/cli.ts
CHANGED
|
@@ -5756,16 +5756,10 @@ export function registerCli(
|
|
|
5756
5756
|
args[0],
|
|
5757
5757
|
(args[1] ?? {}) as Record<string, unknown>,
|
|
5758
5758
|
);
|
|
5759
|
-
//
|
|
5760
|
-
//
|
|
5761
|
-
//
|
|
5762
|
-
//
|
|
5763
|
-
// sequence races with concurrent callers (e.g., a gateway
|
|
5764
|
-
// agent hitting the same orchestrator) and could swap in
|
|
5765
|
-
// their snapshot mid-flight, or our capture could overwrite
|
|
5766
|
-
// theirs (cursor Medium + codex P1 review on #597). The
|
|
5767
|
-
// service enforces CLAUDE.md rules 40 (serialized state) and
|
|
5768
|
-
// 47 (no shared mutable state across async boundaries).
|
|
5759
|
+
// Delegate through `EngramAccessService` to the orchestrator's atomic
|
|
5760
|
+
// capture API, so CLI, HTTP, and MCP callers sharing an orchestrator
|
|
5761
|
+
// also share one X-ray ordering domain and receive the snapshot owned
|
|
5762
|
+
// by their invocation.
|
|
5769
5763
|
const xrayService = new EngramAccessService(orchestrator);
|
|
5770
5764
|
const response = await xrayService.recallXray({
|
|
5771
5765
|
query: parsed.query,
|
package/src/lcm/engine.ts
CHANGED
|
@@ -23,6 +23,15 @@ export interface LcmEngineConfig {
|
|
|
23
23
|
messagePartsRecallMaxResults: number;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/** An expanded archive row with stable lineage identity; content may be budget-truncated. */
|
|
27
|
+
export interface LcmExpandedMessage {
|
|
28
|
+
id: number;
|
|
29
|
+
session_id: string;
|
|
30
|
+
turn_index: number;
|
|
31
|
+
role: string;
|
|
32
|
+
content: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
26
35
|
function positiveInteger(value: unknown, fallback: number, min = 1): number {
|
|
27
36
|
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
|
|
28
37
|
return Math.max(min, Math.floor(value));
|
|
@@ -526,7 +535,7 @@ export class LcmEngine {
|
|
|
526
535
|
fromTurn: number,
|
|
527
536
|
toTurn: number,
|
|
528
537
|
maxTokens: number,
|
|
529
|
-
): Promise<
|
|
538
|
+
): Promise<LcmExpandedMessage[]> {
|
|
530
539
|
if (!this.config.enabled) return [];
|
|
531
540
|
const normalizedSessionId = normalizeLcmSessionId(sessionId);
|
|
532
541
|
if (!normalizedSessionId) return [];
|
|
@@ -542,6 +551,8 @@ export class LcmEngine {
|
|
|
542
551
|
|
|
543
552
|
if (totalChars <= maxChars) {
|
|
544
553
|
return messages.map((m) => ({
|
|
554
|
+
id: m.id,
|
|
555
|
+
session_id: m.session_id,
|
|
545
556
|
turn_index: m.turn_index,
|
|
546
557
|
role: m.role,
|
|
547
558
|
content: m.content,
|
|
@@ -549,8 +560,7 @@ export class LcmEngine {
|
|
|
549
560
|
}
|
|
550
561
|
|
|
551
562
|
// Keep first and last messages, truncate from middle
|
|
552
|
-
const result:
|
|
553
|
-
[];
|
|
563
|
+
const result: LcmExpandedMessage[] = [];
|
|
554
564
|
let budget = maxChars;
|
|
555
565
|
|
|
556
566
|
// Reserve space for the last message
|
|
@@ -567,6 +577,8 @@ export class LcmEngine {
|
|
|
567
577
|
const m = messages[i];
|
|
568
578
|
const truncated = m.content.slice(0, budget);
|
|
569
579
|
result.push({
|
|
580
|
+
id: m.id,
|
|
581
|
+
session_id: m.session_id,
|
|
570
582
|
turn_index: m.turn_index,
|
|
571
583
|
role: m.role,
|
|
572
584
|
content: truncated,
|
|
@@ -576,6 +588,8 @@ export class LcmEngine {
|
|
|
576
588
|
|
|
577
589
|
// Always append the last message
|
|
578
590
|
result.push({
|
|
591
|
+
id: lastMsg.id,
|
|
592
|
+
session_id: lastMsg.session_id,
|
|
579
593
|
turn_index: lastMsg.turn_index,
|
|
580
594
|
role: lastMsg.role,
|
|
581
595
|
content: lastMsg.content.slice(0, lastMsgChars + Math.max(0, budget)),
|
package/src/lcm/index.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export {
|
|
2
|
+
LcmEngine,
|
|
3
|
+
extractLcmConfig,
|
|
4
|
+
type LcmEngineConfig,
|
|
5
|
+
type LcmExpandedMessage,
|
|
6
|
+
} from "./engine.js";
|
|
2
7
|
export { LcmArchive, estimateTokens } from "./archive.js";
|
|
3
8
|
export { LcmDag, type SummaryNode } from "./dag.js";
|
|
4
9
|
export { LcmSummarizer, type SummarizeFn } from "./summarizer.js";
|
package/src/lcm-engine.test.ts
CHANGED
|
@@ -4,7 +4,8 @@ import os from "node:os";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import test from "node:test";
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { LcmArchive } from "./lcm/archive.js";
|
|
8
|
+
import { LcmEngine, extractLcmConfig } from "./lcm/engine.js";
|
|
8
9
|
import { openLcmDatabase } from "./lcm/schema.js";
|
|
9
10
|
import type { PluginConfig } from "./types.js";
|
|
10
11
|
|
|
@@ -546,6 +547,135 @@ test("LCM normalizes session IDs across observe, stats, and clear", async () =>
|
|
|
546
547
|
}
|
|
547
548
|
});
|
|
548
549
|
|
|
550
|
+
test("expandContext preserves distinct archive-row identity for duplicate turns", async () => {
|
|
551
|
+
const memoryDir = await mkdtemp(
|
|
552
|
+
path.join(os.tmpdir(), "engram-lcm-expand-identity-"),
|
|
553
|
+
);
|
|
554
|
+
|
|
555
|
+
try {
|
|
556
|
+
const engine = new LcmEngine(
|
|
557
|
+
createPluginConfig(memoryDir),
|
|
558
|
+
async () => "summary",
|
|
559
|
+
);
|
|
560
|
+
await engine.ensureInitialized();
|
|
561
|
+
|
|
562
|
+
const db = openLcmDatabase(memoryDir);
|
|
563
|
+
let expected: ReturnType<LcmArchive["getMessages"]>;
|
|
564
|
+
try {
|
|
565
|
+
const archive = new LcmArchive(db);
|
|
566
|
+
archive.appendMessages("session-expanded", [
|
|
567
|
+
{ turnIndex: 7, role: "user", content: "duplicate turn user" },
|
|
568
|
+
{
|
|
569
|
+
turnIndex: 7,
|
|
570
|
+
role: "assistant",
|
|
571
|
+
content: "duplicate turn assistant",
|
|
572
|
+
},
|
|
573
|
+
]);
|
|
574
|
+
expected = archive.getMessages("session-expanded", 7, 7);
|
|
575
|
+
} finally {
|
|
576
|
+
db.close();
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
assert.equal(expected.length, 2);
|
|
580
|
+
const [firstDuplicate, secondDuplicate] = expected;
|
|
581
|
+
assert.ok(firstDuplicate);
|
|
582
|
+
assert.ok(secondDuplicate);
|
|
583
|
+
assert.notEqual(firstDuplicate.id, secondDuplicate.id);
|
|
584
|
+
|
|
585
|
+
const expanded = await engine.expandContext(
|
|
586
|
+
" session-expanded ",
|
|
587
|
+
7,
|
|
588
|
+
7,
|
|
589
|
+
100,
|
|
590
|
+
);
|
|
591
|
+
|
|
592
|
+
assert.deepEqual(
|
|
593
|
+
expanded,
|
|
594
|
+
expected.map(({ id, session_id, turn_index, role, content }) => ({
|
|
595
|
+
id,
|
|
596
|
+
session_id,
|
|
597
|
+
turn_index,
|
|
598
|
+
role,
|
|
599
|
+
content,
|
|
600
|
+
})),
|
|
601
|
+
);
|
|
602
|
+
assert.deepEqual(
|
|
603
|
+
expanded.map(({ session_id }) => session_id),
|
|
604
|
+
["session-expanded", "session-expanded"],
|
|
605
|
+
);
|
|
606
|
+
} finally {
|
|
607
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
608
|
+
}
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
test("expandContext preserves first, middle, and last row identity when truncated", async () => {
|
|
612
|
+
const memoryDir = await mkdtemp(
|
|
613
|
+
path.join(os.tmpdir(), "engram-lcm-expand-truncated-identity-"),
|
|
614
|
+
);
|
|
615
|
+
|
|
616
|
+
try {
|
|
617
|
+
const engine = new LcmEngine(
|
|
618
|
+
createPluginConfig(memoryDir),
|
|
619
|
+
async () => "summary",
|
|
620
|
+
);
|
|
621
|
+
await engine.ensureInitialized();
|
|
622
|
+
|
|
623
|
+
const db = openLcmDatabase(memoryDir);
|
|
624
|
+
let archived: ReturnType<LcmArchive["getMessages"]>;
|
|
625
|
+
try {
|
|
626
|
+
const archive = new LcmArchive(db);
|
|
627
|
+
archive.appendMessages("session-truncated", [
|
|
628
|
+
{ turnIndex: 1, role: "user", content: "AAAAAAAA" },
|
|
629
|
+
{ turnIndex: 2, role: "assistant", content: "BBBBBBBB" },
|
|
630
|
+
{ turnIndex: 3, role: "user", content: "CCCCCCCC" },
|
|
631
|
+
{ turnIndex: 4, role: "assistant", content: "DDDDDDDD" },
|
|
632
|
+
]);
|
|
633
|
+
archived = archive.getMessages("session-truncated", 1, 4);
|
|
634
|
+
} finally {
|
|
635
|
+
db.close();
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
assert.equal(archived.length, 4);
|
|
639
|
+
const [first, middle, , last] = archived;
|
|
640
|
+
assert.ok(first);
|
|
641
|
+
assert.ok(middle);
|
|
642
|
+
assert.ok(last);
|
|
643
|
+
|
|
644
|
+
const expanded = await engine.expandContext(
|
|
645
|
+
" session-truncated ",
|
|
646
|
+
1,
|
|
647
|
+
4,
|
|
648
|
+
5,
|
|
649
|
+
);
|
|
650
|
+
|
|
651
|
+
assert.deepEqual(expanded, [
|
|
652
|
+
{
|
|
653
|
+
id: first.id,
|
|
654
|
+
session_id: "session-truncated",
|
|
655
|
+
turn_index: 1,
|
|
656
|
+
role: "user",
|
|
657
|
+
content: "AAAAAAAA",
|
|
658
|
+
},
|
|
659
|
+
{
|
|
660
|
+
id: middle.id,
|
|
661
|
+
session_id: "session-truncated",
|
|
662
|
+
turn_index: 2,
|
|
663
|
+
role: "assistant",
|
|
664
|
+
content: "BBBBBB",
|
|
665
|
+
},
|
|
666
|
+
{
|
|
667
|
+
id: last.id,
|
|
668
|
+
session_id: "session-truncated",
|
|
669
|
+
turn_index: 4,
|
|
670
|
+
role: "assistant",
|
|
671
|
+
content: "DDDDDD",
|
|
672
|
+
},
|
|
673
|
+
]);
|
|
674
|
+
} finally {
|
|
675
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
676
|
+
}
|
|
677
|
+
});
|
|
678
|
+
|
|
549
679
|
test("close prevents deferred observe work from reinitializing the engine", async () => {
|
|
550
680
|
const memoryDir = await mkdtemp(
|
|
551
681
|
path.join(os.tmpdir(), "engram-lcm-engine-close-"),
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
|
|
4
|
+
import { XrayCaptureQueue } from "./xray-capture-queue.js";
|
|
5
|
+
|
|
6
|
+
test("initial snapshot read failure releases the next queued capture", async () => {
|
|
7
|
+
const queue = new XrayCaptureQueue();
|
|
8
|
+
let failNextRead = true;
|
|
9
|
+
let snapshot: string | null = "prior";
|
|
10
|
+
const operations: string[] = [];
|
|
11
|
+
const state = {
|
|
12
|
+
read(): string | null {
|
|
13
|
+
if (failNextRead) {
|
|
14
|
+
failNextRead = false;
|
|
15
|
+
throw new Error("snapshot clone failed");
|
|
16
|
+
}
|
|
17
|
+
return snapshot;
|
|
18
|
+
},
|
|
19
|
+
clear(): void {
|
|
20
|
+
snapshot = null;
|
|
21
|
+
},
|
|
22
|
+
restore(value: string | null): void {
|
|
23
|
+
snapshot = value;
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const failed = queue.run(async () => {
|
|
28
|
+
operations.push("failed");
|
|
29
|
+
return "unexpected";
|
|
30
|
+
}, state);
|
|
31
|
+
const next = queue.run(async () => {
|
|
32
|
+
operations.push("next");
|
|
33
|
+
snapshot = "fresh";
|
|
34
|
+
return "next-result";
|
|
35
|
+
}, state);
|
|
36
|
+
|
|
37
|
+
await assert.rejects(failed, /snapshot clone failed/);
|
|
38
|
+
const result = await next;
|
|
39
|
+
assert.deepEqual(operations, ["next"]);
|
|
40
|
+
assert.equal(result.result, "next-result");
|
|
41
|
+
assert.equal(result.snapshot, "fresh");
|
|
42
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { abortError, throwIfAborted } from "../abort-error.js";
|
|
2
|
+
|
|
3
|
+
export interface AtomicCaptureState<TSnapshot> {
|
|
4
|
+
read(): TSnapshot | null;
|
|
5
|
+
clear(): void;
|
|
6
|
+
restore(snapshot: TSnapshot | null): void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface AtomicCaptureResult<TResult, TSnapshot> {
|
|
10
|
+
result: TResult;
|
|
11
|
+
snapshot: TSnapshot | null;
|
|
12
|
+
recallStartedAt: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Per-owner abortable FIFO for operations that publish through one mutable slot. */
|
|
16
|
+
export class XrayCaptureQueue {
|
|
17
|
+
private tail: Promise<void> = Promise.resolve();
|
|
18
|
+
|
|
19
|
+
async run<TResult, TSnapshot>(
|
|
20
|
+
operation: () => Promise<TResult>,
|
|
21
|
+
state: AtomicCaptureState<TSnapshot>,
|
|
22
|
+
signal?: AbortSignal,
|
|
23
|
+
): Promise<AtomicCaptureResult<TResult, TSnapshot>> {
|
|
24
|
+
throwIfAborted(signal, "x-ray capture aborted before queueing");
|
|
25
|
+
const previous = this.tail;
|
|
26
|
+
let release: () => void = () => {};
|
|
27
|
+
this.tail = new Promise<void>((resolve) => {
|
|
28
|
+
release = resolve;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
await this.waitForTurn(previous, signal);
|
|
33
|
+
} catch (error) {
|
|
34
|
+
// Keep this abandoned node as a barrier until its predecessor settles,
|
|
35
|
+
// so later callers cannot overtake the still-active operation.
|
|
36
|
+
void previous.then(release, release);
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let previousSnapshot: TSnapshot | null = null;
|
|
41
|
+
let didReadPreviousSnapshot = false;
|
|
42
|
+
try {
|
|
43
|
+
previousSnapshot = state.read();
|
|
44
|
+
didReadPreviousSnapshot = true;
|
|
45
|
+
const recallStartedAt = Date.now();
|
|
46
|
+
state.clear();
|
|
47
|
+
const result = await operation();
|
|
48
|
+
const snapshot = state.read();
|
|
49
|
+
if (!snapshot) state.restore(previousSnapshot);
|
|
50
|
+
return { result, snapshot, recallStartedAt };
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (didReadPreviousSnapshot) state.restore(previousSnapshot);
|
|
53
|
+
throw error;
|
|
54
|
+
} finally {
|
|
55
|
+
release();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private async waitForTurn(previous: Promise<void>, signal?: AbortSignal): Promise<void> {
|
|
60
|
+
throwIfAborted(signal, "x-ray capture aborted while queued");
|
|
61
|
+
if (!signal) {
|
|
62
|
+
await previous;
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let onAbort: (() => void) | undefined;
|
|
67
|
+
const aborted = new Promise<never>((_resolve, reject) => {
|
|
68
|
+
onAbort = () => reject(abortError("x-ray capture aborted while queued"));
|
|
69
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
70
|
+
});
|
|
71
|
+
try {
|
|
72
|
+
await Promise.race([previous, aborted]);
|
|
73
|
+
throwIfAborted(signal, "x-ray capture aborted while queued");
|
|
74
|
+
} finally {
|
|
75
|
+
if (onAbort) signal.removeEventListener("abort", onAbort);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|