@mongodb-js/agent-engine-sdk-memory 0.11.3

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/memory.js ADDED
@@ -0,0 +1,601 @@
1
+ /**
2
+ * Transport-free public Memory facade (TypeScript port).
3
+ *
4
+ * `Memory` is the surface platform users interact with. It delegates the
5
+ * high-level workflow operations to an injected `MemoryRuntime` and the CRUD
6
+ * conveniences to an injected `MemoryCrudClient`. Identity is resolved per call;
7
+ * tenancy never appears in any public signature. Methods are async: the
8
+ * underlying transport is HTTP.
9
+ */
10
+ import { randomUUID } from "node:crypto";
11
+ import { MemoryIdentityError, MemoryNotSupportedError } from "./errors.js";
12
+ import { MemoryClientAdapter } from "./http/client_adapter.js";
13
+ import { resolveIdentity } from "./identity.js";
14
+ import { MemoryChunkSchema, SearchSource, toSearchSource, } from "./models.js";
15
+ import { validateMemoryType, validateTagSyntax, } from "./tag_syntax.js";
16
+ import { hasAmbientIdentity, } from "./transport.js";
17
+ /** Reject non-finite / non-integer / non-positive public maxTokens budgets. */
18
+ function requirePositiveMaxTokens(maxTokens) {
19
+ if (maxTokens === undefined) {
20
+ return;
21
+ }
22
+ if (!Number.isInteger(maxTokens) || maxTokens <= 0) {
23
+ throw new RangeError("maxTokens must be a positive integer");
24
+ }
25
+ }
26
+ /** Trim to a non-empty identity value, or `null` when blank/absent. */
27
+ function normalizeId(value) {
28
+ if (value === null || value === undefined || value.trim() === "")
29
+ return null;
30
+ return value;
31
+ }
32
+ /**
33
+ * Return the env var's value, or `null` when unset or blank — an
34
+ * empty-exported placeholder (common in CI/Helm/dotenv files) is not a
35
+ * provided credential, so it must neither authenticate nor count as a second
36
+ * input. Matches the baseUrl/projectId convention.
37
+ */
38
+ function envOrNull(name) {
39
+ const value = process.env[name];
40
+ return value !== undefined && value.trim() !== "" ? value : null;
41
+ }
42
+ const DEFAULT_GATEWAY_URL = "https://agentengine.mongodb.com";
43
+ const SERVICE_ACCOUNT_TOKEN_ENV_VAR = "AGENTIC_MEMORY_SERVICE_ACCOUNT_TOKEN";
44
+ // Deprecated alias for SERVICE_ACCOUNT_TOKEN_ENV_VAR, honored with a
45
+ // DeprecationWarning so existing deployments keep working.
46
+ const API_KEY_ENV_VAR = "AGENTIC_MEMORY_API_KEY";
47
+ const BASE_URL_ENV_VAR = "AGENTIC_MEMORY_BASE_URL";
48
+ const PROJECT_ID_ENV_VAR = "AGENTIC_MEMORY_PROJECT_ID";
49
+ /** Default sources for the zero-argument search(): the two ranked similarity sources. */
50
+ const DEFAULT_SEARCH_SOURCES = [
51
+ SearchSource.SEMANTIC,
52
+ SearchSource.EPISODIC,
53
+ ];
54
+ /** Resolve requested sources to a deduped, order-preserving list. */
55
+ function normalizeSources(sources) {
56
+ if (sources === null || sources === undefined) {
57
+ return [...DEFAULT_SEARCH_SOURCES];
58
+ }
59
+ const list = typeof sources === "string" ? [sources] : [...sources];
60
+ const resolved = [];
61
+ for (const source of list) {
62
+ const member = toSearchSource(source);
63
+ if (!resolved.includes(member)) {
64
+ resolved.push(member);
65
+ }
66
+ }
67
+ return resolved;
68
+ }
69
+ /** Adapt a `discoverProcedures` dict into a `MemoryChunk` for unified search. */
70
+ function procedureToChunk(proc) {
71
+ const rawId = proc.id ?? proc._id ?? "";
72
+ const content = proc.content ?? proc.description ?? proc.procedure ?? "";
73
+ const score = proc.score ?? proc.similarity_score ?? null;
74
+ const rawTs = proc.timestamp ?? proc.created_at ?? proc.updated_at;
75
+ return MemoryChunkSchema.parse({
76
+ id: String(rawId),
77
+ content: String(content),
78
+ source: "procedural",
79
+ // Fall back to the epoch (not now()) so an absent timestamp does not
80
+ // masquerade as a fresh result and distort recency.
81
+ timestamp: rawTs ?? "1970-01-01T00:00:00Z",
82
+ similarity_score: score,
83
+ metadata: proc,
84
+ });
85
+ }
86
+ function isInjected(opts) {
87
+ return "runtime" in opts && opts.runtime !== undefined;
88
+ }
89
+ /** Public, transport-free memory facade. */
90
+ export class Memory {
91
+ runtime;
92
+ client;
93
+ boundCtx;
94
+ ownsRuntime;
95
+ crudUnsupportedReason;
96
+ constructor(opts = {}) {
97
+ if (isInjected(opts)) {
98
+ this.runtime = opts.runtime;
99
+ this.client = opts.client ?? null;
100
+ this.boundCtx = null;
101
+ this.ownsRuntime = false;
102
+ this.crudUnsupportedReason = null;
103
+ return;
104
+ }
105
+ const token = opts.serviceAccountToken ?? envOrNull(SERVICE_ACCOUNT_TOKEN_ENV_VAR);
106
+ const legacyToken = opts.apiKey ?? envOrNull(API_KEY_ENV_VAR);
107
+ if (token !== null && legacyToken !== null) {
108
+ throw new Error(`pass only one of serviceAccountToken (${SERVICE_ACCOUNT_TOKEN_ENV_VAR}) ` +
109
+ `and the deprecated apiKey (${API_KEY_ENV_VAR})`);
110
+ }
111
+ const authToken = token ?? legacyToken;
112
+ if (authToken !== null && authToken.trim() === "") {
113
+ throw new Error(`${token !== null ? "serviceAccountToken" : "apiKey"} must be a non-empty string`);
114
+ }
115
+ // Validation precedes the warning so a rejected value never also warns.
116
+ if (legacyToken !== null) {
117
+ process.emitWarning("apiKey (AGENTIC_MEMORY_API_KEY) is deprecated and will be removed in a " +
118
+ "future release; use serviceAccountToken " +
119
+ "(AGENTIC_MEMORY_SERVICE_ACCOUNT_TOKEN) instead", "DeprecationWarning");
120
+ }
121
+ let projectId = opts.projectId ?? process.env[PROJECT_ID_ENV_VAR] ?? null;
122
+ // A blank projectId is treated as unset (the discriminator is presence, and
123
+ // an empty path segment would misroute); surrounding whitespace is stripped.
124
+ if (projectId !== null) {
125
+ projectId = projectId.trim() || null;
126
+ }
127
+ const resolvedUrl = Memory.resolveUrl(opts.baseUrl ?? null, authToken);
128
+ if (resolvedUrl === null) {
129
+ throw new Error(`Memory requires baseUrl, the ${BASE_URL_ENV_VAR} environment variable, ` +
130
+ `or an auth token (serviceAccountToken; deprecated alias apiKey), which ` +
131
+ `falls back to the hosted default ${DEFAULT_GATEWAY_URL}`);
132
+ }
133
+ // projectId presence, not auth, picks the route shape: set => project-scoped
134
+ // Gateway routes, empty => flat OE routes. Both backends serve the CRUD and
135
+ // core-loop routes, so type-specific operations are always available.
136
+ const apiPrefix = projectId
137
+ ? `/api/v1/projects/${encodeURIComponent(projectId)}/memory`
138
+ : "/api/v1/memory";
139
+ const headers = authToken
140
+ ? { Authorization: `Bearer ${authToken}` }
141
+ : undefined;
142
+ const adapter = new MemoryClientAdapter({
143
+ baseUrl: resolvedUrl,
144
+ apiPrefix,
145
+ routeStyle: "aliased",
146
+ projectScoped: projectId !== null,
147
+ headers,
148
+ fetchImpl: opts.fetchImpl,
149
+ });
150
+ this.runtime = adapter;
151
+ this.client = adapter;
152
+ this.crudUnsupportedReason = null;
153
+ this.boundCtx = null;
154
+ this.ownsRuntime = true;
155
+ }
156
+ static resolveUrl(baseUrl, authToken) {
157
+ if (baseUrl !== null && baseUrl.trim() === "") {
158
+ throw new Error("baseUrl must be a non-empty string");
159
+ }
160
+ const resolved = baseUrl ?? process.env[BASE_URL_ENV_VAR] ?? null;
161
+ if (resolved !== null && resolved.trim() !== "") {
162
+ return resolved.trim();
163
+ }
164
+ if (authToken) {
165
+ return DEFAULT_GATEWAY_URL;
166
+ }
167
+ return null;
168
+ }
169
+ /** Return a new Memory scoped to `ctx` without mutating this handle. */
170
+ bind(ctx) {
171
+ const bound = new Memory({ runtime: this.runtime, client: this.client });
172
+ // Preserve ownership + capability, and apply the bound context. Same-class
173
+ // instances may access each other's private fields.
174
+ bound.boundCtx = ctx;
175
+ bound.ownsRuntime = this.ownsRuntime;
176
+ bound.crudUnsupportedReason = this.crudUnsupportedReason;
177
+ return bound;
178
+ }
179
+ /** Release the underlying transport(s) when this handle owns them. */
180
+ async close() {
181
+ if (!this.ownsRuntime) {
182
+ return;
183
+ }
184
+ await this.runtime.close?.();
185
+ await this.client?.close?.();
186
+ }
187
+ requireClient() {
188
+ if (this.client === null) {
189
+ throw new MemoryNotSupportedError(this.crudUnsupportedReason ??
190
+ "type-specific save/get/list operations require a CRUD client; provide " +
191
+ "one via the client option when injecting a custom runtime, or use " +
192
+ "recordTurn, buildContext, or search");
193
+ }
194
+ return this.client;
195
+ }
196
+ runtimeCtx() {
197
+ // Only runtimes that declare the ambient-identity capability expose per-call
198
+ // identity; read fresh on every resolve so a long-lived facade picks up
199
+ // context that varies per request.
200
+ return hasAmbientIdentity(this.runtime)
201
+ ? this.runtime.requestContext()
202
+ : null;
203
+ }
204
+ resolve(args) {
205
+ const runtimeCtx = this.runtimeCtx();
206
+ // Security: when an ambient (app-bound) identity is present, it is the
207
+ // trusted principal resolved from the execution context. A caller- or
208
+ // bind-supplied `userId` that disagrees with it must never take effect —
209
+ // otherwise a tool that exposes `userId` as an LLM-controllable argument
210
+ // could read or write another user's memory through the same OE-proxied
211
+ // route. Fail closed on a mismatch rather than silently honoring the
212
+ // override; an equal or absent `userId` is fine (matches the documented
213
+ // "no userId threading" contract).
214
+ const ambientUserId = normalizeId(runtimeCtx?.userId);
215
+ if (ambientUserId !== null) {
216
+ const requestedUserId = normalizeId(args.userId) ?? normalizeId(this.boundCtx?.userId);
217
+ if (requestedUserId !== null && requestedUserId !== ambientUserId) {
218
+ throw new MemoryIdentityError("userId does not match the ambient app-bound identity: in app-bound " +
219
+ "mode the user is resolved from the execution context and cannot be " +
220
+ "overridden by a caller-supplied userId");
221
+ }
222
+ }
223
+ return resolveIdentity({
224
+ callArgs: {
225
+ userId: args.userId,
226
+ agentId: args.agentId,
227
+ sessionId: args.sessionId,
228
+ },
229
+ bindCtx: this.boundCtx,
230
+ runtimeCtx,
231
+ required: args.required,
232
+ suppressRuntimeUserId: args.visibility != null && args.visibility !== "private",
233
+ suppressInheritedSessionId: args.suppressInheritedSessionId ?? false,
234
+ });
235
+ }
236
+ // ==========================================================================
237
+ // Conversation turns
238
+ // ==========================================================================
239
+ /** Record a single conversation turn (write-accepted semantics). */
240
+ async recordTurn(args) {
241
+ const resolved = this.resolve({ sessionId: args.sessionId });
242
+ return this.runtime.recordTurn({
243
+ role: args.role,
244
+ content: args.content,
245
+ sessionId: resolved.sessionId,
246
+ userId: resolved.userId,
247
+ agentId: resolved.agentId,
248
+ toolCalls: args.toolCalls,
249
+ toolCallId: args.toolCallId,
250
+ toolName: args.toolName,
251
+ isError: args.isError,
252
+ modelName: args.modelName,
253
+ // Auto-generate per call when omitted so transport-level retries beneath
254
+ // this call are protected; cross-call dedupe needs a caller-supplied key.
255
+ idempotencyKey: args.idempotencyKey ?? randomUUID(),
256
+ });
257
+ }
258
+ // ==========================================================================
259
+ // Semantic memory
260
+ // ==========================================================================
261
+ async saveSemantic(args) {
262
+ const resolved = this.resolve({
263
+ userId: args.userId,
264
+ agentId: args.agentId,
265
+ required: ["userId"],
266
+ });
267
+ return this.requireClient().createSemantic({
268
+ label: args.label,
269
+ text: args.text,
270
+ userId: resolved.userId,
271
+ agentId: resolved.agentId,
272
+ source: args.source ?? "agent",
273
+ visibility: args.visibility ?? "private",
274
+ metadata: args.metadata,
275
+ upsert: args.upsert ?? true,
276
+ });
277
+ }
278
+ async searchSemantic(args) {
279
+ const resolved = this.resolve({
280
+ userId: args.userId,
281
+ visibility: args.visibility,
282
+ });
283
+ return this.runtime.searchSemantic({
284
+ query: args.query,
285
+ userId: resolved.userId,
286
+ visibility: args.visibility,
287
+ topK: args.topK ?? 50,
288
+ });
289
+ }
290
+ async getSemantic(args) {
291
+ const resolved = this.resolve({
292
+ userId: args.userId,
293
+ visibility: args.visibility,
294
+ });
295
+ return this.requireClient().getSemantic({
296
+ label: args.label,
297
+ userId: resolved.userId,
298
+ visibility: args.visibility,
299
+ });
300
+ }
301
+ // ==========================================================================
302
+ // Episodic memory
303
+ // ==========================================================================
304
+ async saveEpisode(args) {
305
+ const resolved = this.resolve({
306
+ userId: args.userId,
307
+ agentId: args.agentId,
308
+ sessionId: args.sessionId,
309
+ required: ["userId", "sessionId"],
310
+ });
311
+ return this.requireClient().createEpisodic({
312
+ title: args.title,
313
+ content: args.content,
314
+ summaryText: args.summary,
315
+ userId: resolved.userId,
316
+ agentId: resolved.agentId,
317
+ sessionId: resolved.sessionId,
318
+ participants: args.participants,
319
+ tags: args.tags,
320
+ visibility: args.visibility ?? "private",
321
+ metadata: args.metadata,
322
+ });
323
+ }
324
+ async searchEpisodes(args) {
325
+ const resolved = this.resolve({
326
+ userId: args.userId,
327
+ sessionId: args.sessionId,
328
+ visibility: args.visibility,
329
+ suppressInheritedSessionId: true,
330
+ });
331
+ return this.runtime.searchEpisodes({
332
+ query: args.query,
333
+ userId: resolved.userId,
334
+ visibility: args.visibility,
335
+ sessionId: resolved.sessionId,
336
+ topK: args.topK ?? 50,
337
+ });
338
+ }
339
+ async listEpisodes(args = {}) {
340
+ const resolved = this.resolve({
341
+ userId: args.userId,
342
+ sessionId: args.sessionId,
343
+ visibility: args.visibility,
344
+ suppressInheritedSessionId: true,
345
+ });
346
+ return this.requireClient().listEpisodic({
347
+ userId: resolved.userId,
348
+ visibility: args.visibility,
349
+ sessionId: resolved.sessionId,
350
+ limit: args.limit ?? 20,
351
+ });
352
+ }
353
+ // ==========================================================================
354
+ // Taxonomic memory
355
+ // ==========================================================================
356
+ async saveTaxonomic(args) {
357
+ const resolved = this.resolve({
358
+ userId: args.userId,
359
+ required: ["userId"],
360
+ });
361
+ return this.requireClient().createTaxonomic({
362
+ domain: args.domain,
363
+ term: args.term,
364
+ definition: args.definition,
365
+ relatedTerms: args.relatedTerms,
366
+ visibility: args.visibility ?? "org",
367
+ userId: resolved.userId,
368
+ });
369
+ }
370
+ async searchTaxonomic(args) {
371
+ const resolved = this.resolve({
372
+ userId: args.userId,
373
+ visibility: args.visibility,
374
+ });
375
+ return this.runtime.searchTaxonomic({
376
+ query: args.query,
377
+ userId: resolved.userId,
378
+ domain: args.domain,
379
+ visibility: args.visibility,
380
+ topK: args.topK ?? 50,
381
+ });
382
+ }
383
+ async getTaxonomicTerm(args) {
384
+ const resolved = this.resolve({
385
+ userId: args.userId,
386
+ visibility: args.visibility,
387
+ });
388
+ return this.requireClient().getTaxonomic({
389
+ domain: args.domain,
390
+ term: args.term,
391
+ userId: resolved.userId,
392
+ visibility: args.visibility,
393
+ });
394
+ }
395
+ async listDomains(args = {}) {
396
+ return this.requireClient().getDistinctDomains({
397
+ visibility: args.visibility,
398
+ });
399
+ }
400
+ // ==========================================================================
401
+ // Procedural memory
402
+ // ==========================================================================
403
+ async discoverProcedures(args) {
404
+ const resolved = this.resolve({
405
+ userId: args.userId,
406
+ visibility: args.visibility,
407
+ });
408
+ return this.runtime.discoverProcedures({
409
+ query: args.query,
410
+ userId: resolved.userId,
411
+ visibility: args.visibility,
412
+ tags: args.tags,
413
+ topK: args.topK ?? 10,
414
+ similarityThreshold: args.similarityThreshold ?? 0.0,
415
+ metadataFilter: args.metadataFilter,
416
+ });
417
+ }
418
+ async getProcedure(args) {
419
+ const resolved = this.resolve({
420
+ userId: args.userId,
421
+ visibility: args.visibility,
422
+ });
423
+ return this.requireClient().getProcedural({
424
+ procedure: args.procedureName,
425
+ userId: resolved.userId,
426
+ visibility: args.visibility,
427
+ includeDeleted: args.includeDeleted ?? false,
428
+ });
429
+ }
430
+ async saveProcedure(args) {
431
+ const resolved = this.resolve({
432
+ userId: args.userId,
433
+ agentId: args.agentId,
434
+ required: ["userId"],
435
+ });
436
+ return this.requireClient().createProcedural({
437
+ procedure: args.procedure,
438
+ description: args.description,
439
+ content: args.content,
440
+ userId: resolved.userId,
441
+ agentId: resolved.agentId,
442
+ steps: args.steps,
443
+ resources: args.resources,
444
+ allowedTools: args.allowedTools,
445
+ compatibility: args.compatibility,
446
+ license: args.license,
447
+ triggerConditions: args.triggerConditions,
448
+ tags: args.tags,
449
+ visibility: args.visibility ?? "private",
450
+ extractionSource: args.extractionSource,
451
+ sourceFormat: args.sourceFormat,
452
+ sourcePath: args.sourcePath,
453
+ updateExisting: args.updateExisting ?? false,
454
+ });
455
+ }
456
+ // ==========================================================================
457
+ // Custom memory types
458
+ // ==========================================================================
459
+ /**
460
+ * Save a memory of a declared custom type.
461
+ *
462
+ * The platform stamps identity (org, project, user) and enforces the
463
+ * type's declared tag schema; this method validates only tag syntax
464
+ * client-side so mistakes fail fast with server-matching messages.
465
+ * Built-in types (semantic, episodic, taxonomic, procedural) are
466
+ * rejected — use their dedicated methods.
467
+ */
468
+ async save(memoryType, content, options) {
469
+ validateMemoryType(memoryType);
470
+ if (options?.tags != null) {
471
+ validateTagSyntax(options.tags);
472
+ }
473
+ return this.requireClient().createCustom({
474
+ memoryType,
475
+ content,
476
+ tags: options?.tags,
477
+ contextualMetadata: options?.contextualMetadata,
478
+ });
479
+ }
480
+ /**
481
+ * Retrieve memories of a declared custom type by semantic query.
482
+ *
483
+ * Filters are exact-match equality on declared tag keys; result ordering
484
+ * may improve between releases and is not contractual. One type per call.
485
+ */
486
+ async retrieve(memoryType, query, options) {
487
+ validateMemoryType(memoryType);
488
+ if (options?.tags != null) {
489
+ validateTagSyntax(options.tags);
490
+ }
491
+ return this.requireClient().retrieveCustom({
492
+ memoryType,
493
+ query,
494
+ tags: options?.tags,
495
+ topK: options?.topK ?? 10,
496
+ });
497
+ }
498
+ // ==========================================================================
499
+ // Context building
500
+ // ==========================================================================
501
+ /** Build a unified memory context across memory types. */
502
+ async buildContext(args) {
503
+ requirePositiveMaxTokens(args.maxTokens);
504
+ const session = args.sessionId && args.sessionId.trim() ? args.sessionId : args.threadId;
505
+ const resolved = this.resolve({
506
+ userId: args.userId,
507
+ sessionId: session,
508
+ visibility: args.visibility,
509
+ });
510
+ // Omit maxTokens from the runtime call when unset so injected runtimes
511
+ // that inspect key presence stay compatible with pre-maxTokens callers.
512
+ if (args.maxTokens !== undefined) {
513
+ return this.runtime.buildContext({
514
+ query: args.query,
515
+ userId: resolved.userId,
516
+ sessionId: resolved.sessionId,
517
+ visibility: args.visibility,
518
+ metadataFilter: args.metadataFilter,
519
+ enabledSources: args.enabledSources,
520
+ maxTokens: args.maxTokens,
521
+ });
522
+ }
523
+ return this.runtime.buildContext({
524
+ query: args.query,
525
+ userId: resolved.userId,
526
+ sessionId: resolved.sessionId,
527
+ visibility: args.visibility,
528
+ metadataFilter: args.metadataFilter,
529
+ enabledSources: args.enabledSources,
530
+ });
531
+ }
532
+ // ==========================================================================
533
+ // Unified search
534
+ // ==========================================================================
535
+ /** Search one or more memory sources and return a single ranked list. */
536
+ async search(args) {
537
+ const topK = args.topK ?? 10;
538
+ // sessionId feeds only the episodic leg; suppressing inheritance on this one
539
+ // shared resolve is safe for every leg and stops a bound/ambient session
540
+ // from filtering episodic. An explicit sessionId still filters.
541
+ const resolved = this.resolve({
542
+ userId: args.userId,
543
+ sessionId: args.sessionId,
544
+ visibility: args.visibility,
545
+ suppressInheritedSessionId: true,
546
+ });
547
+ const userId = resolved.userId;
548
+ const sessionId = resolved.sessionId;
549
+ const chunks = [];
550
+ for (const source of normalizeSources(args.sources)) {
551
+ if (source === SearchSource.SEMANTIC) {
552
+ chunks.push(...(await this.runtime.searchSemantic({
553
+ query: args.query,
554
+ userId,
555
+ visibility: args.visibility,
556
+ topK,
557
+ })));
558
+ }
559
+ else if (source === SearchSource.EPISODIC) {
560
+ chunks.push(...(await this.runtime.searchEpisodes({
561
+ query: args.query,
562
+ userId,
563
+ visibility: args.visibility,
564
+ sessionId,
565
+ topK,
566
+ })));
567
+ }
568
+ else if (source === SearchSource.TAXONOMIC) {
569
+ chunks.push(...(await this.runtime.searchTaxonomic({
570
+ query: args.query,
571
+ userId,
572
+ domain: args.domain,
573
+ visibility: args.visibility,
574
+ topK,
575
+ })));
576
+ }
577
+ else if (source === SearchSource.PROCEDURAL) {
578
+ const procs = await this.runtime.discoverProcedures({
579
+ query: args.query,
580
+ userId,
581
+ visibility: args.visibility,
582
+ tags: args.tags,
583
+ topK,
584
+ similarityThreshold: args.similarityThreshold ?? 0.0,
585
+ metadataFilter: args.metadataFilter,
586
+ });
587
+ chunks.push(...procs.map(procedureToChunk));
588
+ }
589
+ }
590
+ // Sort by similarity_score descending; unscored chunks sort last.
591
+ chunks.sort((a, b) => {
592
+ const aNull = a.similarity_score === null || a.similarity_score === undefined;
593
+ const bNull = b.similarity_score === null || b.similarity_score === undefined;
594
+ if (aNull !== bNull) {
595
+ return aNull ? 1 : -1;
596
+ }
597
+ return (b.similarity_score ?? 0) - (a.similarity_score ?? 0);
598
+ });
599
+ return chunks.slice(0, topK);
600
+ }
601
+ }