@betterdb/agent-memory 0.1.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.
@@ -0,0 +1,583 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MemoryStore = void 0;
4
+ const node_crypto_1 = require("node:crypto");
5
+ const api_1 = require("@opentelemetry/api");
6
+ const valkey_search_kit_1 = require("@betterdb/valkey-search-kit");
7
+ const buildMemoryRecord_1 = require("./buildMemoryRecord");
8
+ const buildMemoryIndex_1 = require("./buildMemoryIndex");
9
+ const buildRecallQuery_1 = require("./buildRecallQuery");
10
+ const parseMemoryItem_1 = require("./parseMemoryItem");
11
+ const compositeScore_1 = require("./compositeScore");
12
+ const selectEvictions_1 = require("./selectEvictions");
13
+ const discovery_1 = require("./discovery");
14
+ const telemetry_1 = require("./telemetry");
15
+ const DEFAULT_THRESHOLD = 0.25;
16
+ const DEFAULT_WEIGHTS = { similarity: 0.6, recency: 0.25, importance: 0.15 };
17
+ const DEFAULT_HALF_LIFE_SECONDS = 604800; // 7 days
18
+ const DEFAULT_RECALL_K = 8;
19
+ const RECALL_OVERFETCH = 4;
20
+ const FORGET_BATCH_SIZE = 500;
21
+ const FORGET_MAX_BATCHES = 10000;
22
+ const EVICTION_SCAN_LIMIT = 10000;
23
+ const CONSOLIDATE_SCAN_LIMIT = 10000;
24
+ const DEFAULT_SUMMARY_IMPORTANCE = 0.7;
25
+ const SUMMARY_SOURCE = 'summary';
26
+ const DEFAULT_IMPORTANCE = 0.5;
27
+ const DEFAULT_CONFIG_REFRESH_MS = 30000;
28
+ const MIN_CONFIG_REFRESH_MS = 1000;
29
+ const MAX_DISTANCE = 2;
30
+ // Read lazily so only discovery users pay the disk read on import (and avoid a
31
+ // bundler hazard, since package.json is not always emitted).
32
+ function packageVersion() {
33
+ return require('../package.json').version;
34
+ }
35
+ class MemoryStore {
36
+ client;
37
+ name;
38
+ embedFn;
39
+ defaultThreshold;
40
+ weights;
41
+ halfLifeSeconds;
42
+ maxItemsPerScope;
43
+ initialThreshold;
44
+ initialWeights;
45
+ initialHalfLifeSeconds;
46
+ initialMaxItemsPerScope;
47
+ configKey;
48
+ configRefreshHandle = null;
49
+ discovery;
50
+ discoveryReady = null;
51
+ telemetry;
52
+ storeLabels;
53
+ dims;
54
+ constructor(options) {
55
+ this.client = options.client;
56
+ this.name = options.name;
57
+ this.embedFn = options.embedFn;
58
+ this.telemetry = (0, telemetry_1.createMemoryTelemetry)(options.telemetry);
59
+ this.storeLabels = { store_name: this.name };
60
+ this.initialThreshold = options.defaultThreshold ?? DEFAULT_THRESHOLD;
61
+ this.initialWeights = { ...(options.weights ?? DEFAULT_WEIGHTS) };
62
+ this.initialHalfLifeSeconds = options.halfLifeSeconds ?? DEFAULT_HALF_LIFE_SECONDS;
63
+ this.initialMaxItemsPerScope = options.maxItemsPerScope;
64
+ this.defaultThreshold = this.initialThreshold;
65
+ this.weights = { ...this.initialWeights };
66
+ this.halfLifeSeconds = this.initialHalfLifeSeconds;
67
+ this.maxItemsPerScope = this.initialMaxItemsPerScope;
68
+ this.configKey = `${this.name}:__mem_config`;
69
+ this.discovery = this.createDiscovery(options.discovery);
70
+ this.startConfigRefresh(options.configRefresh);
71
+ }
72
+ currentConfig() {
73
+ return {
74
+ threshold: this.defaultThreshold,
75
+ weights: { ...this.weights },
76
+ halfLifeSeconds: this.halfLifeSeconds,
77
+ maxItemsPerScope: this.maxItemsPerScope,
78
+ };
79
+ }
80
+ async refreshConfig() {
81
+ try {
82
+ const raw = await this.client.call('HGETALL', this.configKey);
83
+ this.applyConfig(parseHashReply(raw));
84
+ }
85
+ catch {
86
+ // Best-effort: a failed refresh keeps the last-known config in place.
87
+ }
88
+ }
89
+ startConfigRefresh(config) {
90
+ if (!config) {
91
+ return;
92
+ }
93
+ const settings = config === true ? {} : config;
94
+ if (settings.enabled === false) {
95
+ return;
96
+ }
97
+ const intervalMs = Math.max(MIN_CONFIG_REFRESH_MS, settings.intervalMs ?? DEFAULT_CONFIG_REFRESH_MS);
98
+ void this.refreshConfig();
99
+ const handle = setInterval(() => {
100
+ void this.refreshConfig();
101
+ }, intervalMs);
102
+ handle.unref?.();
103
+ this.configRefreshHandle = handle;
104
+ }
105
+ applyConfig(raw) {
106
+ let threshold = this.initialThreshold;
107
+ // Weights are a partial update: if any component is in the config, start
108
+ // from the LIVE weights and overlay only what's present, so tuning one knob
109
+ // (the proposal engine's common case) doesn't reset the others. With no
110
+ // weight field at all, fall back to the constructor values like the rest.
111
+ const weightFieldPresent = raw['recall.weights.similarity'] !== undefined ||
112
+ raw['recall.weights.recency'] !== undefined ||
113
+ raw['recall.weights.importance'] !== undefined;
114
+ const weights = { ...(weightFieldPresent ? this.weights : this.initialWeights) };
115
+ let halfLifeSeconds = this.initialHalfLifeSeconds;
116
+ let maxItemsPerScope = this.initialMaxItemsPerScope;
117
+ for (const [field, value] of Object.entries(raw)) {
118
+ const num = Number(value);
119
+ if (!Number.isFinite(num)) {
120
+ continue;
121
+ }
122
+ switch (field) {
123
+ case 'recall.threshold':
124
+ if (num >= 0 && num <= MAX_DISTANCE) {
125
+ threshold = num;
126
+ }
127
+ break;
128
+ case 'recall.weights.similarity':
129
+ if (num >= 0) {
130
+ weights.similarity = num;
131
+ }
132
+ break;
133
+ case 'recall.weights.recency':
134
+ if (num >= 0) {
135
+ weights.recency = num;
136
+ }
137
+ break;
138
+ case 'recall.weights.importance':
139
+ if (num >= 0) {
140
+ weights.importance = num;
141
+ }
142
+ break;
143
+ case 'recall.halfLifeSeconds':
144
+ if (num > 0) {
145
+ halfLifeSeconds = num;
146
+ }
147
+ break;
148
+ case 'maxItemsPerScope':
149
+ if (num >= 1) {
150
+ maxItemsPerScope = Math.floor(num);
151
+ }
152
+ break;
153
+ default:
154
+ break;
155
+ }
156
+ }
157
+ this.defaultThreshold = threshold;
158
+ // An all-zero weight vector would make every composite score 0 and leave
159
+ // recall ordering undefined, so reject it and keep the configured weights.
160
+ const weightSum = weights.similarity + weights.recency + weights.importance;
161
+ this.weights = weightSum > 0 ? weights : { ...this.initialWeights };
162
+ this.halfLifeSeconds = halfLifeSeconds;
163
+ this.maxItemsPerScope = maxItemsPerScope;
164
+ }
165
+ createDiscovery(config) {
166
+ if (!config) {
167
+ return null;
168
+ }
169
+ const settings = config === true ? {} : config;
170
+ const discovery = new discovery_1.MemoryDiscovery({
171
+ client: this.client,
172
+ name: this.name,
173
+ version: settings.version ?? packageVersion(),
174
+ statsKey: `${this.name}:__mem_stats`,
175
+ heartbeatIntervalMs: settings.heartbeatIntervalMs,
176
+ });
177
+ // Registration is fire-and-forget so construction stays synchronous;
178
+ // close() awaits it before tearing the marker down. The floating catch
179
+ // keeps any rejected registration from surfacing as an unhandled rejection
180
+ // when close() is never called.
181
+ const ready = discovery.register();
182
+ ready.catch(() => undefined);
183
+ this.discoveryReady = ready;
184
+ return discovery;
185
+ }
186
+ async ensureDiscoveryReady() {
187
+ if (this.discoveryReady) {
188
+ await this.discoveryReady.catch(() => undefined);
189
+ }
190
+ }
191
+ async close() {
192
+ if (this.configRefreshHandle) {
193
+ clearInterval(this.configRefreshHandle);
194
+ this.configRefreshHandle = null;
195
+ }
196
+ if (this.discoveryReady) {
197
+ await this.discoveryReady.catch(() => undefined);
198
+ }
199
+ if (this.discovery) {
200
+ await this.discovery.stop({ deleteHeartbeat: true });
201
+ }
202
+ }
203
+ /**
204
+ * Create the `{name}:mem:idx` vector index if it does not already exist.
205
+ * Idempotent — an existing index is left untouched. Resolves the vector
206
+ * dimension from `embedFn` when it has not been observed yet. Call once
207
+ * before the first remember/recall; the AgentMemory facade does this in
208
+ * initialize().
209
+ */
210
+ async ensureIndex() {
211
+ try {
212
+ await this.client.call('FT.INFO', (0, buildMemoryIndex_1.memoryIndexName)(this.name));
213
+ return;
214
+ }
215
+ catch (err) {
216
+ if (!(0, valkey_search_kit_1.isIndexNotFoundError)(err)) {
217
+ throw err;
218
+ }
219
+ }
220
+ const dims = await this.resolveDims();
221
+ await this.client.call('FT.CREATE', ...(0, buildMemoryIndex_1.buildMemoryIndexArgs)(this.name, dims));
222
+ }
223
+ async recall(query, options = {}) {
224
+ return this.traced('recall', async (span) => {
225
+ const startedAt = Date.now();
226
+ const k = options.k ?? DEFAULT_RECALL_K;
227
+ const threshold = options.threshold ?? this.defaultThreshold;
228
+ const weights = options.weights ?? this.weights;
229
+ // Snapshot the half-life alongside threshold/weights so a concurrent
230
+ // configRefresh can't score one recall with a mix of config versions.
231
+ const halfLifeSeconds = this.halfLifeSeconds;
232
+ const fetchK = k * RECALL_OVERFETCH;
233
+ const tags = options.tags ?? [];
234
+ const scope = {
235
+ threadId: options.threadId,
236
+ agentId: options.agentId,
237
+ namespace: options.namespace,
238
+ };
239
+ span.setAttribute('recall.k', k);
240
+ const vector = await this.embed(query);
241
+ const queryString = (0, buildRecallQuery_1.buildRecallQuery)(fetchK, scope, tags);
242
+ const raw = await this.client.call('FT.SEARCH', `${this.name}:mem:idx`, queryString, 'PARAMS', '2', 'vec', (0, valkey_search_kit_1.encodeFloat32)(vector), 'LIMIT', '0', String(fetchK), 'DIALECT', '2');
243
+ const now = Date.now();
244
+ const hits = [];
245
+ for (const hit of (0, valkey_search_kit_1.parseFtSearchResponse)(raw)) {
246
+ const rawScore = hit.fields[buildRecallQuery_1.SCORE_FIELD];
247
+ if (rawScore === undefined || rawScore.trim() === '') {
248
+ continue;
249
+ }
250
+ const distance = Number(rawScore);
251
+ if (!Number.isFinite(distance) || distance > threshold) {
252
+ continue;
253
+ }
254
+ const item = (0, parseMemoryItem_1.parseMemoryItem)(this.name, hit);
255
+ // Recency decays from the last access, not creation, so reinforcement
256
+ // (which bumps last_accessed_at) actually makes a memory more recallable.
257
+ // max() guards against a clock-skewed last_accessed_at older than created_at.
258
+ const lastTouched = Math.max(item.createdAt, item.lastAccessedAt);
259
+ const ageSeconds = (now - lastTouched) / 1000;
260
+ const score = (0, compositeScore_1.compositeScore)({
261
+ similarity: (0, compositeScore_1.similarityFromDistance)(distance),
262
+ ageSeconds,
263
+ importance: item.importance,
264
+ weights,
265
+ halfLifeSeconds,
266
+ });
267
+ if (!Number.isFinite(score)) {
268
+ continue;
269
+ }
270
+ hits.push({ item, similarity: distance, score });
271
+ }
272
+ hits.sort((a, b) => b.score - a.score);
273
+ const result = hits.slice(0, k);
274
+ span.setAttribute('recall.candidate_count', hits.length);
275
+ span.setAttribute('recall.result_count', result.length);
276
+ this.recordRecall(result.length, (Date.now() - startedAt) / 1000);
277
+ if (options.reinforce !== false) {
278
+ // Reinforcement is best-effort and must never break the recall read path.
279
+ await this.reinforce(result, now).catch(() => undefined);
280
+ }
281
+ return result;
282
+ });
283
+ }
284
+ recordRecall(resultCount, latencySeconds) {
285
+ const metrics = this.telemetry.metrics;
286
+ metrics.recallTotal.labels(this.storeLabels).inc();
287
+ if (resultCount > 0) {
288
+ metrics.recallHits.labels(this.storeLabels).inc();
289
+ }
290
+ else {
291
+ metrics.recallEmpty.labels(this.storeLabels).inc();
292
+ }
293
+ metrics.recallLatency.labels(this.storeLabels).observe(latencySeconds);
294
+ }
295
+ traced(operation, fn) {
296
+ return this.telemetry.tracer.startActiveSpan(`agent_memory.${operation}`, async (span) => {
297
+ try {
298
+ const result = await fn(span);
299
+ span.setStatus({ code: api_1.SpanStatusCode.OK });
300
+ return result;
301
+ }
302
+ catch (err) {
303
+ span.setStatus({ code: api_1.SpanStatusCode.ERROR, message: String(err) });
304
+ throw err;
305
+ }
306
+ finally {
307
+ span.end();
308
+ }
309
+ });
310
+ }
311
+ async reinforce(hits, now) {
312
+ for (const hit of hits) {
313
+ const key = `${this.name}:mem:${hit.item.id}`;
314
+ // Only touch live hashes: a recalled key may already be deleted (stale
315
+ // index) and HSET/HINCRBY would otherwise resurrect a partial record.
316
+ const exists = Number(await this.client.call('EXISTS', key));
317
+ if (exists === 0) {
318
+ continue;
319
+ }
320
+ await this.client.call('HSET', key, 'last_accessed_at', String(now));
321
+ await this.client.call('HINCRBY', key, 'access_count', '1');
322
+ }
323
+ }
324
+ async forget(id) {
325
+ const removed = Number(await this.client.call('DEL', `${this.name}:mem:${id}`));
326
+ if (removed > 0) {
327
+ this.telemetry.metrics.items.labels(this.storeLabels).dec(removed);
328
+ }
329
+ return removed > 0;
330
+ }
331
+ async forgetByScope(scope) {
332
+ const tags = scope.tags ?? [];
333
+ const hasFilter = scope.threadId !== undefined ||
334
+ scope.agentId !== undefined ||
335
+ scope.namespace !== undefined ||
336
+ tags.length > 0;
337
+ if (!hasFilter) {
338
+ throw new Error('forgetByScope requires at least one scope field or tag');
339
+ }
340
+ const filter = (0, buildRecallQuery_1.buildScopeFilter)(scope, tags);
341
+ let deleted = 0;
342
+ let batch = 0;
343
+ for (; batch < FORGET_MAX_BATCHES; batch++) {
344
+ const raw = await this.client.call('FT.SEARCH', `${this.name}:mem:idx`, filter, 'LIMIT', '0', String(FORGET_BATCH_SIZE), 'DIALECT', '2');
345
+ const keys = (0, valkey_search_kit_1.parseFtSearchResponse)(raw).map((hit) => hit.key);
346
+ if (keys.length === 0) {
347
+ break;
348
+ }
349
+ const removed = Number(await this.client.call('DEL', ...keys));
350
+ deleted += removed;
351
+ // Stop when a batch makes no progress (every match was already gone),
352
+ // so a lagging index that re-lists deleted keys can't loop forever.
353
+ if (removed === 0) {
354
+ break;
355
+ }
356
+ }
357
+ // Reaching the batch cap with work still flowing means matches may remain;
358
+ // surface it rather than returning a partial count that reads as complete.
359
+ if (batch === FORGET_MAX_BATCHES) {
360
+ console.warn(`forgetByScope hit the ${FORGET_MAX_BATCHES}-batch safety cap for '${this.name}'; ` +
361
+ `${deleted} memories deleted, but some matches may remain — re-run to continue.`);
362
+ }
363
+ if (deleted > 0) {
364
+ this.telemetry.metrics.items.labels(this.storeLabels).dec(deleted);
365
+ }
366
+ return deleted;
367
+ }
368
+ async writeMemory(content, options, now) {
369
+ const vector = await this.embed(content);
370
+ const id = (0, node_crypto_1.randomUUID)();
371
+ const record = (0, buildMemoryRecord_1.buildMemoryRecord)(this.name, id, content, vector, options, now);
372
+ await this.writeRecord(record.key, record.fields, options.ttl);
373
+ this.telemetry.metrics.items.labels(this.storeLabels).inc();
374
+ return id;
375
+ }
376
+ async remember(content, options = {}) {
377
+ return this.traced('remember', async (span) => {
378
+ span.setAttribute('memory.importance', options.importance ?? DEFAULT_IMPORTANCE);
379
+ if (options.ttl !== undefined) {
380
+ span.setAttribute('memory.ttl', options.ttl);
381
+ }
382
+ const now = Date.now();
383
+ const id = await this.writeMemory(content, options, now);
384
+ // Capacity enforcement is best-effort: the memory is already durably stored,
385
+ // so a failed eviction pass must not reject an otherwise successful write.
386
+ await this.enforceCapacity(options, now).catch(() => undefined);
387
+ return id;
388
+ });
389
+ }
390
+ async consolidate(options) {
391
+ return this.traced('consolidate', (span) => this.runConsolidate(options, span));
392
+ }
393
+ async runConsolidate(options, span) {
394
+ const now = Date.now();
395
+ const tags = options.tags ?? [];
396
+ const scope = {
397
+ threadId: options.threadId,
398
+ agentId: options.agentId,
399
+ namespace: options.namespace,
400
+ };
401
+ const hasCriteria = scope.threadId !== undefined ||
402
+ scope.agentId !== undefined ||
403
+ scope.namespace !== undefined ||
404
+ tags.length > 0 ||
405
+ options.olderThanSeconds !== undefined ||
406
+ options.maxImportance !== undefined;
407
+ if (!hasCriteria) {
408
+ throw new Error('consolidate requires a scope, tags, olderThanSeconds, or maxImportance to select candidates');
409
+ }
410
+ // Push olderThanSeconds/maxImportance into the query (both are NUMERIC
411
+ // indexed) so the scan limit applies to actual matches, not an arbitrary
412
+ // first window, and we don't transfer rows we'd only discard. Prior
413
+ // summaries are always excluded (-@source:{summary}) so consolidation never
414
+ // re-folds its own output into a new summary.
415
+ const filter = (0, buildRecallQuery_1.buildConsolidateFilter)(scope, tags, {
416
+ maxCreatedAt: options.olderThanSeconds !== undefined
417
+ ? now - options.olderThanSeconds * 1000
418
+ : undefined,
419
+ maxImportance: options.maxImportance,
420
+ excludeSource: SUMMARY_SOURCE,
421
+ });
422
+ const raw = await this.client.call('FT.SEARCH', `${this.name}:mem:idx`, filter, 'RETURN', '10', 'content', 'importance', 'tags', 'created_at', 'last_accessed_at', 'access_count', 'source', 'threadId', 'agentId', 'namespace', 'LIMIT', '0', String(CONSOLIDATE_SCAN_LIMIT), 'DIALECT', '2');
423
+ const candidates = (0, valkey_search_kit_1.parseFtSearchResponse)(raw).map((hit) => (0, parseMemoryItem_1.parseMemoryItem)(this.name, hit));
424
+ span.setAttribute('consolidate.candidates', candidates.length);
425
+ if (candidates.length === 0) {
426
+ span.setAttribute('consolidate.created', 0);
427
+ span.setAttribute('consolidate.deleted', 0);
428
+ return { consolidated: 0, created: [], deleted: 0 };
429
+ }
430
+ // Write the summary before deleting sources so a failure can never destroy
431
+ // memories without leaving their consolidated replacement behind. Use the
432
+ // capacity-free write path: consolidation is a net reduction (N sources -> 1
433
+ // summary), and the sources still inflate the scope here, so an enforceCapacity
434
+ // pass could otherwise evict the summary we just wrote and then delete the
435
+ // sources — losing the content entirely.
436
+ const summary = await options.summarize(candidates);
437
+ const summaryId = await this.writeMemory(summary, {
438
+ ...scope,
439
+ tags,
440
+ source: SUMMARY_SOURCE,
441
+ importance: options.summaryImportance ?? DEFAULT_SUMMARY_IMPORTANCE,
442
+ }, now);
443
+ let deleted = 0;
444
+ if (options.deleteSources !== false) {
445
+ const keys = candidates.map((item) => `${this.name}:mem:${item.id}`);
446
+ deleted = Number(await this.client.call('DEL', ...keys));
447
+ if (deleted > 0) {
448
+ this.telemetry.metrics.items.labels(this.storeLabels).dec(deleted);
449
+ }
450
+ }
451
+ this.telemetry.metrics.consolidations.labels(this.storeLabels).inc();
452
+ span.setAttribute('consolidate.created', 1);
453
+ span.setAttribute('consolidate.deleted', deleted);
454
+ return { consolidated: candidates.length, created: [summaryId], deleted };
455
+ }
456
+ async writeRecord(key, fields, ttl) {
457
+ if (ttl === undefined || ttl <= 0) {
458
+ await this.client.call('HSET', key, ...fields);
459
+ return;
460
+ }
461
+ // Set the hash and its expiry in one transaction so a crash between the two
462
+ // can't leave a memory that should expire living forever. Atomicity assumes
463
+ // the client routes these calls to a single connection (the MemoryStoreClient
464
+ // contract); on a pooled client that splits them the guarantee is lost.
465
+ await this.client.call('MULTI');
466
+ try {
467
+ await this.client.call('HSET', key, ...fields);
468
+ await this.client.call('EXPIRE', key, String(ttl));
469
+ await this.client.call('EXEC');
470
+ }
471
+ catch (err) {
472
+ // Clear the half-built transaction so the connection isn't left mid-MULTI.
473
+ await this.client.call('DISCARD').catch(() => undefined);
474
+ throw err;
475
+ }
476
+ }
477
+ async enforceCapacity(scope, now) {
478
+ const max = this.maxItemsPerScope;
479
+ if (max === undefined) {
480
+ return;
481
+ }
482
+ // Snapshot the eviction tunables alongside max so an opt-in configRefresh
483
+ // landing mid-pass can't score victims with a different weight/half-life
484
+ // set than the capacity check ran with.
485
+ const weights = this.weights;
486
+ const halfLifeSeconds = this.halfLifeSeconds;
487
+ // Tags are part of the partition (as in recall/forgetByScope), so a
488
+ // tag-scoped write caps its own tag bucket.
489
+ const filter = (0, buildRecallQuery_1.buildScopeFilter)(scope, scope.tags ?? []);
490
+ if (filter === '*') {
491
+ // A fully-unscoped write has no scope to bound: enforcing here would count
492
+ // and evict across the entire index (every other scope's memories), which
493
+ // `maxItemsPerScope` does not promise. Skip — the write stays, uncapped.
494
+ return;
495
+ }
496
+ // Count-first so the common in-capacity write pays only a cheap LIMIT 0 0
497
+ // probe and never fetches candidate rows. Both the count and the candidate
498
+ // scan go through FT.SEARCH, so under HNSW index lag the cap is enforced
499
+ // approximately and up to one write behind (the unit tests mock this exact).
500
+ const countRaw = await this.client.call('FT.SEARCH', `${this.name}:mem:idx`, filter, 'LIMIT', '0', '0', 'DIALECT', '2');
501
+ const total = ftSearchTotal(countRaw);
502
+ if (total <= max) {
503
+ return;
504
+ }
505
+ // Eviction selection is exact while the scope fits EVICTION_SCAN_LIMIT (the
506
+ // expected case); a larger scope evicts from the scanned window and the
507
+ // remainder is reclaimed on subsequent writes.
508
+ const raw = await this.client.call('FT.SEARCH', `${this.name}:mem:idx`, filter, 'RETURN', '2', 'importance', 'last_accessed_at', 'LIMIT', '0', String(EVICTION_SCAN_LIMIT), 'DIALECT', '2');
509
+ const candidates = (0, valkey_search_kit_1.parseFtSearchResponse)(raw).map((hit) => {
510
+ const importance = Number(hit.fields.importance);
511
+ const lastAccessedAt = Number(hit.fields.last_accessed_at);
512
+ return {
513
+ key: hit.key,
514
+ importance: Number.isFinite(importance) ? importance : 0,
515
+ lastAccessedAt: Number.isFinite(lastAccessedAt) ? lastAccessedAt : 0,
516
+ };
517
+ });
518
+ const dropCount = Math.min(total - max, candidates.length);
519
+ const evictKeys = (0, selectEvictions_1.selectEvictions)(candidates, candidates.length - dropCount, {
520
+ now,
521
+ halfLifeSeconds,
522
+ weights,
523
+ });
524
+ if (evictKeys.length === 0) {
525
+ return;
526
+ }
527
+ // Count actual removals, not the keys we asked to drop: the index can list
528
+ // already-deleted keys (stale), so DEL may remove fewer. Using the reply
529
+ // keeps the stats and Prometheus gauges accurate, as forget/forgetByScope/
530
+ // consolidate already do.
531
+ const removed = Number(await this.client.call('DEL', ...evictKeys));
532
+ if (!(removed > 0)) {
533
+ return;
534
+ }
535
+ await this.client.call('HINCRBY', `${this.name}:__mem_stats`, 'evictions', String(removed));
536
+ this.telemetry.metrics.evictions.labels(this.storeLabels).inc(removed);
537
+ this.telemetry.metrics.items.labels(this.storeLabels).dec(removed);
538
+ }
539
+ async resolveDims() {
540
+ if (this.dims !== undefined) {
541
+ return this.dims;
542
+ }
543
+ const probe = await this.embedFn('probe');
544
+ if (probe.length === 0) {
545
+ throw new Error('Cannot resolve memory vector dimension: embedFn returned a zero-length embedding');
546
+ }
547
+ this.dims = probe.length;
548
+ return this.dims;
549
+ }
550
+ async embed(content) {
551
+ this.telemetry.metrics.embeddingCalls.labels(this.storeLabels).inc();
552
+ const vector = await this.embedFn(content);
553
+ if (this.dims === undefined) {
554
+ this.dims = vector.length;
555
+ }
556
+ else if (vector.length !== this.dims) {
557
+ throw new Error(`Embedding dimension mismatch: expected ${this.dims}, embedFn returned ${vector.length}`);
558
+ }
559
+ return vector;
560
+ }
561
+ }
562
+ exports.MemoryStore = MemoryStore;
563
+ function ftSearchTotal(raw) {
564
+ if (!Array.isArray(raw) || raw.length < 1) {
565
+ return 0;
566
+ }
567
+ const total = typeof raw[0] === 'string' ? parseInt(raw[0], 10) : Number(raw[0]);
568
+ return Number.isFinite(total) && total > 0 ? total : 0;
569
+ }
570
+ function parseHashReply(raw) {
571
+ const out = {};
572
+ if (Array.isArray(raw)) {
573
+ for (let i = 0; i + 1 < raw.length; i += 2) {
574
+ out[String(raw[i])] = String(raw[i + 1]);
575
+ }
576
+ }
577
+ else if (raw !== null && typeof raw === 'object') {
578
+ for (const [field, value] of Object.entries(raw)) {
579
+ out[field] = String(value);
580
+ }
581
+ }
582
+ return out;
583
+ }
@@ -0,0 +1,4 @@
1
+ export declare const MEMORY_INDEX_ALGORITHM = "HNSW";
2
+ export declare function memoryIndexName(name: string): string;
3
+ export declare function memoryKeyPrefix(name: string): string;
4
+ export declare function buildMemoryIndexArgs(name: string, dims: number): string[];
@@ -0,0 +1,60 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MEMORY_INDEX_ALGORITHM = void 0;
4
+ exports.memoryIndexName = memoryIndexName;
5
+ exports.memoryKeyPrefix = memoryKeyPrefix;
6
+ exports.buildMemoryIndexArgs = buildMemoryIndexArgs;
7
+ const buildRecallQuery_1 = require("./buildRecallQuery");
8
+ exports.MEMORY_INDEX_ALGORITHM = 'HNSW';
9
+ function memoryIndexName(name) {
10
+ return `${name}:mem:idx`;
11
+ }
12
+ function memoryKeyPrefix(name) {
13
+ return `${name}:mem:`;
14
+ }
15
+ function buildMemoryIndexArgs(name, dims) {
16
+ if (!Number.isInteger(dims) || dims <= 0) {
17
+ throw new Error(`memory index dimension must be a positive integer, got: ${dims}`);
18
+ }
19
+ return [
20
+ memoryIndexName(name),
21
+ 'ON',
22
+ 'HASH',
23
+ 'PREFIX',
24
+ '1',
25
+ memoryKeyPrefix(name),
26
+ 'SCHEMA',
27
+ buildRecallQuery_1.VECTOR_FIELD,
28
+ 'VECTOR',
29
+ exports.MEMORY_INDEX_ALGORITHM,
30
+ '6',
31
+ 'TYPE',
32
+ 'FLOAT32',
33
+ 'DIM',
34
+ String(dims),
35
+ 'DISTANCE_METRIC',
36
+ 'COSINE',
37
+ 'threadId',
38
+ 'TAG',
39
+ 'agentId',
40
+ 'TAG',
41
+ 'namespace',
42
+ 'TAG',
43
+ 'tags',
44
+ 'TAG',
45
+ 'SEPARATOR',
46
+ ',',
47
+ 'source',
48
+ 'TAG',
49
+ 'importance',
50
+ 'NUMERIC',
51
+ 'created_at',
52
+ 'NUMERIC',
53
+ 'last_accessed_at',
54
+ 'NUMERIC',
55
+ 'access_count',
56
+ 'NUMERIC',
57
+ 'content',
58
+ 'TEXT',
59
+ ];
60
+ }
@@ -0,0 +1,6 @@
1
+ import type { RememberOptions } from './types';
2
+ export interface MemoryWrite {
3
+ key: string;
4
+ fields: (string | Buffer)[];
5
+ }
6
+ export declare function buildMemoryRecord(name: string, id: string, content: string, vector: number[], options: RememberOptions, now: number): MemoryWrite;