@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.
- package/dist/access-admin-ops-surface.d.ts +2 -2
- package/dist/access-admin-ops-surface.js +3 -2
- package/dist/access-authorization-probe.d.ts +2 -2
- package/dist/access-authorization-probe.js +4 -3
- package/dist/access-boundary.d.ts +2 -2
- package/dist/access-boundary.js +3 -2
- package/dist/access-cli.js +16 -16
- package/dist/access-extraction-force-flush.d.ts +2 -2
- package/dist/access-extraction-force-flush.js +3 -2
- package/dist/access-http-lcm-compaction.d.ts +2 -2
- package/dist/access-http-lcm-compaction.js +1 -1
- package/dist/access-http-lifecycle-flush.d.ts +2 -2
- package/dist/access-http-lifecycle-flush.js +2 -2
- package/dist/access-http-offline-stream.d.ts +2 -2
- package/dist/access-http-query.js +3 -2
- package/dist/access-http.d.ts +8 -2
- package/dist/access-http.js +11 -10
- package/dist/access-identity-continuity-surface.d.ts +1 -1
- package/dist/access-identity-continuity-surface.js +3 -2
- package/dist/access-lcm-surface.d.ts +2 -2
- package/dist/access-lcm-surface.js +3 -2
- package/dist/access-mcp.d.ts +2 -2
- package/dist/access-mcp.js +7 -6
- package/dist/access-memory-search-fanout.d.ts +163 -2
- package/dist/access-memory-search-fanout.js +20 -3
- package/dist/access-namespace-preflight.js +3 -2
- package/dist/access-observe-write-surface.d.ts +2 -2
- package/dist/access-observe-write-surface.js +3 -2
- package/dist/access-offline-manifest.d.ts +1 -1
- package/dist/access-operations-batch.js +4 -3
- package/dist/access-operations.d.ts +6 -5
- package/dist/access-operations.js +6 -5
- package/dist/access-recall-concurrency.d.ts +2 -2
- package/dist/access-recall-concurrency.js +3 -2
- package/dist/access-recall-response.d.ts +2 -2
- package/dist/access-recall-response.js +3 -2
- package/dist/access-recall-surface.d.ts +2 -2
- package/dist/access-recall-surface.js +3 -2
- package/dist/access-schema.d.ts +84 -84
- package/dist/{access-service-loJadYd7.d.ts → access-service-DNBYHK33.d.ts} +2 -1
- package/dist/access-service-helpers.d.ts +2 -2
- package/dist/access-service.d.ts +2 -2
- package/dist/access-service.js +3 -2
- package/dist/access-surface-catalog.d.ts +2 -2
- package/dist/access-surface-catalog.js +1 -0
- package/dist/access-surface-catalog.js.map +1 -1
- package/dist/active-recall.js +2 -2
- package/dist/bootstrap.d.ts +1 -1
- package/dist/{chunk-WHWDBTCJ.js → chunk-74HN5QUH.js} +2 -2
- package/dist/{chunk-2DSUT7DZ.js → chunk-FQU3HTQM.js} +40 -11
- package/dist/chunk-FQU3HTQM.js.map +1 -0
- package/dist/{chunk-5LRDM4R7.js → chunk-G2L5IZV4.js} +7 -7
- package/dist/{chunk-TQZ4DVBC.js → chunk-GZ3R6XOE.js} +5 -2
- package/dist/chunk-GZ3R6XOE.js.map +1 -0
- package/dist/{chunk-5546W5SI.js → chunk-HLKPRWF4.js} +2 -2
- package/dist/chunk-IXEBUYO4.js +201 -0
- package/dist/chunk-IXEBUYO4.js.map +1 -0
- package/dist/{chunk-RCU25Z66.js → chunk-IXH2U2QT.js} +169 -58
- package/dist/chunk-IXH2U2QT.js.map +1 -0
- package/dist/{chunk-HJ3SVHUW.js → chunk-IZQ5TDFT.js} +9 -4
- package/dist/chunk-IZQ5TDFT.js.map +1 -0
- package/dist/{chunk-RVPTS4U5.js → chunk-JCNJIXZN.js} +2 -2
- package/dist/{chunk-5R6S4DJZ.js → chunk-N5XULCU3.js} +2 -2
- package/dist/{chunk-6H744ZBH.js → chunk-QISYTKG4.js} +2 -2
- package/dist/{chunk-AUMWZSE7.js → chunk-RN53QBM4.js} +4 -4
- package/dist/{chunk-M32URQXV.js → chunk-SIP5DBDN.js} +275 -334
- package/dist/chunk-SIP5DBDN.js.map +1 -0
- package/dist/{chunk-44N6ESJ4.js → chunk-TIDEL77O.js} +2 -2
- package/dist/{chunk-XBMJ35IP.js → chunk-VFZS2HAA.js} +4 -3
- package/dist/chunk-VFZS2HAA.js.map +1 -0
- package/dist/{cli-D_RcqXfZ.d.ts → cli-BWXSPxB9.d.ts} +2 -2
- package/dist/cli.d.ts +3 -3
- package/dist/cli.js +15 -15
- package/dist/config.js +2 -2
- package/dist/explicit-capture.d.ts +1 -1
- package/dist/external-wiki-access.d.ts +2 -2
- package/dist/external-wiki-access.js +4 -3
- package/dist/external-wiki-mcp-tools.d.ts +2 -2
- package/dist/index.d.ts +680 -680
- package/dist/index.js +16 -16
- package/dist/mcp-memory-inspector-app.d.ts +2 -2
- package/dist/operator-toolkit.js +3 -3
- package/dist/{orchestrator-LPGegii3.d.ts → orchestrator-CRR-6sVB.d.ts} +6 -0
- package/dist/orchestrator.d.ts +1 -1
- package/dist/orchestrator.js +17 -17
- package/dist/resume-bundles.js +3 -3
- package/dist/schemas.d.ts +76 -76
- package/dist/shared-context/manager.d.ts +8 -8
- package/dist/skills-registry.js +2 -2
- package/dist/skills-registry.js.map +1 -1
- package/dist/transfer/types.d.ts +66 -66
- package/package.json +2 -2
- package/skills/remnic-memory-workflow/SKILL.md +1 -1
- package/skills/remnic-remember/SKILL.md +1 -1
- package/src/access-http-lcm-compaction.ts +3 -0
- package/src/access-http.test.ts +645 -0
- package/src/access-http.ts +55 -4
- package/src/access-mcp.ts +1 -0
- package/src/access-memory-search-fanout.ts +305 -2
- package/src/access-operations.ts +6 -0
- package/src/access-service-namespace.test.ts +146 -0
- package/src/access-service.ts +39 -40
- package/src/access-surface-catalog.ts +1 -0
- package/src/orchestration/generic-recall-paths.ts +116 -5
- package/src/skills-registry.test.ts +14 -0
- package/src/skills-registry.ts +2 -2
- package/dist/chunk-2DSUT7DZ.js.map +0 -1
- package/dist/chunk-7HXCF4RE.js +0 -72
- package/dist/chunk-7HXCF4RE.js.map +0 -1
- package/dist/chunk-HJ3SVHUW.js.map +0 -1
- package/dist/chunk-M32URQXV.js.map +0 -1
- package/dist/chunk-RCU25Z66.js.map +0 -1
- package/dist/chunk-TQZ4DVBC.js.map +0 -1
- package/dist/chunk-XBMJ35IP.js.map +0 -1
- /package/dist/{chunk-WHWDBTCJ.js.map → chunk-74HN5QUH.js.map} +0 -0
- /package/dist/{chunk-5LRDM4R7.js.map → chunk-G2L5IZV4.js.map} +0 -0
- /package/dist/{chunk-5546W5SI.js.map → chunk-HLKPRWF4.js.map} +0 -0
- /package/dist/{chunk-RVPTS4U5.js.map → chunk-JCNJIXZN.js.map} +0 -0
- /package/dist/{chunk-5R6S4DJZ.js.map → chunk-N5XULCU3.js.map} +0 -0
- /package/dist/{chunk-6H744ZBH.js.map → chunk-QISYTKG4.js.map} +0 -0
- /package/dist/{chunk-AUMWZSE7.js.map → chunk-RN53QBM4.js.map} +0 -0
- /package/dist/{chunk-44N6ESJ4.js.map → chunk-TIDEL77O.js.map} +0 -0
package/src/access-http.ts
CHANGED
|
@@ -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
|
|
767
|
-
|
|
768
|
-
|
|
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
|
+
}
|
package/src/access-operations.ts
CHANGED
|
@@ -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
|
+
});
|