@remnic/core 9.3.715 → 9.3.717

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 (81) hide show
  1. package/dist/access-boundary.d.ts +2 -2
  2. package/dist/access-boundary.js +4 -3
  3. package/dist/access-cli.js +13 -12
  4. package/dist/access-cli.js.map +1 -1
  5. package/dist/access-http.d.ts +2 -2
  6. package/dist/access-http.js +8 -7
  7. package/dist/access-mcp.d.ts +2 -2
  8. package/dist/access-mcp.js +7 -6
  9. package/dist/access-operations-batch.js +5 -4
  10. package/dist/access-operations.d.ts +2 -2
  11. package/dist/access-operations.js +6 -5
  12. package/dist/{access-service-GgnA--CE.d.ts → access-service-d4Tk-Dlr.d.ts} +2 -2
  13. package/dist/access-service.d.ts +2 -2
  14. package/dist/access-service.js +3 -2
  15. package/dist/access-surface-catalog.d.ts +2 -2
  16. package/dist/bootstrap.d.ts +1 -1
  17. package/dist/{chunk-MECU6WSH.js → chunk-2B43BYYZ.js} +6 -6
  18. package/dist/chunk-2KF4HDG2.js +24 -0
  19. package/dist/chunk-2KF4HDG2.js.map +1 -0
  20. package/dist/{chunk-HHLWSWWF.js → chunk-BF2426TN.js} +2 -2
  21. package/dist/{chunk-RBHANGVX.js → chunk-BZAK3RBN.js} +2 -2
  22. package/dist/{chunk-YUJL27ET.js → chunk-CTUWGJRI.js} +40 -63
  23. package/dist/chunk-CTUWGJRI.js.map +1 -0
  24. package/dist/{chunk-6QUPUTT7.js → chunk-DZYARLJ7.js} +5 -9
  25. package/dist/{chunk-6QUPUTT7.js.map → chunk-DZYARLJ7.js.map} +1 -1
  26. package/dist/{chunk-CTAHKIG6.js → chunk-EM22PCQR.js} +7 -7
  27. package/dist/{chunk-SHRRWOVY.js → chunk-J6BH2OBA.js} +64 -21
  28. package/dist/chunk-J6BH2OBA.js.map +1 -0
  29. package/dist/{chunk-GM3BMWKR.js → chunk-KHQ433G5.js} +2 -2
  30. package/dist/{chunk-SYDHFFAW.js → chunk-OUDAU3T5.js} +4 -4
  31. package/dist/{chunk-OQH5XUH3.js → chunk-Q6IP2ARR.js} +2 -5
  32. package/dist/chunk-Q6IP2ARR.js.map +1 -0
  33. package/dist/{chunk-O62P2LFL.js → chunk-VZQXJNBD.js} +2 -2
  34. package/dist/{chunk-BNOAB5HH.js → chunk-YAINYEM3.js} +57 -48
  35. package/dist/chunk-YAINYEM3.js.map +1 -0
  36. package/dist/{chunk-3VUPWDSE.js → chunk-ZAVTEQFM.js} +4 -4
  37. package/dist/{chunk-IHMT6XTY.js → chunk-ZXLSNG3T.js} +3 -3
  38. package/dist/{cli-Do1YtfnH.d.ts → cli-insNLYs6.d.ts} +2 -2
  39. package/dist/cli.d.ts +3 -3
  40. package/dist/cli.js +12 -11
  41. package/dist/direct-answer-wiring.d.ts +9 -9
  42. package/dist/direct-answer-wiring.js +1 -1
  43. package/dist/explicit-capture.d.ts +1 -1
  44. package/dist/index.d.ts +4 -4
  45. package/dist/index.js +23 -22
  46. package/dist/index.js.map +1 -1
  47. package/dist/mcp-memory-inspector-app.d.ts +2 -2
  48. package/dist/operator-toolkit.js +5 -3
  49. package/dist/orchestration/maintenance.js +5 -2
  50. package/dist/{orchestrator-BOaVstFG.d.ts → orchestrator-XPKhY2OV.d.ts} +6 -7
  51. package/dist/orchestrator.d.ts +1 -1
  52. package/dist/orchestrator.js +7 -6
  53. package/dist/recall-planner-llm.d.ts +1 -1
  54. package/dist/recall-planner-llm.js +2 -2
  55. package/dist/recall-planner-llm.js.map +1 -1
  56. package/dist/schemas.d.ts +22 -22
  57. package/dist/transfer/types.d.ts +12 -12
  58. package/package.json +2 -2
  59. package/src/access-service.ts +56 -41
  60. package/src/capabilities.test.ts +28 -0
  61. package/src/direct-answer-wiring.test.ts +20 -13
  62. package/src/direct-answer-wiring.ts +9 -13
  63. package/src/maintenance/namespace-planner.ts +3 -10
  64. package/src/orchestrator.ts +42 -71
  65. package/src/recall-planner-llm.test.ts +30 -11
  66. package/src/recall-planner-llm.ts +4 -5
  67. package/src/scopes/scope-plan.test.ts +168 -1
  68. package/src/scopes/scope-plan.ts +163 -0
  69. package/dist/chunk-BNOAB5HH.js.map +0 -1
  70. package/dist/chunk-OQH5XUH3.js.map +0 -1
  71. package/dist/chunk-SHRRWOVY.js.map +0 -1
  72. package/dist/chunk-YUJL27ET.js.map +0 -1
  73. /package/dist/{chunk-MECU6WSH.js.map → chunk-2B43BYYZ.js.map} +0 -0
  74. /package/dist/{chunk-HHLWSWWF.js.map → chunk-BF2426TN.js.map} +0 -0
  75. /package/dist/{chunk-RBHANGVX.js.map → chunk-BZAK3RBN.js.map} +0 -0
  76. /package/dist/{chunk-CTAHKIG6.js.map → chunk-EM22PCQR.js.map} +0 -0
  77. /package/dist/{chunk-GM3BMWKR.js.map → chunk-KHQ433G5.js.map} +0 -0
  78. /package/dist/{chunk-SYDHFFAW.js.map → chunk-OUDAU3T5.js.map} +0 -0
  79. /package/dist/{chunk-O62P2LFL.js.map → chunk-VZQXJNBD.js.map} +0 -0
  80. /package/dist/{chunk-3VUPWDSE.js.map → chunk-ZAVTEQFM.js.map} +0 -0
  81. /package/dist/{chunk-IHMT6XTY.js.map → chunk-ZXLSNG3T.js.map} +0 -0
@@ -342,7 +342,11 @@ import {
342
342
  recallNamespacesForPrincipal,
343
343
  resolvePrincipal,
344
344
  } from "./namespaces/principal.js";
345
- import { resolveScopePlan } from "./scopes/scope-plan.js";
345
+ import {
346
+ getConfiguredNamespaces,
347
+ resolveNamespaceFromStorageDir,
348
+ resolveScopePlan,
349
+ } from "./scopes/scope-plan.js";
346
350
  import {
347
351
  expandScopeProfileReadNamespaces,
348
352
  resolveScopeProfilePlan,
@@ -1396,12 +1400,11 @@ export async function resolveRecallModeDecisionAsync(
1396
1400
  options: RecallModeGraphOptions & {
1397
1401
  config: PluginConfig;
1398
1402
  /**
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.
1403
+ * Recall-operation capability gates (issue #1523). REQUIRED: the recall
1404
+ * orchestrator always passes a resolved set the LLM planner gate reads
1405
+ * `caps.recallPlannerLlm`, never re-derives from config.
1403
1406
  */
1404
- caps?: CapabilitySet;
1407
+ caps: CapabilitySet;
1405
1408
  hints?: string[];
1406
1409
  llm?: FallbackLlmClient;
1407
1410
  signal?: AbortSignal;
@@ -1410,10 +1413,8 @@ export async function resolveRecallModeDecisionAsync(
1410
1413
  const heuristicDecision = resolveRecallModeDecision(options);
1411
1414
 
1412
1415
  // 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;
1416
+ // Read the resolved capability (issue #1523) never re-derive from config.
1417
+ const plannerLlmEnabled = options.caps.recallPlannerLlm;
1417
1418
  if (!options.plannerEnabled || !plannerLlmEnabled) {
1418
1419
  return heuristicDecision;
1419
1420
  }
@@ -1423,9 +1424,9 @@ export async function resolveRecallModeDecisionAsync(
1423
1424
  options.prompt,
1424
1425
  options.hints,
1425
1426
  options.config,
1427
+ options.caps,
1426
1428
  options.llm,
1427
1429
  options.signal,
1428
- options.caps,
1429
1430
  );
1430
1431
 
1431
1432
  // Shadow mode: record what the LLM would have chosen but keep the heuristic
@@ -2320,18 +2321,11 @@ export class Orchestrator {
2320
2321
  return this.storageRouter.storageFor(ns);
2321
2322
  }
2322
2323
 
2323
- private configuredNamespaces(): string[] {
2324
- return Array.from(
2325
- new Set(
2326
- [
2327
- this.config.defaultNamespace,
2328
- this.config.sharedNamespace,
2329
- ...this.config.namespacePolicies.map((policy) => policy.name),
2330
- ]
2331
- .map((value) => value.trim())
2332
- .filter(Boolean),
2333
- ),
2334
- );
2324
+ private configuredNamespaceList(): string[] {
2325
+ // #1521: delegates to the scope-module resolver. The inline derivation is
2326
+ // retired so the adHocNamespaceResolutions ratchet no longer counts this
2327
+ // site.
2328
+ return getConfiguredNamespaces(this.config);
2335
2329
  }
2336
2330
 
2337
2331
  private rememberNamespaceStorageDirHint(namespace: string, storageDir?: string): void {
@@ -2459,7 +2453,7 @@ export class Orchestrator {
2459
2453
  }
2460
2454
 
2461
2455
  const configured = new Set(
2462
- this.configuredNamespaces().map((namespace) => normalizeNamespaceIdentity(namespace)),
2456
+ this.configuredNamespaceList().map((namespace) => normalizeNamespaceIdentity(namespace)),
2463
2457
  );
2464
2458
  const preferredByStorageDir = new Map<
2465
2459
  string,
@@ -2573,7 +2567,7 @@ export class Orchestrator {
2573
2567
  new Set(
2574
2568
  (options.namespaces?.length
2575
2569
  ? options.namespaces
2576
- : this.configuredNamespaces()
2570
+ : this.configuredNamespaceList()
2577
2571
  )
2578
2572
  .map((value) => value.trim())
2579
2573
  .filter(Boolean),
@@ -4141,7 +4135,7 @@ export class Orchestrator {
4141
4135
  if (options?.dryRun !== true) {
4142
4136
  try {
4143
4137
  await this.processEntitySynthesisQueue(
4144
- this.namespaceFromStorageDir(targetStorage.dir),
4138
+ this.storageDirNamespace(targetStorage.dir),
4145
4139
  5,
4146
4140
  );
4147
4141
  } catch (error) {
@@ -13974,7 +13968,7 @@ export class Orchestrator {
13974
13968
  )
13975
13969
  return false;
13976
13970
  if (
13977
- this.namespaceFromStorageDir(targetStorage.dir) ===
13971
+ this.storageDirNamespace(targetStorage.dir) ===
13978
13972
  this.config.sharedNamespace
13979
13973
  )
13980
13974
  return false;
@@ -14800,7 +14794,7 @@ export class Orchestrator {
14800
14794
  let targetNamespaceName =
14801
14795
  baseNamespace && baseNamespace.length > 0
14802
14796
  ? baseNamespace
14803
- : this.namespaceFromStorageDir(targetStorage.dir);
14797
+ : this.storageDirNamespace(targetStorage.dir);
14804
14798
  let routedRuleId: string | undefined;
14805
14799
  let routedNamespaceExplicit = false;
14806
14800
  if (routeRules.length > 0) {
@@ -14841,7 +14835,7 @@ export class Orchestrator {
14841
14835
  fact.scope === "global" &&
14842
14836
  !routedNamespaceExplicit
14843
14837
  ) {
14844
- const currentNs = this.namespaceFromStorageDir(targetStorage.dir);
14838
+ const currentNs = this.storageDirNamespace(targetStorage.dir);
14845
14839
  if (currentNs !== this.config.sharedNamespace && profileAllowsSharedWrites) {
14846
14840
  try {
14847
14841
  targetStorage = await this.storageRouter.storageFor(
@@ -15109,7 +15103,7 @@ export class Orchestrator {
15109
15103
  this.qmd.isAvailable() &&
15110
15104
  faithfulnessEnforceStatus !== "pending_review"
15111
15105
  ) {
15112
- const targetNamespace = this.namespaceFromStorageDir(targetStorage.dir);
15106
+ const targetNamespace = this.storageDirNamespace(targetStorage.dir);
15113
15107
  const contradiction = await this.checkForContradiction(
15114
15108
  fact.content,
15115
15109
  writeCategory,
@@ -15528,7 +15522,7 @@ export class Orchestrator {
15528
15522
  memoryId: parentId,
15529
15523
  category: writeCategory,
15530
15524
  content: fact.content,
15531
- namespace: this.namespaceFromStorageDir(targetStorage.dir),
15525
+ namespace: this.storageDirNamespace(targetStorage.dir),
15532
15526
  confidence: fact.confidence,
15533
15527
  source: "extraction",
15534
15528
  }),
@@ -15539,7 +15533,7 @@ export class Orchestrator {
15539
15533
 
15540
15534
  // Suggest links for this memory (Phase 3A)
15541
15535
  if (this.config.memoryLinkingEnabled && this.qmd.isAvailable()) {
15542
- const targetNamespace = this.namespaceFromStorageDir(targetStorage.dir);
15536
+ const targetNamespace = this.storageDirNamespace(targetStorage.dir);
15543
15537
  const suggestedLinks = await this.suggestLinksForMemory(
15544
15538
  fact.content,
15545
15539
  writeCategory,
@@ -15645,7 +15639,7 @@ export class Orchestrator {
15645
15639
  memoryId,
15646
15640
  category: writeCategory,
15647
15641
  content: fact.content,
15648
- namespace: this.namespaceFromStorageDir(targetStorage.dir),
15642
+ namespace: this.storageDirNamespace(targetStorage.dir),
15649
15643
  confidence: fact.confidence,
15650
15644
  source: "extraction",
15651
15645
  }),
@@ -15791,7 +15785,7 @@ export class Orchestrator {
15791
15785
  const baseTouchNamespace =
15792
15786
  baseNamespace && baseNamespace.length > 0
15793
15787
  ? baseNamespace
15794
- : this.namespaceFromStorageDir(storage.dir);
15788
+ : this.storageDirNamespace(storage.dir);
15795
15789
  };
15796
15790
  const recordDurableNonFactWrite = () => {
15797
15791
  durableNonFactWritten = true;
@@ -17982,7 +17976,7 @@ export class Orchestrator {
17982
17976
 
17983
17977
  const fallbackNamespace =
17984
17978
  fallbackStorageDir !== null
17985
- ? this.namespaceFromStorageDir(fallbackStorageDir)
17979
+ ? this.storageDirNamespace(fallbackStorageDir)
17986
17980
  : this.config.defaultNamespace;
17987
17981
  if (
17988
17982
  recallNamespaces.length === 0 ||
@@ -18172,7 +18166,7 @@ export class Orchestrator {
18172
18166
 
18173
18167
  const fallbackNamespace =
18174
18168
  fallbackStorageDir !== null
18175
- ? this.namespaceFromStorageDir(fallbackStorageDir)
18169
+ ? this.storageDirNamespace(fallbackStorageDir)
18176
18170
  : this.config.defaultNamespace;
18177
18171
  maybeAddStorage(fallbackStorage, fallbackNamespace);
18178
18172
 
@@ -20037,41 +20031,18 @@ export class Orchestrator {
20037
20031
  return namespaceIdentityFromToken(m[1]) ?? m[1];
20038
20032
  }
20039
20033
 
20040
- private namespaceFromStorageDir(storageDir: string): string {
20041
- if (!this.config.namespacesEnabled) return this.config.defaultNamespace;
20042
- const resolvedStorageDir = path.resolve(storageDir);
20043
- const resolvedMemoryDir = path.resolve(this.config.memoryDir);
20044
- if (resolvedStorageDir === resolvedMemoryDir)
20045
- return this.config.defaultNamespace;
20046
- const m = resolvedStorageDir.match(/[\\/]namespaces[\\/]([^\\/]+)$/);
20047
- if (!m?.[1]) return this.config.defaultNamespace;
20048
- const dirName = m[1];
20049
- // Token-shaped raw names (round 6, codex P2 — NBsFz): a dir name might be a
20050
- // tokenized identity OR a literal raw namespace name that merely LOOKS like a
20051
- // token (e.g. a configured or dynamic name `ns-616c706861`). The round-trip check below
20052
- // (`namespaceIdentityToken(decoded) === dirName`) is TAUTOLOGICAL for a
20053
- // canonical token string, so it cannot tell a tokenized dir for `alpha` apart
20054
- // from the legacy raw root of a namespace literally named `ns-616c706861`
20055
- // (codex NRCve). A dir name that is itself a KNOWN namespace (configured or
20056
- // catalog-owned at this exact storage root) is therefore preserved as the
20057
- // literal namespace BEFORE attempting to decode it.
20058
- if (this.configuredNamespaces().includes(dirName)) {
20059
- return dirName;
20060
- }
20061
- this.loadNamespaceStorageDirHintsFromCatalog();
20062
- const hintedNamespaces = this.namespaceStorageDirHints.get(resolvedStorageDir);
20063
- if (hintedNamespaces?.has(dirName)) {
20064
- return dirName;
20065
- }
20066
- if (hintedNamespaces?.size === 1) {
20067
- const [hintedNamespace] = hintedNamespaces;
20068
- if (hintedNamespace) return hintedNamespace;
20069
- }
20070
- const decoded = namespaceIdentityFromToken(dirName);
20071
- if (decoded && namespaceIdentityToken(decoded) === dirName) {
20072
- return decoded;
20073
- }
20074
- return dirName;
20034
+ private storageDirNamespace(storageDir: string): string {
20035
+ // #1521: delegates to the scope-module resolver. The inline dir→namespace
20036
+ // derivation (token round-trip guard, catalog hints) is retired so the
20037
+ // adHocNamespaceResolutions ratchet no longer counts this site. Hints are
20038
+ // loaded lazily via the callback (only after early returns, matching the
20039
+ // original behavior — codex P2).
20040
+ return resolveNamespaceFromStorageDir(storageDir, {
20041
+ config: this.config,
20042
+ configuredNamespaces: this.configuredNamespaceList(),
20043
+ hints: this.namespaceStorageDirHints,
20044
+ loadHints: () => this.loadNamespaceStorageDirHintsFromCatalog(),
20045
+ });
20075
20046
  }
20076
20047
 
20077
20048
  // #1522: catalog touch methods removed — touches now happen at the storage chokepoint.
@@ -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
  }
@@ -21,7 +21,12 @@
21
21
  import assert from "node:assert/strict";
22
22
  import test from "node:test";
23
23
 
24
- import { resolveScopePlan } from "./scope-plan.js";
24
+ import {
25
+ getConfiguredNamespaces,
26
+ resolveNamespaceFromStorageDir,
27
+ resolveScopePlan,
28
+ resolveWritableNamespaceValue,
29
+ } from "./scope-plan.js";
25
30
  import {
26
31
  combineNamespaces,
27
32
  lcmSessionKeyForNamespace,
@@ -358,3 +363,165 @@ test("scope-plan: LCM session ids match lcmSessionKeyForNamespace encoding", ()
358
363
  );
359
364
  assert.deepEqual([...plan.lcmReadSessionIds], expected);
360
365
  });
366
+
367
+ // ──────────────────────────────────────────────────────────────────────────
368
+ // Fitness tests: centralized namespace-resolution helpers (#1521 step 3–4)
369
+ //
370
+ // These pin the three pure helpers that replace the ad-hoc resolution scattered
371
+ // across orchestrator.ts and access-service.ts. They MUST match the pre-migration
372
+ // behavior byte-for-byte (the snapshots were derived from the original inline
373
+ // implementations).
374
+ // ──────────────────────────────────────────────────────────────────────────
375
+
376
+ test("getConfiguredNamespaces: default + shared + policies, trimmed and deduped", () => {
377
+ const config = baseConfig({
378
+ defaultNamespace: "default",
379
+ sharedNamespace: "shared",
380
+ namespacePolicies: [
381
+ { name: "team-a", readPrincipals: ["*"], writePrincipals: ["*"] },
382
+ { name: "team-b", readPrincipals: ["*"], writePrincipals: ["*"] },
383
+ ],
384
+ });
385
+ const ns = getConfiguredNamespaces(config);
386
+ assert.deepEqual(ns, ["default", "shared", "team-a", "team-b"]);
387
+ });
388
+
389
+ test("getConfiguredNamespaces: trims whitespace and drops empty", () => {
390
+ const config = baseConfig({
391
+ defaultNamespace: " default ",
392
+ sharedNamespace: "",
393
+ namespacePolicies: [
394
+ { name: "team-a ", readPrincipals: ["*"], writePrincipals: ["*"] },
395
+ ],
396
+ });
397
+ const ns = getConfiguredNamespaces(config);
398
+ assert.deepEqual(ns, ["default", "team-a"]);
399
+ });
400
+
401
+ test("getConfiguredNamespaces: deduplicates identical names", () => {
402
+ const config = baseConfig({
403
+ defaultNamespace: "default",
404
+ sharedNamespace: "default",
405
+ namespacePolicies: [
406
+ { name: "default", readPrincipals: ["*"], writePrincipals: ["*"] },
407
+ ],
408
+ });
409
+ const ns = getConfiguredNamespaces(config);
410
+ assert.deepEqual(ns, ["default"]);
411
+ });
412
+
413
+ // ── resolveNamespaceFromStorageDir ──────────────────────────────────────────
414
+
415
+ test("resolveNamespaceFromStorageDir: namespaces disabled → always default", () => {
416
+ const config = baseConfig({ namespacesEnabled: false });
417
+ const ns = resolveNamespaceFromStorageDir("/some/path/namespaces/team-a", {
418
+ config,
419
+ configuredNamespaces: getConfiguredNamespaces(config),
420
+ });
421
+ assert.equal(ns, "default");
422
+ });
423
+
424
+ test("resolveNamespaceFromStorageDir: memory root → default", () => {
425
+ const config = baseConfig({ memoryDir: "/data/memory" });
426
+ const ns = resolveNamespaceFromStorageDir("/data/memory", {
427
+ config,
428
+ configuredNamespaces: getConfiguredNamespaces(config),
429
+ });
430
+ assert.equal(ns, "default");
431
+ });
432
+
433
+ test("resolveNamespaceFromStorageDir: configured namespace dir → that namespace", () => {
434
+ const config = baseConfig({
435
+ memoryDir: "/data/memory",
436
+ namespacePolicies: [
437
+ { name: "team-a", readPrincipals: ["*"], writePrincipals: ["*"] },
438
+ ],
439
+ });
440
+ const ns = resolveNamespaceFromStorageDir("/data/memory/namespaces/team-a", {
441
+ config,
442
+ configuredNamespaces: getConfiguredNamespaces(config),
443
+ });
444
+ assert.equal(ns, "team-a");
445
+ });
446
+
447
+ test("resolveNamespaceFromStorageDir: tokenized dir → decoded namespace", () => {
448
+ const config = baseConfig({ memoryDir: "/data/memory" });
449
+ // "alpha" tokenizes to ns-616c706861
450
+ const ns = resolveNamespaceFromStorageDir("/data/memory/namespaces/ns-616c706861", {
451
+ config,
452
+ configuredNamespaces: getConfiguredNamespaces(config),
453
+ });
454
+ assert.equal(ns, "alpha");
455
+ });
456
+
457
+ test("resolveNamespaceFromStorageDir: unknown dir → literal dir name", () => {
458
+ const config = baseConfig({ memoryDir: "/data/memory" });
459
+ const ns = resolveNamespaceFromStorageDir("/data/memory/namespaces/custom-ns", {
460
+ config,
461
+ configuredNamespaces: getConfiguredNamespaces(config),
462
+ });
463
+ assert.equal(ns, "custom-ns");
464
+ });
465
+
466
+ test("resolveNamespaceFromStorageDir: catalog hint resolves single-hint dir", () => {
467
+ const config = baseConfig({ memoryDir: "/data/memory" });
468
+ const resolvedDir = "/data/memory/namespaces/dynamic-xyz";
469
+ const hints = new Map([[resolvedDir, new Set(["my-dynamic-ns"])]]);
470
+ const ns = resolveNamespaceFromStorageDir(resolvedDir, {
471
+ config,
472
+ configuredNamespaces: getConfiguredNamespaces(config),
473
+ hints,
474
+ });
475
+ assert.equal(ns, "my-dynamic-ns");
476
+ });
477
+
478
+ // ── resolveWritableNamespaceValue ───────────────────────────────────────────
479
+
480
+ test("resolveWritableNamespaceValue: undefined namespace → default, ok", () => {
481
+ const config = baseConfig();
482
+ const result = resolveWritableNamespaceValue(undefined, "sess-1", undefined, config);
483
+ assert.equal(result.ok, true);
484
+ if (result.ok) assert.equal(result.namespace, "default");
485
+ });
486
+
487
+ test("resolveWritableNamespaceValue: explicit namespace, writable principal → ok", () => {
488
+ const config = baseConfig({
489
+ namespacePolicies: [
490
+ { name: "team-a", readPrincipals: ["alice"], writePrincipals: ["alice"] },
491
+ ],
492
+ });
493
+ const result = resolveWritableNamespaceValue("team-a", "alice:sess-1", "alice", config);
494
+ assert.equal(result.ok, true);
495
+ if (result.ok) assert.equal(result.namespace, "team-a");
496
+ });
497
+
498
+ test("resolveWritableNamespaceValue: explicit namespace, non-writable principal → not_writable", () => {
499
+ const config = baseConfig({
500
+ namespacePolicies: [
501
+ { name: "team-a", readPrincipals: ["alice"], writePrincipals: ["alice"] },
502
+ ],
503
+ });
504
+ const result = resolveWritableNamespaceValue("team-a", "bob:sess-1", "bob", config);
505
+ assert.equal(result.ok, false);
506
+ if (!result.ok) {
507
+ assert.equal(result.reason, "not_writable");
508
+ assert.equal(result.namespace, "team-a");
509
+ }
510
+ });
511
+
512
+ test("resolveWritableNamespaceValue: non-default namespace with namespaces disabled → unsupported", () => {
513
+ const config = baseConfig({ namespacesEnabled: false });
514
+ const result = resolveWritableNamespaceValue("custom-ns", "sess-1", undefined, config);
515
+ assert.equal(result.ok, false);
516
+ if (!result.ok) {
517
+ assert.equal(result.reason, "unsupported");
518
+ assert.equal(result.namespace, "custom-ns");
519
+ }
520
+ });
521
+
522
+ test("resolveWritableNamespaceValue: namespaces disabled, default namespace → ok", () => {
523
+ const config = baseConfig({ namespacesEnabled: false });
524
+ const result = resolveWritableNamespaceValue("default", "sess-1", undefined, config);
525
+ assert.equal(result.ok, true);
526
+ if (result.ok) assert.equal(result.namespace, "default");
527
+ });