@yeaft/webchat-agent 1.0.587 → 1.0.589
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/Dockerfile +1 -1
- package/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +2 -2
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +1 -1
- package/local-runtime/web/installers/install.ps1 +13 -2
- package/local-runtime/web/installers/install.sh +3 -1
- package/package.json +4 -1
- package/yeaft/person/bridge.js +5 -10
- package/yeaft/person/capabilities.js +98 -23
- package/yeaft/person/capability-experience.js +60 -0
- package/yeaft/person/contracts.js +5 -3
- package/yeaft/person/local-memory-cache.js +82 -0
- package/yeaft/person/local-memory-embedding.js +54 -0
- package/yeaft/person/local-memory-host.js +40 -0
- package/yeaft/person/local-memory-worker.js +231 -0
- package/yeaft/person/local-memory.js +294 -0
- package/yeaft/person/repository.js +11 -2
- package/yeaft/person/runtime.js +27 -14
- package/yeaft/person/service.js +28 -9
- package/yeaft/person/sqlite-repository.js +123 -0
- package/yeaft/person/sqlite-schema.js +80 -0
- package/yeaft/person/sqlite-store.js +384 -0
- package/yeaft/person/sqlite-worker.js +25 -0
- package/yeaft/person/storage.js +36 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import { mkdirSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { parentPort, workerData } from 'node:worker_threads';
|
|
5
|
+
import { CHUNK_SIZE, CHUNK_OVERLAP, MODEL_FINGERPRINT, VECTOR_DIMENSION } from './local-memory-embedding.js';
|
|
6
|
+
|
|
7
|
+
const { yeaftDir, namespace, embedding = {}, embeddingModule, maxVectorChunks, maxEmbedChunksPerRecall, candidateLimit } = workerData;
|
|
8
|
+
const fingerprint = embeddingModule ? `${MODEL_FINGERPRINT}:injected:${embeddingModule}:${embedding.fingerprint ?? 'test'}` : MODEL_FINGERPRINT;
|
|
9
|
+
let db;
|
|
10
|
+
let embedder;
|
|
11
|
+
let embeddingId = 0;
|
|
12
|
+
const embeddingRequests = new Map();
|
|
13
|
+
function hostEmbedding(texts, type) {
|
|
14
|
+
return new Promise((resolve, reject) => {
|
|
15
|
+
const id = ++embeddingId;
|
|
16
|
+
embeddingRequests.set(id, { resolve, reject });
|
|
17
|
+
parentPort.postMessage({ embeddingRequest: { id, texts, type } });
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
const segmenter = new Intl.Segmenter('zh', { granularity: 'word' });
|
|
21
|
+
|
|
22
|
+
function tokens(text) {
|
|
23
|
+
return [...segmenter.segment(text.normalize('NFKC').toLowerCase())].filter(s => s.isWordLike).map(s => s.segment);
|
|
24
|
+
}
|
|
25
|
+
function chunks(text) {
|
|
26
|
+
const points = Array.from(text);
|
|
27
|
+
const result = [];
|
|
28
|
+
for (let start = 0; start < points.length; start += CHUNK_SIZE - CHUNK_OVERLAP) {
|
|
29
|
+
result.push(points.slice(start, start + CHUNK_SIZE).join(''));
|
|
30
|
+
if (start + CHUNK_SIZE >= points.length) break;
|
|
31
|
+
}
|
|
32
|
+
return result;
|
|
33
|
+
}
|
|
34
|
+
function transaction(fn) {
|
|
35
|
+
db.exec('BEGIN IMMEDIATE');
|
|
36
|
+
try { const result = fn(); db.exec('COMMIT'); return result; }
|
|
37
|
+
catch (error) { db.exec('ROLLBACK'); throw error; }
|
|
38
|
+
}
|
|
39
|
+
function open() {
|
|
40
|
+
if (db) return;
|
|
41
|
+
mkdirSync(path.join(yeaftDir, 'person'), { recursive: true, mode: 0o700 });
|
|
42
|
+
db = new DatabaseSync(path.join(yeaftDir, 'person', 'recall.db'));
|
|
43
|
+
try {
|
|
44
|
+
// Some supported early Node 22 builds omit FTS5. Keep authority usable and
|
|
45
|
+
// report an explicit literal-only fallback; never start a model on this path.
|
|
46
|
+
db.exec('CREATE VIRTUAL TABLE temp.person_fts_probe USING fts5(terms); DROP TABLE temp.person_fts_probe;');
|
|
47
|
+
} catch (error) {
|
|
48
|
+
db.close(); db = null;
|
|
49
|
+
if (/no such module: fts5/i.test(error.message)) error.code = 'FTS_UNAVAILABLE';
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
db.exec(`PRAGMA busy_timeout=2000; PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;
|
|
53
|
+
CREATE TABLE IF NOT EXISTS spaces(namespace TEXT PRIMARY KEY, fingerprint TEXT NOT NULL);
|
|
54
|
+
CREATE TABLE IF NOT EXISTS checkpoints(namespace TEXT NOT NULL, owner TEXT NOT NULL, seq INTEGER NOT NULL, PRIMARY KEY(namespace,owner));
|
|
55
|
+
CREATE TABLE IF NOT EXISTS chunks(
|
|
56
|
+
rowid INTEGER PRIMARY KEY, namespace TEXT NOT NULL, owner TEXT NOT NULL, kind TEXT NOT NULL,
|
|
57
|
+
id TEXT NOT NULL, revision INTEGER NOT NULL, part INTEGER NOT NULL, seq INTEGER NOT NULL,
|
|
58
|
+
text TEXT NOT NULL, terms TEXT NOT NULL, vector BLOB,
|
|
59
|
+
UNIQUE(namespace,owner,kind,id,part));
|
|
60
|
+
CREATE INDEX IF NOT EXISTS recall_scope ON chunks(namespace,owner,kind,seq DESC);
|
|
61
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS chunk_fts USING fts5(terms, content='chunks', content_rowid='rowid', tokenize='unicode61');
|
|
62
|
+
CREATE TRIGGER IF NOT EXISTS chunks_insert AFTER INSERT ON chunks BEGIN
|
|
63
|
+
INSERT INTO chunk_fts(rowid,terms) VALUES (new.rowid,new.terms); END;
|
|
64
|
+
CREATE TRIGGER IF NOT EXISTS chunks_delete AFTER DELETE ON chunks BEGIN
|
|
65
|
+
INSERT INTO chunk_fts(chunk_fts,rowid,terms) VALUES ('delete',old.rowid,old.terms); END;`);
|
|
66
|
+
}
|
|
67
|
+
function ensureSpace() {
|
|
68
|
+
transaction(() => {
|
|
69
|
+
const previous = db.prepare('SELECT fingerprint FROM spaces WHERE namespace=?').get(namespace);
|
|
70
|
+
if (!previous || previous.fingerprint !== fingerprint) {
|
|
71
|
+
db.prepare('DELETE FROM chunks WHERE namespace=?').run(namespace);
|
|
72
|
+
db.prepare('DELETE FROM checkpoints WHERE namespace=?').run(namespace);
|
|
73
|
+
db.prepare('INSERT OR REPLACE INTO spaces VALUES (?,?)').run(namespace, fingerprint);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
function checkpoint(owner) {
|
|
78
|
+
return db.prepare('SELECT seq FROM checkpoints WHERE namespace=? AND owner=?').get(namespace, owner)?.seq ?? 0;
|
|
79
|
+
}
|
|
80
|
+
function assertSpace() {
|
|
81
|
+
if (db.prepare('SELECT fingerprint FROM spaces WHERE namespace=?').get(namespace)?.fingerprint !== fingerprint) throw new Error('space_changed');
|
|
82
|
+
}
|
|
83
|
+
function apply(owner, items, lastSeq) {
|
|
84
|
+
return transaction(() => {
|
|
85
|
+
assertSpace();
|
|
86
|
+
const before = checkpoint(owner);
|
|
87
|
+
const remove = db.prepare('DELETE FROM chunks WHERE namespace=? AND owner=? AND kind=? AND id=?');
|
|
88
|
+
const insert = db.prepare('INSERT INTO chunks(namespace,owner,kind,id,revision,part,seq,text,terms) VALUES (?,?,?,?,?,?,?,?,?)');
|
|
89
|
+
for (const item of items) {
|
|
90
|
+
if (item.seq <= before) continue;
|
|
91
|
+
if (!['messages', 'concepts'].includes(item.kind) || !Number.isSafeInteger(item.seq) || !Number.isSafeInteger(item.revision) || typeof item.id !== 'string') throw new Error('invalid_journal');
|
|
92
|
+
remove.run(namespace, owner, item.kind, item.id);
|
|
93
|
+
if (item.record) {
|
|
94
|
+
const text = item.kind === 'messages' ? item.record.text : item.record.statement;
|
|
95
|
+
if (typeof text !== 'string') throw new Error('invalid_record');
|
|
96
|
+
for (const [part, chunk] of chunks(text).entries()) insert.run(namespace, owner, item.kind, item.id, item.revision, part, item.seq, chunk, tokens(chunk).join(' '));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (!Number.isSafeInteger(lastSeq) || lastSeq < before || items.some(item => item.seq > lastSeq)) throw new Error('invalid_checkpoint');
|
|
100
|
+
db.prepare('INSERT INTO checkpoints VALUES (?,?,?) ON CONFLICT(namespace,owner) DO UPDATE SET seq=excluded.seq').run(namespace, owner, lastSeq);
|
|
101
|
+
return { checkpoint: lastSeq };
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
function vector(value) {
|
|
105
|
+
if ((!Array.isArray(value) && !ArrayBuffer.isView(value)) || value.length !== VECTOR_DIMENSION || !Array.from(value).every(Number.isFinite)) throw new Error('invalid_embedding');
|
|
106
|
+
const norm = Math.sqrt(Array.from(value).reduce((sum, n) => sum + n * n, 0));
|
|
107
|
+
if (!norm) throw new Error('invalid_embedding');
|
|
108
|
+
return Float32Array.from(value, n => n / norm);
|
|
109
|
+
}
|
|
110
|
+
function encode(v) {
|
|
111
|
+
const blob = Buffer.alloc(VECTOR_DIMENSION * 4);
|
|
112
|
+
v.forEach((n, i) => blob.writeFloatLE(n, i * 4));
|
|
113
|
+
return blob;
|
|
114
|
+
}
|
|
115
|
+
async function embed(texts, type) {
|
|
116
|
+
// Trusted deterministic fixtures run in this thread. Production inference runs
|
|
117
|
+
// in its disposable host process to avoid ONNX addon thread reload failures.
|
|
118
|
+
if (embeddingModule && !embedder) {
|
|
119
|
+
const module = await import(embeddingModule);
|
|
120
|
+
embedder = module.createEmbedding({ ...embedding, yeaftDir });
|
|
121
|
+
}
|
|
122
|
+
const results = embedder ? await embedder.embed(texts, type) : await hostEmbedding(texts, type);
|
|
123
|
+
if (!Array.isArray(results) || results.length !== texts.length) throw new Error('invalid_embedding');
|
|
124
|
+
return results.map(vector);
|
|
125
|
+
}
|
|
126
|
+
async function search(owner, kind, query) {
|
|
127
|
+
const reasons = [];
|
|
128
|
+
// Read space identity and vectors in one transaction, including when multiple
|
|
129
|
+
// local controllers use the same DB. Never accept vectors from an ABA space switch.
|
|
130
|
+
const { totalChunks, eligible } = transaction(() => {
|
|
131
|
+
assertSpace();
|
|
132
|
+
const totalChunks = db.prepare('SELECT count(*) AS n FROM chunks WHERE namespace=? AND owner=? AND kind=?').get(namespace, owner, kind).n;
|
|
133
|
+
// Exact cosine is deliberately bounded to the newest N chunks. FTS covers all indexed text.
|
|
134
|
+
const eligible = db.prepare('SELECT rowid,id,revision,seq,text,vector FROM chunks WHERE namespace=? AND owner=? AND kind=? ORDER BY seq DESC,id,part LIMIT ?').all(namespace, owner, kind, maxVectorChunks);
|
|
135
|
+
return { totalChunks, eligible };
|
|
136
|
+
});
|
|
137
|
+
let queryVector;
|
|
138
|
+
if (embedding.enabled !== false) {
|
|
139
|
+
try {
|
|
140
|
+
queryVector = (await embed([query], 'query'))[0];
|
|
141
|
+
const pending = eligible.filter(row => !row.vector).slice(0, maxEmbedChunksPerRecall);
|
|
142
|
+
for (let start = 0; start < pending.length; start += 8) {
|
|
143
|
+
const batch = pending.slice(start, start + 8);
|
|
144
|
+
const values = await embed(batch.map(row => row.text), 'passage');
|
|
145
|
+
transaction(() => {
|
|
146
|
+
assertSpace();
|
|
147
|
+
const update = db.prepare('UPDATE chunks SET vector=? WHERE rowid=? AND namespace=? AND owner=? AND kind=? AND id=? AND revision=? AND seq=?');
|
|
148
|
+
batch.forEach((row, i) => {
|
|
149
|
+
row.vector = encode(values[i]);
|
|
150
|
+
update.run(row.vector, row.rowid, namespace, owner, kind, row.id, row.revision, row.seq);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
} catch {
|
|
155
|
+
queryVector = null;
|
|
156
|
+
reasons.push('embedding_unavailable');
|
|
157
|
+
// The controller replaces this process after delivering keyword results:
|
|
158
|
+
// ONNX itself can cache a rejected native-session initialization promise.
|
|
159
|
+
}
|
|
160
|
+
} else reasons.push('semantic_disabled');
|
|
161
|
+
assertSpace();
|
|
162
|
+
const terms = [...new Set(tokens(query))].slice(0, 32).map(term => `"${term.replaceAll('"', '""')}"`).join(' OR ');
|
|
163
|
+
// Group before limiting parents: many matching chunks must not crowd out a
|
|
164
|
+
// shorter record. MATERIALIZED keeps FTS5 bm25 in its supported row context.
|
|
165
|
+
const keyword = terms ? db.prepare(`WITH matches AS MATERIALIZED (
|
|
166
|
+
SELECT c.id,c.revision,bm25(chunk_fts) AS rank FROM chunk_fts JOIN chunks c ON c.rowid=chunk_fts.rowid
|
|
167
|
+
WHERE chunk_fts MATCH ? AND c.namespace=? AND c.owner=? AND c.kind=?)
|
|
168
|
+
SELECT id,revision,min(rank) AS rank FROM matches GROUP BY id,revision ORDER BY rank,id LIMIT ?`).all(terms, namespace, owner, kind, candidateLimit) : [];
|
|
169
|
+
let vectorScannedChunks = 0;
|
|
170
|
+
const semantic = [];
|
|
171
|
+
if (queryVector) for (const row of eligible) {
|
|
172
|
+
if (!row.vector || row.vector.length !== VECTOR_DIMENSION * 4) continue;
|
|
173
|
+
const bytes = Buffer.from(row.vector);
|
|
174
|
+
let cosine = 0;
|
|
175
|
+
for (let i = 0; i < VECTOR_DIMENSION; i++) cosine += queryVector[i] * bytes.readFloatLE(i * 4);
|
|
176
|
+
vectorScannedChunks++;
|
|
177
|
+
if (Number.isFinite(cosine)) semantic.push({ id: row.id, revision: row.revision, cosine });
|
|
178
|
+
}
|
|
179
|
+
semantic.sort((a, b) => b.cosine - a.cosine || a.id.localeCompare(b.id));
|
|
180
|
+
const ranks = new Map();
|
|
181
|
+
// Deduplicate each channel BEFORE RRF: a verbose record does not receive more votes.
|
|
182
|
+
for (const channel of [keyword, semantic]) {
|
|
183
|
+
const seen = new Set();
|
|
184
|
+
for (const ref of channel) {
|
|
185
|
+
if (seen.has(ref.id)) continue;
|
|
186
|
+
seen.add(ref.id);
|
|
187
|
+
const current = ranks.get(ref.id) ?? { id: ref.id, revision: ref.revision, score: 0 };
|
|
188
|
+
current.score += 1 / (60 + seen.size);
|
|
189
|
+
ranks.set(ref.id, current);
|
|
190
|
+
if (seen.size >= candidateLimit) break;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const refs = [...ranks.values()].sort((a, b) => b.score - a.score || a.id.localeCompare(b.id)).slice(0, candidateLimit).map(({ id, revision }) => ({ id, revision }));
|
|
194
|
+
const semanticUsed = Boolean(queryVector && vectorScannedChunks);
|
|
195
|
+
if (queryVector && vectorScannedChunks < totalChunks) reasons.push('semantic_coverage_limited');
|
|
196
|
+
return { refs, resetWorker: reasons.includes('embedding_unavailable'), retrieval: {
|
|
197
|
+
mode: semanticUsed ? 'hybrid' : 'keyword', semantic: semanticUsed, degraded: reasons.some(r => r !== 'semantic_disabled'), reasons,
|
|
198
|
+
fingerprint, ranking: 'rrf-60', bounded: true, candidateLimit,
|
|
199
|
+
coverage: { totalChunks, vectorEligibleChunks: eligible.length, vectorScannedChunks, maxVectorChunks, maxEmbedChunksPerRecall },
|
|
200
|
+
} };
|
|
201
|
+
}
|
|
202
|
+
async function dispatch({ op, owner, ...args }) {
|
|
203
|
+
if (op === 'close') { await embedder?.close?.(); db?.close(); db = null; return null; }
|
|
204
|
+
if (typeof owner !== 'string' || !owner.length) throw new Error('invalid_owner');
|
|
205
|
+
open();
|
|
206
|
+
if (op === 'checkpoint') { ensureSpace(); return checkpoint(owner); }
|
|
207
|
+
assertSpace();
|
|
208
|
+
if (op === 'apply') return apply(owner, args.items, args.lastSeq);
|
|
209
|
+
if (op === 'search') return search(owner, args.kind, args.query);
|
|
210
|
+
throw new Error('invalid_operation');
|
|
211
|
+
}
|
|
212
|
+
// The controller issues one RPC at a time; serialize defensively inside the worker too.
|
|
213
|
+
let chain = Promise.resolve();
|
|
214
|
+
parentPort.on('message', message => {
|
|
215
|
+
// Results must bypass the serialized RPC chain, whose search is awaiting them.
|
|
216
|
+
if (message.embeddingResult) {
|
|
217
|
+
const { id, value, error } = message.embeddingResult;
|
|
218
|
+
const pending = embeddingRequests.get(id);
|
|
219
|
+
embeddingRequests.delete(id);
|
|
220
|
+
if (error) pending?.reject(new Error('embedding_unavailable'));
|
|
221
|
+
else pending?.resolve(value);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
chain = chain.then(async () => {
|
|
225
|
+
try {
|
|
226
|
+
const value = await dispatch(message);
|
|
227
|
+
if (message.op === 'close') { parentPort.postMessage({ closed: true }); return; }
|
|
228
|
+
parentPort.postMessage({ id: message.id, value });
|
|
229
|
+
} catch (error) { parentPort.postMessage({ id: message.id, error: { code: error.code || 'INDEX_FAILURE' } }); }
|
|
230
|
+
});
|
|
231
|
+
});
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { fork } from 'node:child_process';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
const fault = code => Object.assign(new Error(code), { code });
|
|
6
|
+
function integer(value, fallback, min, max) {
|
|
7
|
+
return Number.isSafeInteger(value) ? Math.max(min, Math.min(max, value)) : fallback;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Owner-scoped, disposable search projection; repository is always authoritative.
|
|
12
|
+
* No I/O, model import or inference until a nonempty recall. The repository must
|
|
13
|
+
* expose searchChanges/resolveMemories plus the ORIGINAL literal recall function
|
|
14
|
+
* (capture it before decorating repository.recall; alternatively pass literalRecall).
|
|
15
|
+
*
|
|
16
|
+
* Defaults: exact scan newest 10k chunks, embed at most 256 missing chunks/recall,
|
|
17
|
+
* return a stable bounded top-200 snapshot (5 minute/32 snapshot cache). FTS covers
|
|
18
|
+
* all journal-indexed text. Coverage/catch-up limits are explicit in retrieval.
|
|
19
|
+
* embeddingModule is a trusted worker-module injection seam for tests, NOT wire input.
|
|
20
|
+
*/
|
|
21
|
+
export class LocalPersonMemory {
|
|
22
|
+
constructor({ repository, yeaftDir, namespace, literalRecall, embedding = {}, embeddingModule,
|
|
23
|
+
maxQueue = 8, timeoutMs = 120000, shutdownTimeoutMs = 5000,
|
|
24
|
+
journalBatchSize = 100, maxJournalPages = 50, maxVectorChunks = 10000,
|
|
25
|
+
maxEmbedChunksPerRecall = 256, candidateLimit = 200, snapshotTtlMs = 300000, maxSnapshots = 32 } = {}) {
|
|
26
|
+
if (!repository || !yeaftDir || typeof namespace !== 'string' || !namespace.trim()) throw new TypeError('repository, yeaftDir and namespace are required');
|
|
27
|
+
if (repository.namespace && repository.namespace !== namespace) throw new TypeError('repository namespace mismatch');
|
|
28
|
+
this.repository = repository;
|
|
29
|
+
this.literalRecall = literalRecall ?? repository.recall.bind(repository);
|
|
30
|
+
this.options = {
|
|
31
|
+
yeaftDir: path.resolve(yeaftDir), namespace, embedding: { enabled: true, allowDownload: true, ...embedding, threads: integer(embedding.threads, 2, 1, 4) }, embeddingModule,
|
|
32
|
+
maxVectorChunks: integer(maxVectorChunks, 10000, 1, 50000),
|
|
33
|
+
maxEmbedChunksPerRecall: integer(maxEmbedChunksPerRecall, 256, 1, 2048),
|
|
34
|
+
candidateLimit: integer(candidateLimit, 200, 1, 1000),
|
|
35
|
+
};
|
|
36
|
+
this.maxQueue = integer(maxQueue, 8, 1, 32);
|
|
37
|
+
this.timeoutMs = integer(timeoutMs, 120000, 20, 600000);
|
|
38
|
+
this.shutdownTimeoutMs = integer(shutdownTimeoutMs, 5000, 20, 10000);
|
|
39
|
+
this.journalBatchSize = integer(journalBatchSize, 100, 1, 500);
|
|
40
|
+
this.maxJournalPages = integer(maxJournalPages, 50, 1, 1000);
|
|
41
|
+
this.snapshotTtlMs = integer(snapshotTtlMs, 300000, 1, 3600000);
|
|
42
|
+
this.maxSnapshots = integer(maxSnapshots, 32, 1, 128);
|
|
43
|
+
this.snapshots = new Map();
|
|
44
|
+
this.queue = [];
|
|
45
|
+
this.active = null;
|
|
46
|
+
this.worker = null;
|
|
47
|
+
this.rpc = null;
|
|
48
|
+
this.nextId = 0;
|
|
49
|
+
this.closed = false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
status() {
|
|
53
|
+
return { enabled: this.options.embedding.enabled !== false, workerStarted: Boolean(this.worker), queued: this.queue.length, closed: this.closed };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async recall(ownerId, { kind = 'messages', query = '', cursor = null, limit = 5 } = {}, { signal } = {}) {
|
|
57
|
+
if (typeof ownerId !== 'string' || !ownerId.trim()) throw new TypeError('ownerId is required');
|
|
58
|
+
if (!['messages', 'concepts'].includes(kind) || typeof query !== 'string' || query.length > 4000) throw fault('INVALID_RECALL');
|
|
59
|
+
if (this.closed) throw fault('MEMORY_CLOSED');
|
|
60
|
+
if (signal?.aborted) throw fault('RECALL_CANCELLED');
|
|
61
|
+
limit = integer(limit, 5, 1, 50);
|
|
62
|
+
if (!query.trim()) return this.literalRecall(ownerId, { kind, query, cursor, limit });
|
|
63
|
+
const request = { ownerId, kind, query, cursor, limit };
|
|
64
|
+
return this._schedule(token => this._recall(request, token), error => {
|
|
65
|
+
if (signal?.aborted) throw fault('RECALL_CANCELLED');
|
|
66
|
+
if (['INVALID_CURSOR', 'MEMORY_CLOSED', 'RECALL_BUSY', 'RECALL_CANCELLED'].includes(error.code)) throw error;
|
|
67
|
+
// A failed continuation must never silently restart ranking or expose a foreign cursor.
|
|
68
|
+
if (cursor) throw fault('RECALL_RETRY');
|
|
69
|
+
return this._literal(request, error.code || 'INDEX_FAILURE', undefined, signal);
|
|
70
|
+
}, signal);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
_alive(token) {
|
|
74
|
+
if (this.closed) throw fault('MEMORY_CLOSED');
|
|
75
|
+
if (token.cancelled) throw fault(token.reason || 'RECALL_TIMEOUT');
|
|
76
|
+
}
|
|
77
|
+
_schedule(fn, onError, signal) {
|
|
78
|
+
if (this.queue.length + Number(Boolean(this.active)) >= this.maxQueue) return Promise.reject(fault('RECALL_BUSY'));
|
|
79
|
+
return new Promise((resolve, reject) => {
|
|
80
|
+
const token = { cancelled: false };
|
|
81
|
+
const job = { fn, onError, token, resolve, reject };
|
|
82
|
+
job.abort = new Promise((_, fail) => { job.fail = fail; });
|
|
83
|
+
// Queued time counts too. Attach a handler before the job becomes active.
|
|
84
|
+
job.abort.catch(() => {});
|
|
85
|
+
const abort = code => {
|
|
86
|
+
if (token.cancelled) return;
|
|
87
|
+
token.cancelled = true;
|
|
88
|
+
token.reason = code;
|
|
89
|
+
const error = fault(code);
|
|
90
|
+
job.fail(error);
|
|
91
|
+
if (this.active !== job) {
|
|
92
|
+
this.queue = this.queue.filter(item => item !== job);
|
|
93
|
+
clearTimeout(job.timer);
|
|
94
|
+
job.cleanup();
|
|
95
|
+
reject(error);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
const onAbort = () => abort('RECALL_CANCELLED');
|
|
99
|
+
job.cleanup = () => signal?.removeEventListener('abort', onAbort);
|
|
100
|
+
job.timer = setTimeout(() => abort('RECALL_TIMEOUT'), this.timeoutMs);
|
|
101
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
102
|
+
this.queue.push(job);
|
|
103
|
+
if (signal?.aborted) onAbort();
|
|
104
|
+
void this._pump();
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
async _pump() {
|
|
108
|
+
if (this.active || this.closed) return;
|
|
109
|
+
const job = this.queue.shift();
|
|
110
|
+
if (!job) return;
|
|
111
|
+
this.active = job;
|
|
112
|
+
try { job.resolve(await Promise.race([job.fn(job.token), job.abort])); }
|
|
113
|
+
catch (error) {
|
|
114
|
+
clearTimeout(job.timer);
|
|
115
|
+
if (job.token.cancelled) await this._stopWorker(false);
|
|
116
|
+
// Fallback occupies the same admission slot; index failure cannot turn a
|
|
117
|
+
// bounded recall queue into unbounded concurrent authoritative reads.
|
|
118
|
+
try { job.resolve(await job.onError(error)); }
|
|
119
|
+
catch (fallbackError) { job.reject(fallbackError); }
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
clearTimeout(job.timer);
|
|
123
|
+
job.cleanup();
|
|
124
|
+
if (job.token.cancelled) await this._stopWorker(false);
|
|
125
|
+
this.active = null;
|
|
126
|
+
void this._pump();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
_getWorker() {
|
|
131
|
+
if (this.worker) return this.worker;
|
|
132
|
+
// onnxruntime-node cannot reliably reload its native addon in a replacement
|
|
133
|
+
// worker_thread. A managed process also makes blocked native inference killable.
|
|
134
|
+
const worker = fork(new URL('./local-memory-host.js', import.meta.url), [], {
|
|
135
|
+
stdio: ['ignore', 'ignore', 'ignore', 'ipc'], serialization: 'advanced',
|
|
136
|
+
// Older supported Node 22 requires this flag; do not inherit --input-type or test loaders.
|
|
137
|
+
execArgv: process.execArgv.filter(arg => arg === '--experimental-sqlite'),
|
|
138
|
+
});
|
|
139
|
+
this.worker = worker;
|
|
140
|
+
worker.send({ init: this.options });
|
|
141
|
+
worker.unref();
|
|
142
|
+
worker.channel?.unref();
|
|
143
|
+
worker.on('message', message => {
|
|
144
|
+
if (this.worker !== worker || this.rpc?.id !== message.id) return;
|
|
145
|
+
const rpc = this.rpc;
|
|
146
|
+
this.rpc = null;
|
|
147
|
+
worker.unref();
|
|
148
|
+
worker.channel?.unref();
|
|
149
|
+
if (message.error) rpc.reject(fault(message.error.code));
|
|
150
|
+
else rpc.resolve(message.value);
|
|
151
|
+
});
|
|
152
|
+
const failed = () => {
|
|
153
|
+
if (this.worker !== worker) return;
|
|
154
|
+
this.worker = null;
|
|
155
|
+
this.rpc?.reject(fault('INDEX_WORKER_FAILED'));
|
|
156
|
+
this.rpc = null;
|
|
157
|
+
worker.unref();
|
|
158
|
+
worker.kill('SIGKILL');
|
|
159
|
+
};
|
|
160
|
+
worker.on('error', failed);
|
|
161
|
+
worker.on('exit', failed);
|
|
162
|
+
return worker;
|
|
163
|
+
}
|
|
164
|
+
_call(token, op, owner, args = {}) {
|
|
165
|
+
this._alive(token);
|
|
166
|
+
const worker = this._getWorker();
|
|
167
|
+
return new Promise((resolve, reject) => {
|
|
168
|
+
const id = ++this.nextId;
|
|
169
|
+
this.rpc = { id, resolve, reject };
|
|
170
|
+
worker.ref();
|
|
171
|
+
worker.channel?.ref();
|
|
172
|
+
worker.send({ id, op, owner, ...args }, error => { if (error) reject(fault('INDEX_WORKER_FAILED')); });
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
async _catchUp(owner, token) {
|
|
176
|
+
let after = await this._call(token, 'checkpoint', owner);
|
|
177
|
+
for (let page = 0; page < this.maxJournalPages; page++) {
|
|
178
|
+
this._alive(token);
|
|
179
|
+
const changes = await this.repository.searchChanges(owner, { after, limit: this.journalBatchSize });
|
|
180
|
+
this._alive(token);
|
|
181
|
+
if (!changes || !Array.isArray(changes.items) || changes.items.length > this.journalBatchSize || !Number.isSafeInteger(changes.lastSeq)) throw fault('INVALID_JOURNAL');
|
|
182
|
+
if (changes.items.length || changes.lastSeq !== after) await this._call(token, 'apply', owner, changes);
|
|
183
|
+
if (!changes.hasMore) return { checkpoint: changes.lastSeq, caughtUp: true };
|
|
184
|
+
if (changes.lastSeq <= after) throw fault('INVALID_JOURNAL');
|
|
185
|
+
after = changes.lastSeq;
|
|
186
|
+
}
|
|
187
|
+
return { checkpoint: after, caughtUp: false };
|
|
188
|
+
}
|
|
189
|
+
_prune() {
|
|
190
|
+
for (const [id, snapshot] of this.snapshots) if (snapshot.expires <= Date.now()) this.snapshots.delete(id);
|
|
191
|
+
while (this.snapshots.size >= this.maxSnapshots) this.snapshots.delete(this.snapshots.keys().next().value);
|
|
192
|
+
}
|
|
193
|
+
_save(snapshot, position) {
|
|
194
|
+
this._prune();
|
|
195
|
+
const id = randomBytes(16).toString('hex');
|
|
196
|
+
this.snapshots.set(id, { ...snapshot, expires: Date.now() + this.snapshotTtlMs });
|
|
197
|
+
return `lm:${id}:${position}`;
|
|
198
|
+
}
|
|
199
|
+
_cursor(request) {
|
|
200
|
+
if (typeof request.cursor !== 'string' || request.cursor.length > 128) throw fault('INVALID_CURSOR');
|
|
201
|
+
const match = /^lm:([a-f0-9]{32}):(\d{1,6})$/.exec(request.cursor);
|
|
202
|
+
const snapshot = match && this.snapshots.get(match[1]);
|
|
203
|
+
if (!snapshot || snapshot.expires <= Date.now() || snapshot.ownerId !== request.ownerId || snapshot.kind !== request.kind || snapshot.query !== request.query) throw fault('INVALID_CURSOR');
|
|
204
|
+
const position = Number(match[2]);
|
|
205
|
+
if (!Number.isSafeInteger(position) || position > (snapshot.refs?.length ?? 0)) throw fault('INVALID_CURSOR');
|
|
206
|
+
return { snapshot, position, id: match[1] };
|
|
207
|
+
}
|
|
208
|
+
async _recall(request, token) {
|
|
209
|
+
let snapshot, position = 0, id;
|
|
210
|
+
if (request.cursor) {
|
|
211
|
+
({ snapshot, position, id } = this._cursor(request));
|
|
212
|
+
if (snapshot.literal) return this._literal({ ...request, cursor: snapshot.rawCursor }, snapshot.reason, token);
|
|
213
|
+
} else {
|
|
214
|
+
const journal = await this._catchUp(request.ownerId, token);
|
|
215
|
+
const result = await this._call(token, 'search', request.ownerId, { kind: request.kind, query: request.query });
|
|
216
|
+
this._alive(token);
|
|
217
|
+
if (result.resetWorker) await this._stopWorker(false);
|
|
218
|
+
this._alive(token);
|
|
219
|
+
snapshot = { ...request, refs: result.refs, retrieval: { ...result.retrieval, journal } };
|
|
220
|
+
if (!journal.caughtUp) {
|
|
221
|
+
snapshot.retrieval.degraded = true;
|
|
222
|
+
snapshot.retrieval.reasons.push('journal_catchup_limited');
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
const items = [];
|
|
226
|
+
// Resolve every page against current authority. Stale/deleted revisions are omitted,
|
|
227
|
+
// never replaced with text from a newer, unranked revision.
|
|
228
|
+
while (position < snapshot.refs.length && items.length < request.limit) {
|
|
229
|
+
const refs = snapshot.refs.slice(position, position + request.limit - items.length);
|
|
230
|
+
const records = await this.repository.resolveMemories(request.ownerId, request.kind, refs);
|
|
231
|
+
this._alive(token);
|
|
232
|
+
const current = new Map(records.map(record => [record.id, record]));
|
|
233
|
+
for (const ref of refs) {
|
|
234
|
+
const record = current.get(ref.id);
|
|
235
|
+
if (record?.revision === ref.revision) items.push(record);
|
|
236
|
+
}
|
|
237
|
+
position += refs.length;
|
|
238
|
+
}
|
|
239
|
+
const nextCursor = position < snapshot.refs.length ? (id ? `lm:${id}:${position}` : this._save(snapshot, position)) : null;
|
|
240
|
+
return { items, nextCursor, retrieval: { ...snapshot.retrieval, snapshot: true, expiresInMs: this.snapshotTtlMs } };
|
|
241
|
+
}
|
|
242
|
+
async _literal(request, reason, token, signal) {
|
|
243
|
+
if (this.closed) throw fault('MEMORY_CLOSED');
|
|
244
|
+
if (signal?.aborted) throw fault('RECALL_CANCELLED');
|
|
245
|
+
let timer, onAbort;
|
|
246
|
+
try {
|
|
247
|
+
const result = await Promise.race([
|
|
248
|
+
this.literalRecall(request.ownerId, { kind: request.kind, query: request.query, cursor: request.cursor, limit: request.limit }),
|
|
249
|
+
new Promise((_, reject) => { timer = setTimeout(() => reject(fault('RECALL_TIMEOUT')), Math.min(this.timeoutMs, 10000)); }),
|
|
250
|
+
new Promise((_, reject) => {
|
|
251
|
+
onAbort = () => reject(fault('RECALL_CANCELLED'));
|
|
252
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
253
|
+
if (signal?.aborted) onAbort();
|
|
254
|
+
}),
|
|
255
|
+
]);
|
|
256
|
+
if (token) this._alive(token);
|
|
257
|
+
if (signal?.aborted) throw fault('RECALL_CANCELLED');
|
|
258
|
+
if (this.closed) throw fault('MEMORY_CLOSED');
|
|
259
|
+
const nextCursor = result.nextCursor ? this._save({ ...request, literal: true, rawCursor: result.nextCursor, reason }, 0) : null;
|
|
260
|
+
return { ...result, nextCursor, retrieval: { mode: 'literal', semantic: false, degraded: true, reasons: [reason], bounded: true, snapshot: false } };
|
|
261
|
+
} finally { clearTimeout(timer); signal?.removeEventListener('abort', onAbort); }
|
|
262
|
+
}
|
|
263
|
+
async _stopWorker(graceful = true) {
|
|
264
|
+
const worker = this.worker;
|
|
265
|
+
if (!worker) return;
|
|
266
|
+
this.worker = null;
|
|
267
|
+
this.rpc?.reject(fault('INDEX_WORKER_STOPPED'));
|
|
268
|
+
this.rpc = null;
|
|
269
|
+
worker.ref();
|
|
270
|
+
let timer;
|
|
271
|
+
try {
|
|
272
|
+
await new Promise(resolve => {
|
|
273
|
+
worker.once('exit', resolve);
|
|
274
|
+
timer = setTimeout(() => { worker.kill('SIGKILL'); resolve(); }, this.shutdownTimeoutMs);
|
|
275
|
+
if (graceful && worker.connected) worker.send({ id: -1, op: 'close' }, () => {});
|
|
276
|
+
else worker.kill('SIGKILL');
|
|
277
|
+
});
|
|
278
|
+
} finally { clearTimeout(timer); worker.unref(); worker.channel?.unref(); }
|
|
279
|
+
}
|
|
280
|
+
async close() {
|
|
281
|
+
if (this.closed) return;
|
|
282
|
+
this.closed = true;
|
|
283
|
+
this.snapshots.clear();
|
|
284
|
+
for (const job of [...this.queue, ...(this.active ? [this.active] : [])]) {
|
|
285
|
+
clearTimeout(job.timer);
|
|
286
|
+
job.cleanup();
|
|
287
|
+
job.token.cancelled = true;
|
|
288
|
+
job.fail(fault('MEMORY_CLOSED'));
|
|
289
|
+
job.reject(fault('MEMORY_CLOSED'));
|
|
290
|
+
}
|
|
291
|
+
this.queue = [];
|
|
292
|
+
await this._stopWorker();
|
|
293
|
+
}
|
|
294
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { bytes, digest, fail, LIMITS, PersonError, safeError } from './contracts.js';
|
|
3
|
+
import { capabilityExperienceView, recordCapabilityExperience } from './capability-experience.js';
|
|
3
4
|
|
|
4
5
|
const COLLECTIONS = ['persons', 'messages', 'episodes', 'states', 'concepts', 'concept_revisions', 'state_commits', 'traces'];
|
|
5
6
|
const txOptions = { readConcern: { level: 'snapshot' }, writeConcern: { w: 'majority' }, readPreference: 'primary', maxCommitTimeMS: 5000, timeoutMS: 10000 };
|
|
@@ -172,7 +173,9 @@ export class MongoPersonRepository {
|
|
|
172
173
|
});
|
|
173
174
|
}
|
|
174
175
|
fence(episode, withLease = true) {
|
|
175
|
-
|
|
176
|
+
const scope = this.scope(episode.ownerId);
|
|
177
|
+
if (episode.namespace !== scope.namespace || episode.personId !== scope.personId) fail('STALE');
|
|
178
|
+
return { ...scope, activeEpisodeId: episode.id, epoch: episode.epoch, leaseOwner: episode.workerId,
|
|
176
179
|
stateVersion: episode.baseStateVersion, controlVersion: episode.controlVersion, inputWatermark: episode.inputWatermark, ...(withLease ? fresh() : {}) };
|
|
177
180
|
}
|
|
178
181
|
async heartbeat(episode) {
|
|
@@ -186,6 +189,11 @@ export class MongoPersonRepository {
|
|
|
186
189
|
return this.transaction(async session => {
|
|
187
190
|
const p = await this.collections.persons.findOneAndUpdate(this.fence(episode), { $inc: { writeSerial: 1 } }, { session, returnDocument: 'after' });
|
|
188
191
|
if (!p) fail('STALE');
|
|
192
|
+
const scope = this.scope(episode.ownerId);
|
|
193
|
+
const record = await this.collections.episodes.findOne({ ...scope, id: episode.id, status: 'running' }, { session });
|
|
194
|
+
if (!record) fail('STALE');
|
|
195
|
+
const experience = recordCapabilityExperience(p.capabilityExperience, record, kind, data, new Date().toISOString());
|
|
196
|
+
if (experience) await this.collections.persons.updateOne(scope, { $set: { capabilityExperience: experience } }, { session });
|
|
189
197
|
return this.trace(session, p, episode.id, kind, data);
|
|
190
198
|
});
|
|
191
199
|
}
|
|
@@ -242,7 +250,8 @@ export class MongoPersonRepository {
|
|
|
242
250
|
this.collections.concepts.find(scope).sort({ updatedAt: -1, id: 1 }).limit(12).toArray(),
|
|
243
251
|
]);
|
|
244
252
|
const concepts = [...new Map([...focused, ...recent].map(c => [c.id, publicDoc(c)])).values()];
|
|
245
|
-
return { person: this.personView(p),
|
|
253
|
+
return { person: this.personView(p), capabilityExperience: capabilityExperienceView(p.capabilityExperience),
|
|
254
|
+
state: publicDoc(state), messages: messages.reverse().map(publicDoc), concepts };
|
|
246
255
|
}
|
|
247
256
|
async recall(ownerId, { kind = 'messages', query = '', cursor = null, limit = 5 }) {
|
|
248
257
|
const scope = this.scope(ownerId);
|