@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
package/yeaft/person/runtime.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { CAPABILITY_MAP, PersonCapabilities, catalogRevision as capabilityCatalogRevision } from './capabilities.js';
|
|
2
|
+
import { CAPABILITY_MAP, CAPABILITY_LIMITS, foundationCapabilities, PersonCapabilities, catalogRevision as capabilityCatalogRevision } from './capabilities.js';
|
|
3
3
|
import { abortable, collectOutput } from './provider.js';
|
|
4
4
|
import { bytes, fail, LIMITS, PersonError, PROPOSAL_INSTRUCTIONS, reportedLineage, safeError, validateProposal, validateSelection } from './contracts.js';
|
|
5
5
|
|
|
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
|
|
10
|
-
export function assembleContext({ snapshot, episode, provider, selection, previous, capabilityResult, remainingCalls, dependencyRefs = [] }) {
|
|
9
|
+
/** Assemble bounded request copies. Omitting a record never deletes or truncates its durable original. */
|
|
10
|
+
export function assembleContext({ snapshot, episode, provider, selection, previous, capabilityResult, remainingCalls, dependencyRefs = [], activeCapabilities = foundationCapabilities() }) {
|
|
11
11
|
const model = validateSelection(selection, provider.catalog);
|
|
12
12
|
// UTF-8 bytes is a conservative text-token bound; reserve explicit envelope/output overhead.
|
|
13
13
|
const contextCap = Math.min(LIMITS.contextBytes, model.contextWindow - model.maxOutput - 1024);
|
|
@@ -17,11 +17,11 @@ export function assembleContext({ snapshot, episode, provider, selection, previo
|
|
|
17
17
|
person: { id: snapshot.person.id, name: snapshot.person.name, soulRevision: snapshot.person.soulRevision },
|
|
18
18
|
state: snapshot.state, trigger: { kind: episode.kind, text: episode.text, ref: triggerRef },
|
|
19
19
|
models: provider.catalog, modelCatalogRevision: provider.catalogRevision,
|
|
20
|
-
capabilities: CAPABILITY_MAP, capabilityCatalogRevision,
|
|
20
|
+
capabilities: { ...CAPABILITY_MAP, active: [] }, capabilityCatalogRevision,
|
|
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]);
|
|
@@ -41,6 +41,15 @@ export function assembleContext({ snapshot, episode, provider, selection, previo
|
|
|
41
41
|
context.sourceRefs = [...sourceRefs];
|
|
42
42
|
const fits = () => bytes(system) + bytes(context) <= contextCap;
|
|
43
43
|
if (!fits()) fail('CONTEXT_LIMIT');
|
|
44
|
+
const omittedCapabilities = [];
|
|
45
|
+
for (const contract of activeCapabilities) {
|
|
46
|
+
context.capabilities.active.push(contract);
|
|
47
|
+
if (bytes(context.capabilities.active) > CAPABILITY_LIMITS.activeBytes || !fits()) {
|
|
48
|
+
context.capabilities.active.pop();
|
|
49
|
+
if (contract.availability.layer === 'foundation') fail('CONTEXT_LIMIT');
|
|
50
|
+
omittedCapabilities.push({ id: contract.id, reason: 'context-budget', inspect: 'catalog.view' });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
44
53
|
const add = (field, item, ref) => {
|
|
45
54
|
if (renderedRefs.has(ref)) return;
|
|
46
55
|
const inherited = sourceRefs.has(ref);
|
|
@@ -52,11 +61,12 @@ export function assembleContext({ snapshot, episode, provider, selection, previo
|
|
|
52
61
|
context.messages.reverse();
|
|
53
62
|
for (const c of snapshot.concepts) add('concepts', c, conceptRef(c));
|
|
54
63
|
return {
|
|
55
|
-
system, messages: [{ role: 'user', content: JSON.stringify(context) }], sourceRefs, concepts: conceptMap, sources,
|
|
64
|
+
activeCapabilities: context.capabilities.active, system, messages: [{ role: 'user', content: JSON.stringify(context) }], sourceRefs, concepts: conceptMap, sources,
|
|
56
65
|
manifest: { stateVersion: snapshot.state.version, sourceRefs: [...sourceRefs], renderedSourceRefs: [...renderedRefs], inputDependencyRefs: [...sourceRefs], omitted,
|
|
57
66
|
boundedRecentWindow: { messages: 12, recentConcepts: 12, focusedConcepts: 12 },
|
|
58
67
|
contextBytes: bytes(system) + bytes(context), contextBudgetBytes: contextCap, outputTokensReserved: model.maxOutput,
|
|
59
|
-
modelCatalogRevision: provider.catalogRevision, capabilityCatalogRevision
|
|
68
|
+
modelCatalogRevision: provider.catalogRevision, capabilityCatalogRevision,
|
|
69
|
+
activeCapabilities: context.capabilities.active.map(({ id, version, revision, availability }) => ({ id, version, revision, ...availability })), omittedCapabilities }, maxTokens: model.maxOutput,
|
|
60
70
|
};
|
|
61
71
|
}
|
|
62
72
|
|
|
@@ -95,7 +105,7 @@ export class PersonRuntime {
|
|
|
95
105
|
try { validateSelection(snapshot.state.lastSelection, provider.catalog); selection = { ...snapshot.state.lastSelection, reason: 'last-accepted-choice', origin: 'persisted' }; }
|
|
96
106
|
catch { await this.repository.append(episode, 'selection_rejected', { requested: snapshot.state.lastSelection, code: 'MODEL_SELECTION', fallback: 'configured-default' }); }
|
|
97
107
|
}
|
|
98
|
-
const capabilities = new PersonCapabilities(this.repository, episode.ownerId);
|
|
108
|
+
const capabilities = new PersonCapabilities(this.repository, episode.ownerId, { experience: snapshot.capabilityExperience, triggerKind: episode.kind });
|
|
99
109
|
let previous = null, capabilityResult = null, dependencyRefs = [];
|
|
100
110
|
// Validation retains actual reads across calls, independently of the bounded rendered request.
|
|
101
111
|
// Candidate proposals never enter this read-set or establish new provenance.
|
|
@@ -103,7 +113,8 @@ export class PersonRuntime {
|
|
|
103
113
|
for (let index = 0; index < episode.budget.calls; index++) {
|
|
104
114
|
signal.throwIfAborted();
|
|
105
115
|
const callId = randomUUID();
|
|
106
|
-
const context = assembleContext({ snapshot, episode, provider, selection, previous, capabilityResult, dependencyRefs, remainingCalls: episode.budget.calls - index });
|
|
116
|
+
const context = assembleContext({ snapshot, episode, provider, selection, previous, capabilityResult, dependencyRefs, remainingCalls: episode.budget.calls - index, activeCapabilities: capabilities.context() });
|
|
117
|
+
capabilities.activate(context.activeCapabilities);
|
|
107
118
|
dependencyRefs = context.manifest.inputDependencyRefs;
|
|
108
119
|
for (const [id, concept] of context.concepts) readConcepts.set(id, concept);
|
|
109
120
|
for (const [ref, source] of context.sources) readSources.set(ref, source);
|
|
@@ -155,14 +166,16 @@ export class PersonRuntime {
|
|
|
155
166
|
capabilityResult = null;
|
|
156
167
|
if (proposal.next.capability) {
|
|
157
168
|
const invocation = proposal.next.capability;
|
|
158
|
-
|
|
169
|
+
const manifest = capabilities.executionManifest(invocation.id);
|
|
170
|
+
const execution = manifest ? { capabilityManifest: manifest } : {};
|
|
171
|
+
await this.repository.append(episode, 'capability_started', { callId, capability: invocation, ...execution, access: 'read-only' });
|
|
159
172
|
try {
|
|
160
173
|
signal.throwIfAborted();
|
|
161
|
-
capabilityResult = await abortable(capabilities.execute(invocation), signal);
|
|
162
|
-
await this.repository.append(episode, 'capability_result', { callId, capability: invocation, result: capabilityResult });
|
|
174
|
+
capabilityResult = await abortable(capabilities.execute(invocation, { signal }), signal);
|
|
175
|
+
await this.repository.append(episode, 'capability_result', { callId, capability: invocation, ...execution, result: capabilityResult });
|
|
163
176
|
} catch (error) {
|
|
164
177
|
const safe = safeError(error, 'UNSUPPORTED');
|
|
165
|
-
await this.repository.append(episode, 'capability_failed', { callId, capabilityId: invocation.id, code: safe.code }).catch(() => {});
|
|
178
|
+
await this.repository.append(episode, 'capability_failed', { callId, capabilityId: invocation.id, ...execution, code: safe.code }).catch(() => {});
|
|
166
179
|
throw safe;
|
|
167
180
|
}
|
|
168
181
|
}
|
|
@@ -170,7 +183,7 @@ export class PersonRuntime {
|
|
|
170
183
|
} catch (error) {
|
|
171
184
|
const safe = safeError(signal.aborted ? signal.reason : error, 'PROVIDER_FAILED');
|
|
172
185
|
const status = safe.code === 'CANCELLED' ? 'cancelled' : safe.code === 'INTERRUPTED' || safe.code === 'STALE' ? 'interrupted' : 'failed';
|
|
173
|
-
// On
|
|
186
|
+
// On authority failure no alternate memory store is used. Expired leases become durable interrupted records on next access.
|
|
174
187
|
await this.repository.finish(episode, status, safe.code).catch(() => {});
|
|
175
188
|
} finally {
|
|
176
189
|
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
|
+
`;
|