-
Notifications
You must be signed in to change notification settings - Fork 12
Expand file tree
/
Copy pathmemory.js
More file actions
511 lines (468 loc) · 22 KB
/
Copy pathmemory.js
File metadata and controls
511 lines (468 loc) · 22 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
// Memory v2 — the stateful coordination layer for stateless agents.
//
// A single, ephemeral, sandboxed agent cannot give itself any of this: durable
// state, a portable identity (the paying wallet IS the account — no signup),
// a place OTHER agents can reach (shared namespaces via grants), atomic
// coordination primitives (counters/locks), tamper-evident history, or a
// similarity index. That is the part that is not vibe-codable.
//
// Everything is namespaced by a wallet address. Access to a namespace you do
// not own requires an explicit grant from the owner — so cross-agent sharing is
// opt-in and authenticated by x402 payment identity.
import Database from "better-sqlite3";
import { createHash, randomBytes } from "node:crypto";
import { existsSync } from "node:fs";
import { join } from "node:path";
// Memory is the WORST case for a silent /data → /tmp fallback: agents pay
// USDC per write, and the value of that storage is precisely its durability
// across restarts. Mirror the same fail-loud contract as pow.js + stats.js —
// refuse to boot in production without /data unless an explicit opt-out is
// set (local tests, FREE_MODE sweeps, edge runners). Without this gate a
// misconfigured deploy would charge buyers for memory that vanishes on the
// next container restart.
const HAS_DATA_DIR = existsSync("/data");
const ALLOW_EPHEMERAL =
process.env.MEMORY_ALLOW_EPHEMERAL === "true" ||
process.env.FREE_MODE === "true" ||
process.env.NODE_ENV !== "production";
if (!HAS_DATA_DIR && !ALLOW_EPHEMERAL) {
console.error(
"Memory DB has no persistent volume (/data missing) and NODE_ENV=production. Mount /data, or set MEMORY_ALLOW_EPHEMERAL=true to accept losing paid agent memory on restart."
);
process.exit(1);
}
const DATA_DIR = HAS_DATA_DIR ? "/data" : "/tmp";
export const PERSISTENT = HAS_DATA_DIR;
const db = new Database(join(DATA_DIR, "agent402.db"));
db.pragma("journal_mode = WAL");
db.exec(`
CREATE TABLE IF NOT EXISTS kv (
ns TEXT NOT NULL, k TEXT NOT NULL, v TEXT NOT NULL,
updated INTEGER NOT NULL, exp INTEGER,
PRIMARY KEY (ns, k)
);
CREATE TABLE IF NOT EXISTS grants (
owner TEXT NOT NULL, grantee TEXT NOT NULL, mode TEXT NOT NULL,
created INTEGER NOT NULL, exp INTEGER,
PRIMARY KEY (owner, grantee)
);
CREATE TABLE IF NOT EXISTS memlog (
ns TEXT NOT NULL, seq INTEGER NOT NULL, ts INTEGER NOT NULL,
actor TEXT NOT NULL, action TEXT NOT NULL, key TEXT,
data TEXT, prev_hash TEXT NOT NULL, hash TEXT NOT NULL,
PRIMARY KEY (ns, seq)
);
CREATE TABLE IF NOT EXISTS docs (
ns TEXT NOT NULL, id TEXT NOT NULL, text TEXT NOT NULL,
meta TEXT, vec TEXT NOT NULL, model TEXT, updated INTEGER NOT NULL,
PRIMARY KEY (ns, id)
);
`);
// Migrate older tables in place if needed.
const kvCols = db.prepare("PRAGMA table_info(kv)").all().map((c) => c.name);
if (!kvCols.includes("exp")) db.exec("ALTER TABLE kv ADD COLUMN exp INTEGER");
const docCols = db.prepare("PRAGMA table_info(docs)").all().map((c) => c.name);
if (!docCols.includes("model")) db.exec("ALTER TABLE docs ADD COLUMN model TEXT");
// After migrations so the column is guaranteed to exist on older databases.
db.exec("CREATE INDEX IF NOT EXISTS kv_exp ON kv (exp) WHERE exp IS NOT NULL");
const MAX_KEY = 256;
const MAX_VALUE = 64 * 1024;
// Per-namespace key cap. Env-tunable and read at call time (same contract as
// MAX_NS_BYTES below) so tests can exercise the quota without 10k writes.
// 413, not 400: the request is well-formed — the store is full.
const MAX_KEYS_PER_NS = () => Number(process.env.MEMORY_MAX_NS_KEYS) || 10000;
const MAX_DOCS_PER_NS = 2000;
const MAX_DOC_TEXT = 8 * 1024;
const EMBED_DIM = 256;
const now = () => Date.now();
const nowSec = () => Math.floor(Date.now() / 1000);
function bad(message, code = 400) {
const err = new Error(message);
err.statusCode = code;
return err;
}
// The key-count cap alone doesn't bound DISK: 10k keys × 64KB values is
// 640MB per wallet, and a handful of cheap wallets could fill the /data
// volume — which the stats/PoW/memory databases all share, so a full disk
// takes down the serving path, not just memory. Budget the namespace's
// TOTAL stored value bytes too. Env-tunable (read at call time so tests can
// shrink it); expired rows are reclaimed before rejecting, same as the
// key-count path. 413 = the request is fine, the store is full.
const MAX_NS_BYTES = () => Number(process.env.MEMORY_MAX_NS_BYTES) || 32 * 1024 * 1024;
function assertByteBudget(owner, key, incomingBytes) {
const existing = kvGet.get(owner, key);
const delta = incomingBytes - (existing ? existing.v.length : 0);
if (delta <= 0) return; // shrinking or same-size overwrite always allowed
if (kvBytes.get(owner).b + delta > MAX_NS_BYTES()) {
kvPruneExpired.run(owner, nowSec());
if (kvBytes.get(owner).b + delta > MAX_NS_BYTES()) {
throw bad(`Namespace byte budget exceeded (${MAX_NS_BYTES()} bytes of stored values) - delete keys, shrink values, or let TTLs expire`, 413);
}
}
}
// --- statements -----------------------------------------------------------
const kvPut = db.prepare(
"INSERT INTO kv (ns, k, v, updated, exp) VALUES (@ns, @k, @v, @updated, @exp) " +
"ON CONFLICT(ns, k) DO UPDATE SET v = excluded.v, updated = excluded.updated, exp = excluded.exp"
);
const kvGet = db.prepare("SELECT v, updated, exp FROM kv WHERE ns = ? AND k = ?");
const kvDel = db.prepare("DELETE FROM kv WHERE ns = ? AND k = ?");
const kvList = db.prepare("SELECT k, updated, exp FROM kv WHERE ns = ? ORDER BY updated DESC LIMIT 1000");
const kvCount = db.prepare("SELECT COUNT(*) AS n FROM kv WHERE ns = ?");
const kvBytes = db.prepare("SELECT COALESCE(SUM(LENGTH(v)), 0) AS b FROM kv WHERE ns = ?");
const kvPruneExpired = db.prepare("DELETE FROM kv WHERE ns = ? AND exp IS NOT NULL AND exp < ?");
const kvPruneAll = db.prepare("DELETE FROM kv WHERE exp IS NOT NULL AND exp < ?");
// Expired rows in namespaces nobody reads anymore would otherwise live forever
// on the persistent volume — sweep globally on a timer (cheap: exp is indexed).
setInterval(() => {
try {
kvPruneAll.run(nowSec());
} catch {
/* best-effort */
}
}, 10 * 60 * 1000).unref();
const grantPut = db.prepare(
"INSERT INTO grants (owner, grantee, mode, created, exp) VALUES (@owner, @grantee, @mode, @created, @exp) " +
"ON CONFLICT(owner, grantee) DO UPDATE SET mode = excluded.mode, created = excluded.created, exp = excluded.exp"
);
const grantGet = db.prepare("SELECT mode, exp FROM grants WHERE owner = ? AND grantee = ?");
const grantDel = db.prepare("DELETE FROM grants WHERE owner = ? AND grantee = ?");
const grantList = db.prepare("SELECT grantee, mode, created, exp FROM grants WHERE owner = ?");
const logLast = db.prepare("SELECT seq, hash FROM memlog WHERE ns = ? ORDER BY seq DESC LIMIT 1");
const logIns = db.prepare(
"INSERT INTO memlog (ns, seq, ts, actor, action, key, data, prev_hash, hash) " +
"VALUES (@ns, @seq, @ts, @actor, @action, @key, @data, @prev_hash, @hash)"
);
const logRead = db.prepare("SELECT seq, ts, actor, action, key, data, prev_hash, hash FROM memlog WHERE ns = ? ORDER BY seq ASC LIMIT ?");
const docPut = db.prepare(
"INSERT INTO docs (ns, id, text, meta, vec, model, updated) VALUES (@ns, @id, @text, @meta, @vec, @model, @updated) " +
"ON CONFLICT(ns, id) DO UPDATE SET text = excluded.text, meta = excluded.meta, vec = excluded.vec, model = excluded.model, updated = excluded.updated"
);
const docCount = db.prepare("SELECT COUNT(*) AS n FROM docs WHERE ns = ?");
const docAll = db.prepare("SELECT id, text, meta, vec, model, updated FROM docs WHERE ns = ?");
const docDel = db.prepare("DELETE FROM docs WHERE ns = ? AND id = ?");
// --- access control -------------------------------------------------------
/** True if `actor` may act on `owner`'s namespace at the required level. */
export function authorize(owner, actor, need /* "read" | "write" */) {
if (owner === actor) return true;
const g = grantGet.get(owner, actor);
if (!g) return false;
if (g.exp && g.exp < nowSec()) return false;
return need === "write" ? g.mode === "readwrite" : true;
}
function requireAccess(owner, actor, need) {
if (!authorize(owner, actor, need)) {
throw bad(
owner === actor
? "No payer identity on this request"
: `Wallet ${actor} has no ${need} grant on namespace ${owner}`,
403
);
}
}
// --- tamper-evident audit chain ------------------------------------------
function appendLog(ns, actor, action, key, dataObj) {
const last = logLast.get(ns);
const seq = (last?.seq ?? 0) + 1;
const prev = last?.hash ?? "";
const ts = now();
const data = dataObj === undefined ? null : JSON.stringify(dataObj);
const hash = createHash("sha256")
.update(`${prev}|${seq}|${ts}|${actor}|${action}|${key ?? ""}|${data ?? ""}`)
.digest("hex");
logIns.run({ ns, seq, ts, actor, action, key: key ?? null, data, prev_hash: prev, hash });
return { seq, hash };
}
export function getLog(owner, actor, limit = 100) {
requireAccess(owner, actor, "read");
const rows = logRead.all(owner, Math.min(Math.max(limit, 1), 1000));
return {
ns: owner,
entries: rows.map((r) => ({
seq: r.seq,
ts: r.ts,
actor: r.actor,
action: r.action,
key: r.key,
data: r.data ? JSON.parse(r.data) : null,
prevHash: r.prev_hash,
hash: r.hash,
})),
verify:
"hash[i] = sha256(prevHash + '|' + seq + '|' + ts + '|' + actor + '|' + action + '|' + (key||'') + '|' + (JSON.stringify(data)||''))",
persistent: PERSISTENT,
};
}
// --- key/value with TTL ---------------------------------------------------
function freshKv(row) {
if (!row) return null;
if (row.exp && row.exp < nowSec()) return null;
return row;
}
export function memoryPut(owner, key, value, { actor = owner, ttlSeconds } = {}) {
requireAccess(owner, actor, "write");
if (typeof key !== "string" || !key || key.length > MAX_KEY)
throw bad(`"key" must be a non-empty string of at most ${MAX_KEY} chars`);
const serialized = typeof value === "string" ? value : JSON.stringify(value);
if (serialized === undefined || serialized.length > MAX_VALUE)
throw bad(`"value" is required and must serialize to at most ${MAX_VALUE} bytes`);
if (kvCount.get(owner).n >= MAX_KEYS_PER_NS() && !kvGet.get(owner, key)) {
// Expired rows must not consume quota — reclaim before rejecting.
kvPruneExpired.run(owner, nowSec());
if (kvCount.get(owner).n >= MAX_KEYS_PER_NS()) throw bad(`Namespace is full (${MAX_KEYS_PER_NS()} keys)`, 413);
}
assertByteBudget(owner, key, serialized.length);
let exp = null;
if (ttlSeconds !== undefined && ttlSeconds !== null) {
const t = parseInt(ttlSeconds, 10);
if (!Number.isFinite(t) || t <= 0) throw bad('"ttlSeconds" must be a positive integer');
exp = nowSec() + t;
}
const updated = now();
kvPut.run({ ns: owner, k: key, v: serialized, updated, exp });
appendLog(owner, actor, "put", key, { bytes: serialized.length, exp });
return { key, bytes: serialized.length, updated, expiresAt: exp, owner, persistent: PERSISTENT };
}
export function memoryGet(owner, key, { actor = owner } = {}) {
requireAccess(owner, actor, "read");
if (!key) {
kvPruneExpired.run(owner, nowSec());
return { keys: kvList.all(owner).filter((r) => !(r.exp && r.exp < nowSec())), owner, persistent: PERSISTENT };
}
const row = freshKv(kvGet.get(owner, key));
if (!row) throw bad("Key not found", 404);
let value;
try {
value = JSON.parse(row.v);
} catch {
value = row.v;
}
return { key, value, updated: row.updated, expiresAt: row.exp, owner, persistent: PERSISTENT };
}
export function memoryDelete(owner, key, { actor = owner } = {}) {
requireAccess(owner, actor, "write");
if (!key) throw bad('"key" is required');
const deleted = kvDel.run(owner, key).changes > 0;
if (deleted) appendLog(owner, actor, "delete", key);
return { key, deleted, owner };
}
/** Atomic numeric counter — a coordination primitive only a shared store can offer. */
export const memoryIncr = db.transaction((owner, key, by, actor) => {
requireAccess(owner, actor, "write");
if (typeof key !== "string" || !key || key.length > MAX_KEY) throw bad(`Invalid "key"`);
const amount = by === undefined ? 1 : Number(by);
if (!Number.isFinite(amount)) throw bad('"by" must be a number');
const row = freshKv(kvGet.get(owner, key));
let current = 0;
if (row) {
const n = Number(row.v);
if (!Number.isFinite(n)) throw bad(`Key "${key}" holds a non-numeric value; cannot increment`);
current = n;
} else if (kvCount.get(owner).n >= MAX_KEYS_PER_NS()) {
kvPruneExpired.run(owner, nowSec());
if (kvCount.get(owner).n >= MAX_KEYS_PER_NS()) throw bad(`Namespace is full (${MAX_KEYS_PER_NS()} keys)`, 413);
}
const next = current + amount;
kvPut.run({ ns: owner, k: key, v: String(next), updated: now(), exp: row?.exp ?? null });
appendLog(owner, actor, "incr", key, { by: amount, value: next });
return { key, value: next, owner };
});
/**
* Atomic compare-and-set — the general coordination primitive. Writes (or, when
* no value is supplied, deletes) a key only if its current value equals
* `expected`. This is what distributed locks and optimistic concurrency are
* built from:
* - acquire a lock: expected = null (key absent/expired), value = <token>, ttlSeconds = <lease>
* - release a lock: expected = <token>, no value → deletes on match
* - safe update: expected = <old value>, value = <new value>
* `hasValue` distinguishes "set to a value" (even null) from "no value = delete".
* Values are compared as JSON values (same canonicalization on both sides).
*/
export const memoryCas = db.transaction((owner, key, expected, value, { actor = owner, ttlSeconds, hasValue = false } = {}) => {
requireAccess(owner, actor, "write");
if (typeof key !== "string" || !key || key.length > MAX_KEY) throw bad(`"key" must be a non-empty string of at most ${MAX_KEY} chars`);
const row = freshKv(kvGet.get(owner, key));
let current = null;
if (row) { try { current = JSON.parse(row.v); } catch { current = row.v; } }
const want = expected === undefined ? null : expected;
if (JSON.stringify(current) !== JSON.stringify(want)) {
return { key, swapped: false, value: current, owner };
}
// Matched → release (no value supplied) or write the new value.
if (!hasValue || value === undefined) {
const deleted = kvDel.run(owner, key).changes > 0;
if (deleted) appendLog(owner, actor, "cas-del", key, { expected: want });
return { key, swapped: true, value: null, owner };
}
const serialized = typeof value === "string" ? value : JSON.stringify(value);
if (serialized === undefined || serialized.length > MAX_VALUE) throw bad(`"value" must serialize to at most ${MAX_VALUE} bytes`);
if (!row && kvCount.get(owner).n >= MAX_KEYS_PER_NS()) {
kvPruneExpired.run(owner, nowSec());
if (kvCount.get(owner).n >= MAX_KEYS_PER_NS()) throw bad(`Namespace is full (${MAX_KEYS_PER_NS()} keys)`, 413);
}
assertByteBudget(owner, key, serialized.length);
let exp = null;
if (ttlSeconds !== undefined && ttlSeconds !== null) {
const t = parseInt(ttlSeconds, 10);
if (!Number.isFinite(t) || t <= 0) throw bad('"ttlSeconds" must be a positive integer');
exp = nowSec() + t;
}
kvPut.run({ ns: owner, k: key, v: serialized, updated: now(), exp });
appendLog(owner, actor, "cas-set", key, { expected: want, bytes: serialized.length, exp });
return { key, swapped: true, value, owner, expiresAt: exp };
});
// --- grants (cross-agent sharing) ----------------------------------------
const ADDR = /^0x[0-9a-fA-F]{40}$/;
export function grant(owner, grantee, mode, ttlSeconds) {
if (typeof grantee !== "string" || !ADDR.test(grantee)) throw bad('"grantee" must be a 0x wallet address');
const g = grantee.toLowerCase();
if (g === owner) throw bad("You already own this namespace");
if (mode !== "read" && mode !== "readwrite") throw bad('"mode" must be "read" or "readwrite"');
let exp = null;
if (ttlSeconds !== undefined && ttlSeconds !== null) {
const t = parseInt(ttlSeconds, 10);
if (!Number.isFinite(t) || t <= 0) throw bad('"ttlSeconds" must be a positive integer');
exp = nowSec() + t;
}
grantPut.run({ owner, grantee: g, mode, created: now(), exp });
appendLog(owner, owner, "grant", g, { mode, exp });
return { owner, grantee: g, mode, expiresAt: exp };
}
export function revoke(owner, grantee) {
if (typeof grantee !== "string" || !ADDR.test(grantee)) throw bad('"grantee" must be a 0x wallet address');
const g = grantee.toLowerCase();
const removed = grantDel.run(owner, g).changes > 0;
if (removed) appendLog(owner, owner, "revoke", g);
return { owner, grantee: g, revoked: removed };
}
export function listGrants(owner) {
return {
owner,
grants: grantList.all(owner).map((r) => ({
grantee: r.grantee,
mode: r.mode,
created: r.created,
expiresAt: r.exp,
active: !r.exp || r.exp >= nowSec(),
})),
};
}
// --- similarity recall (local embeddings; pluggable provider) -------------
function fnv1a(str) {
let h = 0x811c9dc5;
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return h >>> 0;
}
function l2normalize(arr) {
let norm = 0;
for (const x of arr) norm += x * x;
norm = Math.sqrt(norm) || 1;
return arr.map((x) => +(x / norm).toFixed(6));
}
/**
* Deterministic local embedding: L2-normalized hashed bag of unigrams+bigrams
* (the hashing trick with signed buckets). No external service or key.
*/
function embedLocal(text) {
const vec = new Float64Array(EMBED_DIM);
const tokens = String(text).toLowerCase().match(/[a-z0-9]+/g) || [];
const grams = [...tokens];
for (let i = 0; i < tokens.length - 1; i++) grams.push(tokens[i] + "_" + tokens[i + 1]);
for (const tok of grams) {
const h = fnv1a(tok) % EMBED_DIM;
const sign = fnv1a(tok + "#") & 1 ? 1 : -1;
vec[h] += sign;
}
return l2normalize(Array.from(vec));
}
// Optional real embeddings provider (OpenAI-compatible /embeddings shape:
// Voyage, OpenAI, Together, DeepInfra, etc.). Configure to upgrade recall from
// lexical to true semantic similarity without touching callers.
const EMBEDDINGS_URL = process.env.EMBEDDINGS_URL || "";
const EMBEDDINGS_MODEL = process.env.EMBEDDINGS_MODEL || "text-embedding-3-small";
const EMBEDDINGS_KEY = process.env.EMBEDDINGS_API_KEY || "";
export const EMBEDDER = EMBEDDINGS_URL ? `provider:${EMBEDDINGS_MODEL}` : "local-v1";
async function embedRemote(text) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 15000);
try {
const res = await fetch(EMBEDDINGS_URL, {
method: "POST",
signal: controller.signal,
headers: {
"Content-Type": "application/json",
...(EMBEDDINGS_KEY ? { Authorization: `Bearer ${EMBEDDINGS_KEY}` } : {}),
},
body: JSON.stringify({ model: EMBEDDINGS_MODEL, input: text }),
});
if (!res.ok) throw new Error(`embeddings provider HTTP ${res.status}`);
const json = await res.json();
const vec = json?.data?.[0]?.embedding;
if (!Array.isArray(vec) || !vec.length) throw new Error("embeddings provider returned no vector");
return l2normalize(vec);
} catch (e) {
throw Object.assign(new Error(`Embedding failed: ${e.message}`), { statusCode: 502 });
} finally {
clearTimeout(timer);
}
}
/** Embed text into an L2-normalized vector. Returns { vec, model }. */
async function embedText(text) {
if (EMBEDDINGS_URL) return { vec: await embedRemote(text), model: EMBEDDER };
return { vec: embedLocal(text), model: EMBEDDER };
}
function cosine(a, b) {
let dot = 0;
for (let i = 0; i < a.length && i < b.length; i++) dot += a[i] * b[i];
return dot; // both are L2-normalized
}
let docSeq = 0;
function newDocId() {
// Crypto-random entropy segment so doc IDs don't collide (collisions would
// ON CONFLICT-overwrite a prior doc in the same namespace).
return `${nowSec().toString(36)}${(docSeq++ & 0xffff).toString(36)}${randomBytes(6).toString("hex")}`;
}
export async function remember(owner, text, meta, { actor = owner } = {}) {
requireAccess(owner, actor, "write");
if (typeof text !== "string" || !text.trim()) throw bad('"text" is required');
if (text.length > MAX_DOC_TEXT) throw bad(`"text" exceeds ${MAX_DOC_TEXT} chars`);
if (docCount.get(owner).n >= MAX_DOCS_PER_NS) throw bad(`Recall store is full (${MAX_DOCS_PER_NS} docs)`);
const { vec, model } = await embedText(text);
const id = newDocId();
const metaStr = meta === undefined ? null : JSON.stringify(meta);
docPut.run({ ns: owner, id, text, meta: metaStr, vec: JSON.stringify(vec), model, updated: now() });
appendLog(owner, actor, "remember", id, { chars: text.length });
return { id, owner, stored: true, embedder: model };
}
export async function recall(owner, query, k, { actor = owner } = {}) {
requireAccess(owner, actor, "read");
if (typeof query !== "string" || !query.trim()) throw bad('"query" is required');
const topK = Math.min(Math.max(parseInt(k, 10) || 5, 1), 50);
const { vec: qv, model } = await embedText(query);
// Only compare against docs embedded by the SAME embedder (a provider switch
// would otherwise compare incompatible vector spaces).
const docs = docAll.all(owner);
const comparable = docs.filter((d) => (d.model ?? "local-v1") === model);
const scored = comparable.map((d) => ({
id: d.id,
score: +cosine(qv, JSON.parse(d.vec)).toFixed(4),
text: d.text,
meta: d.meta ? JSON.parse(d.meta) : null,
updated: d.updated,
}));
scored.sort((a, b) => b.score - a.score);
const out = { owner, query, embedder: model, results: scored.slice(0, topK).filter((r) => r.score > 0) };
const skipped = docs.length - comparable.length;
if (skipped > 0) out.note = `${skipped} doc(s) embedded with a different model were skipped; re-remember them to use ${model}.`;
return out;
}
export function forget(owner, id, { actor = owner } = {}) {
requireAccess(owner, actor, "write");
if (!id) throw bad('"id" is required');
const deleted = docDel.run(owner, id).changes > 0;
if (deleted) appendLog(owner, actor, "forget", id);
return { id, deleted, owner };
}