@remnic/core 9.3.664 → 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 (150) 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.js +3 -3
  9. package/dist/{access-service-D0SLB4MH.d.ts → access-service-DsS-TatL.d.ts} +1 -1
  10. package/dist/access-service.d.ts +3 -2
  11. package/dist/access-service.js +21 -21
  12. package/dist/adapters/index.js +4 -4
  13. package/dist/adapters/registry.js +2 -2
  14. package/dist/bootstrap.d.ts +2 -1
  15. package/dist/briefing.js +4 -3
  16. package/dist/capabilities.d.ts +73 -0
  17. package/dist/capabilities.js +8 -0
  18. package/dist/capabilities.js.map +1 -0
  19. package/dist/causal-behavior.js +2 -2
  20. package/dist/causal-chain.js +2 -2
  21. package/dist/causal-consolidation.js +7 -6
  22. package/dist/causal-consolidation.js.map +1 -1
  23. package/dist/causal-retrieval.js +2 -2
  24. package/dist/causal-trajectory.js +1 -1
  25. package/dist/{chunk-ROHLEUTH.js → chunk-23EBQ27U.js} +5 -5
  26. package/dist/{chunk-YW52BQSU.js → chunk-2TCHDANJ.js} +2 -2
  27. package/dist/{chunk-IROWLAWG.js → chunk-46WUVFOD.js} +4 -4
  28. package/dist/{chunk-XB5P5P2L.js → chunk-4T7P2HLJ.js} +3 -3
  29. package/dist/{chunk-7XH7VJN4.js → chunk-6T4LTI2F.js} +4 -4
  30. package/dist/{chunk-TVVEYCNW.js → chunk-7K5Q6COX.js} +4 -4
  31. package/dist/{chunk-BZG2CWOQ.js → chunk-A5TEHAR4.js} +3 -3
  32. package/dist/{chunk-C7AF236A.js → chunk-AARDBQTA.js} +2 -2
  33. package/dist/{chunk-IHG6CC7T.js → chunk-BQJUPECT.js} +2 -2
  34. package/dist/{chunk-7OGJQP7T.js → chunk-CRO4LCQ6.js} +5 -5
  35. package/dist/{chunk-YNDLCWXS.js → chunk-EZ25VE3G.js} +4 -4
  36. package/dist/{chunk-LIERUFPO.js → chunk-GZ6QAYSH.js} +94 -74
  37. package/dist/chunk-GZ6QAYSH.js.map +1 -0
  38. package/dist/{chunk-UXA5L2DZ.js → chunk-HQCGRSRU.js} +2 -2
  39. package/dist/{chunk-RKNJBZ55.js → chunk-JBPKEARU.js} +4 -4
  40. package/dist/{chunk-XW3W4PV4.js → chunk-JTPXSXHC.js} +2 -2
  41. package/dist/{chunk-OHJFJ4HI.js → chunk-KOXGLQS7.js} +2 -2
  42. package/dist/{chunk-NLF54XMD.js → chunk-MPXYHC35.js} +26 -26
  43. package/dist/{chunk-6JBKHTQD.js → chunk-MR4PJ277.js} +2 -2
  44. package/dist/{chunk-EXXBA5OM.js → chunk-OI4BXFSB.js} +4 -4
  45. package/dist/{chunk-SQZ42MKH.js → chunk-OQH5XUH3.js} +6 -3
  46. package/dist/chunk-OQH5XUH3.js.map +1 -0
  47. package/dist/{chunk-2HEZXPYU.js → chunk-Q2LQZYQ7.js} +3 -3
  48. package/dist/{chunk-YKX63GBK.js → chunk-QHWJG5C5.js} +8 -8
  49. package/dist/{chunk-T2AN3BSP.js → chunk-QZ7ODIVL.js} +2 -2
  50. package/dist/chunk-RI5XBIZ6.js +23 -0
  51. package/dist/chunk-RI5XBIZ6.js.map +1 -0
  52. package/dist/{chunk-7ILWCUWH.js → chunk-TJ7HH5LB.js} +28 -3
  53. package/dist/chunk-TJ7HH5LB.js.map +1 -0
  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.d.ts +13 -0
  87. package/dist/namespaces/storage.js +4 -3
  88. package/dist/operator-toolkit.js +15 -15
  89. package/dist/{orchestrator-Cg1UkvmO.d.ts → orchestrator-DZqPVoMI.d.ts} +8 -0
  90. package/dist/orchestrator.d.ts +2 -1
  91. package/dist/orchestrator.js +32 -31
  92. package/dist/recall-planner-llm.d.ts +2 -1
  93. package/dist/recall-planner-llm.js +3 -2
  94. package/dist/recall-planner-llm.js.map +1 -1
  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/storage.js +3 -2
  104. package/dist/transfer/backup.js +2 -2
  105. package/dist/transfer/capsule-export.js +2 -2
  106. package/dist/transfer/capsule-import.js +1 -1
  107. package/dist/verified-recall.js +4 -3
  108. package/package.json +1 -1
  109. package/src/capabilities.test.ts +97 -0
  110. package/src/capabilities.ts +86 -0
  111. package/src/direct-answer-wiring.test.ts +53 -2
  112. package/src/direct-answer-wiring.ts +18 -5
  113. package/src/namespaces/catalog.test.ts +12 -12
  114. package/src/namespaces/storage.ts +28 -1
  115. package/src/orchestrator.ts +69 -19
  116. package/src/recall-planner-llm.test.ts +12 -11
  117. package/src/recall-planner-llm.ts +7 -1
  118. package/src/storage-fallback-category-dirs.test.ts +150 -1
  119. package/src/storage.ts +51 -14
  120. package/dist/chunk-7ILWCUWH.js.map +0 -1
  121. package/dist/chunk-LIERUFPO.js.map +0 -1
  122. package/dist/chunk-SQZ42MKH.js.map +0 -1
  123. package/dist/chunk-VH6EIKVS.js.map +0 -1
  124. /package/dist/{chunk-ROHLEUTH.js.map → chunk-23EBQ27U.js.map} +0 -0
  125. /package/dist/{chunk-YW52BQSU.js.map → chunk-2TCHDANJ.js.map} +0 -0
  126. /package/dist/{chunk-IROWLAWG.js.map → chunk-46WUVFOD.js.map} +0 -0
  127. /package/dist/{chunk-XB5P5P2L.js.map → chunk-4T7P2HLJ.js.map} +0 -0
  128. /package/dist/{chunk-7XH7VJN4.js.map → chunk-6T4LTI2F.js.map} +0 -0
  129. /package/dist/{chunk-TVVEYCNW.js.map → chunk-7K5Q6COX.js.map} +0 -0
  130. /package/dist/{chunk-BZG2CWOQ.js.map → chunk-A5TEHAR4.js.map} +0 -0
  131. /package/dist/{chunk-C7AF236A.js.map → chunk-AARDBQTA.js.map} +0 -0
  132. /package/dist/{chunk-IHG6CC7T.js.map → chunk-BQJUPECT.js.map} +0 -0
  133. /package/dist/{chunk-7OGJQP7T.js.map → chunk-CRO4LCQ6.js.map} +0 -0
  134. /package/dist/{chunk-YNDLCWXS.js.map → chunk-EZ25VE3G.js.map} +0 -0
  135. /package/dist/{chunk-UXA5L2DZ.js.map → chunk-HQCGRSRU.js.map} +0 -0
  136. /package/dist/{chunk-RKNJBZ55.js.map → chunk-JBPKEARU.js.map} +0 -0
  137. /package/dist/{chunk-XW3W4PV4.js.map → chunk-JTPXSXHC.js.map} +0 -0
  138. /package/dist/{chunk-OHJFJ4HI.js.map → chunk-KOXGLQS7.js.map} +0 -0
  139. /package/dist/{chunk-NLF54XMD.js.map → chunk-MPXYHC35.js.map} +0 -0
  140. /package/dist/{chunk-6JBKHTQD.js.map → chunk-MR4PJ277.js.map} +0 -0
  141. /package/dist/{chunk-EXXBA5OM.js.map → chunk-OI4BXFSB.js.map} +0 -0
  142. /package/dist/{chunk-2HEZXPYU.js.map → chunk-Q2LQZYQ7.js.map} +0 -0
  143. /package/dist/{chunk-YKX63GBK.js.map → chunk-QHWJG5C5.js.map} +0 -0
  144. /package/dist/{chunk-T2AN3BSP.js.map → chunk-QZ7ODIVL.js.map} +0 -0
  145. /package/dist/{chunk-V25ZAOSB.js.map → chunk-UOBLE67F.js.map} +0 -0
  146. /package/dist/{chunk-JIX3ZL2J.js.map → chunk-UVUTV7CM.js.map} +0 -0
  147. /package/dist/{chunk-SSOMTUCA.js.map → chunk-WXGTC424.js.map} +0 -0
  148. /package/dist/{chunk-KHGE6PMF.js.map → chunk-WXXLSZHA.js.map} +0 -0
  149. /package/dist/{chunk-DSLUOQDY.js.map → chunk-XMWF6AU3.js.map} +0 -0
  150. /package/dist/{chunk-DQY7NJ5L.js.map → chunk-XS2CWEHZ.js.map} +0 -0
@@ -7,7 +7,7 @@ import {
7
7
  type DirectAnswerWiringInput,
8
8
  } from "./direct-answer-wiring.js";
9
9
  import { DEFAULT_TAXONOMY } from "./taxonomy/default-taxonomy.js";
10
- import type { MemoryFile, PluginConfig } from "./types.js";
10
+ import type { MemoryFile } from "./types.js";
11
11
  import type { TrustZoneName } from "./trust-zones.js";
12
12
 
13
13
  type WiringConfig = DirectAnswerWiringInput["config"];
@@ -94,7 +94,8 @@ test("tryDirectAnswer disabled-path does not call any source accessor", async ()
94
94
  const result = await tryDirectAnswer({
95
95
  query: "does not matter",
96
96
  namespace: "default",
97
- config: { ...BASE_CONFIG, recallDirectAnswerEnabled: false },
97
+ config: BASE_CONFIG,
98
+ enabled: false,
98
99
  sources,
99
100
  });
100
101
  assert.equal(result.eligible, false);
@@ -104,6 +105,42 @@ test("tryDirectAnswer disabled-path does not call any source accessor", async ()
104
105
  assert.deepEqual(sources.calls.importance, []);
105
106
  });
106
107
 
108
+ // ── Backward-compat (#1523): omit top-level `enabled`, fall back to config ──
109
+
110
+ test("tryDirectAnswer falls back to config.recallDirectAnswerEnabled when `enabled` is omitted (disabled)", async () => {
111
+ // Old input shape: config carries recallDirectAnswerEnabled: false and no
112
+ // top-level `enabled`. Must short-circuit as "disabled" (identical to the
113
+ // pre-#1523 behavior) rather than treating undefined as enabled.
114
+ const sources = makeMockSources({ memories: [makeMemory()] });
115
+ const result = await tryDirectAnswer({
116
+ query: "does not matter",
117
+ namespace: "default",
118
+ config: { ...BASE_CONFIG, recallDirectAnswerEnabled: false },
119
+ sources,
120
+ });
121
+ assert.equal(result.eligible, false);
122
+ assert.equal(result.reason, "disabled");
123
+ assert.equal(sources.calls.listCandidates, 0);
124
+ });
125
+
126
+ test("tryDirectAnswer falls back to config.recallDirectAnswerEnabled when `enabled` is omitted (enabled)", async () => {
127
+ // Old input shape with recallDirectAnswerEnabled: true and no `enabled` must
128
+ // NOT short-circuit — it proceeds to materialize candidates.
129
+ const sources = makeMockSources({
130
+ memories: [makeMemory({ tags: ["pnpm"], content: "remnic uses pnpm" })],
131
+ trustZones: { m1: "trusted" },
132
+ importance: { m1: 0.9 },
133
+ });
134
+ const result = await tryDirectAnswer({
135
+ query: "package manager remnic",
136
+ namespace: "default",
137
+ config: BASE_CONFIG, // recallDirectAnswerEnabled: true
138
+ sources,
139
+ });
140
+ assert.notEqual(result.reason, "disabled");
141
+ assert.equal(sources.calls.listCandidates, 1);
142
+ });
143
+
107
144
  // ── Empty-query short-circuit: no I/O ───────────────────────────────────────
108
145
 
109
146
  test("tryDirectAnswer skips all I/O when query normalizes to zero searchable tokens", async () => {
@@ -119,6 +156,7 @@ test("tryDirectAnswer skips all I/O when query normalizes to zero searchable tok
119
156
  query: "? !!! ",
120
157
  namespace: "default",
121
158
  config: BASE_CONFIG,
159
+ enabled: true,
122
160
  sources,
123
161
  });
124
162
  assert.equal(result.reason, "empty-query");
@@ -135,6 +173,7 @@ test("tryDirectAnswer with empty memory list returns no-candidates", async () =>
135
173
  query: "package manager remnic",
136
174
  namespace: "default",
137
175
  config: BASE_CONFIG,
176
+ enabled: true,
138
177
  sources,
139
178
  });
140
179
  assert.equal(result.reason, "no-candidates");
@@ -158,6 +197,7 @@ test("tryDirectAnswer skips importance resolution for non-trusted memories", asy
158
197
  query: "package manager remnic",
159
198
  namespace: "default",
160
199
  config: BASE_CONFIG,
200
+ enabled: true,
161
201
  sources,
162
202
  });
163
203
  assert.equal(result.eligible, false);
@@ -182,6 +222,7 @@ test("tryDirectAnswer skips importance for quarantine-zone memories", async () =
182
222
  query: "package manager remnic",
183
223
  namespace: "default",
184
224
  config: BASE_CONFIG,
225
+ enabled: true,
185
226
  sources,
186
227
  });
187
228
  assert.equal(result.eligible, false);
@@ -203,6 +244,7 @@ test("tryDirectAnswer skips importance when trust zone is missing (null)", async
203
244
  query: "package manager remnic",
204
245
  namespace: "default",
205
246
  config: BASE_CONFIG,
247
+ enabled: true,
206
248
  sources,
207
249
  });
208
250
  assert.equal(result.eligible, false);
@@ -229,6 +271,7 @@ test("tryDirectAnswer skips importance when taxonomy bucket is not eligible", as
229
271
  query: "package manager remnic",
230
272
  namespace: "default",
231
273
  config: BASE_CONFIG,
274
+ enabled: true,
232
275
  sources,
233
276
  });
234
277
  assert.equal(result.eligible, false);
@@ -254,6 +297,7 @@ test("tryDirectAnswer returns eligible for a single trusted user-confirmed decis
254
297
  query: "package manager remnic",
255
298
  namespace: "default",
256
299
  config: BASE_CONFIG,
300
+ enabled: true,
257
301
  sources,
258
302
  });
259
303
  assert.equal(result.eligible, true);
@@ -284,6 +328,7 @@ test("tryDirectAnswer defers to hybrid when two trusted candidates are within am
284
328
  query: "package manager remnic",
285
329
  namespace: "default",
286
330
  config: BASE_CONFIG,
331
+ enabled: true,
287
332
  sources,
288
333
  });
289
334
  assert.equal(result.eligible, false);
@@ -327,6 +372,7 @@ test("tryDirectAnswer throws AbortError when signal aborts mid-loop", async () =
327
372
  query: "package manager remnic",
328
373
  namespace: "default",
329
374
  config: BASE_CONFIG,
375
+ enabled: true,
330
376
  sources,
331
377
  abortSignal: controller.signal,
332
378
  }),
@@ -363,6 +409,7 @@ test("tryDirectAnswer throws when abort lands during trustZoneFor on the only me
363
409
  query: "package manager remnic",
364
410
  namespace: "default",
365
411
  config: BASE_CONFIG,
412
+ enabled: true,
366
413
  sources,
367
414
  abortSignal: controller.signal,
368
415
  }),
@@ -391,6 +438,7 @@ test("tryDirectAnswer throws when abort lands during trustZoneFor on the last of
391
438
  query: "package manager remnic",
392
439
  namespace: "default",
393
440
  config: BASE_CONFIG,
441
+ enabled: true,
394
442
  sources,
395
443
  abortSignal: controller.signal,
396
444
  }),
@@ -408,6 +456,7 @@ test("tryDirectAnswer throws when signal is already aborted before I/O", async (
408
456
  query: "anything",
409
457
  namespace: "default",
410
458
  config: BASE_CONFIG,
459
+ enabled: true,
411
460
  sources,
412
461
  abortSignal: controller.signal,
413
462
  }),
@@ -434,6 +483,7 @@ test("tryDirectAnswer passes the requested namespace to listCandidateMemories",
434
483
  query: "anything",
435
484
  namespace: "project-x",
436
485
  config: BASE_CONFIG,
486
+ enabled: true,
437
487
  sources,
438
488
  });
439
489
  assert.equal(observedNamespace, "project-x");
@@ -465,6 +515,7 @@ test("tryDirectAnswer forwards queryEntityRefs to the eligibility gate", async (
465
515
  query: "package manager remnic",
466
516
  namespace: "default",
467
517
  config: BASE_CONFIG,
518
+ enabled: true,
468
519
  sources,
469
520
  queryEntityRefs: ["remnic"],
470
521
  });
@@ -14,9 +14,10 @@
14
14
  *
15
15
  * Short-circuit contract:
16
16
  *
17
- * - When `config.recallDirectAnswerEnabled === false`, the function
18
- * returns the eligibility verdict with reason `"disabled"` without
19
- * touching any source accessor. This is the documented default.
17
+ * - When the resolved gate (`input.enabled` if supplied, else
18
+ * `config.recallDirectAnswerEnabled`) is `false`, the function returns the
19
+ * eligibility verdict with reason `"disabled"` without touching any source
20
+ * accessor. This is the documented default.
20
21
  * - When enabled, the wiring cheaply drops non-trusted-zone memories
21
22
  * and ineligible taxonomy buckets before computing importance, so
22
23
  * the eligibility module sees a pre-filtered candidate set. The
@@ -79,6 +80,15 @@ export interface DirectAnswerWiringInput {
79
80
  | "recallDirectAnswerAmbiguityMargin"
80
81
  | "recallDirectAnswerEligibleTaxonomyBuckets"
81
82
  >;
83
+ /**
84
+ * Direct-answer capability gate, resolved once at the recall-operation entry
85
+ * (issue #1523: `caps.recallDirectAnswer`). OPTIONAL and additive: when the
86
+ * caller supplies it, this module and the orchestrator agree on a single
87
+ * resolved gate value for the whole operation. When omitted, we fall back to
88
+ * `config.recallDirectAnswerEnabled` so existing callers on the old input
89
+ * shape keep identical behavior.
90
+ */
91
+ enabled?: boolean;
82
92
  sources: DirectAnswerSources;
83
93
  queryEntityRefs?: string[];
84
94
  abortSignal?: AbortSignal;
@@ -92,10 +102,13 @@ export interface DirectAnswerWiringInput {
92
102
  export async function tryDirectAnswer(
93
103
  input: DirectAnswerWiringInput,
94
104
  ): Promise<DirectAnswerResult> {
95
- const { query, namespace, config, sources, queryEntityRefs, abortSignal } = input;
105
+ const { query, namespace, config, enabled, sources, queryEntityRefs, abortSignal } = input;
96
106
 
97
107
  const eligibilityConfig: DirectAnswerConfig = {
98
- enabled: config.recallDirectAnswerEnabled,
108
+ // Prefer the resolved capability when supplied; fall back to the config
109
+ // flag so callers on the old input shape (config-only, no `enabled`) get
110
+ // identical gating (issue #1523 backward-compat).
111
+ enabled: enabled ?? config.recallDirectAnswerEnabled,
99
112
  tokenOverlapFloor: config.recallDirectAnswerTokenOverlapFloor,
100
113
  importanceFloor: config.recallDirectAnswerImportanceFloor,
101
114
  ambiguityMargin: config.recallDirectAnswerAmbiguityMargin,
@@ -487,13 +487,13 @@ test("StorageRouter integration: catalog registers namespace on storageFor", asy
487
487
  const config = makeConfig(memoryDir);
488
488
  const catalog = new NamespaceCatalog(config);
489
489
  const router = new NamespaceStorageRouter(config, {
490
- onResolve: (namespace, storageDir) => {
491
- void catalog.registerResolved(namespace, storageDir);
492
- },
490
+ // Return the registration promise so the router tracks it as in-flight and
491
+ // `whenResolveHooksSettled()` can await it deterministically (no timer race).
492
+ onResolve: (namespace, storageDir) => catalog.registerResolved(namespace, storageDir),
493
493
  });
494
494
  await router.storageFor("project-origin-abc123");
495
- // allow the fire-and-forget registration to settle
496
- await new Promise((r) => setTimeout(r, 10));
495
+ // Deterministically await the fire-and-forget registration instead of sleeping.
496
+ await router.whenResolveHooksSettled();
497
497
 
498
498
  const record = await catalog.getNamespaceRecord("project-origin-abc123");
499
499
  assert.ok(record, "storageFor should have registered the namespace");
@@ -2523,8 +2523,8 @@ test("an async onResolve hook rejection does not crash storage resolution", asyn
2523
2523
  // Must not throw or produce an unhandled rejection that fails the test.
2524
2524
  const sm = await router.storageFor("default");
2525
2525
  assert.ok(sm, "storage resolution succeeds despite a rejecting async hook");
2526
- // Give the swallowed rejection a tick to settle.
2527
- await new Promise((r) => setTimeout(r, 10));
2526
+ // Deterministically await the swallowed rejection instead of sleeping.
2527
+ await router.whenResolveHooksSettled();
2528
2528
  assert.ok(called >= 1, "the async hook was invoked");
2529
2529
  } finally {
2530
2530
  await rm(memoryDir, { recursive: true, force: true });
@@ -2564,7 +2564,7 @@ test("concurrent storageFor() for one namespace fires the resolve hook ONCE whil
2564
2564
  // Let the in-flight registration settle, then a steady-state cache hit must
2565
2565
  // still be a catalog no-op (now deduped via notifiedResolved).
2566
2566
  release();
2567
- await new Promise((r) => setTimeout(r, 10));
2567
+ await router.whenResolveHooksSettled();
2568
2568
  await router.storageFor("project-origin-inflight");
2569
2569
  assert.equal(calls, 1, "a steady-state cache hit after settle must not re-fire the hook");
2570
2570
  } finally {
@@ -2589,20 +2589,20 @@ test("a dropped resolve registration (hook returns false) is retried on a later
2589
2589
  });
2590
2590
 
2591
2591
  await router.storageFor("project-origin-retry");
2592
- // Give the async hook a tick to settle and clear the in-flight marker.
2593
- await new Promise((r) => setTimeout(r, 10));
2592
+ // Deterministically await the async hook so the in-flight marker is cleared.
2593
+ await router.whenResolveHooksSettled();
2594
2594
  assert.equal(calls, 1, "the hook fired once for the dropped registration");
2595
2595
 
2596
2596
  // Now the registration will succeed; a later resolve must RETRY (not be
2597
2597
  // suppressed by a stale in-flight/notified marker from the dropped attempt).
2598
2598
  result = undefined; // success (legacy void)
2599
2599
  await router.storageFor("project-origin-retry");
2600
- await new Promise((r) => setTimeout(r, 10));
2600
+ await router.whenResolveHooksSettled();
2601
2601
  assert.equal(calls, 2, "a dropped registration must be retried on the next storageFor()");
2602
2602
 
2603
2603
  // After a SUCCESSFUL registration, further cache hits are deduped (no retry).
2604
2604
  await router.storageFor("project-origin-retry");
2605
- await new Promise((r) => setTimeout(r, 10));
2605
+ await router.whenResolveHooksSettled();
2606
2606
  assert.equal(calls, 2, "a successful registration is not re-fired on subsequent cache hits");
2607
2607
  } finally {
2608
2608
  await rm(memoryDir, { recursive: true, force: true });
@@ -223,6 +223,11 @@ export class NamespaceStorageRouter {
223
223
  // entry is always removed when the promise settles, so the map cannot grow
224
224
  // unbounded (one transient entry per concurrently-resolving namespace).
225
225
  private readonly inFlightResolved = new Map<string, string>();
226
+ // Tracks every in-flight resolve-hook promise so callers can deterministically
227
+ // await the fire-and-forget registrations that `storageFor()` kicks off (see
228
+ // `whenResolveHooksSettled`). Entries are removed as each hook settles, so the
229
+ // set holds at most one promise per concurrently-resolving namespace.
230
+ private readonly pendingResolveHooks = new Set<Promise<unknown>>();
226
231
 
227
232
  // Normalized (trimmed) default namespace identity (NH-FH). `storageFor`
228
233
  // normalizes its input, so default-namespace branches must compare against the
@@ -325,7 +330,10 @@ export class NamespaceStorageRouter {
325
330
  // the map holds at most one transient entry per concurrently-resolving
326
331
  // namespace and cannot grow unbounded.
327
332
  this.inFlightResolved.set(namespace, storageDir);
328
- Promise.resolve(hook(namespace, storageDir)).then(
333
+ const hookResult = Promise.resolve(hook(namespace, storageDir));
334
+ // Track the in-flight promise so `whenResolveHooksSettled()` can await it.
335
+ this.pendingResolveHooks.add(hookResult);
336
+ hookResult.then(
329
337
  (persisted) => {
330
338
  // Clear the in-flight marker ONLY if it is still ours (a newer resolve
331
339
  // for a different dir may have replaced it).
@@ -338,6 +346,7 @@ export class NamespaceStorageRouter {
338
346
  // On `false` (dropped touch) we intentionally do NOT mark notified, so
339
347
  // a later `storageFor()` retries the registration. Clearing the
340
348
  // in-flight marker above is what re-enables that retry.
349
+ this.pendingResolveHooks.delete(hookResult);
341
350
  },
342
351
  () => {
343
352
  // Registration failed — clear in-flight AND do NOT mark as notified, so
@@ -348,6 +357,7 @@ export class NamespaceStorageRouter {
348
357
  if (this.notifiedResolved.get(namespace) === storageDir) {
349
358
  this.notifiedResolved.delete(namespace);
350
359
  }
360
+ this.pendingResolveHooks.delete(hookResult);
351
361
  },
352
362
  );
353
363
  } catch {
@@ -358,4 +368,21 @@ export class NamespaceStorageRouter {
358
368
  }
359
369
  }
360
370
  }
371
+
372
+ /**
373
+ * Resolve once every in-flight `onResolve` registration has settled.
374
+ *
375
+ * `storageFor()` fires the resolve hook fire-and-forget, so its catalog side
376
+ * effect (e.g. `registerResolved(...)`) is not observable the moment
377
+ * `storageFor()` returns. Callers that must act on that side effect — notably
378
+ * tests asserting the catalog was updated — should await this instead of
379
+ * racing a timer. Resolves immediately when no hook is registered or nothing
380
+ * is in flight. The loop re-checks because a settling hook could, in
381
+ * principle, trigger a follow-on resolution.
382
+ */
383
+ async whenResolveHooksSettled(): Promise<void> {
384
+ while (this.pendingResolveHooks.size > 0) {
385
+ await Promise.allSettled([...this.pendingResolveHooks]);
386
+ }
387
+ }
361
388
  }