@remnic/core 9.46.1 → 9.47.0

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 (122) hide show
  1. package/dist/access-admin-ops-surface.d.ts +2 -2
  2. package/dist/access-admin-ops-surface.js +3 -2
  3. package/dist/access-authorization-probe.d.ts +2 -2
  4. package/dist/access-authorization-probe.js +4 -3
  5. package/dist/access-boundary.d.ts +2 -2
  6. package/dist/access-boundary.js +3 -2
  7. package/dist/access-cli.js +16 -16
  8. package/dist/access-extraction-force-flush.d.ts +2 -2
  9. package/dist/access-extraction-force-flush.js +3 -2
  10. package/dist/access-http-lcm-compaction.d.ts +2 -2
  11. package/dist/access-http-lcm-compaction.js +1 -1
  12. package/dist/access-http-lifecycle-flush.d.ts +2 -2
  13. package/dist/access-http-lifecycle-flush.js +2 -2
  14. package/dist/access-http-offline-stream.d.ts +2 -2
  15. package/dist/access-http-query.js +3 -2
  16. package/dist/access-http.d.ts +8 -2
  17. package/dist/access-http.js +11 -10
  18. package/dist/access-identity-continuity-surface.d.ts +1 -1
  19. package/dist/access-identity-continuity-surface.js +3 -2
  20. package/dist/access-lcm-surface.d.ts +2 -2
  21. package/dist/access-lcm-surface.js +3 -2
  22. package/dist/access-mcp.d.ts +2 -2
  23. package/dist/access-mcp.js +7 -6
  24. package/dist/access-memory-search-fanout.d.ts +163 -2
  25. package/dist/access-memory-search-fanout.js +20 -3
  26. package/dist/access-namespace-preflight.js +3 -2
  27. package/dist/access-observe-write-surface.d.ts +2 -2
  28. package/dist/access-observe-write-surface.js +3 -2
  29. package/dist/access-offline-manifest.d.ts +1 -1
  30. package/dist/access-operations-batch.js +4 -3
  31. package/dist/access-operations.d.ts +6 -5
  32. package/dist/access-operations.js +6 -5
  33. package/dist/access-recall-concurrency.d.ts +2 -2
  34. package/dist/access-recall-concurrency.js +3 -2
  35. package/dist/access-recall-response.d.ts +2 -2
  36. package/dist/access-recall-response.js +3 -2
  37. package/dist/access-recall-surface.d.ts +2 -2
  38. package/dist/access-recall-surface.js +3 -2
  39. package/dist/access-schema.d.ts +84 -84
  40. package/dist/{access-service-loJadYd7.d.ts → access-service-DNBYHK33.d.ts} +2 -1
  41. package/dist/access-service-helpers.d.ts +2 -2
  42. package/dist/access-service.d.ts +2 -2
  43. package/dist/access-service.js +3 -2
  44. package/dist/access-surface-catalog.d.ts +2 -2
  45. package/dist/access-surface-catalog.js +1 -0
  46. package/dist/access-surface-catalog.js.map +1 -1
  47. package/dist/active-recall.js +2 -2
  48. package/dist/bootstrap.d.ts +1 -1
  49. package/dist/{chunk-WHWDBTCJ.js → chunk-74HN5QUH.js} +2 -2
  50. package/dist/{chunk-2DSUT7DZ.js → chunk-FQU3HTQM.js} +40 -11
  51. package/dist/chunk-FQU3HTQM.js.map +1 -0
  52. package/dist/{chunk-5LRDM4R7.js → chunk-G2L5IZV4.js} +7 -7
  53. package/dist/{chunk-TQZ4DVBC.js → chunk-GZ3R6XOE.js} +5 -2
  54. package/dist/chunk-GZ3R6XOE.js.map +1 -0
  55. package/dist/{chunk-5546W5SI.js → chunk-HLKPRWF4.js} +2 -2
  56. package/dist/chunk-IXEBUYO4.js +201 -0
  57. package/dist/chunk-IXEBUYO4.js.map +1 -0
  58. package/dist/{chunk-RCU25Z66.js → chunk-IXH2U2QT.js} +169 -58
  59. package/dist/chunk-IXH2U2QT.js.map +1 -0
  60. package/dist/{chunk-HJ3SVHUW.js → chunk-IZQ5TDFT.js} +9 -4
  61. package/dist/chunk-IZQ5TDFT.js.map +1 -0
  62. package/dist/{chunk-RVPTS4U5.js → chunk-JCNJIXZN.js} +2 -2
  63. package/dist/{chunk-5R6S4DJZ.js → chunk-N5XULCU3.js} +2 -2
  64. package/dist/{chunk-6H744ZBH.js → chunk-QISYTKG4.js} +2 -2
  65. package/dist/{chunk-AUMWZSE7.js → chunk-RN53QBM4.js} +4 -4
  66. package/dist/{chunk-M32URQXV.js → chunk-SIP5DBDN.js} +275 -334
  67. package/dist/chunk-SIP5DBDN.js.map +1 -0
  68. package/dist/{chunk-44N6ESJ4.js → chunk-TIDEL77O.js} +2 -2
  69. package/dist/{chunk-XBMJ35IP.js → chunk-VFZS2HAA.js} +4 -3
  70. package/dist/chunk-VFZS2HAA.js.map +1 -0
  71. package/dist/{cli-D_RcqXfZ.d.ts → cli-BWXSPxB9.d.ts} +2 -2
  72. package/dist/cli.d.ts +3 -3
  73. package/dist/cli.js +15 -15
  74. package/dist/config.js +2 -2
  75. package/dist/explicit-capture.d.ts +1 -1
  76. package/dist/external-wiki-access.d.ts +2 -2
  77. package/dist/external-wiki-access.js +4 -3
  78. package/dist/external-wiki-mcp-tools.d.ts +2 -2
  79. package/dist/index.d.ts +680 -680
  80. package/dist/index.js +16 -16
  81. package/dist/mcp-memory-inspector-app.d.ts +2 -2
  82. package/dist/operator-toolkit.js +3 -3
  83. package/dist/{orchestrator-LPGegii3.d.ts → orchestrator-CRR-6sVB.d.ts} +6 -0
  84. package/dist/orchestrator.d.ts +1 -1
  85. package/dist/orchestrator.js +17 -17
  86. package/dist/resume-bundles.js +3 -3
  87. package/dist/schemas.d.ts +76 -76
  88. package/dist/shared-context/manager.d.ts +8 -8
  89. package/dist/skills-registry.js +2 -2
  90. package/dist/skills-registry.js.map +1 -1
  91. package/dist/transfer/types.d.ts +66 -66
  92. package/package.json +2 -2
  93. package/skills/remnic-memory-workflow/SKILL.md +1 -1
  94. package/skills/remnic-remember/SKILL.md +1 -1
  95. package/src/access-http-lcm-compaction.ts +3 -0
  96. package/src/access-http.test.ts +645 -0
  97. package/src/access-http.ts +55 -4
  98. package/src/access-mcp.ts +1 -0
  99. package/src/access-memory-search-fanout.ts +305 -2
  100. package/src/access-operations.ts +6 -0
  101. package/src/access-service-namespace.test.ts +146 -0
  102. package/src/access-service.ts +39 -40
  103. package/src/access-surface-catalog.ts +1 -0
  104. package/src/orchestration/generic-recall-paths.ts +116 -5
  105. package/src/skills-registry.test.ts +14 -0
  106. package/src/skills-registry.ts +2 -2
  107. package/dist/chunk-2DSUT7DZ.js.map +0 -1
  108. package/dist/chunk-7HXCF4RE.js +0 -72
  109. package/dist/chunk-7HXCF4RE.js.map +0 -1
  110. package/dist/chunk-HJ3SVHUW.js.map +0 -1
  111. package/dist/chunk-M32URQXV.js.map +0 -1
  112. package/dist/chunk-RCU25Z66.js.map +0 -1
  113. package/dist/chunk-TQZ4DVBC.js.map +0 -1
  114. package/dist/chunk-XBMJ35IP.js.map +0 -1
  115. /package/dist/{chunk-WHWDBTCJ.js.map → chunk-74HN5QUH.js.map} +0 -0
  116. /package/dist/{chunk-5LRDM4R7.js.map → chunk-G2L5IZV4.js.map} +0 -0
  117. /package/dist/{chunk-5546W5SI.js.map → chunk-HLKPRWF4.js.map} +0 -0
  118. /package/dist/{chunk-RVPTS4U5.js.map → chunk-JCNJIXZN.js.map} +0 -0
  119. /package/dist/{chunk-5R6S4DJZ.js.map → chunk-N5XULCU3.js.map} +0 -0
  120. /package/dist/{chunk-6H744ZBH.js.map → chunk-QISYTKG4.js.map} +0 -0
  121. /package/dist/{chunk-AUMWZSE7.js.map → chunk-RN53QBM4.js.map} +0 -0
  122. /package/dist/{chunk-44N6ESJ4.js.map → chunk-TIDEL77O.js.map} +0 -0
@@ -758,15 +758,30 @@ export class EngramAccessHttpServer {
758
758
  * routes, otherwise a scoped bearer can scope its call to another tenant
759
759
  * by setting `body.namespace` (issue #1850 finding 2). Throws 403 for a
760
760
  * scoped token whose allow-list does not cover the effective namespace.
761
+ *
762
+ * A `namespace` that is neither a string nor `null` is REJECTED here rather
763
+ * than coerced to `undefined`: silently reinterpreting it would default the
764
+ * request to the principal's namespace set and answer 200, hiding the
765
+ * caller's mistake behind a plausible result (AGENTS.md pattern 39). `null`
766
+ * and an absent field keep their documented "no explicit namespace" meaning.
761
767
  */
762
768
  private gatedBodyNamespace(
763
769
  req: IncomingMessage,
764
770
  body: Record<string, unknown>,
765
771
  ): Record<string, unknown> {
766
- const namespace = this.resolveNamespace(
767
- req,
768
- typeof body.namespace === "string" ? body.namespace : undefined,
769
- );
772
+ const requested = body.namespace;
773
+ if (requested !== undefined && requested !== null && typeof requested !== "string") {
774
+ throw new EngramAccessInputError(
775
+ `namespace must be a string or null (got: ${typeof requested})`,
776
+ );
777
+ }
778
+ // Trim BEFORE the allow-list gate: the operation schemas normalize
779
+ // `namespace` with `.trim()`, so checking the raw value would 403 a
780
+ // `" team "` that the MCP path accepts as `team` — the same envelope
781
+ // succeeding or failing on harmless whitespace. The trimmed value is what
782
+ // gets stamped, so the gate and the operation see one namespace.
783
+ const trimmed = typeof requested === "string" ? requested.trim() : undefined;
784
+ const namespace = this.resolveNamespace(req, trimmed || undefined);
770
785
  return { ...body, namespace };
771
786
  }
772
787
 
@@ -972,6 +987,42 @@ export class EngramAccessHttpServer {
972
987
  this.respondJson(res, 200, output.result);
973
988
  return;
974
989
  }
990
+ if (
991
+ req.method === "POST" &&
992
+ (pathname === "/engram/v1/memories/search" ||
993
+ pathname === "/remnic/v1/memories/search")
994
+ ) {
995
+ // Semantic memory search over HTTP. `GET /engram/v1/memories` is a
996
+ // substring browse; this is the QMD-backed ranked search the MCP
997
+ // `memory_search` tool already exposes, reachable by HTTP-only clients.
998
+ this.enforceTokenOp("memory_search"); // boundary dispatch (issue #1525)
999
+ const operation = getOperation("memory_search");
1000
+ if (!operation) {
1001
+ throw new EngramAccessInputError(
1002
+ "access-boundary: operation not registered: memory_search",
1003
+ );
1004
+ }
1005
+ // The body `namespace` is user-controlled, so it must pass the same
1006
+ // effective-namespace allow-list gate as every other namespace-scoped
1007
+ // route (issue #1850 finding 2); the authenticated principal — never a
1008
+ // client-supplied value — then scopes the readable namespace fan-out.
1009
+ const body = this.gatedBodyNamespace(req, await this.readJsonBody(req));
1010
+ // memory_search is a FAN-OUT: an absent namespace searches everything
1011
+ // the principal can read. A namespace-scoped bearer may read fewer
1012
+ // namespaces than its principal, so leaving it absent would return
1013
+ // results the token was never authorized for. The allow-list gate above
1014
+ // already proved the server default is permitted for such a token, so
1015
+ // binding the effective namespace explicitly is both safe and closed.
1016
+ if (body.namespace === undefined && tokenCapabilityStore.getStore()?.namespaces !== undefined) {
1017
+ body.namespace = this.service.configRef?.defaultNamespace ?? "";
1018
+ }
1019
+ const output = (await operation.run(body, {
1020
+ service: this.service,
1021
+ authenticatedPrincipal: this.resolveRequestPrincipal(req),
1022
+ })) as { result: unknown };
1023
+ this.respondJson(res, 200, output.result);
1024
+ return;
1025
+ }
975
1026
 
976
1027
  if (req.method === "POST" && pathname === "/engram/v1/recall") {
977
1028
  this.enforceTokenOp("recall"); // boundary dispatch (issue #1525)
package/src/access-mcp.ts CHANGED
@@ -1440,6 +1440,7 @@ export class EngramMcpServer {
1440
1440
  description:
1441
1441
  "QMD collection. With namespaces enabled, omitted, base, and 'global' searches stay scoped to readable namespaces; namespace-derived collections require matching namespace access.",
1442
1442
  },
1443
+ mode: { type: "string", enum: ["search", "hybrid", "bm25", "vector"], description: "Ranking mode; omitted uses the backend default. Not supported with 'collection' on a flat corpus." },
1443
1444
  },
1444
1445
  required: ["query"],
1445
1446
  additionalProperties: false,
@@ -1,5 +1,6 @@
1
1
  import { canReadNamespace, defaultNamespaceForPrincipal } from "./namespaces/principal.js";
2
2
  import type { ResolvedScopeProfilePlan } from "./namespaces/scope-profiles.js";
3
+ import { EngramAccessInputError } from "./access-errors.js";
3
4
  import { log } from "./logger.js";
4
5
  import type { SearchDegradation, SearchExecutionOptions } from "./search/port.js";
5
6
  import type { PluginConfig } from "./types.js";
@@ -126,11 +127,12 @@ export async function runMemorySearchFanout<TResult>(options: {
126
127
  principal?: string;
127
128
  requestedNamespace?: string;
128
129
  collection?: string;
130
+ mode?: "search" | "hybrid" | "bm25" | "vector";
129
131
  search(params: {
130
132
  query: string;
131
133
  namespaces: string[];
132
134
  maxResults?: number;
133
- mode: "search";
135
+ mode: "search" | "hybrid" | "bm25" | "vector";
134
136
  execution: SearchExecutionOptions;
135
137
  }): Promise<TResult[]>;
136
138
  }): Promise<TResult[]> {
@@ -153,7 +155,7 @@ export async function runMemorySearchFanout<TResult>(options: {
153
155
  query,
154
156
  namespaces,
155
157
  maxResults,
156
- mode: "search",
158
+ mode: options.mode ?? "search",
157
159
  execution: {
158
160
  onDegradation: (degradation) => degradations.push(degradation),
159
161
  },
@@ -166,3 +168,304 @@ export async function runMemorySearchFanout<TResult>(options: {
166
168
  }
167
169
  return results;
168
170
  }
171
+
172
+ /**
173
+ * Resolve results for a FLAT corpus (namespaces disabled).
174
+ *
175
+ * An explicit ranking mode routes through the namespace-aware search even
176
+ * here, because that is the only path honoring it; the legacy direct-QMD calls
177
+ * stay the default so nothing else moves.
178
+ */
179
+ /**
180
+ * Reject an option combination a flat corpus cannot honor.
181
+ *
182
+ * A `mode` routes through the namespace-aware backend, which cannot target a
183
+ * specific collection, so pairing the two would silently search the default
184
+ * collection and return unrelated results (AGENTS.md pattern 39).
185
+ *
186
+ * Exported as its own step because callers validate BEFORE any budget
187
+ * short-circuit: changing only the requested result count must never make an
188
+ * invalid request succeed.
189
+ */
190
+ export const MEMORY_SEARCH_MODES = ["search", "hybrid", "bm25", "vector"] as const;
191
+
192
+ export type MemorySearchMode = (typeof MEMORY_SEARCH_MODES)[number];
193
+
194
+ /**
195
+ * Reject an unrecognized ranking mode at the service boundary.
196
+ *
197
+ * The HTTP and MCP schemas already validate their own input, but an
198
+ * in-process caller reaches this service untyped. Both namespace backends
199
+ * route an unknown mode through their DEFAULT ordinary-search branch, so a
200
+ * typo would silently rank differently instead of reporting bad input
201
+ * (AGENTS.md §39).
202
+ */
203
+ /**
204
+ * Reject a budget that is not a finite non-negative integer.
205
+ *
206
+ * The HTTP and MCP schemas validate their own input, but an in-process caller
207
+ * reaches this service untyped: a negative would hit the `budget <= 0`
208
+ * short-circuit and return a successful EMPTY page, and a fraction or a
209
+ * non-finite value would flow into the backend limit and top-up arithmetic.
210
+ * Zero keeps its documented meaning — an empty result with no backend call.
211
+ */
212
+ export function assertMemorySearchLimit(maxResults: unknown): void {
213
+ if (maxResults === undefined) return;
214
+ if (typeof maxResults !== "number" || !Number.isInteger(maxResults) || maxResults < 0) {
215
+ throw new EngramAccessInputError(
216
+ `maxResults must be a non-negative integer (got ${JSON.stringify(maxResults)})`,
217
+ );
218
+ }
219
+ }
220
+
221
+ export function assertMemorySearchMode(mode: unknown): void {
222
+ if (mode === undefined) return;
223
+ if (typeof mode !== "string" || !(MEMORY_SEARCH_MODES as readonly string[]).includes(mode)) {
224
+ throw new EngramAccessInputError(
225
+ `mode must be one of ${MEMORY_SEARCH_MODES.join(", ")} (got ${JSON.stringify(mode)})`,
226
+ );
227
+ }
228
+ }
229
+
230
+ export function assertFlatCorpusOptions(
231
+ mode: string | undefined,
232
+ collection: string | undefined,
233
+ ): void {
234
+ assertMemorySearchMode(mode);
235
+ if (mode && collection) {
236
+ throw new EngramAccessInputError(
237
+ `mode is not supported together with collection on a flat corpus (got collection: ${collection})`,
238
+ );
239
+ }
240
+ }
241
+
242
+ export async function runFlatCorpusMemorySearch<TResult>(options: {
243
+ query: string;
244
+ maxResults?: number;
245
+ collection?: string;
246
+ mode?: "search" | "hybrid" | "bm25" | "vector";
247
+ searchAcrossNamespaces(params: {
248
+ query: string;
249
+ maxResults?: number;
250
+ mode: "search" | "hybrid" | "bm25" | "vector";
251
+ }): Promise<TResult[]>;
252
+ searchGlobal(query: string, maxResults?: number): Promise<TResult[]>;
253
+ search(query: string, collection: string | undefined, maxResults?: number): Promise<TResult[]>;
254
+ }): Promise<TResult[]> {
255
+ const { query, maxResults, collection, mode } = options;
256
+ assertFlatCorpusOptions(options.mode, options.collection);
257
+ if (mode) {
258
+ // The mode-aware backend has no collection selector on a flat corpus, so
259
+ // honoring the mode would silently search the default collection instead
260
+ return options.searchAcrossNamespaces({ query, maxResults, mode });
261
+ }
262
+ return collection === "global"
263
+ ? options.searchGlobal(query, maxResults)
264
+ : options.search(query, collection, maxResults);
265
+ }
266
+
267
+ /**
268
+ * How far one generic memory search may page before it stops.
269
+ *
270
+ * A backend-safety bound, NOT a stand-in for corpus exhaustion — which is why
271
+ * it is ABSOLUTE rather than a multiple of the caller's budget. Scaling it to
272
+ * the budget made "the excluded paths happen to rank first" indistinguishable
273
+ * from "there is nothing else": a 1,000-row request whose first 4,000 hits are
274
+ * artifacts stopped at 4,000 and returned an empty page while valid memories
275
+ * sat at rank 4,001. Pages that come back FULL mean the corpus is not
276
+ * exhausted, so the loop keeps going until a short page proves it is or this
277
+ * cap protects the backend.
278
+ */
279
+ const MEMORY_SEARCH_CANDIDATE_CAP = 25_000;
280
+
281
+
282
+ /**
283
+ * Run a ranked memory search and apply the generic-recall path exclusions
284
+ * BEFORE the user-facing cap.
285
+ *
286
+ * Artifact isolation is a retrieval contract, not a ranking preference:
287
+ * artifacts flow only through the dedicated verbatim path. Filtering after the
288
+ * backend's own cap would let a handful of top-ranked artifacts shrink - or
289
+ * empty - a page that has valid memories right behind them, so the search runs
290
+ * with candidate headroom and tops up until the post-filter budget is met or
291
+ * the corpus is exhausted.
292
+ */
293
+ export async function searchWithGenericExclusion<TResult extends { path: string }>(options: {
294
+ budget: number;
295
+ /**
296
+ * `false` omits `maxResults` on the FIRST request, so a caller that named no
297
+ * budget keeps the backend's own page size on the wire; the resolved budget
298
+ * is still what the filtered page is measured against.
299
+ */
300
+ sendInitialLimit: boolean;
301
+ search(limit: number | undefined): Promise<TResult[]>;
302
+ isExcluded(memoryPath: string): boolean;
303
+ }): Promise<TResult[]> {
304
+ const { budget } = options;
305
+ if (budget <= 0) return [];
306
+ let results: TResult[] = [];
307
+ let limit: number | undefined = options.sendInitialLimit ? budget : undefined;
308
+ // With no explicit limit the caller asked for the BACKEND's page, so that
309
+ // page's size is the target — not the configured cap. Using the cap would
310
+ // reissue the query whenever the backend's default page is smaller, and
311
+ // return more rows than the same request used to.
312
+ let target = budget;
313
+ let firstPage = true;
314
+ for (;;) {
315
+ const raw = await options.search(limit);
316
+ results = raw.filter((hit) => !options.isExcluded(hit.path));
317
+ if (firstPage) {
318
+ firstPage = false;
319
+ if (!options.sendInitialLimit) target = Math.min(budget, raw.length);
320
+ if (target <= 0) return [];
321
+ }
322
+ if (results.length >= target) break;
323
+ // What the backend actually served this round: its own page size when we
324
+ // named no limit.
325
+ const served = limit ?? raw.length;
326
+ // A short page means the corpus is exhausted - asking for more is wasted
327
+ // work that returns the same rows.
328
+ if (raw.length === 0 || raw.length < served) break;
329
+ // The cap protects the backend from an unbounded walk, but it can never
330
+ // sit at or below what the caller explicitly asked for: a request for N
331
+ // rows needs room BEYOND N to replace the excluded hits among them, or a
332
+ // large search returns a short page for a count the operation deliberately
333
+ // supports.
334
+ const cap = Math.max(MEMORY_SEARCH_CANDIDATE_CAP, target * 2);
335
+ if (served >= cap) break;
336
+ limit = Math.min(served * 2, cap);
337
+ }
338
+ return results.slice(0, target);
339
+ }
340
+
341
+ /**
342
+ * The whole ranked memory-search path behind `POST /engram/v1/memories/search`
343
+ * and the `memory_search` tool: pick the flat-corpus or namespace-aware
344
+ * backend, then apply generic-recall exclusions before the caller's cap.
345
+ */
346
+ export async function runScopedMemorySearch(options: {
347
+ query: string;
348
+ budget: number;
349
+ /** `false` when the caller named no `maxResults`; see the helper below. */
350
+ sendInitialLimit: boolean;
351
+ /**
352
+ * Authorize the scope. Runs BEFORE any budget decision so a zero budget - a
353
+ * valid empty search - can never skip the namespace/principal gate and turn
354
+ * an access error into a successful empty result.
355
+ */
356
+ authorizeScope(): Promise<void> | void;
357
+ collection?: string;
358
+ mode?: "search" | "hybrid" | "bm25" | "vector";
359
+ namespacesEnabled: boolean;
360
+ isExcluded(memoryPath: string): boolean;
361
+ flatCorpus(
362
+ limit: number | undefined,
363
+ ): Promise<Array<{ path: string; score: number; snippet?: string }>>;
364
+ namespaced(
365
+ limit: number | undefined,
366
+ ): Promise<Array<{ path: string; score: number; snippet?: string }>>;
367
+ }): Promise<Array<{ path: string; score: number; snippet: string }>> {
368
+ await options.authorizeScope();
369
+ const results = await searchWithGenericExclusion({
370
+ budget: options.budget,
371
+ sendInitialLimit: options.sendInitialLimit,
372
+ isExcluded: options.isExcluded,
373
+ search: (limit) =>
374
+ options.namespacesEnabled ? options.namespaced(limit) : options.flatCorpus(limit),
375
+ });
376
+ return results.map((hit) => ({
377
+ path: hit.path,
378
+ score: hit.score,
379
+ snippet: (hit.snippet ?? "").slice(0, 800),
380
+ }));
381
+ }
382
+
383
+ /** Everything the ranked memory-search surface needs from the service. */
384
+ export interface ScopedMemorySearchDeps {
385
+ namespacesEnabled: boolean;
386
+ defaultBudget: number;
387
+ isExcluded(memoryPath: string): boolean;
388
+ /** Flat-corpus authorization; throws when the namespace is unreadable. */
389
+ authorizeFlatCorpus(namespace: string | undefined, principal: string | undefined): void;
390
+ /** Namespace-aware authorization; throws, else returns the search fan-out. */
391
+ authorizeNamespaces(
392
+ namespace: string | undefined,
393
+ principal: string | undefined,
394
+ collection: string | undefined,
395
+ ): Promise<string[]>;
396
+ searchAcrossNamespaces(params: {
397
+ query: string;
398
+ namespaces?: string[];
399
+ maxResults?: number;
400
+ mode?: "search" | "hybrid" | "bm25" | "vector";
401
+ }): Promise<Array<{ path: string; score: number; snippet?: string }>>;
402
+ searchGlobal(
403
+ query: string,
404
+ maxResults?: number,
405
+ ): Promise<Array<{ path: string; score: number; snippet?: string }>>;
406
+ search(
407
+ query: string,
408
+ collection?: string,
409
+ maxResults?: number,
410
+ ): Promise<Array<{ path: string; score: number; snippet?: string }>>;
411
+ }
412
+
413
+ /** The whole `memory_search` surface: validate, authorize, search, shape. */
414
+ export async function memorySearchThroughScope(
415
+ deps: ScopedMemorySearchDeps,
416
+ request: {
417
+ query: string;
418
+ namespace?: string;
419
+ maxResults?: number;
420
+ collection?: string;
421
+ mode?: "search" | "hybrid" | "bm25" | "vector";
422
+ principal?: string;
423
+ },
424
+ ): Promise<{
425
+ query: string;
426
+ results: Array<{ path: string; score: number; snippet: string }>;
427
+ count: number;
428
+ }> {
429
+ const { query, namespace, maxResults, mode, principal } = request;
430
+ // BOTH branches take the mode, and only the flat one reaches
431
+ // `assertFlatCorpusOptions` — validate here so a namespaced request cannot
432
+ // rank differently on a typo, and before any budget short-circuit.
433
+ assertMemorySearchMode(mode);
434
+ assertMemorySearchLimit(maxResults);
435
+ const collection = request.collection?.trim();
436
+ if (request.collection !== undefined && !collection) {
437
+ throw new EngramAccessInputError("collection must be a non-empty string");
438
+ }
439
+ let searchNamespaces: string[] = [];
440
+ const results = await runScopedMemorySearch({
441
+ query, collection, mode,
442
+ namespacesEnabled: deps.namespacesEnabled,
443
+ isExcluded: deps.isExcluded,
444
+ budget: maxResults ?? deps.defaultBudget,
445
+ sendInitialLimit: maxResults !== undefined,
446
+ authorizeScope: async () => {
447
+ if (!deps.namespacesEnabled) {
448
+ // Validate the option combination here too: a zero budget must not be
449
+ // a way to slip an invalid request past the check.
450
+ assertFlatCorpusOptions(mode, collection);
451
+ return deps.authorizeFlatCorpus(namespace, principal);
452
+ }
453
+ searchNamespaces = await deps.authorizeNamespaces(namespace, principal, collection);
454
+ },
455
+ flatCorpus: (limit) =>
456
+ runFlatCorpusMemorySearch({
457
+ query, maxResults: limit, collection, mode,
458
+ searchAcrossNamespaces: (p) => deps.searchAcrossNamespaces(p),
459
+ searchGlobal: (q, globalLimit) => deps.searchGlobal(q, globalLimit),
460
+ search: (q, coll, searchLimit) => deps.search(q, coll, searchLimit),
461
+ }),
462
+ namespaced: (limit) =>
463
+ runMemorySearchFanout({
464
+ query, maxResults: limit, principal, collection, mode,
465
+ requestedNamespace: namespace,
466
+ namespaces: searchNamespaces,
467
+ search: (p) => deps.searchAcrossNamespaces(p),
468
+ }),
469
+ });
470
+ return { query, results, count: results.length };
471
+ }
@@ -102,6 +102,10 @@ const memorySearchSchema = z.object({
102
102
  // 100 would reject existing clients that request larger result sets.
103
103
  maxResults: z.number().int().min(1).nullable().optional(),
104
104
  collection: z.string().trim().min(1).max(256).nullable().optional(),
105
+ // Ranking mode. Mirrors the in-process manager's override so a host that
106
+ // asks for vector or lexical ranking gets the same semantics whether it
107
+ // talks to an embedded orchestrator or a standalone daemon (issue #2120).
108
+ mode: z.enum(["search", "hybrid", "bm25", "vector"]).nullable().optional(),
105
109
  });
106
110
 
107
111
  export interface MemorySearchInput {
@@ -109,6 +113,7 @@ export interface MemorySearchInput {
109
113
  readonly namespace?: string | null;
110
114
  readonly maxResults?: number | null;
111
115
  readonly collection?: string | null;
116
+ readonly mode?: "search" | "hybrid" | "bm25" | "vector" | null;
112
117
  }
113
118
 
114
119
  export interface MemorySearchOutput {
@@ -129,6 +134,7 @@ export const memorySearchOperation = defineOperation<MemorySearchInput, MemorySe
129
134
  namespace: input.namespace ?? undefined,
130
135
  maxResults: input.maxResults ?? undefined,
131
136
  collection: input.collection ?? undefined,
137
+ mode: input.mode ?? undefined,
132
138
  principal: ctx.authenticatedPrincipal,
133
139
  });
134
140
  return { result };
@@ -1824,3 +1824,149 @@ test("memorySearch does not probe default storage for an explicit namespace or b
1824
1824
  "default-namespace storage must not be probed for an explicit-namespace query",
1825
1825
  );
1826
1826
  });
1827
+
1828
+ test("memorySearch authorizes the scope even when the budget is zero", async () => {
1829
+ // A zero budget is a valid empty search, never a way to skip the namespace
1830
+ // gate: returning [] for an unreadable namespace would turn an access error
1831
+ // into a successful result.
1832
+ const { service } = makeService();
1833
+ let searchCalls = 0;
1834
+ (service as unknown as {
1835
+ orchestrator: { searchAcrossNamespaces(params: unknown): Promise<unknown[]> };
1836
+ }).orchestrator.searchAcrossNamespaces = async () => {
1837
+ searchCalls += 1;
1838
+ return [];
1839
+ };
1840
+ await assert.rejects(
1841
+ () =>
1842
+ service.memorySearch({
1843
+ query: "release note",
1844
+ namespace: "team",
1845
+ maxResults: 0,
1846
+ principal: "stranger",
1847
+ }),
1848
+ /namespace is not readable: team/,
1849
+ );
1850
+ assert.equal(searchCalls, 0, "and the backend is never consulted");
1851
+ });
1852
+
1853
+ test("memorySearch rejects mode+collection on a flat corpus even at a zero budget", async () => {
1854
+ // Changing only the requested result count must never make an invalid
1855
+ // request succeed.
1856
+ const { service } = makeServiceWithConfig({
1857
+ ...makeConfig(),
1858
+ namespacesEnabled: false,
1859
+ } as unknown as PluginConfig);
1860
+ for (const maxResults of [0, 5]) {
1861
+ await assert.rejects(
1862
+ () =>
1863
+ service.memorySearch({
1864
+ query: "q",
1865
+ collection: "some-collection",
1866
+ mode: "vector",
1867
+ maxResults,
1868
+ }),
1869
+ /mode is not supported together with collection on a flat corpus/,
1870
+ `maxResults: ${maxResults} must reject`,
1871
+ );
1872
+ }
1873
+ });
1874
+
1875
+ test("a configured qmdMaxResults of 0 is preserved, not coerced to a default", async () => {
1876
+ // A zero limit is a runtime compatibility guarantee (AGENTS.md guardrail 4):
1877
+ // it means the same thing as an explicit `maxResults: 0` - an empty result
1878
+ // with no backend call - and must never be silently raised to a default.
1879
+ const { service } = makeServiceWithConfig({
1880
+ ...makeConfig(),
1881
+ qmdMaxResults: 0,
1882
+ } as unknown as PluginConfig);
1883
+ let searchCalls = 0;
1884
+ (service as unknown as {
1885
+ orchestrator: { searchAcrossNamespaces(params: unknown): Promise<unknown[]> };
1886
+ }).orchestrator.searchAcrossNamespaces = async () => {
1887
+ searchCalls += 1;
1888
+ return [{ path: "facts/a.md", score: 0.9, snippet: "a" }];
1889
+ };
1890
+ const result = await service.memorySearch({ query: "anything", principal: "operator-x" });
1891
+ assert.equal(result.count, 0, "the configured zero cap is honored");
1892
+ assert.equal(searchCalls, 0, "and the backend is never consulted");
1893
+ // The scope is still authorized first, so a zero cap cannot bypass the gate.
1894
+ await assert.rejects(
1895
+ () => service.memorySearch({ query: "q", namespace: "team", principal: "stranger" }),
1896
+ /namespace is not readable: team/,
1897
+ );
1898
+ });
1899
+
1900
+ test("an unrecognized search mode is rejected, not silently reranked", async () => {
1901
+ // Both namespace backends route an unknown mode through their default
1902
+ // ordinary-search branch, so a typo from an untyped in-process caller would
1903
+ // succeed with different ranking instead of reporting bad input.
1904
+ for (const namespacesEnabled of [true, false]) {
1905
+ const label = `namespacesEnabled=${namespacesEnabled}`;
1906
+ const { service } = makeService();
1907
+ (service as unknown as { orchestrator: { config: PluginConfig } }).orchestrator.config = {
1908
+ ...makeConfig(),
1909
+ namespacesEnabled,
1910
+ };
1911
+ let searchCalls = 0;
1912
+ (service as unknown as {
1913
+ orchestrator: { searchAcrossNamespaces(params: unknown): Promise<unknown[]> };
1914
+ }).orchestrator.searchAcrossNamespaces = async () => {
1915
+ searchCalls += 1;
1916
+ return [];
1917
+ };
1918
+
1919
+ await assert.rejects(
1920
+ () => service.memorySearch({ query: "q", mode: "vectors" as never, principal: "reader" }),
1921
+ /mode must be one of search, hybrid, bm25, vector/,
1922
+ label,
1923
+ );
1924
+ // A zero budget must not be a way to slip an invalid mode past the check.
1925
+ await assert.rejects(
1926
+ () =>
1927
+ service.memorySearch({
1928
+ query: "q",
1929
+ mode: "vectors" as never,
1930
+ maxResults: 0,
1931
+ principal: "reader",
1932
+ }),
1933
+ /mode must be one of/,
1934
+ `zero budget, ${label}`,
1935
+ );
1936
+ assert.equal(searchCalls, 0, `no backend call on an invalid mode, ${label}`);
1937
+
1938
+ // Every accepted mode still reaches the backend (AGENTS.md §40).
1939
+ for (const mode of ["search", "hybrid", "bm25", "vector"] as const) {
1940
+ await service.memorySearch({ query: "q", mode, principal: "reader" });
1941
+ }
1942
+ assert.equal(searchCalls, 4, `every valid mode dispatches, ${label}`);
1943
+ }
1944
+ });
1945
+
1946
+ test("an out-of-range search limit is rejected, not turned into an empty page", async () => {
1947
+ // A negative would hit the `budget <= 0` short-circuit and answer with a
1948
+ // successful EMPTY page; a fraction or non-finite value would flow into the
1949
+ // backend limit and top-up arithmetic.
1950
+ const { service } = makeService();
1951
+ let searchCalls = 0;
1952
+ (service as unknown as {
1953
+ orchestrator: { searchAcrossNamespaces(params: unknown): Promise<unknown[]> };
1954
+ }).orchestrator.searchAcrossNamespaces = async () => {
1955
+ searchCalls += 1;
1956
+ return [];
1957
+ };
1958
+ for (const maxResults of [-1, 2.5, Number.NaN, Number.POSITIVE_INFINITY]) {
1959
+ await assert.rejects(
1960
+ () => service.memorySearch({ query: "q", maxResults, principal: "reader" }),
1961
+ /maxResults must be a non-negative integer/,
1962
+ String(maxResults),
1963
+ );
1964
+ }
1965
+ assert.equal(searchCalls, 0, "no backend call on an invalid budget");
1966
+ // Zero keeps its documented meaning: an empty result, no backend call.
1967
+ const zero = await service.memorySearch({ query: "q", maxResults: 0, principal: "reader" });
1968
+ assert.equal(zero.count, 0);
1969
+ assert.equal(searchCalls, 0, "a zero budget still short-circuits");
1970
+ await service.memorySearch({ query: "q", maxResults: 3, principal: "reader" });
1971
+ assert.equal(searchCalls, 1, "an ordinary budget dispatches");
1972
+ });