@yeaft/webchat-agent 1.0.587 → 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,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
|
+
}
|
package/yeaft/person/runtime.js
CHANGED
|
@@ -6,7 +6,7 @@ import { bytes, fail, LIMITS, PersonError, PROPOSAL_INSTRUCTIONS, reportedLineag
|
|
|
6
6
|
const messageRef = m => `message:${m.id}:${m.revision}`;
|
|
7
7
|
const conceptRef = c => `concept:${c.id}:${c.revision}`;
|
|
8
8
|
|
|
9
|
-
/** Assemble bounded request copies. Omitting a record never deletes or truncates its
|
|
9
|
+
/** Assemble bounded request copies. Omitting a record never deletes or truncates its durable original. */
|
|
10
10
|
export function assembleContext({ snapshot, episode, provider, selection, previous, capabilityResult, remainingCalls, dependencyRefs = [] }) {
|
|
11
11
|
const model = validateSelection(selection, provider.catalog);
|
|
12
12
|
// UTF-8 bytes is a conservative text-token bound; reserve explicit envelope/output overhead.
|
|
@@ -21,7 +21,7 @@ export function assembleContext({ snapshot, episode, provider, selection, previo
|
|
|
21
21
|
budget: { remainingCalls, maxOutputBytes: LIMITS.outputBytes },
|
|
22
22
|
previousProposal: previous ?? null, capabilityResult: capabilityResult ?? null,
|
|
23
23
|
messages: [], concepts: [], sourceRefs: [triggerRef], inheritedSourceRefs: dependencyRefs,
|
|
24
|
-
contextNotice: 'This is bounded short-term context, not all memory. Omitted records remain in
|
|
24
|
+
contextNotice: 'This is bounded short-term context, not all memory. Omitted records remain in long-term storage. Recall pages are scoped to this Person. A previous proposal is not committed state. Inherited source refs were read by an earlier call of this episode, not necessarily rendered here; recall again to check their content.',
|
|
25
25
|
};
|
|
26
26
|
const sourceRefs = new Set([triggerRef, ...dependencyRefs]);
|
|
27
27
|
const renderedRefs = new Set([triggerRef]);
|
|
@@ -158,7 +158,7 @@ export class PersonRuntime {
|
|
|
158
158
|
await this.repository.append(episode, 'capability_started', { callId, capability: invocation, access: 'read-only' });
|
|
159
159
|
try {
|
|
160
160
|
signal.throwIfAborted();
|
|
161
|
-
capabilityResult = await abortable(capabilities.execute(invocation), signal);
|
|
161
|
+
capabilityResult = await abortable(capabilities.execute(invocation, { signal }), signal);
|
|
162
162
|
await this.repository.append(episode, 'capability_result', { callId, capability: invocation, result: capabilityResult });
|
|
163
163
|
} catch (error) {
|
|
164
164
|
const safe = safeError(error, 'UNSUPPORTED');
|
|
@@ -170,7 +170,7 @@ export class PersonRuntime {
|
|
|
170
170
|
} catch (error) {
|
|
171
171
|
const safe = safeError(signal.aborted ? signal.reason : error, 'PROVIDER_FAILED');
|
|
172
172
|
const status = safe.code === 'CANCELLED' ? 'cancelled' : safe.code === 'INTERRUPTED' || safe.code === 'STALE' ? 'interrupted' : 'failed';
|
|
173
|
-
// On
|
|
173
|
+
// On authority failure no alternate memory store is used. Expired leases become durable interrupted records on next access.
|
|
174
174
|
await this.repository.finish(episode, status, safe.code).catch(() => {});
|
|
175
175
|
} finally {
|
|
176
176
|
clearTimeout(timeout); clearInterval(heartbeat);
|
package/yeaft/person/service.js
CHANGED
|
@@ -1,28 +1,46 @@
|
|
|
1
1
|
import { MongoPersonRepository } from './repository.js';
|
|
2
|
+
import { SqlitePersonRepository } from './sqlite-repository.js';
|
|
3
|
+
import { LocalPersonMemory } from './local-memory.js';
|
|
4
|
+
import { selectPersonStorage, bindPersonStorage } from './storage.js';
|
|
2
5
|
import { PersonRuntime } from './runtime.js';
|
|
3
6
|
import { createPersonProvider } from './provider.js';
|
|
4
7
|
import { fail, identifier, LIMITS, object, page, safeError, text } from './contracts.js';
|
|
5
8
|
|
|
6
9
|
/**
|
|
7
|
-
* One
|
|
10
|
+
* One durable Person per (namespace, authenticated ownerId). The transport MUST
|
|
8
11
|
* supply authenticated ownerId, never accept it from a browser payload. URI/provider
|
|
9
12
|
* secrets stay inside this Agent. Constructor and status never initiate model calls.
|
|
10
13
|
* Native provider configuration is read from yeaftDir only (no Session initialization).
|
|
11
|
-
*
|
|
14
|
+
* SQLite is the local default; an existing Mongo URI retains Mongo. No authority fallback.
|
|
12
15
|
*/
|
|
13
16
|
export function createPersonService(options = {}) {
|
|
14
17
|
const { uri, dbName = 'yeaft_person', namespace = 'default', yeaftDir, MongoClient, config, adapter, allowedModels } = options;
|
|
15
18
|
identifier(namespace);
|
|
16
19
|
if (typeof dbName !== 'string' || !/^[a-zA-Z0-9_-]{1,63}$/.test(dbName)) fail('INVALID_REQUEST');
|
|
17
20
|
if (uri != null && typeof uri !== 'string') fail('INVALID_REQUEST');
|
|
18
|
-
const configured =
|
|
21
|
+
const { storage, configured } = selectPersonStorage({ ...options, yeaftDir });
|
|
19
22
|
const calls = options.maxCalls ?? LIMITS.calls;
|
|
20
23
|
const timeoutMs = options.timeoutMs ?? LIMITS.timeoutMs;
|
|
21
24
|
const leaseMs = options.leaseMs ?? LIMITS.leaseMs;
|
|
22
25
|
if (!Number.isInteger(calls) || calls < 1 || calls > 8 || !Number.isInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > 300000 ||
|
|
23
26
|
!Number.isInteger(leaseMs) || leaseMs < 300 || leaseMs > 60000) fail('INVALID_REQUEST');
|
|
24
27
|
if (allowedModels != null && (!Array.isArray(allowedModels) || !allowedModels.length || allowedModels.length > 8 || allowedModels.some(m => typeof m !== 'string'))) fail('INVALID_REQUEST');
|
|
25
|
-
const repository =
|
|
28
|
+
const repository = !configured ? null : storage === 'mongodb'
|
|
29
|
+
? new MongoPersonRepository({ uri, dbName, namespace, MongoClient, leaseMs })
|
|
30
|
+
: new SqlitePersonRepository({ yeaftDir, namespace, leaseMs });
|
|
31
|
+
const initialize = repository?.init.bind(repository);
|
|
32
|
+
let binding;
|
|
33
|
+
if (repository) repository.init = async () => {
|
|
34
|
+
if (!binding) binding = bindPersonStorage(yeaftDir, namespace, storage).catch(error => { binding = null; throw error; });
|
|
35
|
+
await binding;
|
|
36
|
+
return initialize();
|
|
37
|
+
};
|
|
38
|
+
const literalRecall = repository?.recall.bind(repository);
|
|
39
|
+
const memory = repository && storage === 'sqlite' ? new LocalPersonMemory({
|
|
40
|
+
repository, literalRecall, yeaftDir, namespace, embedding: options.embedding,
|
|
41
|
+
}) : null;
|
|
42
|
+
if (memory) repository.recall = (ownerId, args = {}, { signal } = {}) => args.query?.trim()
|
|
43
|
+
? memory.recall(ownerId, args, { signal }) : literalRecall(ownerId, args);
|
|
26
44
|
// Config/adapter are loaded per explicit episode, not a permanent stale cache.
|
|
27
45
|
const getProvider = () => createPersonProvider({ yeaftDir, config, adapter, allowedModels, effortEnabled: options.effortEnabled });
|
|
28
46
|
const runtime = new PersonRuntime({ repository, getProvider, budget: { calls, timeoutMs } });
|
|
@@ -34,13 +52,13 @@ export function createPersonService(options = {}) {
|
|
|
34
52
|
if (typeof op !== 'string') fail('INVALID_REQUEST');
|
|
35
53
|
if (op === 'status') {
|
|
36
54
|
object(payload, []);
|
|
37
|
-
if (!configured) return { configured: false, reason: '
|
|
55
|
+
if (!configured) return { configured: false, storage, reason: 'Digital person storage configuration is missing.', storageReady: false, modelReady: false };
|
|
38
56
|
try { await repository.init(); }
|
|
39
|
-
catch { return { configured: true, reason:
|
|
57
|
+
catch (error) { return { configured: true, storage, reason: safeError(error).message, storageReady: false, modelReady: false }; }
|
|
40
58
|
try {
|
|
41
59
|
const provider = await getProvider();
|
|
42
|
-
return { configured: true, reason: null, storageReady: true, modelReady: true, models: provider.catalog, autonomySupported: false };
|
|
43
|
-
} catch { return { configured: true, reason: 'No permitted native model is configured for digital person.', storageReady: true, modelReady: false }; }
|
|
60
|
+
return { configured: true, storage, reason: null, storageReady: true, modelReady: true, models: provider.catalog, autonomySupported: false };
|
|
61
|
+
} catch { return { configured: true, storage, reason: 'No permitted native model is configured for digital person.', storageReady: true, modelReady: false }; }
|
|
44
62
|
}
|
|
45
63
|
if (!configured) fail('NOT_CONFIGURED');
|
|
46
64
|
switch (op) {
|
|
@@ -93,7 +111,8 @@ export function createPersonService(options = {}) {
|
|
|
93
111
|
closed = true;
|
|
94
112
|
await Promise.allSettled([...requests]);
|
|
95
113
|
await runtime.close();
|
|
96
|
-
await
|
|
114
|
+
await memory?.close();
|
|
115
|
+
await repository?.close().catch(() => {});
|
|
97
116
|
},
|
|
98
117
|
};
|
|
99
118
|
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { Worker } from 'node:worker_threads';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { digest, fail, identifier, PersonError, text } from './contracts.js';
|
|
4
|
+
|
|
5
|
+
const storageError = code => {
|
|
6
|
+
const error = new PersonError(code);
|
|
7
|
+
// Keep transport errors independent of backend topology, SQL and local paths.
|
|
8
|
+
if (error.code === 'STORAGE_UNAVAILABLE') error.message = 'Digital person storage is unavailable.';
|
|
9
|
+
return error;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/** One worker/connection per repository, shared WAL authority across processes.
|
|
13
|
+
* ownerId must come from the authenticated transport, never a request payload.
|
|
14
|
+
* A worker crash/timeout fails this instance closed: pending outcomes may have
|
|
15
|
+
* committed, so callers reconcile with a NEW instance and the same idempotency key.
|
|
16
|
+
* No automatic retry, takeover, model dispatch or local-memory fallback occurs.
|
|
17
|
+
*/
|
|
18
|
+
export class SqlitePersonRepository {
|
|
19
|
+
constructor({ yeaftDir, namespace = 'default', leaseMs = 15000, busyTimeoutMs = 1000, requestTimeoutMs = 30000, maxPending = 256 } = {}) {
|
|
20
|
+
text(yeaftDir, 4096); identifier(namespace);
|
|
21
|
+
if (!Number.isInteger(leaseMs) || leaseMs < 1 || leaseMs > 60000 || !Number.isInteger(busyTimeoutMs) || busyTimeoutMs < 0 || busyTimeoutMs > 5000 ||
|
|
22
|
+
!Number.isInteger(requestTimeoutMs) || requestTimeoutMs < busyTimeoutMs + 100 || requestTimeoutMs > 120000 ||
|
|
23
|
+
!Number.isInteger(maxPending) || maxPending < 1 || maxPending > 4096) fail('INVALID_REQUEST');
|
|
24
|
+
this.namespace = namespace; this.leaseMs = leaseMs;
|
|
25
|
+
this.dbPath = join(resolve(yeaftDir), 'person', 'person.db');
|
|
26
|
+
this.options = { dbPath: this.dbPath, namespace, leaseMs, busyTimeoutMs };
|
|
27
|
+
this.requestTimeoutMs = requestTimeoutMs; this.maxPending = maxPending;
|
|
28
|
+
this.pending = new Map(); this.requests = new Set(); this.nextId = 0; this.closed = false; this.failed = false;
|
|
29
|
+
}
|
|
30
|
+
scope(ownerId) {
|
|
31
|
+
text(ownerId, 256);
|
|
32
|
+
return { ownerId, namespace: this.namespace, personId: `person-${digest([this.namespace, ownerId]).slice(0, 32)}` };
|
|
33
|
+
}
|
|
34
|
+
async init() {
|
|
35
|
+
if (this.closed) fail('CLOSED');
|
|
36
|
+
if (this.failed) throw storageError('STORAGE_UNAVAILABLE');
|
|
37
|
+
if (!this.initializing) this.initializing = new Promise((resolveReady, rejectReady) => {
|
|
38
|
+
const timer = setTimeout(() => this.breakWorker(), this.requestTimeoutMs); timer.unref();
|
|
39
|
+
this.rejectReady = error => { clearTimeout(timer); rejectReady(error); };
|
|
40
|
+
try {
|
|
41
|
+
this.worker = new Worker(new URL('./sqlite-worker.js', import.meta.url), { workerData: this.options });
|
|
42
|
+
this.worker.on('message', message => {
|
|
43
|
+
if (message.ready) { clearTimeout(timer); this.rejectReady = null; this.worker.unref(); resolveReady(); return; }
|
|
44
|
+
if (message.startupError) { this.breakWorker(); return; }
|
|
45
|
+
const pending = this.pending.get(message.id);
|
|
46
|
+
if (!pending) return;
|
|
47
|
+
this.pending.delete(message.id); clearTimeout(pending.timer);
|
|
48
|
+
if (!this.pending.size) this.worker.unref();
|
|
49
|
+
if (message.error) pending.reject(storageError(message.error)); else pending.resolve(message.result);
|
|
50
|
+
});
|
|
51
|
+
this.worker.on('error', () => this.breakWorker());
|
|
52
|
+
this.worker.on('exit', () => {
|
|
53
|
+
if (!this.closingWorker || this.pending.size) this.breakWorker();
|
|
54
|
+
});
|
|
55
|
+
} catch { this.breakWorker(); }
|
|
56
|
+
});
|
|
57
|
+
return this.initializing;
|
|
58
|
+
}
|
|
59
|
+
breakWorker() {
|
|
60
|
+
if (this.failed) return;
|
|
61
|
+
this.failed = true;
|
|
62
|
+
const error = storageError('STORAGE_UNAVAILABLE');
|
|
63
|
+
this.rejectReady?.(error); this.rejectReady = null;
|
|
64
|
+
for (const pending of this.pending.values()) { clearTimeout(pending.timer); pending.reject(error); }
|
|
65
|
+
this.pending.clear();
|
|
66
|
+
this.terminating = this.worker?.terminate().catch(() => {});
|
|
67
|
+
}
|
|
68
|
+
send(method, args) {
|
|
69
|
+
if (this.failed) return Promise.reject(storageError('STORAGE_UNAVAILABLE'));
|
|
70
|
+
return new Promise((resolveResult, reject) => {
|
|
71
|
+
const id = ++this.nextId;
|
|
72
|
+
const timer = setTimeout(() => this.breakWorker(), this.requestTimeoutMs); timer.unref();
|
|
73
|
+
this.pending.set(id, { resolve: resolveResult, reject, timer });
|
|
74
|
+
this.worker.ref();
|
|
75
|
+
try { this.worker.postMessage({ id, method, args }); }
|
|
76
|
+
catch { this.breakWorker(); }
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
async request(method, args) {
|
|
80
|
+
if (this.closed) fail('CLOSED');
|
|
81
|
+
if (this.requests.size >= this.maxPending) fail('BUSY');
|
|
82
|
+
const promise = (async () => { await this.init(); return this.send(method, args); })();
|
|
83
|
+
this.requests.add(promise);
|
|
84
|
+
try { return await promise; } finally { this.requests.delete(promise); }
|
|
85
|
+
}
|
|
86
|
+
async open(ownerId, name) { return this.request('open', [ownerId, name]); }
|
|
87
|
+
async getPerson(ownerId) { return this.request('getPerson', [ownerId]); }
|
|
88
|
+
async recover(ownerId) { return this.request('recover', [ownerId]); }
|
|
89
|
+
async admit(ownerId, input) { return this.request('admit', [ownerId, input]); }
|
|
90
|
+
async heartbeat(episode) { return this.request('heartbeat', [episode]); }
|
|
91
|
+
async append(episode, kind, data) { return this.request('append', [episode, kind, data]); }
|
|
92
|
+
async startCall(episode, data) { return this.request('startCall', [episode, data]); }
|
|
93
|
+
async finalizeCall(episode, data) { return this.request('finalizeCall', [episode, data]); }
|
|
94
|
+
async context(episode) { return this.request('context', [episode]); }
|
|
95
|
+
async recall(ownerId, options) { return this.request('recall', [ownerId, options]); }
|
|
96
|
+
async commit(episode, proposal, selection, callId, reportedSources = new Map()) { return this.request('commit', [episode, proposal, selection, callId, reportedSources]); }
|
|
97
|
+
async finish(episode, status, code) { return this.request('finish', [episode, status, code]); }
|
|
98
|
+
async cancel(ownerId, episodeId) { return this.request('cancel', [ownerId, episodeId]); }
|
|
99
|
+
async settings(ownerId, settings) { return this.request('settings', [ownerId, settings]); }
|
|
100
|
+
async list(ownerId, collection, options, filter) { return this.request('list', [ownerId, collection, options, filter]); }
|
|
101
|
+
async snapshot(ownerId) { return this.request('snapshot', [ownerId]); }
|
|
102
|
+
/** Ascending durable journal, scoped before paging. Empty pages retain after. */
|
|
103
|
+
async searchChanges(ownerId, options) { return this.request('searchChanges', [ownerId, options]); }
|
|
104
|
+
/** kind is 'messages'|'concepts'; returns a deduplicated array of current public records. */
|
|
105
|
+
async resolveMemories(ownerId, kind, refs) { return this.request('resolveMemories', [ownerId, kind, refs]); }
|
|
106
|
+
async close() {
|
|
107
|
+
if (!this.closing) {
|
|
108
|
+
this.closed = true;
|
|
109
|
+
this.closing = (async () => {
|
|
110
|
+
await Promise.allSettled([...this.requests]);
|
|
111
|
+
await this.initializing?.catch(() => {});
|
|
112
|
+
if (this.worker && !this.failed) {
|
|
113
|
+
this.closingWorker = true;
|
|
114
|
+
await this.send('close', []).catch(() => {});
|
|
115
|
+
// terminate also joins the worker if it already completed its normal exit.
|
|
116
|
+
await this.worker.terminate().catch(() => {});
|
|
117
|
+
}
|
|
118
|
+
await this.terminating;
|
|
119
|
+
})();
|
|
120
|
+
}
|
|
121
|
+
return this.closing;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Identifiers below are static application schema, never request-controlled SQL.
|
|
2
|
+
export const TABLES = Object.freeze({
|
|
3
|
+
persons: { keys: [], columns: [] },
|
|
4
|
+
states: { keys: [], columns: ['version'] },
|
|
5
|
+
episodes: { keys: ['id'], columns: ['clientMessageId', 'inputWatermark', 'status', 'callFinalizeUntil'] },
|
|
6
|
+
messages: { keys: ['id'], columns: ['seq', 'revision', 'text'] },
|
|
7
|
+
concepts: { keys: ['id'], columns: ['revision', 'updatedAt', 'statement'] },
|
|
8
|
+
concept_revisions: { keys: ['id', 'revision'], columns: [] },
|
|
9
|
+
state_commits: { keys: ['version'], columns: ['id'] },
|
|
10
|
+
traces: { keys: ['id'], columns: ['seq'] },
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export const SCHEMA = `
|
|
14
|
+
CREATE TABLE IF NOT EXISTS persons (
|
|
15
|
+
namespace TEXT NOT NULL, ownerId TEXT NOT NULL, personId TEXT NOT NULL,
|
|
16
|
+
record TEXT NOT NULL CHECK(json_valid(record)), PRIMARY KEY(namespace, ownerId, personId)
|
|
17
|
+
) STRICT;
|
|
18
|
+
CREATE TABLE IF NOT EXISTS states (
|
|
19
|
+
namespace TEXT NOT NULL, ownerId TEXT NOT NULL, personId TEXT NOT NULL,
|
|
20
|
+
version INTEGER NOT NULL, record TEXT NOT NULL CHECK(json_valid(record)),
|
|
21
|
+
PRIMARY KEY(namespace, ownerId, personId),
|
|
22
|
+
FOREIGN KEY(namespace, ownerId, personId) REFERENCES persons(namespace, ownerId, personId)
|
|
23
|
+
) STRICT;
|
|
24
|
+
CREATE TABLE IF NOT EXISTS episodes (
|
|
25
|
+
namespace TEXT NOT NULL, ownerId TEXT NOT NULL, personId TEXT NOT NULL,
|
|
26
|
+
id TEXT NOT NULL, clientMessageId TEXT NOT NULL, inputWatermark INTEGER NOT NULL,
|
|
27
|
+
status TEXT NOT NULL, callFinalizeUntil INTEGER,
|
|
28
|
+
record TEXT NOT NULL CHECK(json_valid(record)), PRIMARY KEY(namespace, ownerId, personId, id),
|
|
29
|
+
UNIQUE(namespace, ownerId, personId, clientMessageId),
|
|
30
|
+
FOREIGN KEY(namespace, ownerId, personId) REFERENCES persons(namespace, ownerId, personId)
|
|
31
|
+
) STRICT;
|
|
32
|
+
CREATE INDEX IF NOT EXISTS episodes_watermark ON episodes(namespace, ownerId, personId, inputWatermark DESC);
|
|
33
|
+
CREATE INDEX IF NOT EXISTS episodes_drain ON episodes(namespace, ownerId, personId, status, callFinalizeUntil)
|
|
34
|
+
WHERE json_type(record, '$.openCall') IS NOT NULL;
|
|
35
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
36
|
+
namespace TEXT NOT NULL, ownerId TEXT NOT NULL, personId TEXT NOT NULL,
|
|
37
|
+
id TEXT NOT NULL, seq INTEGER NOT NULL, revision INTEGER NOT NULL, text TEXT NOT NULL,
|
|
38
|
+
record TEXT NOT NULL CHECK(json_valid(record)), PRIMARY KEY(namespace, ownerId, personId, id),
|
|
39
|
+
UNIQUE(namespace, ownerId, personId, seq),
|
|
40
|
+
FOREIGN KEY(namespace, ownerId, personId) REFERENCES persons(namespace, ownerId, personId)
|
|
41
|
+
) STRICT;
|
|
42
|
+
CREATE UNIQUE INDEX IF NOT EXISTS messages_revision ON messages(namespace, ownerId, personId, id, revision);
|
|
43
|
+
CREATE TABLE IF NOT EXISTS concepts (
|
|
44
|
+
namespace TEXT NOT NULL, ownerId TEXT NOT NULL, personId TEXT NOT NULL,
|
|
45
|
+
id TEXT NOT NULL, revision INTEGER NOT NULL, updatedAt INTEGER NOT NULL, statement TEXT NOT NULL,
|
|
46
|
+
record TEXT NOT NULL CHECK(json_valid(record)), PRIMARY KEY(namespace, ownerId, personId, id),
|
|
47
|
+
FOREIGN KEY(namespace, ownerId, personId) REFERENCES persons(namespace, ownerId, personId)
|
|
48
|
+
) STRICT;
|
|
49
|
+
CREATE UNIQUE INDEX IF NOT EXISTS concepts_revision ON concepts(namespace, ownerId, personId, id, revision);
|
|
50
|
+
CREATE INDEX IF NOT EXISTS concepts_recent ON concepts(namespace, ownerId, personId, updatedAt DESC, id);
|
|
51
|
+
CREATE TABLE IF NOT EXISTS concept_revisions (
|
|
52
|
+
namespace TEXT NOT NULL, ownerId TEXT NOT NULL, personId TEXT NOT NULL,
|
|
53
|
+
id TEXT NOT NULL, revision INTEGER NOT NULL, record TEXT NOT NULL CHECK(json_valid(record)),
|
|
54
|
+
PRIMARY KEY(namespace, ownerId, personId, id, revision),
|
|
55
|
+
FOREIGN KEY(namespace, ownerId, personId) REFERENCES persons(namespace, ownerId, personId)
|
|
56
|
+
) STRICT;
|
|
57
|
+
CREATE TABLE IF NOT EXISTS state_commits (
|
|
58
|
+
namespace TEXT NOT NULL, ownerId TEXT NOT NULL, personId TEXT NOT NULL,
|
|
59
|
+
version INTEGER NOT NULL, id TEXT NOT NULL, record TEXT NOT NULL CHECK(json_valid(record)),
|
|
60
|
+
PRIMARY KEY(namespace, ownerId, personId, version),
|
|
61
|
+
FOREIGN KEY(namespace, ownerId, personId) REFERENCES persons(namespace, ownerId, personId)
|
|
62
|
+
) STRICT;
|
|
63
|
+
CREATE TABLE IF NOT EXISTS traces (
|
|
64
|
+
namespace TEXT NOT NULL, ownerId TEXT NOT NULL, personId TEXT NOT NULL,
|
|
65
|
+
id TEXT NOT NULL, seq INTEGER NOT NULL, record TEXT NOT NULL CHECK(json_valid(record)),
|
|
66
|
+
PRIMARY KEY(namespace, ownerId, personId, id), UNIQUE(namespace, ownerId, personId, seq),
|
|
67
|
+
FOREIGN KEY(namespace, ownerId, personId) REFERENCES persons(namespace, ownerId, personId)
|
|
68
|
+
) STRICT;
|
|
69
|
+
-- This journal is authority data, not a disposable search index. A committed seq is
|
|
70
|
+
-- never reused, including after reopening; gaps between owners are intentional.
|
|
71
|
+
CREATE TABLE IF NOT EXISTS memory_changes (
|
|
72
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
73
|
+
namespace TEXT NOT NULL, ownerId TEXT NOT NULL, personId TEXT NOT NULL,
|
|
74
|
+
kind TEXT NOT NULL CHECK(kind IN ('messages', 'concepts')), id TEXT NOT NULL,
|
|
75
|
+
revision INTEGER NOT NULL, record TEXT NOT NULL CHECK(json_valid(record)),
|
|
76
|
+
UNIQUE(namespace, ownerId, personId, kind, id, revision),
|
|
77
|
+
FOREIGN KEY(namespace, ownerId, personId) REFERENCES persons(namespace, ownerId, personId)
|
|
78
|
+
) STRICT;
|
|
79
|
+
CREATE INDEX IF NOT EXISTS memory_changes_scope ON memory_changes(namespace, ownerId, personId, seq);
|
|
80
|
+
`;
|