@remnic/core 9.3.665 → 9.3.666

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 (149) hide show
  1. package/dist/access-audit.js +2 -2
  2. package/dist/access-cli.js +41 -40
  3. package/dist/access-cli.js.map +1 -1
  4. package/dist/access-http.d.ts +3 -2
  5. package/dist/access-http.js +25 -25
  6. package/dist/access-mcp.d.ts +3 -2
  7. package/dist/access-mcp.js +22 -22
  8. package/dist/access-schema.d.ts +36 -36
  9. package/dist/access-schema.js +3 -3
  10. package/dist/{access-service-D0SLB4MH.d.ts → access-service-DsS-TatL.d.ts} +1 -1
  11. package/dist/access-service.d.ts +3 -2
  12. package/dist/access-service.js +21 -21
  13. package/dist/adapters/index.js +4 -4
  14. package/dist/adapters/registry.js +2 -2
  15. package/dist/bootstrap.d.ts +2 -1
  16. package/dist/briefing.js +4 -3
  17. package/dist/capabilities.d.ts +73 -0
  18. package/dist/capabilities.js +8 -0
  19. package/dist/capabilities.js.map +1 -0
  20. package/dist/causal-behavior.js +2 -2
  21. package/dist/causal-chain.js +2 -2
  22. package/dist/causal-consolidation.js +7 -6
  23. package/dist/causal-consolidation.js.map +1 -1
  24. package/dist/causal-retrieval.js +2 -2
  25. package/dist/causal-trajectory.js +1 -1
  26. package/dist/{chunk-ROHLEUTH.js → chunk-23EBQ27U.js} +5 -5
  27. package/dist/{chunk-YW52BQSU.js → chunk-2TCHDANJ.js} +2 -2
  28. package/dist/{chunk-IROWLAWG.js → chunk-46WUVFOD.js} +4 -4
  29. package/dist/{chunk-7C4MPEPE.js → chunk-4T7P2HLJ.js} +3 -3
  30. package/dist/{chunk-7XH7VJN4.js → chunk-6T4LTI2F.js} +4 -4
  31. package/dist/{chunk-TVVEYCNW.js → chunk-7K5Q6COX.js} +4 -4
  32. package/dist/{chunk-BZG2CWOQ.js → chunk-A5TEHAR4.js} +3 -3
  33. package/dist/{chunk-C7AF236A.js → chunk-AARDBQTA.js} +2 -2
  34. package/dist/{chunk-IHG6CC7T.js → chunk-BQJUPECT.js} +2 -2
  35. package/dist/{chunk-7OGJQP7T.js → chunk-CRO4LCQ6.js} +5 -5
  36. package/dist/{chunk-YNDLCWXS.js → chunk-EZ25VE3G.js} +4 -4
  37. package/dist/{chunk-WH4SKYPX.js → chunk-GZ6QAYSH.js} +94 -74
  38. package/dist/chunk-GZ6QAYSH.js.map +1 -0
  39. package/dist/{chunk-UXA5L2DZ.js → chunk-HQCGRSRU.js} +2 -2
  40. package/dist/{chunk-RKNJBZ55.js → chunk-JBPKEARU.js} +4 -4
  41. package/dist/{chunk-XW3W4PV4.js → chunk-JTPXSXHC.js} +2 -2
  42. package/dist/{chunk-OHJFJ4HI.js → chunk-KOXGLQS7.js} +2 -2
  43. package/dist/{chunk-2OPARZ4B.js → chunk-MPXYHC35.js} +26 -26
  44. package/dist/{chunk-6JBKHTQD.js → chunk-MR4PJ277.js} +2 -2
  45. package/dist/{chunk-EXXBA5OM.js → chunk-OI4BXFSB.js} +4 -4
  46. package/dist/{chunk-SQZ42MKH.js → chunk-OQH5XUH3.js} +6 -3
  47. package/dist/chunk-OQH5XUH3.js.map +1 -0
  48. package/dist/{chunk-2HEZXPYU.js → chunk-Q2LQZYQ7.js} +3 -3
  49. package/dist/{chunk-XRSIGVTS.js → chunk-QHWJG5C5.js} +8 -8
  50. package/dist/{chunk-T2AN3BSP.js → chunk-QZ7ODIVL.js} +2 -2
  51. package/dist/chunk-RI5XBIZ6.js +23 -0
  52. package/dist/chunk-RI5XBIZ6.js.map +1 -0
  53. package/dist/{chunk-D7IXTY5E.js → chunk-TJ7HH5LB.js} +2 -2
  54. package/dist/{chunk-V25ZAOSB.js → chunk-UOBLE67F.js} +4 -4
  55. package/dist/{chunk-JIX3ZL2J.js → chunk-UVUTV7CM.js} +15 -15
  56. package/dist/{chunk-VH6EIKVS.js → chunk-WKMCC4NQ.js} +35 -16
  57. package/dist/chunk-WKMCC4NQ.js.map +1 -0
  58. package/dist/{chunk-SSOMTUCA.js → chunk-WXGTC424.js} +1 -1
  59. package/dist/{chunk-KHGE6PMF.js → chunk-WXXLSZHA.js} +2 -2
  60. package/dist/{chunk-DSLUOQDY.js → chunk-XMWF6AU3.js} +2 -2
  61. package/dist/{chunk-DQY7NJ5L.js → chunk-XS2CWEHZ.js} +2 -2
  62. package/dist/{cli-BQRqR9N-.d.ts → cli-BypxcNqq.d.ts} +2 -2
  63. package/dist/cli.d.ts +4 -3
  64. package/dist/cli.js +42 -42
  65. package/dist/compounding/engine.js +4 -3
  66. package/dist/connectors/codex-materialize-runner.js +4 -3
  67. package/dist/connectors/index.js +4 -3
  68. package/dist/consolidation-provenance-check.js +2 -2
  69. package/dist/conversation-index/backend.js +2 -2
  70. package/dist/dashboard-runtime.js +2 -2
  71. package/dist/direct-answer-wiring.d.ts +13 -3
  72. package/dist/direct-answer-wiring.js +1 -1
  73. package/dist/entity-retrieval.js +4 -3
  74. package/dist/explicit-capture.d.ts +2 -1
  75. package/dist/index.d.ts +5 -4
  76. package/dist/index.js +66 -65
  77. package/dist/index.js.map +1 -1
  78. package/dist/lcm/engine.js +2 -2
  79. package/dist/lcm/index.js +4 -4
  80. package/dist/maintenance/memory-governance.js +4 -4
  81. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +4 -3
  82. package/dist/maintenance/rebuild-memory-projection.js +5 -5
  83. package/dist/mcp-memory-inspector-app.d.ts +3 -2
  84. package/dist/namespaces/migrate.js +11 -11
  85. package/dist/namespaces/search.js +7 -7
  86. package/dist/namespaces/storage.js +4 -3
  87. package/dist/operator-toolkit.js +15 -15
  88. package/dist/{orchestrator-Cg1UkvmO.d.ts → orchestrator-DZqPVoMI.d.ts} +8 -0
  89. package/dist/orchestrator.d.ts +2 -1
  90. package/dist/orchestrator.js +32 -31
  91. package/dist/recall-planner-llm.d.ts +2 -1
  92. package/dist/recall-planner-llm.js +3 -2
  93. package/dist/recall-planner-llm.js.map +1 -1
  94. package/dist/schemas.d.ts +42 -42
  95. package/dist/search/factory.js +6 -6
  96. package/dist/search/index.js +10 -10
  97. package/dist/search/lancedb-backend.js +1 -1
  98. package/dist/search/meilisearch-backend.js +1 -1
  99. package/dist/search/orama-backend.js +1 -1
  100. package/dist/semantic-consolidation.js +5 -4
  101. package/dist/semantic-rule-promotion.js +4 -3
  102. package/dist/semantic-rule-verifier.js +4 -3
  103. package/dist/shared-context/manager.d.ts +2 -2
  104. package/dist/storage.js +3 -2
  105. package/dist/transfer/backup.js +2 -2
  106. package/dist/transfer/capsule-export.js +2 -2
  107. package/dist/transfer/capsule-import.js +1 -1
  108. package/dist/verified-recall.js +4 -3
  109. package/package.json +1 -1
  110. package/src/capabilities.test.ts +97 -0
  111. package/src/capabilities.ts +86 -0
  112. package/src/direct-answer-wiring.test.ts +53 -2
  113. package/src/direct-answer-wiring.ts +18 -5
  114. package/src/orchestrator.ts +69 -19
  115. package/src/recall-planner-llm.test.ts +12 -11
  116. package/src/recall-planner-llm.ts +7 -1
  117. package/src/storage-fallback-category-dirs.test.ts +150 -1
  118. package/src/storage.ts +51 -14
  119. package/dist/chunk-SQZ42MKH.js.map +0 -1
  120. package/dist/chunk-VH6EIKVS.js.map +0 -1
  121. package/dist/chunk-WH4SKYPX.js.map +0 -1
  122. /package/dist/{chunk-ROHLEUTH.js.map → chunk-23EBQ27U.js.map} +0 -0
  123. /package/dist/{chunk-YW52BQSU.js.map → chunk-2TCHDANJ.js.map} +0 -0
  124. /package/dist/{chunk-IROWLAWG.js.map → chunk-46WUVFOD.js.map} +0 -0
  125. /package/dist/{chunk-7C4MPEPE.js.map → chunk-4T7P2HLJ.js.map} +0 -0
  126. /package/dist/{chunk-7XH7VJN4.js.map → chunk-6T4LTI2F.js.map} +0 -0
  127. /package/dist/{chunk-TVVEYCNW.js.map → chunk-7K5Q6COX.js.map} +0 -0
  128. /package/dist/{chunk-BZG2CWOQ.js.map → chunk-A5TEHAR4.js.map} +0 -0
  129. /package/dist/{chunk-C7AF236A.js.map → chunk-AARDBQTA.js.map} +0 -0
  130. /package/dist/{chunk-IHG6CC7T.js.map → chunk-BQJUPECT.js.map} +0 -0
  131. /package/dist/{chunk-7OGJQP7T.js.map → chunk-CRO4LCQ6.js.map} +0 -0
  132. /package/dist/{chunk-YNDLCWXS.js.map → chunk-EZ25VE3G.js.map} +0 -0
  133. /package/dist/{chunk-UXA5L2DZ.js.map → chunk-HQCGRSRU.js.map} +0 -0
  134. /package/dist/{chunk-RKNJBZ55.js.map → chunk-JBPKEARU.js.map} +0 -0
  135. /package/dist/{chunk-XW3W4PV4.js.map → chunk-JTPXSXHC.js.map} +0 -0
  136. /package/dist/{chunk-OHJFJ4HI.js.map → chunk-KOXGLQS7.js.map} +0 -0
  137. /package/dist/{chunk-2OPARZ4B.js.map → chunk-MPXYHC35.js.map} +0 -0
  138. /package/dist/{chunk-6JBKHTQD.js.map → chunk-MR4PJ277.js.map} +0 -0
  139. /package/dist/{chunk-EXXBA5OM.js.map → chunk-OI4BXFSB.js.map} +0 -0
  140. /package/dist/{chunk-2HEZXPYU.js.map → chunk-Q2LQZYQ7.js.map} +0 -0
  141. /package/dist/{chunk-XRSIGVTS.js.map → chunk-QHWJG5C5.js.map} +0 -0
  142. /package/dist/{chunk-T2AN3BSP.js.map → chunk-QZ7ODIVL.js.map} +0 -0
  143. /package/dist/{chunk-D7IXTY5E.js.map → chunk-TJ7HH5LB.js.map} +0 -0
  144. /package/dist/{chunk-V25ZAOSB.js.map → chunk-UOBLE67F.js.map} +0 -0
  145. /package/dist/{chunk-JIX3ZL2J.js.map → chunk-UVUTV7CM.js.map} +0 -0
  146. /package/dist/{chunk-SSOMTUCA.js.map → chunk-WXGTC424.js.map} +0 -0
  147. /package/dist/{chunk-KHGE6PMF.js.map → chunk-WXXLSZHA.js.map} +0 -0
  148. /package/dist/{chunk-DSLUOQDY.js.map → chunk-XMWF6AU3.js.map} +0 -0
  149. /package/dist/{chunk-DQY7NJ5L.js.map → chunk-XS2CWEHZ.js.map} +0 -0
@@ -245,6 +245,7 @@ import {
245
245
  type TrustZoneSearchResult,
246
246
  } from "./trust-zones.js";
247
247
  import { tryDirectAnswer, type DirectAnswerSources } from "./direct-answer-wiring.js";
248
+ import { resolveCapabilities, type CapabilitySet } from "./capabilities.js";
248
249
  import { DEFAULT_TAXONOMY } from "./taxonomy/index.js";
249
250
  import {
250
251
  searchHarmonicRetrieval,
@@ -1376,6 +1377,13 @@ export function resolveRecallModeDecision(options: RecallModeGraphOptions): Reca
1376
1377
  export async function resolveRecallModeDecisionAsync(
1377
1378
  options: RecallModeGraphOptions & {
1378
1379
  config: PluginConfig;
1380
+ /**
1381
+ * Recall-operation capability gates (issue #1523). OPTIONAL and additive:
1382
+ * the recall orchestrator passes a resolved set, but existing callers that
1383
+ * only pass `config` + planner flags stay backward-compatible — the LLM
1384
+ * planner gate falls back to `config.recallPlannerLlmEnabled` when omitted.
1385
+ */
1386
+ caps?: CapabilitySet;
1379
1387
  hints?: string[];
1380
1388
  llm?: FallbackLlmClient;
1381
1389
  signal?: AbortSignal;
@@ -1384,7 +1392,11 @@ export async function resolveRecallModeDecisionAsync(
1384
1392
  const heuristicDecision = resolveRecallModeDecision(options);
1385
1393
 
1386
1394
  // Planner globally off, or LLM planning not opted into → heuristic only.
1387
- if (!options.plannerEnabled || !options.config.recallPlannerLlmEnabled) {
1395
+ // Prefer the resolved capability when supplied; otherwise fall back to the
1396
+ // config flag so callers on the old option shape get identical gating.
1397
+ const plannerLlmEnabled =
1398
+ options.caps?.recallPlannerLlm ?? options.config.recallPlannerLlmEnabled;
1399
+ if (!options.plannerEnabled || !plannerLlmEnabled) {
1388
1400
  return heuristicDecision;
1389
1401
  }
1390
1402
 
@@ -1395,6 +1407,7 @@ export async function resolveRecallModeDecisionAsync(
1395
1407
  options.config,
1396
1408
  options.llm,
1397
1409
  options.signal,
1410
+ options.caps,
1398
1411
  );
1399
1412
 
1400
1413
  // Shadow mode: record what the LLM would have chosen but keep the heuristic
@@ -5738,6 +5751,10 @@ export class Orchestrator {
5738
5751
  sessionKey?: string,
5739
5752
  options: RecallInvocationOptions = {},
5740
5753
  ): Promise<string> {
5754
+ // Resolve the recall-operation capability gates ONCE, at the operation
5755
+ // entry, and thread the frozen set down (issue #1523). Never re-read the
5756
+ // migrated flags off `this.config` mid-operation.
5757
+ const caps = resolveCapabilities(this.config);
5741
5758
  const abortController = new AbortController();
5742
5759
  const onAbort = () => {
5743
5760
  abortController.abort();
@@ -5813,7 +5830,7 @@ export class Orchestrator {
5813
5830
  const recallPromise = this.recallInternal(prompt, sessionKey, {
5814
5831
  ...options,
5815
5832
  abortSignal: abortController.signal,
5816
- });
5833
+ }, caps);
5817
5834
  const RECALL_TIMEOUT_MS = this.config.recallOuterTimeoutMs ?? 75_000;
5818
5835
  if (RECALL_TIMEOUT_MS <= 0) {
5819
5836
  return await recallPromise;
@@ -5837,13 +5854,14 @@ export class Orchestrator {
5837
5854
  // Observation-mode direct-answer tier (issue #518 slice 3c).
5838
5855
  // Runs after the user's recall already succeeded, fire-and-forget,
5839
5856
  // so annotation latency can never delay the caller's response.
5840
- if (this.config.recallDirectAnswerEnabled && sessionKey) {
5857
+ if (caps.recallDirectAnswer && sessionKey) {
5841
5858
  try {
5842
5859
  this.enqueueDirectAnswerObservation(
5843
5860
  prompt,
5844
5861
  sessionKey,
5845
5862
  options.namespace?.trim() || undefined,
5846
5863
  options.principalOverride,
5864
+ caps,
5847
5865
  );
5848
5866
  } catch (err) {
5849
5867
  log.debug(`direct-answer observation setup failed: ${err}`);
@@ -5912,6 +5930,7 @@ export class Orchestrator {
5912
5930
  sessionKey: string,
5913
5931
  namespaceOverride: string | undefined,
5914
5932
  principalOverride: string | undefined,
5933
+ caps: CapabilitySet,
5915
5934
  ): void {
5916
5935
  const expectedSnapshot = this.lastRecall.get(sessionKey);
5917
5936
  if (expectedSnapshot === null) return;
@@ -5992,6 +6011,7 @@ export class Orchestrator {
5992
6011
  sessionKey,
5993
6012
  observationNamespaces,
5994
6013
  expectedIdentity,
6014
+ caps,
5995
6015
  undefined,
5996
6016
  );
5997
6017
  } catch (err) {
@@ -6007,6 +6027,7 @@ export class Orchestrator {
6007
6027
  expectedIdentity:
6008
6028
  | { writeNonce?: string; traceId?: string; recordedAt?: string }
6009
6029
  | undefined,
6030
+ caps: CapabilitySet,
6010
6031
  _parentAbortSignal?: AbortSignal,
6011
6032
  ): Promise<void> {
6012
6033
  const tierStart = Date.now();
@@ -6091,6 +6112,7 @@ export class Orchestrator {
6091
6112
  query: prompt,
6092
6113
  namespace: ns,
6093
6114
  config: this.config,
6115
+ enabled: caps.recallDirectAnswer,
6094
6116
  sources,
6095
6117
  });
6096
6118
  if (r.eligible && r.winner) {
@@ -7277,6 +7299,7 @@ export class Orchestrator {
7277
7299
  prompt: string,
7278
7300
  sessionKey?: string,
7279
7301
  options: RecallInvocationOptions = {},
7302
+ caps: CapabilitySet = resolveCapabilities(this.config),
7280
7303
  ): Promise<string> {
7281
7304
  const recallStart = Date.now();
7282
7305
  // Backend degradations observed by this recall's QMD searches (#1536):
@@ -7436,11 +7459,10 @@ export class Orchestrator {
7436
7459
  let identityInjectionTruncated = false;
7437
7460
  timings.queryPolicy = `${queryPolicy.promptShape}/${queryPolicy.retrievalBudgetMode}${queryPolicy.skipConversationRecall ? "/skip-conv" : ""}`;
7438
7461
  const recallModeDecisionOptions = {
7439
- plannerEnabled: this.config.recallPlannerEnabled,
7440
- graphRecallEnabled: this.config.graphRecallEnabled,
7462
+ plannerEnabled: caps.recallPlanner,
7463
+ graphRecallEnabled: caps.graphRecall,
7441
7464
  multiGraphMemoryEnabled: this.config.multiGraphMemoryEnabled,
7442
- graphExpandedIntentEnabled:
7443
- this.config.graphExpandedIntentEnabled === true,
7465
+ graphExpandedIntentEnabled: caps.graphExpandedIntent,
7444
7466
  prompt,
7445
7467
  };
7446
7468
  const requestedMode = options.mode;
@@ -7454,6 +7476,7 @@ export class Orchestrator {
7454
7476
  : await resolveRecallModeDecisionAsync({
7455
7477
  ...recallModeDecisionOptions,
7456
7478
  config: this.config,
7479
+ caps,
7457
7480
  signal: options.abortSignal,
7458
7481
  });
7459
7482
  if (
@@ -7779,7 +7802,7 @@ export class Orchestrator {
7779
7802
  promptLength: prompt.length,
7780
7803
  retrievalQueryHash,
7781
7804
  retrievalQueryLength: retrievalQuery.length,
7782
- plannerEnabled: this.config.recallPlannerEnabled,
7805
+ plannerEnabled: caps.recallPlanner,
7783
7806
  plannedMode: requestedMode ?? recallDecision.plannedMode,
7784
7807
  effectiveMode: recallMode,
7785
7808
  recallResultLimit,
@@ -7790,7 +7813,7 @@ export class Orchestrator {
7790
7813
  reason: graphDecisionReason,
7791
7814
  shadowMode: graphDecisionShadowMode,
7792
7815
  qmdAvailable,
7793
- graphRecallEnabled: this.config.graphRecallEnabled,
7816
+ graphRecallEnabled: caps.graphRecall,
7794
7817
  multiGraphMemoryEnabled: this.config.multiGraphMemoryEnabled,
7795
7818
  },
7796
7819
  });
@@ -11012,7 +11035,7 @@ export class Orchestrator {
11012
11035
 
11013
11036
  const isFullModeGraphAssist =
11014
11037
  this.config.multiGraphMemoryEnabled &&
11015
- this.config.graphAssistInFullModeEnabled !== false &&
11038
+ caps.graphAssistInFullMode &&
11016
11039
  recallMode === "full" &&
11017
11040
  memoryResults.length >=
11018
11041
  Math.max(1, this.config.graphAssistMinSeedResults ?? 3);
@@ -11192,7 +11215,7 @@ export class Orchestrator {
11192
11215
  timeoutMs: this.config.rerankTimeoutMs,
11193
11216
  maxCandidates: this.config.rerankMaxCandidates,
11194
11217
  cache: this.rerankCache,
11195
- cacheEnabled: this.config.rerankCacheEnabled,
11218
+ cacheEnabled: caps.rerankCache,
11196
11219
  cacheTtlMs: this.config.rerankCacheTtlMs,
11197
11220
  });
11198
11221
  if (ranked && ranked.length > 0) {
@@ -11222,7 +11245,7 @@ export class Orchestrator {
11222
11245
  // flips the default once bench shows tie-or-win. Fail-open: any
11223
11246
  // lookup error leaves the original scores untouched rather than
11224
11247
  // breaking recall for the whole namespace.
11225
- if (this.config.recallMemoryWorthFilterEnabled && memoryResults.length > 0) {
11248
+ if (caps.recallMemoryWorthFilter && memoryResults.length > 0) {
11226
11249
  try {
11227
11250
  memoryResults = await this.applyMemoryWorthRerank(memoryResults, recallNamespaces);
11228
11251
  } catch (err) {
@@ -11260,7 +11283,7 @@ export class Orchestrator {
11260
11283
  memoryResults.length,
11261
11284
  );
11262
11285
  let confidenceGateRejected = false;
11263
- if (this.config.recallConfidenceGateEnabled && effectiveGateScore > 0) {
11286
+ if (caps.recallConfidenceGate && effectiveGateScore > 0) {
11264
11287
  if (effectiveGateScore < this.config.recallConfidenceGateThreshold) {
11265
11288
  log.debug(
11266
11289
  `recall: confidence gate rejected ${memoryResults.length} results (effective score ${effectiveGateScore.toFixed(3)} below ${this.config.recallConfidenceGateThreshold})`,
@@ -11278,6 +11301,7 @@ export class Orchestrator {
11278
11301
  memoryResults,
11279
11302
  recallResultLimit,
11280
11303
  retrievalQuery,
11304
+ caps,
11281
11305
  );
11282
11306
 
11283
11307
  // E-Mem-inspired memory reconstruction: fill gaps for referenced entities
@@ -11394,6 +11418,7 @@ export class Orchestrator {
11394
11418
  boostedScoped,
11395
11419
  recallResultLimit,
11396
11420
  retrievalQuery,
11421
+ caps,
11397
11422
  );
11398
11423
  },
11399
11424
  [] as QmdSearchResult[],
@@ -11431,6 +11456,7 @@ export class Orchestrator {
11431
11456
  recallNamespaces,
11432
11457
  recallResultLimit,
11433
11458
  recallMode,
11459
+ caps,
11434
11460
  queryAwarePrefilter,
11435
11461
  abortSignal: options.abortSignal,
11436
11462
  onDegradation: (degradation) => {
@@ -11551,6 +11577,7 @@ export class Orchestrator {
11551
11577
  boostedScoped,
11552
11578
  recallResultLimit,
11553
11579
  retrievalQuery,
11580
+ caps,
11554
11581
  );
11555
11582
  },
11556
11583
  [] as QmdSearchResult[],
@@ -11656,6 +11683,7 @@ export class Orchestrator {
11656
11683
  recallNamespaces,
11657
11684
  recallResultLimit,
11658
11685
  recallMode,
11686
+ caps,
11659
11687
  queryAwarePrefilter,
11660
11688
  abortSignal: options.abortSignal,
11661
11689
  onDegradation: (degradation) => {
@@ -11730,6 +11758,7 @@ export class Orchestrator {
11730
11758
  boostedRecent,
11731
11759
  recallResultLimit,
11732
11760
  retrievalQuery,
11761
+ caps,
11733
11762
  );
11734
11763
  },
11735
11764
  [] as QmdSearchResult[],
@@ -11768,6 +11797,7 @@ export class Orchestrator {
11768
11797
  recallNamespaces,
11769
11798
  recallResultLimit,
11770
11799
  recallMode,
11800
+ caps,
11771
11801
  queryAwarePrefilter,
11772
11802
  abortSignal: options.abortSignal,
11773
11803
  onDegradation: (degradation) => {
@@ -11815,6 +11845,7 @@ export class Orchestrator {
11815
11845
  recallNamespaces,
11816
11846
  recallResultLimit,
11817
11847
  recallMode,
11848
+ caps,
11818
11849
  queryAwarePrefilter,
11819
11850
  abortSignal: options.abortSignal,
11820
11851
  onDegradation: (degradation) => {
@@ -18361,6 +18392,11 @@ export class Orchestrator {
18361
18392
  results: QmdSearchResult[],
18362
18393
  limit: number,
18363
18394
  retrievalQuery?: string,
18395
+ // `caps` is additive AND last (issue #1523) so the positional call shape
18396
+ // stays backward-compatible: the recall pipeline threads a resolved set,
18397
+ // but callers that omit it (e.g. direct unit-test invocations) get an
18398
+ // equivalent set derived from the same config — behavior-preserving.
18399
+ caps: CapabilitySet = resolveCapabilities(this.config),
18364
18400
  ): QmdSearchResult[] {
18365
18401
  const safeLimit =
18366
18402
  typeof limit === "number" && Number.isFinite(limit)
@@ -18377,13 +18413,13 @@ export class Orchestrator {
18377
18413
  // facts/decisions before MMR picks the final section. No-op when the
18378
18414
  // flag is off or the query is not a problem-solving ask.
18379
18415
  const boosted =
18380
- this.config.recallReasoningTraceBoostEnabled && typeof retrievalQuery === "string"
18416
+ caps.recallReasoningTraceBoost && typeof retrievalQuery === "string"
18381
18417
  ? applyReasoningTraceBoost(results, {
18382
18418
  enabled: true,
18383
18419
  query: retrievalQuery,
18384
18420
  })
18385
18421
  : results;
18386
- const diversified = this.applyMmrToQmdResults(sectionId, boosted);
18422
+ const diversified = this.applyMmrToQmdResults(sectionId, boosted, caps);
18387
18423
  return diversified.slice(0, safeLimit);
18388
18424
  }
18389
18425
 
@@ -18398,8 +18434,11 @@ export class Orchestrator {
18398
18434
  private applyMmrToQmdResults(
18399
18435
  sectionId: string,
18400
18436
  results: QmdSearchResult[],
18437
+ // Additive `caps` (issue #1523); defaults to a config-derived set so direct
18438
+ // callers that omit it behave identically to the threaded recall path.
18439
+ caps: CapabilitySet = resolveCapabilities(this.config),
18401
18440
  ): QmdSearchResult[] {
18402
- if (this.config.recallMmrEnabled === false) return results;
18441
+ if (!caps.recallMmr) return results;
18403
18442
  if (!Array.isArray(results) || results.length < 2) return results;
18404
18443
 
18405
18444
  // Config is runtime API (see AGENTS.md §4): preserve `0` as a true zero
@@ -18698,6 +18737,13 @@ export class Orchestrator {
18698
18737
  recallNamespaces: string[];
18699
18738
  recallResultLimit: number;
18700
18739
  recallMode: RecallPlanMode;
18740
+ /**
18741
+ * Recall-operation capability gates resolved once at recall entry (#1523).
18742
+ * OPTIONAL and additive: the recall pipeline threads a resolved set, but
18743
+ * callers that omit it (e.g. direct unit-test invocations) get an
18744
+ * equivalent config-derived set — behavior-preserving.
18745
+ */
18746
+ caps?: CapabilitySet;
18701
18747
  queryAwarePrefilter?: QueryAwarePrefilter;
18702
18748
  abortSignal?: AbortSignal;
18703
18749
  /** Backend degradation observer — cold-tier QMD must report like hot (#1536). */
@@ -18717,6 +18763,9 @@ export class Orchestrator {
18717
18763
  /** Issue #681 — when true, bypass graphTraversalConfidenceFloor. */
18718
18764
  includeLowConfidence?: boolean;
18719
18765
  }): Promise<QmdSearchResult[]> {
18766
+ // Prefer the threaded set; fall back to a config-derived set so direct
18767
+ // callers (unit tests) behave identically to the recall pipeline (#1523).
18768
+ const caps = options.caps ?? resolveCapabilities(this.config);
18720
18769
  if (options.queryAwarePrefilter?.candidatePaths?.size === 0) {
18721
18770
  if (options.xrayPoolSizeSink) options.xrayPoolSizeSink.size = 0;
18722
18771
  return [];
@@ -18932,7 +18981,7 @@ export class Orchestrator {
18932
18981
  const isFullModeGraphAssist =
18933
18982
  this.config.qmdTierParityGraphEnabled &&
18934
18983
  this.config.multiGraphMemoryEnabled &&
18935
- this.config.graphAssistInFullModeEnabled !== false &&
18984
+ caps.graphAssistInFullMode &&
18936
18985
  options.recallMode === "full" &&
18937
18986
  results.length >= Math.max(1, this.config.graphAssistMinSeedResults ?? 3);
18938
18987
  const shouldRunGraphExpansion =
@@ -19028,7 +19077,7 @@ export class Orchestrator {
19028
19077
  timeoutMs: this.config.rerankTimeoutMs,
19029
19078
  maxCandidates: this.config.rerankMaxCandidates,
19030
19079
  cache: this.rerankCache,
19031
- cacheEnabled: this.config.rerankCacheEnabled,
19080
+ cacheEnabled: caps.rerankCache,
19032
19081
  cacheTtlMs: this.config.rerankCacheTtlMs,
19033
19082
  });
19034
19083
  if (ranked && ranked.length > 0) {
@@ -19054,7 +19103,7 @@ export class Orchestrator {
19054
19103
  // Memory Worth filter — must fire on the cold fallback path too, or the
19055
19104
  // feature flag produces divergent behavior by retrieval path (CLAUDE.md
19056
19105
  // rule 39). Fail-open on lookup errors.
19057
- if (this.config.recallMemoryWorthFilterEnabled && results.length > 0) {
19106
+ if (caps.recallMemoryWorthFilter && results.length > 0) {
19058
19107
  try {
19059
19108
  results = await this.applyMemoryWorthRerank(results, options.recallNamespaces);
19060
19109
  } catch (err) {
@@ -19079,6 +19128,7 @@ export class Orchestrator {
19079
19128
  results,
19080
19129
  options.recallResultLimit,
19081
19130
  options.prompt,
19131
+ caps,
19082
19132
  );
19083
19133
  }
19084
19134
 
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
 
4
4
  import { parseConfig } from "./config.js";
5
+ import { resolveCapabilities } from "./capabilities.js";
5
6
  import {
6
7
  planRecallModeLLM,
7
8
  resolveRecallPlannerLlmOptions,
@@ -40,7 +41,7 @@ test("returns heuristic without calling the LLM when recallPlannerLlmEnabled is
40
41
  const captured: Array<Record<string, unknown>> = [];
41
42
  const llm = stubLlm({ capturedOptions: captured, result: { mode: "no_recall" } });
42
43
 
43
- const result = await planRecallModeLLM("what did we decide about auth?", undefined, config, llm);
44
+ const result = await planRecallModeLLM("what did we decide about auth?", undefined, config, llm, undefined, resolveCapabilities(config));
44
45
 
45
46
  assert.equal(captured.length, 0, "LLM must not be contacted when disabled");
46
47
  assert.equal(result.source, "heuristic");
@@ -54,7 +55,7 @@ test("uses the LLM classification when enabled", async () => {
54
55
  const config = parseConfig({ recallPlannerLlmEnabled: true });
55
56
  const llm = stubLlm({ result: { mode: "graph_mode", reason: "asks for root cause" }, modelUsed: "anthropic/claude" });
56
57
 
57
- const result = await planRecallModeLLM("restart the gateway", undefined, config, llm);
58
+ const result = await planRecallModeLLM("restart the gateway", undefined, config, llm, undefined, resolveCapabilities(config));
58
59
 
59
60
  assert.equal(result.source, "llm");
60
61
  assert.equal(result.mode, "graph_mode");
@@ -74,7 +75,7 @@ test("forwards taskModelChain AND recallPlannerModel in gateway mode (provider-a
74
75
  const captured: Array<Record<string, unknown>> = [];
75
76
  const llm = stubLlm({ capturedOptions: captured, result: { mode: "minimal" } });
76
77
 
77
- await planRecallModeLLM("check status", undefined, config, llm);
78
+ await planRecallModeLLM("check status", undefined, config, llm, undefined, resolveCapabilities(config));
78
79
 
79
80
  assert.equal(captured.length, 1);
80
81
  // recallPlannerModel is tried first (prepended), taskModelChain is the fallback chain.
@@ -97,7 +98,7 @@ test("plugin mode passes only the explicit model, no gateway chain", async () =>
97
98
  const captured: Array<Record<string, unknown>> = [];
98
99
  const llm = stubLlm({ capturedOptions: captured, result: { mode: "full" } });
99
100
 
100
- await planRecallModeLLM("summarize the project", undefined, config, llm);
101
+ await planRecallModeLLM("summarize the project", undefined, config, llm, undefined, resolveCapabilities(config));
101
102
 
102
103
  assert.equal(captured.length, 1);
103
104
  assert.equal(captured[0]?.model, "openai/gpt-5.5");
@@ -109,7 +110,7 @@ test("falls back to heuristic when the LLM throws", async () => {
109
110
  const config = parseConfig({ recallPlannerLlmEnabled: true });
110
111
  const llm = stubLlm({ throwError: "boom" });
111
112
 
112
- const result = await planRecallModeLLM("what happened during the outage?", undefined, config, llm);
113
+ const result = await planRecallModeLLM("what happened during the outage?", undefined, config, llm, undefined, resolveCapabilities(config));
113
114
 
114
115
  assert.equal(result.source, "heuristic-fallback");
115
116
  assert.equal(result.fallbackUsed, true);
@@ -123,7 +124,7 @@ test("falls back to heuristic when the LLM returns no parseable result", async (
123
124
  const config = parseConfig({ recallPlannerLlmEnabled: true });
124
125
  const llm = stubLlm({ result: null });
125
126
 
126
- const result = await planRecallModeLLM("how did we get here?", undefined, config, llm);
127
+ const result = await planRecallModeLLM("how did we get here?", undefined, config, llm, undefined, resolveCapabilities(config));
127
128
 
128
129
  assert.equal(result.source, "heuristic-fallback");
129
130
  assert.equal(result.fallbackUsed, true);
@@ -139,7 +140,7 @@ test("falls back without a network attempt when the chain is empty and the model
139
140
  const captured: Array<Record<string, unknown>> = [];
140
141
  const llm = stubLlm({ available: false, capturedOptions: captured, result: { mode: "full" } });
141
142
 
142
- const result = await planRecallModeLLM("anything", undefined, config, llm);
143
+ const result = await planRecallModeLLM("anything", undefined, config, llm, undefined, resolveCapabilities(config));
143
144
 
144
145
  assert.equal(captured.length, 0, "no network attempt when nothing is routable");
145
146
  assert.equal(result.source, "heuristic-fallback");
@@ -155,7 +156,7 @@ test("attempts the call (and falls back) when a provider-qualified model overrid
155
156
  const captured: Array<Record<string, unknown>> = [];
156
157
  const llm = stubLlm({ available: false, capturedOptions: captured, result: null });
157
158
 
158
- const result = await planRecallModeLLM("anything", undefined, config, llm);
159
+ const result = await planRecallModeLLM("anything", undefined, config, llm, undefined, resolveCapabilities(config));
159
160
 
160
161
  assert.equal(captured.length, 1, "qualified model override → still attempt the call");
161
162
  assert.equal(captured[0]?.model, "openai/gpt-5.5");
@@ -170,7 +171,7 @@ test("an already-aborted recall short-circuits to the heuristic without an LLM c
170
171
  const ac = new AbortController();
171
172
  ac.abort();
172
173
 
173
- const result = await planRecallModeLLM("what did we decide?", undefined, config, llm, ac.signal);
174
+ const result = await planRecallModeLLM("what did we decide?", undefined, config, llm, ac.signal, resolveCapabilities(config));
174
175
 
175
176
  assert.equal(captured.length, 0, "no LLM call when the recall is already aborted");
176
177
  assert.equal(result.source, "heuristic-fallback");
@@ -184,7 +185,7 @@ test("forwards the abort signal into the LLM call (cancellation contract)", asyn
184
185
  const llm = stubLlm({ capturedOptions: captured, result: { mode: "minimal" } });
185
186
  const ac = new AbortController();
186
187
 
187
- await planRecallModeLLM("check status", undefined, config, llm, ac.signal);
188
+ await planRecallModeLLM("check status", undefined, config, llm, ac.signal, resolveCapabilities(config));
188
189
 
189
190
  assert.equal(captured.length, 1);
190
191
  assert.equal(captured[0]?.signal, ac.signal, "recall abort signal must reach FallbackLlmClient");
@@ -195,7 +196,7 @@ test("empty prompts skip the LLM entirely", async () => {
195
196
  const captured: Array<Record<string, unknown>> = [];
196
197
  const llm = stubLlm({ capturedOptions: captured, result: { mode: "full" } });
197
198
 
198
- const result = await planRecallModeLLM(" ", undefined, config, llm);
199
+ const result = await planRecallModeLLM(" ", undefined, config, llm, undefined, resolveCapabilities(config));
199
200
 
200
201
  assert.equal(captured.length, 0);
201
202
  assert.equal(result.mode, "no_recall"); // heuristic returns no_recall for empty
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
 
3
3
  import type { PluginConfig, RecallPlanMode } from "./types.js";
4
+ import type { CapabilitySet } from "./capabilities.js";
4
5
  import { planRecallMode } from "./intent.js";
5
6
  import {
6
7
  FallbackLlmClient,
@@ -189,10 +190,15 @@ export async function planRecallModeLLM(
189
190
  config: PluginConfig,
190
191
  llm?: FallbackLlmClient,
191
192
  signal?: AbortSignal,
193
+ caps?: CapabilitySet,
192
194
  ): Promise<RecallPlannerLlmResult> {
193
195
  const heuristicMode = planRecallMode(prompt);
194
196
 
195
- if (!config.recallPlannerLlmEnabled) {
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;
201
+ if (!plannerLlmEnabled) {
196
202
  return heuristicResult(heuristicMode, "heuristic", "llm-disabled", 0, false);
197
203
  }
198
204
 
@@ -27,7 +27,7 @@
27
27
  */
28
28
 
29
29
  import assert from "node:assert/strict";
30
- import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
30
+ import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
31
31
  import os from "node:os";
32
32
  import path from "node:path";
33
33
  import test from "node:test";
@@ -356,6 +356,155 @@ test("question-queue items written by writeQuestion() do NOT leak into fallback
356
356
  * directly with a config object that explicitly disables QMD, proving the disk
357
357
  * scan a QMD-disabled deployment relies on returns every category.
358
358
  */
359
+ /**
360
+ * Symlink containment (same walker-hardening class as PR #1563 / issue #1546).
361
+ *
362
+ * `collectActiveMemoryPaths()` walks the RECALL_FALLBACK_DIRS category dirs for
363
+ * the QMD-unavailable filesystem recall fallback. A category dir symlinked
364
+ * outside `memoryDir` (e.g. `decisions/` -> an external directory) must NOT be
365
+ * followed — otherwise out-of-store files leak into recall results. The walker
366
+ * now `lstat`s each dir (skipping symlinks/non-dirs), realpaths it, and asserts
367
+ * it stays inside the memory root; per entry it skips symlinks and asserts the
368
+ * file resolves inside the root before including it.
369
+ */
370
+ test("category dir symlinked outside memoryDir does NOT leak files into fallback recall", async (t) => {
371
+ if (process.platform === "win32") {
372
+ t.skip("directory symlink setup is platform-specific");
373
+ return;
374
+ }
375
+ const baseDir = await mkdtemp(path.join(os.tmpdir(), "engram-1497-symlink-dir-"));
376
+ const outsideDir = await mkdtemp(path.join(os.tmpdir(), "engram-1497-symlink-out-"));
377
+ const storage = new StorageManager(baseDir);
378
+ StorageManager.clearAllStaticCaches();
379
+ storage.invalidateAllMemoriesCacheForDir();
380
+ try {
381
+ // A genuine in-store memory that MUST still be recalled.
382
+ await seedMemory(baseDir, "facts", "real-fact", "fact", "A genuine in-store fact.");
383
+
384
+ // An out-of-store memory reachable only via a symlinked category dir.
385
+ await writeFile(
386
+ path.join(outsideDir, "leaked.md"),
387
+ memoryFile("leaked-secret", "decision", "Out-of-store file must never be recalled."),
388
+ "utf-8",
389
+ );
390
+ // `decisions/` is a RECALL_FALLBACK_DIRS category dir; point it outside the store.
391
+ await symlink(outsideDir, path.join(baseDir, "decisions"), "dir");
392
+
393
+ storage.invalidateAllMemoriesCacheForDir();
394
+ const memories = await storage.readAllMemories();
395
+ const foundIds = new Set(memories.map((m) => m.frontmatter.id));
396
+
397
+ assert.ok(foundIds.has("real-fact"), "the genuine in-store fact must still be recalled");
398
+ assert.ok(
399
+ !foundIds.has("leaked-secret"),
400
+ "a symlinked-out category dir must not leak files into recall",
401
+ );
402
+ assert.equal(memories.length, 1, "only the in-store memory should be returned");
403
+ } finally {
404
+ StorageManager.clearAllStaticCaches();
405
+ await rm(baseDir, { recursive: true, force: true });
406
+ await rm(outsideDir, { recursive: true, force: true });
407
+ }
408
+ });
409
+
410
+ test("symlinked entry nested inside a category dir does NOT leak into fallback recall", async (t) => {
411
+ if (process.platform === "win32") {
412
+ t.skip("directory symlink setup is platform-specific");
413
+ return;
414
+ }
415
+ const baseDir = await mkdtemp(path.join(os.tmpdir(), "engram-1497-symlink-entry-"));
416
+ const outsideDir = await mkdtemp(path.join(os.tmpdir(), "engram-1497-symlink-entry-out-"));
417
+ const storage = new StorageManager(baseDir);
418
+ StorageManager.clearAllStaticCaches();
419
+ storage.invalidateAllMemoriesCacheForDir();
420
+ try {
421
+ // A genuine in-store memory in the same category dir that holds the symlink.
422
+ await seedMemory(baseDir, "facts", "real-fact", "fact", "A genuine in-store fact.");
423
+
424
+ await writeFile(
425
+ path.join(outsideDir, "leaked.md"),
426
+ memoryFile("nested-leak", "fact", "Out-of-store nested file must never be recalled."),
427
+ "utf-8",
428
+ );
429
+ // A symlinked *entry* inside facts/ that escapes the store.
430
+ await symlink(outsideDir, path.join(baseDir, "facts", "escape"), "dir");
431
+
432
+ storage.invalidateAllMemoriesCacheForDir();
433
+ const memories = await storage.readAllMemories();
434
+ const foundIds = new Set(memories.map((m) => m.frontmatter.id));
435
+
436
+ assert.ok(foundIds.has("real-fact"), "the genuine in-store fact must still be recalled");
437
+ assert.ok(
438
+ !foundIds.has("nested-leak"),
439
+ "a symlinked entry escaping the store must not leak files into recall",
440
+ );
441
+ assert.equal(memories.length, 1, "only the in-store memory should be returned");
442
+ } finally {
443
+ StorageManager.clearAllStaticCaches();
444
+ await rm(baseDir, { recursive: true, force: true });
445
+ await rm(outsideDir, { recursive: true, force: true });
446
+ }
447
+ });
448
+
449
+ /**
450
+ * Regression for Cursor Bugbot "Poisoned md skips sibling subdirs".
451
+ *
452
+ * Per-entry containment/realpath failures must be isolated so they cannot drop
453
+ * sibling `.md` files or, crucially, the nested-subdir recursion. This asserts
454
+ * that a category dir which also contains a symlinked-out entry still recurses
455
+ * into its real nested subdirectory and returns every in-store memory, while
456
+ * the out-of-store target stays excluded.
457
+ */
458
+ test("a guarded/skipped sibling entry does NOT drop nested in-store memories", async (t) => {
459
+ if (process.platform === "win32") {
460
+ t.skip("directory symlink setup is platform-specific");
461
+ return;
462
+ }
463
+ const baseDir = await mkdtemp(path.join(os.tmpdir(), "engram-1497-sibling-"));
464
+ const outsideDir = await mkdtemp(path.join(os.tmpdir(), "engram-1497-sibling-out-"));
465
+ const storage = new StorageManager(baseDir);
466
+ StorageManager.clearAllStaticCaches();
467
+ storage.invalidateAllMemoriesCacheForDir();
468
+ try {
469
+ const factsDir = path.join(baseDir, "facts");
470
+ await mkdir(factsDir, { recursive: true });
471
+ // A top-level in-store memory and a nested in-store memory in the SAME
472
+ // category dir that also holds the symlinked-out sibling.
473
+ await writeFile(
474
+ path.join(factsDir, "top.md"),
475
+ memoryFile("top-fact", "fact", "Top-level in-store fact."),
476
+ "utf-8",
477
+ );
478
+ await seedMemory(baseDir, "facts", "deep-fact", "fact", "Deeply nested in-store fact.", {
479
+ nested: true,
480
+ });
481
+
482
+ await writeFile(
483
+ path.join(outsideDir, "leaked.md"),
484
+ memoryFile("leaked-secret", "fact", "Out-of-store file must never be recalled."),
485
+ "utf-8",
486
+ );
487
+ // A symlinked-out sibling entry alongside the real files/subdir.
488
+ await symlink(outsideDir, path.join(factsDir, "escape"), "dir");
489
+
490
+ storage.invalidateAllMemoriesCacheForDir();
491
+ const memories = await storage.readAllMemories();
492
+ const foundIds = new Set(memories.map((m) => m.frontmatter.id));
493
+
494
+ assert.ok(foundIds.has("top-fact"), "the top-level in-store fact must be recalled");
495
+ assert.ok(
496
+ foundIds.has("deep-fact"),
497
+ "the nested in-store fact must still be recalled past a skipped sibling",
498
+ );
499
+ assert.ok(!foundIds.has("leaked-secret"), "the symlinked-out file must not leak");
500
+ assert.equal(memories.length, 2, "exactly the two in-store memories should be returned");
501
+ } finally {
502
+ StorageManager.clearAllStaticCaches();
503
+ await rm(baseDir, { recursive: true, force: true });
504
+ await rm(outsideDir, { recursive: true, force: true });
505
+ }
506
+ });
507
+
359
508
  test("QMD-disabled deployment: disk-scan collector returns all categories", async () => {
360
509
  const baseDir = await mkdtemp(path.join(os.tmpdir(), "engram-1497-qmd-off-"));
361
510
  // entitySchemas is the only other constructor arg; QMD is never constructed by