@remnic/core 9.3.714 → 9.3.716

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 (39) hide show
  1. package/dist/access-boundary.d.ts +2 -2
  2. package/dist/access-cli.js +2 -2
  3. package/dist/access-http.d.ts +2 -2
  4. package/dist/access-mcp.d.ts +2 -2
  5. package/dist/access-operations.d.ts +2 -2
  6. package/dist/{access-service-lddjZoRh.d.ts → access-service-BW8cm_uU.d.ts} +1 -1
  7. package/dist/access-service.d.ts +2 -2
  8. package/dist/access-surface-catalog.d.ts +2 -2
  9. package/dist/bootstrap.d.ts +1 -1
  10. package/dist/{chunk-OQH5XUH3.js → chunk-Q6IP2ARR.js} +2 -5
  11. package/dist/chunk-Q6IP2ARR.js.map +1 -0
  12. package/dist/{chunk-FXZKXW7C.js → chunk-SQ4LU27I.js} +48 -20
  13. package/dist/chunk-SQ4LU27I.js.map +1 -0
  14. package/dist/{cli-iAiVkbYJ.d.ts → cli-Dpivy32W.d.ts} +2 -2
  15. package/dist/cli.d.ts +3 -3
  16. package/dist/direct-answer-wiring.d.ts +9 -9
  17. package/dist/direct-answer-wiring.js +1 -1
  18. package/dist/explicit-capture.d.ts +1 -1
  19. package/dist/index.d.ts +4 -4
  20. package/dist/index.js +2 -2
  21. package/dist/mcp-memory-inspector-app.d.ts +2 -2
  22. package/dist/{orchestrator-iVZWi8aW.d.ts → orchestrator-vygo2YIM.d.ts} +15 -5
  23. package/dist/orchestrator.d.ts +1 -1
  24. package/dist/orchestrator.js +2 -2
  25. package/dist/recall-planner-llm.d.ts +1 -1
  26. package/dist/recall-planner-llm.js +2 -2
  27. package/dist/recall-planner-llm.js.map +1 -1
  28. package/dist/schemas.d.ts +22 -22
  29. package/dist/transfer/types.d.ts +12 -12
  30. package/package.json +2 -2
  31. package/src/capabilities.test.ts +28 -0
  32. package/src/direct-answer-wiring.test.ts +20 -13
  33. package/src/direct-answer-wiring.ts +9 -13
  34. package/src/faithfulness-graph-guard.test.ts +214 -8
  35. package/src/orchestrator.ts +68 -27
  36. package/src/recall-planner-llm.test.ts +30 -11
  37. package/src/recall-planner-llm.ts +4 -5
  38. package/dist/chunk-FXZKXW7C.js.map +0 -1
  39. package/dist/chunk-OQH5XUH3.js.map +0 -1
@@ -313,13 +313,13 @@ declare const CapsuleBlockSchema: z.ZodObject<{
313
313
  peerProfiles: boolean;
314
314
  }>;
315
315
  }, "strip", z.ZodTypeAny, {
316
- schemaVersion: string;
317
316
  includes: {
318
317
  procedural: boolean;
319
318
  taxonomy: boolean;
320
319
  identityAnchors: boolean;
321
320
  peerProfiles: boolean;
322
321
  };
322
+ schemaVersion: string;
323
323
  id: string;
324
324
  version: string;
325
325
  description: string;
@@ -334,13 +334,13 @@ declare const CapsuleBlockSchema: z.ZodObject<{
334
334
  directAnswerEnabled: boolean;
335
335
  };
336
336
  }, {
337
- schemaVersion: string;
338
337
  includes: {
339
338
  procedural: boolean;
340
339
  taxonomy: boolean;
341
340
  identityAnchors: boolean;
342
341
  peerProfiles: boolean;
343
342
  };
343
+ schemaVersion: string;
344
344
  id: string;
345
345
  version: string;
346
346
  description: string;
@@ -464,13 +464,13 @@ declare const ExportManifestV2Schema: z.ZodObject<{
464
464
  peerProfiles: boolean;
465
465
  }>;
466
466
  }, "strip", z.ZodTypeAny, {
467
- schemaVersion: string;
468
467
  includes: {
469
468
  procedural: boolean;
470
469
  taxonomy: boolean;
471
470
  identityAnchors: boolean;
472
471
  peerProfiles: boolean;
473
472
  };
473
+ schemaVersion: string;
474
474
  id: string;
475
475
  version: string;
476
476
  description: string;
@@ -485,13 +485,13 @@ declare const ExportManifestV2Schema: z.ZodObject<{
485
485
  directAnswerEnabled: boolean;
486
486
  };
487
487
  }, {
488
- schemaVersion: string;
489
488
  includes: {
490
489
  procedural: boolean;
491
490
  taxonomy: boolean;
492
491
  identityAnchors: boolean;
493
492
  peerProfiles: boolean;
494
493
  };
494
+ schemaVersion: string;
495
495
  id: string;
496
496
  version: string;
497
497
  description: string;
@@ -518,13 +518,13 @@ declare const ExportManifestV2Schema: z.ZodObject<{
518
518
  pluginVersion: string;
519
519
  includesTranscripts: boolean;
520
520
  capsule: {
521
- schemaVersion: string;
522
521
  includes: {
523
522
  procedural: boolean;
524
523
  taxonomy: boolean;
525
524
  identityAnchors: boolean;
526
525
  peerProfiles: boolean;
527
526
  };
527
+ schemaVersion: string;
528
528
  id: string;
529
529
  version: string;
530
530
  description: string;
@@ -551,13 +551,13 @@ declare const ExportManifestV2Schema: z.ZodObject<{
551
551
  pluginVersion: string;
552
552
  includesTranscripts: boolean;
553
553
  capsule: {
554
- schemaVersion: string;
555
554
  includes: {
556
555
  procedural: boolean;
557
556
  taxonomy: boolean;
558
557
  identityAnchors: boolean;
559
558
  peerProfiles: boolean;
560
559
  };
560
+ schemaVersion: string;
561
561
  id: string;
562
562
  version: string;
563
563
  description: string;
@@ -683,13 +683,13 @@ declare const ExportBundleV2Schema: z.ZodObject<{
683
683
  peerProfiles: boolean;
684
684
  }>;
685
685
  }, "strip", z.ZodTypeAny, {
686
- schemaVersion: string;
687
686
  includes: {
688
687
  procedural: boolean;
689
688
  taxonomy: boolean;
690
689
  identityAnchors: boolean;
691
690
  peerProfiles: boolean;
692
691
  };
692
+ schemaVersion: string;
693
693
  id: string;
694
694
  version: string;
695
695
  description: string;
@@ -704,13 +704,13 @@ declare const ExportBundleV2Schema: z.ZodObject<{
704
704
  directAnswerEnabled: boolean;
705
705
  };
706
706
  }, {
707
- schemaVersion: string;
708
707
  includes: {
709
708
  procedural: boolean;
710
709
  taxonomy: boolean;
711
710
  identityAnchors: boolean;
712
711
  peerProfiles: boolean;
713
712
  };
713
+ schemaVersion: string;
714
714
  id: string;
715
715
  version: string;
716
716
  description: string;
@@ -737,13 +737,13 @@ declare const ExportBundleV2Schema: z.ZodObject<{
737
737
  pluginVersion: string;
738
738
  includesTranscripts: boolean;
739
739
  capsule: {
740
- schemaVersion: string;
741
740
  includes: {
742
741
  procedural: boolean;
743
742
  taxonomy: boolean;
744
743
  identityAnchors: boolean;
745
744
  peerProfiles: boolean;
746
745
  };
746
+ schemaVersion: string;
747
747
  id: string;
748
748
  version: string;
749
749
  description: string;
@@ -770,13 +770,13 @@ declare const ExportBundleV2Schema: z.ZodObject<{
770
770
  pluginVersion: string;
771
771
  includesTranscripts: boolean;
772
772
  capsule: {
773
- schemaVersion: string;
774
773
  includes: {
775
774
  procedural: boolean;
776
775
  taxonomy: boolean;
777
776
  identityAnchors: boolean;
778
777
  peerProfiles: boolean;
779
778
  };
779
+ schemaVersion: string;
780
780
  id: string;
781
781
  version: string;
782
782
  description: string;
@@ -819,13 +819,13 @@ declare const ExportBundleV2Schema: z.ZodObject<{
819
819
  pluginVersion: string;
820
820
  includesTranscripts: boolean;
821
821
  capsule: {
822
- schemaVersion: string;
823
822
  includes: {
824
823
  procedural: boolean;
825
824
  taxonomy: boolean;
826
825
  identityAnchors: boolean;
827
826
  peerProfiles: boolean;
828
827
  };
828
+ schemaVersion: string;
829
829
  id: string;
830
830
  version: string;
831
831
  description: string;
@@ -858,13 +858,13 @@ declare const ExportBundleV2Schema: z.ZodObject<{
858
858
  pluginVersion: string;
859
859
  includesTranscripts: boolean;
860
860
  capsule: {
861
- schemaVersion: string;
862
861
  includes: {
863
862
  procedural: boolean;
864
863
  taxonomy: boolean;
865
864
  identityAnchors: boolean;
866
865
  peerProfiles: boolean;
867
866
  };
867
+ schemaVersion: string;
868
868
  id: string;
869
869
  version: string;
870
870
  description: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remnic/core",
3
- "version": "9.3.714",
3
+ "version": "9.3.716",
4
4
  "description": "Framework-agnostic Remnic memory engine — orchestrator, storage, extraction, search, trust zones",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -2966,7 +2966,7 @@
2966
2966
  "core"
2967
2967
  ],
2968
2968
  "peerDependencies": {
2969
- "@remnic/coding-graph": "^9.3.714"
2969
+ "@remnic/coding-graph": "^9.3.716"
2970
2970
  },
2971
2971
  "peerDependenciesMeta": {
2972
2972
  "@remnic/coding-graph": {
@@ -96,6 +96,34 @@ test("resolveCapabilities returns a frozen object", () => {
96
96
  assert.equal(Object.isFrozen(caps), true, "CapabilitySet must be frozen");
97
97
  });
98
98
 
99
+ test("resolveCapabilities is deterministic — same config always yields identical gate values (one-resolution-per-op #1523)", () => {
100
+ // The one-resolution-per-op contract (issue #1523): resolve ONCE at the
101
+ // operation entry, thread the frozen result down. This test proves the
102
+ // resolution is deterministic — calling it twice with the same config
103
+ // produces identical values for every gate, so there is never a reason to
104
+ // re-resolve mid-operation.
105
+ const config = parseConfig({
106
+ rerankCacheEnabled: true,
107
+ recallDirectAnswerEnabled: false,
108
+ recallMmrEnabled: true,
109
+ recallPlannerLlmEnabled: true,
110
+ graphRecallEnabled: true,
111
+ });
112
+ const first = resolveCapabilities(config);
113
+ const second = resolveCapabilities(config);
114
+ for (const field of FIELDS) {
115
+ assert.equal(
116
+ first[field],
117
+ second[field],
118
+ `field ${field} must be deterministic across resolutions`,
119
+ );
120
+ }
121
+ // Both must be frozen — a mid-operation mutation would break the contract.
122
+ assert.equal(Object.isFrozen(first), true);
123
+ assert.equal(Object.isFrozen(second), true);
124
+ });
125
+
126
+
99
127
  // ---------------------------------------------------------------------------
100
128
  // GraphConstructionCapabilitySet — gate-parity tests (issue #1566 Cluster A).
101
129
  //
@@ -13,7 +13,6 @@ import type { TrustZoneName } from "./trust-zones.js";
13
13
  type WiringConfig = DirectAnswerWiringInput["config"];
14
14
 
15
15
  const BASE_CONFIG: WiringConfig = {
16
- recallDirectAnswerEnabled: true,
17
16
  recallDirectAnswerTokenOverlapFloor: 0.5,
18
17
  recallDirectAnswerImportanceFloor: 0.7,
19
18
  recallDirectAnswerAmbiguityMargin: 0.15,
@@ -105,17 +104,24 @@ test("tryDirectAnswer disabled-path does not call any source accessor", async ()
105
104
  assert.deepEqual(sources.calls.importance, []);
106
105
  });
107
106
 
108
- // ── Backward-compat (#1523): omit top-level `enabled`, fall back to config ──
107
+ // ── One-resolution-per-op (#1523): `enabled` is the sole gate ──────────────
109
108
 
110
- test("tryDirectAnswer falls back to config.recallDirectAnswerEnabled when `enabled` is omitted (disabled)", async () => {
111
- // Old input shape: config carries recallDirectAnswerEnabled: false and no
112
- // top-level `enabled`. Must short-circuit as "disabled" (identical to the
113
- // pre-#1523 behavior) rather than treating undefined as enabled.
114
- const sources = makeMockSources({ memories: [makeMemory()] });
109
+ test("tryDirectAnswer disabled when `enabled: false` even with matching candidates (caps wins over any config-derived value)", async () => {
110
+ // Issue #1523 contract: the resolved capability is the sole gate.
111
+ // `enabled` comes from `resolveCapabilities(config).recallDirectAnswer`,
112
+ // resolved ONCE at the recall entry. This test proves the gate value is
113
+ // authoritative when it says disabled, the function short-circuits
114
+ // regardless of candidate quality.
115
+ const sources = makeMockSources({
116
+ memories: [makeMemory({ tags: ["pnpm"], content: "remnic uses pnpm" })],
117
+ trustZones: { m1: "trusted" },
118
+ importance: { m1: 0.9 },
119
+ });
115
120
  const result = await tryDirectAnswer({
116
- query: "does not matter",
121
+ query: "package manager remnic",
117
122
  namespace: "default",
118
- config: { ...BASE_CONFIG, recallDirectAnswerEnabled: false },
123
+ config: BASE_CONFIG,
124
+ enabled: false,
119
125
  sources,
120
126
  });
121
127
  assert.equal(result.eligible, false);
@@ -123,9 +129,9 @@ test("tryDirectAnswer falls back to config.recallDirectAnswerEnabled when `enabl
123
129
  assert.equal(sources.calls.listCandidates, 0);
124
130
  });
125
131
 
126
- test("tryDirectAnswer falls back to config.recallDirectAnswerEnabled when `enabled` is omitted (enabled)", async () => {
127
- // Old input shape with recallDirectAnswerEnabled: true and no `enabled` must
128
- // NOT short-circuit — it proceeds to materialize candidates.
132
+ test("tryDirectAnswer proceeds when `enabled: true` and candidates match", async () => {
133
+ // Same contract, positive case: when the resolved capability says enabled,
134
+ // the function proceeds to materialize candidates.
129
135
  const sources = makeMockSources({
130
136
  memories: [makeMemory({ tags: ["pnpm"], content: "remnic uses pnpm" })],
131
137
  trustZones: { m1: "trusted" },
@@ -134,7 +140,8 @@ test("tryDirectAnswer falls back to config.recallDirectAnswerEnabled when `enabl
134
140
  const result = await tryDirectAnswer({
135
141
  query: "package manager remnic",
136
142
  namespace: "default",
137
- config: BASE_CONFIG, // recallDirectAnswerEnabled: true
143
+ config: BASE_CONFIG,
144
+ enabled: true,
138
145
  sources,
139
146
  });
140
147
  assert.notEqual(result.reason, "disabled");
@@ -14,8 +14,9 @@
14
14
  *
15
15
  * Short-circuit contract:
16
16
  *
17
- * - When the resolved gate (`input.enabled` if supplied, else
18
- * `config.recallDirectAnswerEnabled`) is `false`, the function returns the
17
+ * - When the resolved gate (`input.enabled`, projected from
18
+ * `caps.recallDirectAnswer` at the recall-operation entry) is `false`, the
19
+ * function returns the
19
20
  * eligibility verdict with reason `"disabled"` without touching any source
20
21
  * accessor. This is the documented default.
21
22
  * - When enabled, the wiring cheaply drops non-trusted-zone memories
@@ -74,7 +75,6 @@ export interface DirectAnswerWiringInput {
74
75
  namespace: string;
75
76
  config: Pick<
76
77
  PluginConfig,
77
- | "recallDirectAnswerEnabled"
78
78
  | "recallDirectAnswerTokenOverlapFloor"
79
79
  | "recallDirectAnswerImportanceFloor"
80
80
  | "recallDirectAnswerAmbiguityMargin"
@@ -82,13 +82,12 @@ export interface DirectAnswerWiringInput {
82
82
  >;
83
83
  /**
84
84
  * Direct-answer capability gate, resolved once at the recall-operation entry
85
- * (issue #1523: `caps.recallDirectAnswer`). OPTIONAL and additive: when the
86
- * caller supplies it, this module and the orchestrator agree on a single
87
- * resolved gate value for the whole operation. When omitted, we fall back to
88
- * `config.recallDirectAnswerEnabled` so existing callers on the old input
89
- * shape keep identical behavior.
85
+ * via `resolveCapabilities(config).recallDirectAnswer` (issue #1523). This
86
+ * is the sole gate the function no longer reads the config flag directly.
87
+ * Callers MUST pass the resolved capability so the whole operation agrees on
88
+ * a single gate value.
90
89
  */
91
- enabled?: boolean;
90
+ enabled: boolean;
92
91
  sources: DirectAnswerSources;
93
92
  queryEntityRefs?: string[];
94
93
  abortSignal?: AbortSignal;
@@ -105,10 +104,7 @@ export async function tryDirectAnswer(
105
104
  const { query, namespace, config, enabled, sources, queryEntityRefs, abortSignal } = input;
106
105
 
107
106
  const eligibilityConfig: DirectAnswerConfig = {
108
- // Prefer the resolved capability when supplied; fall back to the config
109
- // flag so callers on the old input shape (config-only, no `enabled`) get
110
- // identical gating (issue #1523 backward-compat).
111
- enabled: enabled ?? config.recallDirectAnswerEnabled,
107
+ enabled,
112
108
  tokenOverlapFloor: config.recallDirectAnswerTokenOverlapFloor,
113
109
  importanceFloor: config.recallDirectAnswerImportanceFloor,
114
110
  ambiguityMargin: config.recallDirectAnswerAmbiguityMargin,
@@ -25,6 +25,7 @@ import { Orchestrator } from "./orchestrator.js";
25
25
  import { readEdges } from "./graph.js";
26
26
  import type { ExtractionResult, PluginConfig } from "./types.js";
27
27
  import type { LocalLlmClient } from "./local-llm.js";
28
+ import type { BufferTurn, ConversationThread } from "./types.js";
28
29
 
29
30
  /**
30
31
  * Stub local LLM whose `chatCompletion` only answers faithfulness-gate calls
@@ -42,15 +43,20 @@ function faithfulnessStubLocalLlm(
42
43
  ) => {
43
44
  if (options.operation !== "extraction-faithfulness") return null;
44
45
  const userMsg = messages.find((m) => m.role === "user");
45
- // The prompt emits `FACT: "<text>"` fact text never contains a raw
46
- // double-quote, so capture up to the closing quote.
47
- const match = userMsg?.content.match(/FACT: "([^"]*)"/);
48
- const factContent = match ? (match[1] ?? "") : "";
49
- const verdict = verdictForFactContent(factContent);
46
+ // The batch prompt emits one `FACT: "<text>"` line per fact, each
47
+ // followed by `Respond with the JSON array entry for index <i>`. The
48
+ // gate maps verdicts back to facts by that index, so return one entry
49
+ // per fact in prompt order. (Single-fact callers still get one entry.)
50
+ const factMatches = [...(userMsg?.content ?? "").matchAll(/FACT: "([^"]*)"/g)];
51
+ const entries = factMatches.map((m, i) => ({
52
+ index: i,
53
+ verdict: verdictForFactContent(m[1] ?? ""),
54
+ rationale: "stub verdict",
55
+ }));
50
56
  return {
51
- content: JSON.stringify([
52
- { index: 0, verdict, rationale: "stub verdict" },
53
- ]),
57
+ content: JSON.stringify(
58
+ entries.length > 0 ? entries : [{ index: 0, verdict: "entailed" as const, rationale: "stub" }],
59
+ ),
54
60
  };
55
61
  },
56
62
  } as unknown as LocalLlmClient;
@@ -250,3 +256,203 @@ test("pending_review fact gets no graph edge; an active fact with the same entit
250
256
  await rm(memoryDir, { recursive: true, force: true });
251
257
  }
252
258
  });
259
+
260
+
261
+ /**
262
+ * Thread-enabled harness for issue #1635: time + causal graph ON (so
263
+ * thread-predecessor edges are built), faithfulness gate in enforce mode.
264
+ * Entity graph is left OFF to isolate the thread-episode predecessor
265
+ * mechanism (entity-sibling edges are a separate, out-of-scope path).
266
+ */
267
+ async function makeThreadHarness(): Promise<OrchHarness> {
268
+ const memoryDir = await mkdtemp(
269
+ path.join(os.tmpdir(), "remnic-faithfulness-thread-"),
270
+ );
271
+ const config: PluginConfig = parseConfig({
272
+ openaiApiKey: "sk-test",
273
+ memoryDir,
274
+ workspaceDir: path.join(memoryDir, "workspace"),
275
+ multiGraphMemoryEnabled: true,
276
+ entityGraphEnabled: false,
277
+ timeGraphEnabled: true,
278
+ causalGraphEnabled: true,
279
+ extractionFaithfulnessGate: "enforce",
280
+ extractionFaithfulnessContextChars: 400,
281
+ extractionFaithfulnessTimeoutMs: 2000,
282
+ extractionJudgeEnabled: false,
283
+ temporalSupersessionEnabled: false,
284
+ contradictionDetectionEnabled: false,
285
+ chunkingEnabled: false,
286
+ embeddingFallbackEnabled: false,
287
+ qmdEnabled: false,
288
+ extractionMinChars: 0,
289
+ extractionMinImportanceLevel: "trivial",
290
+ inlineSourceAttributionEnabled: false,
291
+ });
292
+ const orchestrator = new Orchestrator(config);
293
+ return { orchestrator, memoryDir };
294
+ }
295
+
296
+ /** ExtractionResult with two facts (order preserved by the persist loop). */
297
+ function twoFactResult(
298
+ contentA: string,
299
+ entityRefA: string,
300
+ contentB: string,
301
+ entityRefB: string,
302
+ ): ExtractionResult {
303
+ return {
304
+ facts: [
305
+ { content: contentA, category: "fact", tags: [], confidence: 0.9, entityRef: entityRefA },
306
+ { content: contentB, category: "fact", tags: [], confidence: 0.9, entityRef: entityRefB },
307
+ ],
308
+ entities: [],
309
+ relationships: [],
310
+ questions: [],
311
+ profileUpdates: [],
312
+ } as unknown as ExtractionResult;
313
+ }
314
+
315
+ interface StorageLike {
316
+ ensureDirectories: () => Promise<void>;
317
+ dir: string;
318
+ readAllMemories: () => Promise<
319
+ Array<{ path: string; frontmatter: { id: string; status?: string } }>
320
+ >;
321
+ }
322
+
323
+ interface PersistExtractionSig {
324
+ (
325
+ r: ExtractionResult,
326
+ s: unknown,
327
+ t: string | null,
328
+ ctx: unknown,
329
+ bn: unknown,
330
+ plan: unknown,
331
+ src: string,
332
+ ): Promise<string[]>;
333
+ }
334
+
335
+ test("pending_review id must NOT enter persisted thread episodes across a cross-flush (#1635)", async () => {
336
+ const { orchestrator, memoryDir } = await makeThreadHarness();
337
+ try {
338
+ // fact content containing "closed permanently" → unsupported → pending_review.
339
+ (orchestrator as unknown as { localLlm: LocalLlmClient }).localLlm =
340
+ faithfulnessStubLocalLlm((c) =>
341
+ c.includes("closed permanently") ? "unsupported" : "entailed",
342
+ );
343
+
344
+ const getStorage = orchestrator as unknown as {
345
+ getStorage: (ns: string) => Promise<StorageLike>;
346
+ };
347
+ const storage = await getStorage.getStorage("default");
348
+ await storage.ensureDirectories();
349
+
350
+ // Establish a real thread (mirrors runExtraction's processTurn step) so
351
+ // appendEpisodeIds has a thread file to write episode ids into.
352
+ const threading = orchestrator as unknown as {
353
+ threading: {
354
+ processTurn: (turn: BufferTurn, ids: string[]) => Promise<string>;
355
+ loadThread: (id: string) => Promise<ConversationThread | null>;
356
+ };
357
+ };
358
+ const seedTurn: BufferTurn = {
359
+ role: "user",
360
+ content: "Tell me about the widget factory.",
361
+ timestamp: "2026-07-06T10:00:00.000Z",
362
+ sessionKey: "agent:test:main",
363
+ };
364
+ const threadId = await threading.threading.processTurn(seedTurn, []);
365
+ assert.ok(threadId, "thread established via processTurn");
366
+
367
+ const persist = orchestrator as unknown as {
368
+ persistExtraction: PersistExtractionSig;
369
+ appendPersistedThreadEpisodes: (
370
+ threadId: string,
371
+ persistedIds: string[],
372
+ ) => Promise<void>;
373
+ };
374
+
375
+ const sourceText =
376
+ "The widget factory reopened this quarter after being closed permanently last year.";
377
+
378
+ // --- Flush 1: an active fact + a pending_review fact, same thread ---
379
+ const flush1 = await persist.persistExtraction(
380
+ twoFactResult(
381
+ "The widget factory reopened this quarter.", "wf-active",
382
+ "The widget factory closed permanently last year.", "wf-pending",
383
+ ),
384
+ storage,
385
+ threadId,
386
+ { sessionKey: "agent:test:main", principal: "test" },
387
+ undefined,
388
+ null,
389
+ sourceText,
390
+ );
391
+ const [activeId1, pendingId] = flush1;
392
+ assert.ok(activeId1, "flush1 active fact persisted");
393
+ assert.ok(pendingId, "flush1 pending_review fact persisted");
394
+
395
+ // runExtraction's append step (the real helper; filters pending_review).
396
+ await persist.appendPersistedThreadEpisodes(threadId, flush1);
397
+
398
+ // PART (a): the thread FILE must hold the active id but NOT the pending id.
399
+ const threadAfter1 = await threading.threading.loadThread(threadId);
400
+ assert.ok(threadAfter1, "thread file readable after flush 1");
401
+ assert.ok(
402
+ threadAfter1!.episodeIds.includes(activeId1),
403
+ "active fact id is in persisted thread episodes",
404
+ );
405
+ assert.ok(
406
+ !threadAfter1!.episodeIds.includes(pendingId),
407
+ "pending_review id must NOT enter persisted thread episodes (#1635)",
408
+ );
409
+
410
+ // The pending fact IS durable (with status pending_review) — proves the
411
+ // gate routed it correctly; the guard is about its absence from the thread.
412
+ const memsAfter1 = await storage.readAllMemories();
413
+ const pendingMem = memsAfter1.find((m) => m.frontmatter.id === pendingId);
414
+ assert.ok(pendingMem, "pending_review fact is durable");
415
+ assert.equal(
416
+ pendingMem!.frontmatter.status,
417
+ "pending_review",
418
+ "unsupported fact is pending_review under enforce",
419
+ );
420
+
421
+ // --- Flush 2: an active fact in the SAME thread ---
422
+ const flush2 = await persist.persistExtraction(
423
+ factResult("The widget factory hired ten new engineers.", "wf-active"),
424
+ storage,
425
+ threadId,
426
+ { sessionKey: "agent:test:main", principal: "test" },
427
+ undefined,
428
+ null,
429
+ sourceText,
430
+ );
431
+ const activeId2 = flush2[0];
432
+ assert.ok(activeId2, "flush2 active fact persisted");
433
+ await persist.appendPersistedThreadEpisodes(threadId, flush2);
434
+
435
+ // PART (a)+(b): no predecessor edge may reference the pending fact. Under
436
+ // the bug, flush 1 would have appended pendingId to the thread file, flush
437
+ // 2's loadThread would re-seed it into threadEpisodeIdsForGraph, and the
438
+ // active fact2 would build a time/causal predecessor edge to it.
439
+ const pendingPath = path.relative(memoryDir, pendingMem!.path);
440
+ for (const gtype of ["time", "causal"] as const) {
441
+ const edges = await readEdges(memoryDir, gtype);
442
+ assert.ok(
443
+ !edges.some((e) => e.from === pendingPath || e.to === pendingPath),
444
+ `no ${gtype} edge references the pending_review fact path (${pendingPath}); got ${edges.length} edges`,
445
+ );
446
+ }
447
+
448
+ // Control: the graph + threading are wired — the active fact2 builds a
449
+ // time predecessor edge to the active fact1 (which IS in the thread file).
450
+ const timeEdges = await readEdges(memoryDir, "time");
451
+ assert.ok(
452
+ timeEdges.length > 0,
453
+ "active fact2 builds a time predecessor edge (control: graph+threading wired)",
454
+ );
455
+ } finally {
456
+ await rm(memoryDir, { recursive: true, force: true });
457
+ }
458
+ });