@yeaft/webchat-agent 1.0.586 → 1.0.588
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 +7 -5
- package/yeaft/person/contracts.js +3 -2
- 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/runtime.js +4 -4
- 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 +377 -0
- package/yeaft/person/sqlite-worker.js +25 -0
- package/yeaft/person/storage.js +36 -0
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import { mkdirSync } from 'node:fs';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { isMainThread } from 'node:worker_threads';
|
|
6
|
+
import { bytes, digest, fail, identifier, LIMITS, PersonError, text } from './contracts.js';
|
|
7
|
+
import { SCHEMA, TABLES } from './sqlite-schema.js';
|
|
8
|
+
|
|
9
|
+
const SCOPE = 'namespace = ? AND ownerId = ? AND personId = ?';
|
|
10
|
+
const scopeValues = s => [s.namespace, s.ownerId, s.personId];
|
|
11
|
+
// Query-only projections; record retains the original public Unicode text.
|
|
12
|
+
const SEARCH_COLUMNS = Object.freeze({ messages: 'text', concepts: 'statement' });
|
|
13
|
+
const dates = new Set(['createdAt', 'updatedAt', 'endedAt', 'leaseUntil', 'callFinalizeUntil']);
|
|
14
|
+
const decode = row => row ? JSON.parse(row.record, (key, value) => dates.has(key) && typeof value === 'string' ? new Date(value) : value) : null;
|
|
15
|
+
const publicDoc = doc => {
|
|
16
|
+
if (!doc) return null;
|
|
17
|
+
const { _id, ownerId, namespace, personId, ...rest } = doc;
|
|
18
|
+
return rest;
|
|
19
|
+
};
|
|
20
|
+
const unavailableOutput = () => ({ text: '', retainedBytes: 0, observedBytes: null, complete: false, accepted: false, availability: 'unavailable', reason: 'worker-unavailable' });
|
|
21
|
+
const publicOutput = (output, failed) => {
|
|
22
|
+
if (!output) return unavailableOutput();
|
|
23
|
+
if (typeof output.text !== 'string' || bytes(output.text) > LIMITS.outputBytes) fail('OUTPUT_LIMIT');
|
|
24
|
+
const retainedBytes = bytes(output.text), observedBytes = output.observedBytes ?? output.bytes ?? retainedBytes;
|
|
25
|
+
if (!Number.isSafeInteger(observedBytes) || observedBytes < retainedBytes) fail('INVALID_REQUEST');
|
|
26
|
+
const usage = output.usage ? {} : null;
|
|
27
|
+
if (usage) for (const key of ['inputTokens', 'outputTokens', 'reasoningTokens', 'cacheReadTokens', 'cacheWriteTokens']) {
|
|
28
|
+
if (Number.isFinite(output.usage[key]) && output.usage[key] >= 0) usage[key] = output.usage[key];
|
|
29
|
+
}
|
|
30
|
+
const stopReason = typeof output.stopReason === 'string' && bytes(output.stopReason) <= 128 ? output.stopReason : null;
|
|
31
|
+
return failed ? { text: output.text, retainedBytes, observedBytes, complete: false, accepted: false, availability: 'captured', usage, stopReason }
|
|
32
|
+
: { text: output.text, bytes: retainedBytes, complete: true, usage, stopReason };
|
|
33
|
+
};
|
|
34
|
+
const READS = new Set(['getPerson', 'context', 'recall', 'list', 'searchChanges', 'resolveMemories']);
|
|
35
|
+
const WRITES = new Set(['open', 'recover', 'admit', 'heartbeat', 'append', 'startCall', 'finalizeCall', 'commit', 'finish', 'cancel', 'settings', 'snapshot']);
|
|
36
|
+
const memoryKind = kind => { if (!['messages', 'concepts'].includes(kind)) fail('INVALID_REQUEST'); return kind; };
|
|
37
|
+
const boundedLimit = (limit, max = 100) => { if (!Number.isSafeInteger(limit) || limit < 1 || limit > max) fail('INVALID_REQUEST'); return limit; };
|
|
38
|
+
const sequence = value => {
|
|
39
|
+
const n = typeof value === 'string' && /^[1-9][0-9]{0,14}$/.test(value) ? Number(value) : value;
|
|
40
|
+
if (!Number.isSafeInteger(n) || n < 0) fail('INVALID_REQUEST');
|
|
41
|
+
return n;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Synchronous authority, instantiated only by sqlite-worker. Never perform I/O or
|
|
45
|
+
* await application callbacks inside execute's short database transactions. */
|
|
46
|
+
export class SqlitePersonStore {
|
|
47
|
+
constructor({ dbPath, namespace, leaseMs, busyTimeoutMs }) {
|
|
48
|
+
if (isMainThread) throw new Error('Person SQLite must run in its managed worker.');
|
|
49
|
+
this.namespace = namespace; this.leaseMs = leaseMs;
|
|
50
|
+
mkdirSync(dirname(dbPath), { recursive: true, mode: 0o700 });
|
|
51
|
+
this.db = new DatabaseSync(dbPath);
|
|
52
|
+
try {
|
|
53
|
+
// Numeric option is validated again here before interpolating a PRAGMA literal.
|
|
54
|
+
if (!Number.isInteger(busyTimeoutMs) || busyTimeoutMs < 0 || busyTimeoutMs > 5000) fail('INVALID_REQUEST');
|
|
55
|
+
this.db.exec('PRAGMA foreign_keys = ON;');
|
|
56
|
+
// Concurrent first opens can fail WAL's lock upgrade immediately, even with
|
|
57
|
+
// busy_timeout. Retry ONLY this idempotent setup step within the same bound;
|
|
58
|
+
// never retry a logical operation whose commit outcome may be unknown.
|
|
59
|
+
const deadline = Date.now() + busyTimeoutMs;
|
|
60
|
+
const wait = new Int32Array(new SharedArrayBuffer(4));
|
|
61
|
+
while (true) {
|
|
62
|
+
try {
|
|
63
|
+
if (this.db.prepare('PRAGMA journal_mode = WAL').get().journal_mode !== 'wal') fail('STORAGE_UNAVAILABLE');
|
|
64
|
+
break;
|
|
65
|
+
} catch (error) {
|
|
66
|
+
const remaining = deadline - Date.now();
|
|
67
|
+
if ((error.errcode & 0xff) !== 5 || remaining <= 0) throw error;
|
|
68
|
+
Atomics.wait(wait, 0, 0, Math.min(10, remaining));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
this.db.exec(`PRAGMA busy_timeout = ${busyTimeoutMs}; PRAGMA synchronous = FULL;`);
|
|
72
|
+
const version = this.db.prepare('PRAGMA user_version').get().user_version;
|
|
73
|
+
if (version > 1) fail('STORAGE_UNAVAILABLE');
|
|
74
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
75
|
+
try { this.db.exec(SCHEMA); this.db.exec('PRAGMA user_version = 1; COMMIT;'); }
|
|
76
|
+
catch (error) { this.db.exec('ROLLBACK'); throw error; }
|
|
77
|
+
this.statements = new Map();
|
|
78
|
+
} catch (error) { this.db.close(); throw error; }
|
|
79
|
+
}
|
|
80
|
+
sql(query) {
|
|
81
|
+
if (!this.statements.has(query)) this.statements.set(query, this.db.prepare(query));
|
|
82
|
+
return this.statements.get(query);
|
|
83
|
+
}
|
|
84
|
+
execute(method, args) {
|
|
85
|
+
if (!READS.has(method) && !WRITES.has(method)) fail('INVALID_REQUEST');
|
|
86
|
+
this.db.exec(WRITES.has(method) ? 'BEGIN IMMEDIATE' : 'BEGIN');
|
|
87
|
+
try {
|
|
88
|
+
this.now = new Date(); // Taken after acquiring the writer lock, never before busy waiting.
|
|
89
|
+
const result = this[method](...args);
|
|
90
|
+
this.db.exec('COMMIT');
|
|
91
|
+
return result;
|
|
92
|
+
} catch (error) { this.db.exec('ROLLBACK'); throw error; }
|
|
93
|
+
}
|
|
94
|
+
scope(ownerId) {
|
|
95
|
+
text(ownerId, 256);
|
|
96
|
+
return { ownerId, namespace: this.namespace, personId: `person-${digest([this.namespace, ownerId]).slice(0, 32)}` };
|
|
97
|
+
}
|
|
98
|
+
doc(scope, values) { return { ...values, schemaVersion: 1, ...scope }; }
|
|
99
|
+
table(name) { if (!Object.hasOwn(TABLES, name)) fail('INVALID_REQUEST'); return TABLES[name]; }
|
|
100
|
+
// All clauses at call sites below are fixed application strings; values are bound.
|
|
101
|
+
rows(table, scope, clause = '', params = []) {
|
|
102
|
+
this.table(table);
|
|
103
|
+
return this.sql(`SELECT record FROM ${table} WHERE ${SCOPE}${clause}`).all(...scopeValues(scope), ...params).map(decode);
|
|
104
|
+
}
|
|
105
|
+
one(table, scope, clause = '', params = []) { return this.rows(table, scope, `${clause} LIMIT 1`, params)[0] ?? null; }
|
|
106
|
+
put(table, record, insert = false) {
|
|
107
|
+
const spec = this.table(table), keys = ['namespace', 'ownerId', 'personId', ...spec.keys];
|
|
108
|
+
const columns = [...keys, ...spec.columns, 'record'];
|
|
109
|
+
// Node 22.5 has no DatabaseSync.function; SQLite lower() only folds ASCII.
|
|
110
|
+
// Fold once on write in JS, then use instr() in SQL before ORDER BY / LIMIT.
|
|
111
|
+
// This keeps Unicode literal recall scoped and avoids materializing a JS scan.
|
|
112
|
+
const values = columns.map(key => key === 'record' ? JSON.stringify(record) : key === SEARCH_COLUMNS[table] ? record[key].toLowerCase()
|
|
113
|
+
: record[key] instanceof Date ? record[key].getTime() : record[key] ?? null);
|
|
114
|
+
const update = insert ? '' : ` ON CONFLICT(${keys.join(',')}) DO UPDATE SET ${[...spec.columns, 'record'].map(key => `${key} = excluded.${key}`).join(',')}`;
|
|
115
|
+
this.sql(`INSERT INTO ${table} (${columns.join(',')}) VALUES (${columns.map(() => '?').join(',')})${update}`).run(...values);
|
|
116
|
+
}
|
|
117
|
+
journal(scope, kind, record) {
|
|
118
|
+
this.sql('INSERT INTO memory_changes(namespace, ownerId, personId, kind, id, revision, record) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
|
119
|
+
.run(...scopeValues(scope), kind, record.id, record.revision, JSON.stringify(publicDoc(record)));
|
|
120
|
+
}
|
|
121
|
+
personView(p) {
|
|
122
|
+
return { id: p.personId, name: p.name, soul: p.soul, soulRevision: p.soulRevision, createdAt: p.createdAt.toISOString(), settings: p.settings };
|
|
123
|
+
}
|
|
124
|
+
getPerson(ownerId) {
|
|
125
|
+
const p = this.one('persons', this.scope(ownerId));
|
|
126
|
+
if (!p) fail('NOT_OPEN');
|
|
127
|
+
return p;
|
|
128
|
+
}
|
|
129
|
+
open(ownerId, name = 'Digital Person') {
|
|
130
|
+
text(name, 160);
|
|
131
|
+
const scope = this.scope(ownerId);
|
|
132
|
+
let p = this.one('persons', scope);
|
|
133
|
+
if (!p) {
|
|
134
|
+
p = this.doc(scope, {
|
|
135
|
+
name, soul: 'A continuous, curious and honest digital person. Preserve uncertainty, reconsider your judgments, distinguish imagination from experience, and respect permissions. You may disagree without acting without authority.',
|
|
136
|
+
soulRevision: 1, createdAt: this.now, settings: { autonomyEnabled: false },
|
|
137
|
+
epoch: 0, writeSerial: 0, inputWatermark: 0, controlVersion: 0, stateVersion: 0,
|
|
138
|
+
traceSeq: 0, messageSeq: 0, activeEpisodeId: null, leaseOwner: null, leaseUntil: new Date(0),
|
|
139
|
+
});
|
|
140
|
+
this.put('persons', p, true);
|
|
141
|
+
this.put('states', this.doc(scope, { version: 0, summary: '', appraisal: '', focusConceptIds: [], conceptRefs: [], lastSelection: null, updatedAt: this.now }), true);
|
|
142
|
+
}
|
|
143
|
+
return { person: this.personView(p) };
|
|
144
|
+
}
|
|
145
|
+
trace(ownerId, episodeId, kind, data = {}) {
|
|
146
|
+
const scope = this.scope(ownerId), p = this.getPerson(ownerId);
|
|
147
|
+
p.traceSeq++; p.writeSerial++; this.put('persons', p);
|
|
148
|
+
const record = this.doc(scope, { ...data, id: randomUUID(), episodeId, kind, seq: p.traceSeq, createdAt: this.now });
|
|
149
|
+
this.put('traces', record, true);
|
|
150
|
+
return publicDoc(record);
|
|
151
|
+
}
|
|
152
|
+
abandonCall(ownerId, episode, code) {
|
|
153
|
+
if (!episode?.openCall) return;
|
|
154
|
+
const call = episode.openCall;
|
|
155
|
+
delete episode.openCall; delete episode.callFinalizeUntil;
|
|
156
|
+
this.put('episodes', episode);
|
|
157
|
+
this.trace(ownerId, episode.id, 'call_failed', { ...call, code, output: unavailableOutput() });
|
|
158
|
+
}
|
|
159
|
+
recover(ownerId) {
|
|
160
|
+
const scope = this.scope(ownerId), p = this.one('persons', scope);
|
|
161
|
+
const expired = Boolean(p?.activeEpisodeId && p.leaseUntil <= this.now);
|
|
162
|
+
if (expired) {
|
|
163
|
+
const id = p.activeEpisodeId;
|
|
164
|
+
p.epoch++; p.writeSerial++; p.activeEpisodeId = null; p.leaseOwner = null; p.leaseUntil = new Date(0);
|
|
165
|
+
this.put('persons', p);
|
|
166
|
+
const episode = this.one('episodes', scope, ' AND id = ?', [id]);
|
|
167
|
+
if (episode?.status === 'running') {
|
|
168
|
+
Object.assign(episode, { status: 'interrupted', terminalCode: 'INTERRUPTED', endedAt: this.now });
|
|
169
|
+
this.put('episodes', episode); this.abandonCall(ownerId, episode, 'INTERRUPTED');
|
|
170
|
+
}
|
|
171
|
+
this.trace(ownerId, id, 'interrupted', { code: 'INTERRUPTED', reason: 'lease-expired', baseStateVersion: p.stateVersion });
|
|
172
|
+
}
|
|
173
|
+
for (const episode of this.rows('episodes', scope, " AND status = ? AND callFinalizeUntil <= ? AND json_type(record, '$.openCall') IS NOT NULL", ['cancelled', this.now.getTime()])) {
|
|
174
|
+
this.abandonCall(ownerId, episode, 'CANCELLED');
|
|
175
|
+
}
|
|
176
|
+
return expired;
|
|
177
|
+
}
|
|
178
|
+
admit(ownerId, { kind, text: input, clientMessageId, workerId, budget }) {
|
|
179
|
+
if (!['send', 'think', 'dream'].includes(kind)) fail('INVALID_REQUEST');
|
|
180
|
+
text(input, LIMITS.inputBytes, kind !== 'send'); identifier(clientMessageId); text(workerId, 256);
|
|
181
|
+
this.recover(ownerId);
|
|
182
|
+
const scope = this.scope(ownerId), requestHash = digest([kind, input]);
|
|
183
|
+
const existing = this.one('episodes', scope, ' AND clientMessageId = ?', [clientMessageId]);
|
|
184
|
+
if (existing) {
|
|
185
|
+
if (existing.requestHash !== requestHash) fail('IDEMPOTENCY_CONFLICT');
|
|
186
|
+
return { episodeId: existing.id, duplicate: true, status: existing.status };
|
|
187
|
+
}
|
|
188
|
+
const p = this.getPerson(ownerId);
|
|
189
|
+
if (p.activeEpisodeId) fail('BUSY');
|
|
190
|
+
const id = randomUUID();
|
|
191
|
+
p.activeEpisodeId = id; p.leaseOwner = workerId; p.leaseUntil = new Date(this.now.getTime() + this.leaseMs);
|
|
192
|
+
p.epoch++; p.writeSerial++; p.inputWatermark++; if (kind === 'send') p.messageSeq++;
|
|
193
|
+
this.put('persons', p);
|
|
194
|
+
let messageId = null;
|
|
195
|
+
if (kind === 'send') {
|
|
196
|
+
messageId = randomUUID();
|
|
197
|
+
const message = this.doc(scope, { id: messageId, revision: 1, seq: p.messageSeq, episodeId: id, role: 'user', text: input, createdAt: this.now, clientMessageId });
|
|
198
|
+
this.put('messages', message, true); this.journal(scope, 'messages', message);
|
|
199
|
+
}
|
|
200
|
+
const episode = this.doc(scope, { id, clientMessageId, requestHash, kind, text: input, messageId, status: 'running', workerId, epoch: p.epoch,
|
|
201
|
+
baseStateVersion: p.stateVersion, inputWatermark: p.inputWatermark, controlVersion: p.controlVersion, budget, createdAt: this.now });
|
|
202
|
+
this.put('episodes', episode, true);
|
|
203
|
+
this.trace(ownerId, id, 'accepted', { trigger: { kind, text: input, messageId }, baseStateVersion: p.stateVersion, budget });
|
|
204
|
+
return { episodeId: id, duplicate: false, status: 'running', episode };
|
|
205
|
+
}
|
|
206
|
+
episodeScope(episode) {
|
|
207
|
+
const scope = this.scope(episode.ownerId);
|
|
208
|
+
if (episode.namespace !== scope.namespace || episode.personId !== scope.personId) fail('STALE');
|
|
209
|
+
return scope;
|
|
210
|
+
}
|
|
211
|
+
fenced(episode, withLease = true) {
|
|
212
|
+
const p = this.one('persons', this.episodeScope(episode));
|
|
213
|
+
return p && p.activeEpisodeId === episode.id && p.epoch === episode.epoch && p.leaseOwner === episode.workerId &&
|
|
214
|
+
p.stateVersion === episode.baseStateVersion && p.controlVersion === episode.controlVersion && p.inputWatermark === episode.inputWatermark &&
|
|
215
|
+
(!withLease || p.leaseUntil > this.now) ? p : null;
|
|
216
|
+
}
|
|
217
|
+
own(episode) { const p = this.fenced(episode); if (!p) fail('STALE'); return p; }
|
|
218
|
+
heartbeat(episode) {
|
|
219
|
+
const p = this.own(episode); p.leaseUntil = new Date(this.now.getTime() + this.leaseMs); p.writeSerial++; this.put('persons', p);
|
|
220
|
+
}
|
|
221
|
+
append(episode, kind, data) {
|
|
222
|
+
const p = this.own(episode); p.writeSerial++; this.put('persons', p);
|
|
223
|
+
return this.trace(episode.ownerId, episode.id, kind, data);
|
|
224
|
+
}
|
|
225
|
+
startCall(episode, data) {
|
|
226
|
+
const p = this.own(episode), scope = this.episodeScope(episode);
|
|
227
|
+
const record = this.one('episodes', scope, ' AND id = ?', [episode.id]);
|
|
228
|
+
if (!record || record.status !== 'running' || record.openCall) fail('STALE');
|
|
229
|
+
p.writeSerial++; this.put('persons', p);
|
|
230
|
+
record.openCall = { callId: data.callId, requested: data.requested, effective: data.effective, ...(data.manifest ? { manifest: data.manifest } : {}) };
|
|
231
|
+
this.put('episodes', record);
|
|
232
|
+
return this.trace(episode.ownerId, episode.id, 'call_started', data);
|
|
233
|
+
}
|
|
234
|
+
finalizeCall(episode, { callId, effective, output, code = null }) {
|
|
235
|
+
const scope = this.episodeScope(episode), record = this.one('episodes', scope, ' AND id = ?', [episode.id]);
|
|
236
|
+
if (!record || record.workerId !== episode.workerId || record.epoch !== episode.epoch || record.openCall?.callId !== callId) return false;
|
|
237
|
+
const live = record.status === 'running' && Boolean(this.fenced(episode));
|
|
238
|
+
const draining = record.status === 'cancelled' && record.callFinalizeUntil > this.now;
|
|
239
|
+
if (!live && !draining) return false;
|
|
240
|
+
const failed = Boolean(code || !live || !output), terminalCode = !live ? 'CANCELLED' : code || 'INTERRUPTED';
|
|
241
|
+
const call = record.openCall;
|
|
242
|
+
const safeEffective = { model: call.requested.model, effort: typeof effective?.effort === 'string' && effective.effort.length <= 128 ? effective.effort : null,
|
|
243
|
+
effortObserved: effective?.effortObserved === true };
|
|
244
|
+
if (typeof effective?.wireMode === 'string' && effective.wireMode.length <= 128) safeEffective.wireMode = effective.wireMode;
|
|
245
|
+
delete record.openCall; delete record.callFinalizeUntil; this.put('episodes', record);
|
|
246
|
+
this.trace(episode.ownerId, episode.id, failed ? 'call_failed' : 'call_output', {
|
|
247
|
+
callId, requested: call.requested, effective: safeEffective, output: publicOutput(output, failed),
|
|
248
|
+
...(!failed && call.manifest ? { manifest: call.manifest } : {}), ...(failed ? { code: new PersonError(terminalCode).code } : {}),
|
|
249
|
+
});
|
|
250
|
+
return live;
|
|
251
|
+
}
|
|
252
|
+
focused(scope, ids) {
|
|
253
|
+
return ids.map(id => this.one('concepts', scope, ' AND id = ?', [id])).filter(Boolean);
|
|
254
|
+
}
|
|
255
|
+
context(episode) {
|
|
256
|
+
const p = this.own(episode), scope = this.episodeScope(episode), state = this.one('states', scope);
|
|
257
|
+
const focused = this.focused(scope, state.focusConceptIds).slice(0, 12);
|
|
258
|
+
const recent = this.rows('concepts', scope, ' ORDER BY updatedAt DESC, id ASC LIMIT 12');
|
|
259
|
+
return { person: this.personView(p), state: publicDoc(state), messages: this.rows('messages', scope, ' ORDER BY seq DESC LIMIT 12').reverse().map(publicDoc),
|
|
260
|
+
concepts: [...new Map([...focused, ...recent].map(c => [c.id, publicDoc(c)])).values()] };
|
|
261
|
+
}
|
|
262
|
+
commit(episode, proposal, selection, callId, reportedSources = new Map()) {
|
|
263
|
+
const p = this.own(episode), scope = this.episodeScope(episode);
|
|
264
|
+
if (proposal.baseStateVersion !== episode.baseStateVersion) fail('STALE');
|
|
265
|
+
p.stateVersion++; p.writeSerial++; if (proposal.reply) p.messageSeq++;
|
|
266
|
+
p.activeEpisodeId = null; p.leaseOwner = null; p.leaseUntil = new Date(0);
|
|
267
|
+
this.put('persons', p);
|
|
268
|
+
const revisions = [];
|
|
269
|
+
for (const patch of proposal.concepts) {
|
|
270
|
+
const { expectedRevision, ...fields } = patch, reportedSourceRefs = reportedSources.get(patch.id) ?? [];
|
|
271
|
+
if (patch.epistemicState === 'reported' && !reportedSourceRefs.length) fail('INVALID_PROPOSAL');
|
|
272
|
+
const current = this.one('concepts', scope, ' AND id = ?', [patch.id]);
|
|
273
|
+
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0 || (current?.revision ?? 0) !== expectedRevision) fail('STALE');
|
|
274
|
+
const record = this.doc(scope, { ...fields, reportedSourceRefs, revision: expectedRevision + 1, episodeId: episode.id, callId, stateVersion: p.stateVersion, updatedAt: this.now });
|
|
275
|
+
this.put('concepts', record); this.put('concept_revisions', record, true); this.journal(scope, 'concepts', record);
|
|
276
|
+
revisions.push({ id: patch.id, revision: record.revision });
|
|
277
|
+
}
|
|
278
|
+
const focus = this.focused(scope, proposal.state.focusConceptIds);
|
|
279
|
+
if (new Set(proposal.state.focusConceptIds).size !== proposal.state.focusConceptIds.length || focus.length !== proposal.state.focusConceptIds.length) fail('STALE');
|
|
280
|
+
const state = this.doc(scope, { ...proposal.state, version: p.stateVersion, conceptRefs: focus.map(c => ({ id: c.id, revision: c.revision })),
|
|
281
|
+
decision: proposal.decision, lastSelection: { model: selection.model, effort: selection.effort }, updatedAt: this.now, episodeId: episode.id });
|
|
282
|
+
if (this.one('states', scope)?.version !== episode.baseStateVersion) fail('STALE');
|
|
283
|
+
this.put('states', state);
|
|
284
|
+
this.put('state_commits', this.doc(scope, { id: randomUUID(), version: p.stateVersion, parentVersion: episode.baseStateVersion,
|
|
285
|
+
episodeId: episode.id, callId, conceptRevisions: revisions, state: publicDoc(state), createdAt: this.now }), true);
|
|
286
|
+
if (proposal.reply) {
|
|
287
|
+
const message = this.doc(scope, { id: randomUUID(), revision: 1, seq: p.messageSeq, episodeId: episode.id, role: 'assistant', text: proposal.reply, createdAt: this.now });
|
|
288
|
+
this.put('messages', message, true); this.journal(scope, 'messages', message);
|
|
289
|
+
}
|
|
290
|
+
const record = this.one('episodes', scope, ' AND id = ?', [episode.id]);
|
|
291
|
+
if (!record || record.status !== 'running') fail('STALE');
|
|
292
|
+
Object.assign(record, { status: 'completed', endedAt: this.now, stateVersion: p.stateVersion }); this.put('episodes', record);
|
|
293
|
+
this.trace(episode.ownerId, episode.id, 'committed', { callId, parentVersion: episode.baseStateVersion, stateVersion: p.stateVersion, conceptRevisions: revisions, decision: proposal.decision });
|
|
294
|
+
return { state: publicDoc(state) };
|
|
295
|
+
}
|
|
296
|
+
finish(episode, status, code) {
|
|
297
|
+
const p = this.fenced(episode, false);
|
|
298
|
+
if (!p) return false;
|
|
299
|
+
p.writeSerial++; p.epoch++; p.activeEpisodeId = null; p.leaseOwner = null; p.leaseUntil = new Date(0); this.put('persons', p);
|
|
300
|
+
const record = this.one('episodes', this.episodeScope(episode), ' AND id = ?', [episode.id]);
|
|
301
|
+
if (record?.status === 'running') {
|
|
302
|
+
Object.assign(record, { status, terminalCode: code, endedAt: this.now }); this.put('episodes', record); this.abandonCall(episode.ownerId, record, code);
|
|
303
|
+
}
|
|
304
|
+
this.trace(episode.ownerId, episode.id, status, { code, baseStateVersion: episode.baseStateVersion });
|
|
305
|
+
return true;
|
|
306
|
+
}
|
|
307
|
+
cancel(ownerId, episodeId = null) {
|
|
308
|
+
const scope = this.scope(ownerId), p = this.getPerson(ownerId);
|
|
309
|
+
if (!p.activeEpisodeId || (episodeId && p.activeEpisodeId !== episodeId)) return { cancelled: false, episodeId };
|
|
310
|
+
const id = p.activeEpisodeId, until = p.leaseUntil;
|
|
311
|
+
p.epoch++; p.controlVersion++; p.writeSerial++; p.activeEpisodeId = null; p.leaseOwner = null; p.leaseUntil = new Date(0); this.put('persons', p);
|
|
312
|
+
const record = this.one('episodes', scope, ' AND id = ?', [id]);
|
|
313
|
+
if (record?.status === 'running') {
|
|
314
|
+
Object.assign(record, { status: 'cancelled', terminalCode: 'CANCELLED', endedAt: this.now, callFinalizeUntil: until }); this.put('episodes', record);
|
|
315
|
+
}
|
|
316
|
+
this.trace(ownerId, id, 'cancelled', { code: 'CANCELLED' });
|
|
317
|
+
return { cancelled: true, episodeId: id };
|
|
318
|
+
}
|
|
319
|
+
settings(ownerId, settings) {
|
|
320
|
+
const p = this.getPerson(ownerId);
|
|
321
|
+
if (p.activeEpisodeId) fail('BUSY');
|
|
322
|
+
p.settings = { ...p.settings, ...settings }; p.controlVersion++; p.writeSerial++; this.put('persons', p);
|
|
323
|
+
if (Object.keys(settings).length) this.trace(ownerId, null, 'settings', { settings: p.settings });
|
|
324
|
+
return { settings: p.settings };
|
|
325
|
+
}
|
|
326
|
+
list(ownerId, collection, { cursor = null, limit = 20 } = {}, filter = {}) {
|
|
327
|
+
if (!['messages', 'traces'].includes(collection)) fail('INVALID_REQUEST');
|
|
328
|
+
this.getPerson(ownerId); boundedLimit(limit);
|
|
329
|
+
if (Object.keys(filter).some(k => k !== 'text') || (filter.text && (typeof filter.text.$regex !== 'string' || filter.text.$options !== 'i'))) fail('INVALID_REQUEST');
|
|
330
|
+
// Compatibility for Mongo's literal recall filter only; arbitrary selectors are forbidden.
|
|
331
|
+
const query = filter.text?.$regex?.replace(/\\([.*+?^${}()|[\]\\])/g, '$1') ?? '';
|
|
332
|
+
if (query && collection !== 'messages') fail('INVALID_REQUEST');
|
|
333
|
+
let clause = '', params = [];
|
|
334
|
+
if (cursor != null) { clause += ' AND seq < ?'; params.push(sequence(cursor)); }
|
|
335
|
+
if (query) { clause += ' AND instr(text, ?) > 0'; params.push(query.toLowerCase()); }
|
|
336
|
+
const docs = this.rows(collection, this.scope(ownerId), `${clause} ORDER BY seq DESC LIMIT ?`, [...params, limit + 1]);
|
|
337
|
+
return { items: docs.slice(0, limit).map(publicDoc), nextCursor: docs.length > limit ? String(docs[limit - 1].seq) : null };
|
|
338
|
+
}
|
|
339
|
+
recall(ownerId, { kind = 'messages', query = '', cursor = null, limit = 5 } = {}) {
|
|
340
|
+
memoryKind(kind); text(query, LIMITS.inputBytes, true); boundedLimit(limit);
|
|
341
|
+
if (kind === 'messages') return this.list(ownerId, kind, { cursor, limit }, query ? { text: { $regex: query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), $options: 'i' } } : {});
|
|
342
|
+
this.getPerson(ownerId);
|
|
343
|
+
if (cursor != null) text(cursor, 128);
|
|
344
|
+
const docs = this.rows('concepts', this.scope(ownerId), ' AND (? IS NULL OR id > ?) AND instr(statement, ?) > 0 ORDER BY id ASC LIMIT ?', [cursor, cursor, query.toLowerCase(), limit + 1]);
|
|
345
|
+
return { items: docs.slice(0, limit).map(publicDoc), nextCursor: docs.length > limit ? docs[limit - 1].id : null };
|
|
346
|
+
}
|
|
347
|
+
snapshot(ownerId) {
|
|
348
|
+
this.recover(ownerId);
|
|
349
|
+
const scope = this.scope(ownerId), p = this.getPerson(ownerId), state = this.one('states', scope);
|
|
350
|
+
const messages = this.rows('messages', scope, ' ORDER BY seq DESC LIMIT 21');
|
|
351
|
+
const episode = this.one('episodes', scope, ' ORDER BY inputWatermark DESC');
|
|
352
|
+
const latestEpisode = episode ? { id: episode.id, status: episode.status,
|
|
353
|
+
...(episode.terminalCode ? { terminalCode: episode.terminalCode } : {}), ...(episode.endedAt ? { endedAt: episode.endedAt } : {}) } : null;
|
|
354
|
+
return { latestEpisode, person: this.personView(p), state: publicDoc(state), concepts: this.focused(scope, state.focusConceptIds).slice(0, 12).map(publicDoc),
|
|
355
|
+
messages: messages.slice(0, 20).reverse().map(publicDoc), nextMessagesCursor: messages.length > 20 ? String(messages[19].seq) : null,
|
|
356
|
+
busy: Boolean(p.activeEpisodeId), episodeId: p.activeEpisodeId };
|
|
357
|
+
}
|
|
358
|
+
searchChanges(ownerId, { after = 0, limit = 100 } = {}) {
|
|
359
|
+
this.getPerson(ownerId); after = sequence(after); boundedLimit(limit, 1000);
|
|
360
|
+
const rows = this.sql(`SELECT seq, kind, id, revision, record FROM memory_changes WHERE ${SCOPE} AND seq > ? ORDER BY seq ASC LIMIT ?`)
|
|
361
|
+
.all(...scopeValues(this.scope(ownerId)), after, limit + 1);
|
|
362
|
+
const items = rows.slice(0, limit).map(row => ({ ...row, record: decode(row) }));
|
|
363
|
+
return { items, lastSeq: items.at(-1)?.seq ?? after, hasMore: rows.length > limit };
|
|
364
|
+
}
|
|
365
|
+
resolveMemories(ownerId, kind, refs) {
|
|
366
|
+
memoryKind(kind); this.getPerson(ownerId);
|
|
367
|
+
if (!Array.isArray(refs) || refs.length > 1000) fail('INVALID_REQUEST');
|
|
368
|
+
const scope = this.scope(ownerId), records = [], seen = new Set();
|
|
369
|
+
for (const ref of refs) {
|
|
370
|
+
if (!ref || typeof ref.id !== 'string' || !Number.isSafeInteger(ref.revision) || ref.revision < 1) fail('INVALID_REQUEST');
|
|
371
|
+
const record = this.one(kind, scope, ' AND id = ? AND revision = ?', [ref.id, ref.revision]);
|
|
372
|
+
if (record && !seen.has(record.id)) { seen.add(record.id); records.push(publicDoc(record)); }
|
|
373
|
+
}
|
|
374
|
+
return records;
|
|
375
|
+
}
|
|
376
|
+
close() { this.db.close(); }
|
|
377
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { parentPort, workerData } from 'node:worker_threads';
|
|
2
|
+
import { PersonError } from './contracts.js';
|
|
3
|
+
import { SqlitePersonStore } from './sqlite-store.js';
|
|
4
|
+
|
|
5
|
+
// The protocol exposes logical repository operations, never SQL or callbacks.
|
|
6
|
+
let store;
|
|
7
|
+
try {
|
|
8
|
+
store = new SqlitePersonStore(workerData);
|
|
9
|
+
parentPort.postMessage({ ready: true });
|
|
10
|
+
} catch {
|
|
11
|
+
parentPort.postMessage({ startupError: 'STORAGE_UNAVAILABLE' });
|
|
12
|
+
parentPort.close();
|
|
13
|
+
}
|
|
14
|
+
if (store) parentPort.on('message', ({ id, method, args }) => {
|
|
15
|
+
try {
|
|
16
|
+
if (method === 'close') {
|
|
17
|
+
store.close(); parentPort.postMessage({ id, result: null }); parentPort.close(); return;
|
|
18
|
+
}
|
|
19
|
+
parentPort.postMessage({ id, result: store.execute(method, args) });
|
|
20
|
+
} catch (error) {
|
|
21
|
+
// SQLite errors include filesystem paths, SQL and possibly user data. Never cross
|
|
22
|
+
// the worker boundary with their message/stack; typed application errors are safe.
|
|
23
|
+
parentPort.postMessage({ id, error: error instanceof PersonError ? error.code : 'STORAGE_UNAVAILABLE' });
|
|
24
|
+
}
|
|
25
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile, link, unlink } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { digest, fail } from './contracts.js';
|
|
5
|
+
|
|
6
|
+
/** Deployment-only selection. Never fall back from an unavailable authority to another one. */
|
|
7
|
+
export function selectPersonStorage({ storage, uri, yeaftDir }) {
|
|
8
|
+
if (storage != null && !['sqlite', 'mongodb'].includes(storage)) fail('INVALID_REQUEST');
|
|
9
|
+
if (uri != null && typeof uri !== 'string') fail('INVALID_REQUEST');
|
|
10
|
+
const hasMongo = Boolean(uri?.trim());
|
|
11
|
+
const selected = storage ?? (hasMongo ? 'mongodb' : 'sqlite');
|
|
12
|
+
if (selected === 'sqlite' && hasMongo) fail('STORAGE_MISMATCH');
|
|
13
|
+
return { storage: selected, configured: selected === 'mongodb' ? hasMongo : typeof yeaftDir === 'string' && Boolean(yeaftDir.trim()) };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** This marker is deployment metadata, not memory. Changing backend requires an explicit migration.
|
|
17
|
+
* The first configured access binds a namespace; removing Mongo credentials cannot create a new Person.
|
|
18
|
+
* Legacy Mongo deployments are bound on their first access after upgrade, not retroactively discovered.
|
|
19
|
+
*/
|
|
20
|
+
export async function bindPersonStorage(yeaftDir, namespace, storage) {
|
|
21
|
+
if (!yeaftDir) return; // Direct Mongo integrations may have no local instance directory.
|
|
22
|
+
const directory = join(yeaftDir, 'person');
|
|
23
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
24
|
+
const path = join(directory, `storage-${digest(namespace)}.json`);
|
|
25
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
26
|
+
await writeFile(temporary, JSON.stringify({ version: 1, storage }), { flag: 'wx', mode: 0o600 });
|
|
27
|
+
try {
|
|
28
|
+
// A fully written inode is linked atomically; a competing opener never reads partial JSON.
|
|
29
|
+
await link(temporary, path);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (error.code !== 'EEXIST') throw error;
|
|
32
|
+
let binding;
|
|
33
|
+
try { binding = JSON.parse(await readFile(path, 'utf8')); } catch { fail('STORAGE_MISMATCH'); }
|
|
34
|
+
if (binding.version !== 1 || binding.storage !== storage) fail('STORAGE_MISMATCH');
|
|
35
|
+
} finally { await unlink(temporary).catch(() => {}); }
|
|
36
|
+
}
|