@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
@@ -1396,12 +1396,11 @@ export async function resolveRecallModeDecisionAsync(
1396
1396
  options: RecallModeGraphOptions & {
1397
1397
  config: PluginConfig;
1398
1398
  /**
1399
- * Recall-operation capability gates (issue #1523). OPTIONAL and additive:
1400
- * the recall orchestrator passes a resolved set, but existing callers that
1401
- * only pass `config` + planner flags stay backward-compatible the LLM
1402
- * planner gate falls back to `config.recallPlannerLlmEnabled` when omitted.
1399
+ * Recall-operation capability gates (issue #1523). REQUIRED: the recall
1400
+ * orchestrator always passes a resolved set the LLM planner gate reads
1401
+ * `caps.recallPlannerLlm`, never re-derives from config.
1403
1402
  */
1404
- caps?: CapabilitySet;
1403
+ caps: CapabilitySet;
1405
1404
  hints?: string[];
1406
1405
  llm?: FallbackLlmClient;
1407
1406
  signal?: AbortSignal;
@@ -1410,10 +1409,8 @@ export async function resolveRecallModeDecisionAsync(
1410
1409
  const heuristicDecision = resolveRecallModeDecision(options);
1411
1410
 
1412
1411
  // Planner globally off, or LLM planning not opted into → heuristic only.
1413
- // Prefer the resolved capability when supplied; otherwise fall back to the
1414
- // config flag so callers on the old option shape get identical gating.
1415
- const plannerLlmEnabled =
1416
- options.caps?.recallPlannerLlm ?? options.config.recallPlannerLlmEnabled;
1412
+ // Read the resolved capability (issue #1523) never re-derive from config.
1413
+ const plannerLlmEnabled = options.caps.recallPlannerLlm;
1417
1414
  if (!options.plannerEnabled || !plannerLlmEnabled) {
1418
1415
  return heuristicDecision;
1419
1416
  }
@@ -1423,9 +1420,9 @@ export async function resolveRecallModeDecisionAsync(
1423
1420
  options.prompt,
1424
1421
  options.hints,
1425
1422
  options.config,
1423
+ options.caps,
1426
1424
  options.llm,
1427
1425
  options.signal,
1428
- options.caps,
1429
1426
  );
1430
1427
 
1431
1428
  // Shadow mode: record what the LLM would have chosen but keep the heuristic
@@ -1731,8 +1728,16 @@ export function resolveRecentThreadMemoryPaths(options: {
1731
1728
  buildMemoryPathById(options.allMemsForGraph, options.storageDir);
1732
1729
  if (pathById.size === 0) return [];
1733
1730
 
1731
+ // #1635 (defensive): skip pending_review ids from legacy episode sets.
1732
+ const pendingReviewIds = new Set<string>(
1733
+ (options.allMemsForGraph ?? [])
1734
+ .filter((m) => m.frontmatter.status === "pending_review" && m.frontmatter.id)
1735
+ .map((m) => m.frontmatter.id as string),
1736
+ );
1737
+
1734
1738
  return options.threadEpisodeIds
1735
1739
  .filter((id) => id !== options.currentMemoryId)
1740
+ .filter((id) => !pendingReviewIds.has(id))
1736
1741
  .slice(-maxRecent)
1737
1742
  .map((id) => pathById.get(id))
1738
1743
  .filter((p): p is string => typeof p === "string" && p.length > 0);
@@ -1858,6 +1863,11 @@ export class Orchestrator {
1858
1863
  * callers already destructure `persistedIds` by position.
1859
1864
  */
1860
1865
  private lastPersistExtractionDeferredCount: number = 0;
1866
+ /**
1867
+ * Side-channel (#1635): pending_review persisted ids from the last
1868
+ * persistExtraction; runExtraction excludes them from the thread episode set.
1869
+ */
1870
+ private lastPersistExtractionPendingReviewIds: string[] = [];
1861
1871
  private readonly _fastGatewayLlm: FallbackLlmClient | null;
1862
1872
 
1863
1873
  get fastGatewayLlm(): FallbackLlmClient | null {
@@ -13461,23 +13471,16 @@ export class Orchestrator {
13461
13471
  }
13462
13472
 
13463
13473
  // Batch-append persisted IDs so non-fact memories (entities/questions) are
13464
- // always attached to the thread.
13474
+ // always attached to the thread. The helper excludes pending_review ids (#1635).
13465
13475
  if (
13466
13476
  this.config.threadingEnabled &&
13467
13477
  threadIdForExtraction &&
13468
13478
  persistedIds.length > 0
13469
13479
  ) {
13470
- try {
13471
- await this.threading.appendEpisodeIds(
13472
- threadIdForExtraction,
13473
- persistedIds,
13474
- );
13475
- } catch (err) {
13476
- log.warn(
13477
- "[threading] appendEpisodeIds failed after persistence (non-fatal)",
13478
- err,
13479
- );
13480
- }
13480
+ await this.appendPersistedThreadEpisodes(
13481
+ threadIdForExtraction,
13482
+ persistedIds,
13483
+ );
13481
13484
  }
13482
13485
 
13483
13486
  // Thread title update for the already-established thread context.
@@ -13831,9 +13834,11 @@ export class Orchestrator {
13831
13834
  // to avoid a maintenance hazard where the two guard paths could diverge.
13832
13835
  return attachCitation(content, citationContext, citationTemplate);
13833
13836
  };
13837
+ const persistedIds: string[] = [];
13834
13838
  const supersessionOrderingAt = (validAt?: string): string =>
13835
13839
  validAt && validAt.length > 0 ? validAt : new Date().toISOString();
13836
- const persistedIds: string[] = [];
13840
+ // #1635: pending_review persisted ids, excluded from the thread episode set below.
13841
+ const pendingReviewPersistedIds: string[] = [];
13837
13842
  const persistedIdsByStorage = new Map<
13838
13843
  string,
13839
13844
  { storage: StorageManager; ids: string[] }
@@ -13841,11 +13846,18 @@ export class Orchestrator {
13841
13846
  const trackPersistedId = (
13842
13847
  targetStorage: StorageManager,
13843
13848
  id: string,
13844
- options: { includeReturnedIds?: boolean } = {},
13849
+ options: {
13850
+ includeReturnedIds?: boolean;
13851
+ /** #1635: keep this id out of the persisted thread episode set. */
13852
+ pendingReview?: boolean;
13853
+ } = {},
13845
13854
  ): void => {
13846
13855
  if (options.includeReturnedIds !== false) {
13847
13856
  persistedIds.push(id);
13848
13857
  }
13858
+ if (options.pendingReview) {
13859
+ pendingReviewPersistedIds.push(id);
13860
+ }
13849
13861
  const key = targetStorage.dir;
13850
13862
  const existing = persistedIdsByStorage.get(key);
13851
13863
  if (existing) {
@@ -15347,7 +15359,9 @@ export class Orchestrator {
15347
15359
  log.debug(
15348
15360
  `chunked memory ${parentId} into ${chunkResult.chunks.length} chunks`,
15349
15361
  );
15350
- trackPersistedId(targetStorage, parentId);
15362
+ trackPersistedId(targetStorage, parentId, {
15363
+ pendingReview: faithfulnessEnforceStatus === "pending_review",
15364
+ });
15351
15365
  // #1576 (cursor Medium): keep pending_review ids out of threadEpisodeIdsForGraph — else later active facts build thread-predecessor edges to an unfaithful memory.
15352
15366
  if (
15353
15367
  faithfulnessEnforceStatus !== "pending_review" &&
@@ -15633,8 +15647,9 @@ export class Orchestrator {
15633
15647
  source: "extraction",
15634
15648
  }),
15635
15649
  );
15636
- trackPersistedId(targetStorage, memoryId);
15637
- // #1576 (cursor Medium): same thread-episode guard on the non-chunked path.
15650
+ trackPersistedId(targetStorage, memoryId, {
15651
+ pendingReview: faithfulnessEnforceStatus === "pending_review",
15652
+ });
15638
15653
  if (
15639
15654
  faithfulnessEnforceStatus !== "pending_review" &&
15640
15655
  threadEpisodeIdsForGraph &&
@@ -15944,9 +15959,35 @@ export class Orchestrator {
15944
15959
  log.debug(`temporal-index update error (non-fatal): ${err}`),
15945
15960
  );
15946
15961
 
15962
+ // #1635: surface pending_review ids so the thread episode set excludes them.
15963
+ this.lastPersistExtractionPendingReviewIds = pendingReviewPersistedIds;
15947
15964
  // Return the persisted fact IDs for threading
15948
15965
  return persistedIds;
15949
15966
  }
15967
+ /**
15968
+ * Append persisted ids to the thread episode set, excluding pending_review
15969
+ * fact ids (#1635) so they don't re-seed predecessor edges. Fail-open like
15970
+ * the raw appendEpisodeIds call it replaces.
15971
+ */
15972
+ private async appendPersistedThreadEpisodes(
15973
+ threadId: string,
15974
+ persistedIds: string[],
15975
+ ): Promise<void> {
15976
+ const pendingReviewIds = this.lastPersistExtractionPendingReviewIds ?? [];
15977
+ const episodeIds =
15978
+ pendingReviewIds.length > 0
15979
+ ? persistedIds.filter((id) => !pendingReviewIds.includes(id))
15980
+ : persistedIds;
15981
+ if (episodeIds.length === 0) return;
15982
+ try {
15983
+ await this.threading.appendEpisodeIds(threadId, episodeIds);
15984
+ } catch (err) {
15985
+ log.warn(
15986
+ "[threading] appendEpisodeIds failed after persistence (non-fatal)",
15987
+ err,
15988
+ );
15989
+ }
15990
+ }
15950
15991
 
15951
15992
  private async indexPersistedMemory(
15952
15993
  storage: StorageManager,
@@ -41,7 +41,7 @@ test("returns heuristic without calling the LLM when recallPlannerLlmEnabled is
41
41
  const captured: Array<Record<string, unknown>> = [];
42
42
  const llm = stubLlm({ capturedOptions: captured, result: { mode: "no_recall" } });
43
43
 
44
- const result = await planRecallModeLLM("what did we decide about auth?", undefined, config, llm, undefined, resolveCapabilities(config));
44
+ const result = await planRecallModeLLM("what did we decide about auth?", undefined, config, resolveCapabilities(config), llm, undefined);
45
45
 
46
46
  assert.equal(captured.length, 0, "LLM must not be contacted when disabled");
47
47
  assert.equal(result.source, "heuristic");
@@ -55,7 +55,7 @@ test("uses the LLM classification when enabled", async () => {
55
55
  const config = parseConfig({ recallPlannerLlmEnabled: true });
56
56
  const llm = stubLlm({ result: { mode: "graph_mode", reason: "asks for root cause" }, modelUsed: "anthropic/claude" });
57
57
 
58
- const result = await planRecallModeLLM("restart the gateway", undefined, config, llm, undefined, resolveCapabilities(config));
58
+ const result = await planRecallModeLLM("restart the gateway", undefined, config, resolveCapabilities(config), llm, undefined);
59
59
 
60
60
  assert.equal(result.source, "llm");
61
61
  assert.equal(result.mode, "graph_mode");
@@ -75,7 +75,7 @@ test("forwards taskModelChain AND recallPlannerModel in gateway mode (provider-a
75
75
  const captured: Array<Record<string, unknown>> = [];
76
76
  const llm = stubLlm({ capturedOptions: captured, result: { mode: "minimal" } });
77
77
 
78
- await planRecallModeLLM("check status", undefined, config, llm, undefined, resolveCapabilities(config));
78
+ await planRecallModeLLM("check status", undefined, config, resolveCapabilities(config), llm, undefined);
79
79
 
80
80
  assert.equal(captured.length, 1);
81
81
  // recallPlannerModel is tried first (prepended), taskModelChain is the fallback chain.
@@ -98,7 +98,7 @@ test("plugin mode passes only the explicit model, no gateway chain", async () =>
98
98
  const captured: Array<Record<string, unknown>> = [];
99
99
  const llm = stubLlm({ capturedOptions: captured, result: { mode: "full" } });
100
100
 
101
- await planRecallModeLLM("summarize the project", undefined, config, llm, undefined, resolveCapabilities(config));
101
+ await planRecallModeLLM("summarize the project", undefined, config, resolveCapabilities(config), llm, undefined);
102
102
 
103
103
  assert.equal(captured.length, 1);
104
104
  assert.equal(captured[0]?.model, "openai/gpt-5.5");
@@ -110,7 +110,7 @@ test("falls back to heuristic when the LLM throws", async () => {
110
110
  const config = parseConfig({ recallPlannerLlmEnabled: true });
111
111
  const llm = stubLlm({ throwError: "boom" });
112
112
 
113
- const result = await planRecallModeLLM("what happened during the outage?", undefined, config, llm, undefined, resolveCapabilities(config));
113
+ const result = await planRecallModeLLM("what happened during the outage?", undefined, config, resolveCapabilities(config), llm, undefined);
114
114
 
115
115
  assert.equal(result.source, "heuristic-fallback");
116
116
  assert.equal(result.fallbackUsed, true);
@@ -124,7 +124,7 @@ test("falls back to heuristic when the LLM returns no parseable result", async (
124
124
  const config = parseConfig({ recallPlannerLlmEnabled: true });
125
125
  const llm = stubLlm({ result: null });
126
126
 
127
- const result = await planRecallModeLLM("how did we get here?", undefined, config, llm, undefined, resolveCapabilities(config));
127
+ const result = await planRecallModeLLM("how did we get here?", undefined, config, resolveCapabilities(config), llm, undefined);
128
128
 
129
129
  assert.equal(result.source, "heuristic-fallback");
130
130
  assert.equal(result.fallbackUsed, true);
@@ -140,7 +140,7 @@ test("falls back without a network attempt when the chain is empty and the model
140
140
  const captured: Array<Record<string, unknown>> = [];
141
141
  const llm = stubLlm({ available: false, capturedOptions: captured, result: { mode: "full" } });
142
142
 
143
- const result = await planRecallModeLLM("anything", undefined, config, llm, undefined, resolveCapabilities(config));
143
+ const result = await planRecallModeLLM("anything", undefined, config, resolveCapabilities(config), llm, undefined);
144
144
 
145
145
  assert.equal(captured.length, 0, "no network attempt when nothing is routable");
146
146
  assert.equal(result.source, "heuristic-fallback");
@@ -156,7 +156,7 @@ test("attempts the call (and falls back) when a provider-qualified model overrid
156
156
  const captured: Array<Record<string, unknown>> = [];
157
157
  const llm = stubLlm({ available: false, capturedOptions: captured, result: null });
158
158
 
159
- const result = await planRecallModeLLM("anything", undefined, config, llm, undefined, resolveCapabilities(config));
159
+ const result = await planRecallModeLLM("anything", undefined, config, resolveCapabilities(config), llm, undefined);
160
160
 
161
161
  assert.equal(captured.length, 1, "qualified model override → still attempt the call");
162
162
  assert.equal(captured[0]?.model, "openai/gpt-5.5");
@@ -171,7 +171,7 @@ test("an already-aborted recall short-circuits to the heuristic without an LLM c
171
171
  const ac = new AbortController();
172
172
  ac.abort();
173
173
 
174
- const result = await planRecallModeLLM("what did we decide?", undefined, config, llm, ac.signal, resolveCapabilities(config));
174
+ const result = await planRecallModeLLM("what did we decide?", undefined, config, resolveCapabilities(config), llm, ac.signal);
175
175
 
176
176
  assert.equal(captured.length, 0, "no LLM call when the recall is already aborted");
177
177
  assert.equal(result.source, "heuristic-fallback");
@@ -185,7 +185,7 @@ test("forwards the abort signal into the LLM call (cancellation contract)", asyn
185
185
  const llm = stubLlm({ capturedOptions: captured, result: { mode: "minimal" } });
186
186
  const ac = new AbortController();
187
187
 
188
- await planRecallModeLLM("check status", undefined, config, llm, ac.signal, resolveCapabilities(config));
188
+ await planRecallModeLLM("check status", undefined, config, resolveCapabilities(config), llm, ac.signal);
189
189
 
190
190
  assert.equal(captured.length, 1);
191
191
  assert.equal(captured[0]?.signal, ac.signal, "recall abort signal must reach FallbackLlmClient");
@@ -196,7 +196,7 @@ test("empty prompts skip the LLM entirely", async () => {
196
196
  const captured: Array<Record<string, unknown>> = [];
197
197
  const llm = stubLlm({ capturedOptions: captured, result: { mode: "full" } });
198
198
 
199
- const result = await planRecallModeLLM(" ", undefined, config, llm, undefined, resolveCapabilities(config));
199
+ const result = await planRecallModeLLM(" ", undefined, config, resolveCapabilities(config), llm, undefined);
200
200
 
201
201
  assert.equal(captured.length, 0);
202
202
  assert.equal(result.mode, "no_recall"); // heuristic returns no_recall for empty
@@ -223,3 +223,22 @@ test("resolveRecallPlannerLlmOptions drops bare model names but keeps provider-q
223
223
  );
224
224
  assert.equal(qualified.model, "anthropic/claude-haiku-4-5");
225
225
  });
226
+
227
+ test("caps.recallPlannerLlm is authoritative — disabled caps wins over enabled config flag (one-resolution-per-op #1523)", async () => {
228
+ // Issue #1523 contract: the capability resolved at the operation entry is
229
+ // THE gate. Construct a deliberate mismatch: config says enabled, but the
230
+ // resolved CapabilitySet says disabled. The function must NOT call the LLM.
231
+ const config = parseConfig({ recallPlannerLlmEnabled: true });
232
+ const captured: Array<Record<string, unknown>> = [];
233
+ const llm = stubLlm({ capturedOptions: captured, result: { mode: "full" } });
234
+
235
+ // Manually construct caps with recallPlannerLlm: false (simulating a
236
+ // resolution that disagrees with the raw config — e.g. session toggle off).
237
+ const caps = { ...resolveCapabilities(config), recallPlannerLlm: false } as any;
238
+
239
+ const result = await planRecallModeLLM("what did we decide?", undefined, config, caps, llm, undefined);
240
+
241
+ assert.equal(captured.length, 0, "LLM must not be contacted when caps says disabled, even if config says enabled");
242
+ assert.equal(result.source, "heuristic");
243
+ assert.equal(result.reason, "llm-disabled");
244
+ });
@@ -188,16 +188,15 @@ export async function planRecallModeLLM(
188
188
  prompt: string,
189
189
  hints: string[] | undefined,
190
190
  config: PluginConfig,
191
+ caps: CapabilitySet,
191
192
  llm?: FallbackLlmClient,
192
193
  signal?: AbortSignal,
193
- caps?: CapabilitySet,
194
194
  ): Promise<RecallPlannerLlmResult> {
195
195
  const heuristicMode = planRecallMode(prompt);
196
196
 
197
- // `caps` is OPTIONAL and additive (issue #1523). Prefer the resolved
198
- // capability when supplied; fall back to the config flag so existing callers
199
- // that pass only `config` keep identical gating.
200
- const plannerLlmEnabled = caps?.recallPlannerLlm ?? config.recallPlannerLlmEnabled;
197
+ // The recall-operation entry resolved this gate once (issue #1523); read the
198
+ // capability, never re-derive it from raw config here.
199
+ const plannerLlmEnabled = caps.recallPlannerLlm;
201
200
  if (!plannerLlmEnabled) {
202
201
  return heuristicResult(heuristicMode, "heuristic", "llm-disabled", 0, false);
203
202
  }