@yeaft/webchat-agent 1.0.588 → 1.0.590
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/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +104 -104
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +1 -1
- package/package.json +2 -1
- package/yeaft/person/capabilities.js +145 -21
- package/yeaft/person/capability-experience.js +60 -0
- package/yeaft/person/contracts.js +6 -4
- package/yeaft/person/created-capability-contract.js +80 -0
- package/yeaft/person/repository.js +81 -11
- package/yeaft/person/runtime.js +33 -11
- package/yeaft/person/script-executor.js +88 -0
- package/yeaft/person/script-worker.js +77 -0
- package/yeaft/person/sqlite-repository.js +3 -0
- package/yeaft/person/sqlite-schema.js +17 -0
- package/yeaft/person/sqlite-store.js +53 -8
package/yeaft/person/runtime.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
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
|
|
|
@@ -7,7 +7,7 @@ const messageRef = m => `message:${m.id}:${m.revision}`;
|
|
|
7
7
|
const conceptRef = c => `concept:${c.id}:${c.revision}`;
|
|
8
8
|
|
|
9
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 = [] }) {
|
|
10
|
+
export function assembleContext({ snapshot, episode, provider, selection, previous, capabilityResult, remainingCalls, dependencyRefs = [], activeCapabilities = foundationCapabilities(), capabilityMap = CAPABILITY_MAP }) {
|
|
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,7 +17,7 @@ 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:
|
|
20
|
+
capabilities: { ...capabilityMap, active: [] }, capabilityCatalogRevision: capabilityMap.revision ?? capabilityCatalogRevision,
|
|
21
21
|
budget: { remainingCalls, maxOutputBytes: LIMITS.outputBytes },
|
|
22
22
|
previousProposal: previous ?? null, capabilityResult: capabilityResult ?? null,
|
|
23
23
|
messages: [], concepts: [], sourceRefs: [triggerRef], inheritedSourceRefs: dependencyRefs,
|
|
@@ -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: capabilityMap.revision ?? 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,8 @@ 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
|
|
108
|
+
const created = await abortable(this.repository.createdCapabilities(episode), signal);
|
|
109
|
+
const capabilities = new PersonCapabilities(this.repository, episode.ownerId, { experience: snapshot.capabilityExperience, triggerKind: episode.kind, created, episode });
|
|
99
110
|
let previous = null, capabilityResult = null, dependencyRefs = [];
|
|
100
111
|
// Validation retains actual reads across calls, independently of the bounded rendered request.
|
|
101
112
|
// Candidate proposals never enter this read-set or establish new provenance.
|
|
@@ -103,7 +114,8 @@ export class PersonRuntime {
|
|
|
103
114
|
for (let index = 0; index < episode.budget.calls; index++) {
|
|
104
115
|
signal.throwIfAborted();
|
|
105
116
|
const callId = randomUUID();
|
|
106
|
-
const context = assembleContext({ snapshot, episode, provider, selection, previous, capabilityResult, dependencyRefs, remainingCalls: episode.budget.calls - index });
|
|
117
|
+
const context = assembleContext({ snapshot, episode, provider, selection, previous, capabilityResult, dependencyRefs, remainingCalls: episode.budget.calls - index, activeCapabilities: capabilities.context(), capabilityMap: capabilities.catalog() });
|
|
118
|
+
capabilities.activate(context.activeCapabilities);
|
|
107
119
|
dependencyRefs = context.manifest.inputDependencyRefs;
|
|
108
120
|
for (const [id, concept] of context.concepts) readConcepts.set(id, concept);
|
|
109
121
|
for (const [ref, source] of context.sources) readSources.set(ref, source);
|
|
@@ -155,14 +167,24 @@ export class PersonRuntime {
|
|
|
155
167
|
capabilityResult = null;
|
|
156
168
|
if (proposal.next.capability) {
|
|
157
169
|
const invocation = proposal.next.capability;
|
|
158
|
-
|
|
170
|
+
const manifest = capabilities.executionManifest(invocation.id);
|
|
171
|
+
const execution = manifest ? { capabilityManifest: manifest } : {};
|
|
172
|
+
await this.repository.append(episode, 'capability_started', { callId, capability: invocation, ...execution, access: capabilities.active.get(invocation.id)?.access ?? 'catalog-read' });
|
|
159
173
|
try {
|
|
160
174
|
signal.throwIfAborted();
|
|
161
|
-
|
|
162
|
-
|
|
175
|
+
// Script cancellation joins its worker before runtime.close can finish.
|
|
176
|
+
const executionPromise = capabilities.execute(invocation, { signal, callId });
|
|
177
|
+
capabilityResult = invocation.id === 'Capability.create' || invocation.id.startsWith('Script.')
|
|
178
|
+
? await executionPromise : await abortable(executionPromise, signal);
|
|
179
|
+
signal.throwIfAborted();
|
|
180
|
+
if (capabilityResult?.ok === false) {
|
|
181
|
+
await this.repository.append(episode, 'capability_failed', { callId, capabilityId: invocation.id, ...execution, code: capabilityResult.code, result: capabilityResult });
|
|
182
|
+
} else {
|
|
183
|
+
await this.repository.append(episode, 'capability_result', { callId, capability: invocation, ...execution, result: capabilityResult });
|
|
184
|
+
}
|
|
163
185
|
} catch (error) {
|
|
164
186
|
const safe = safeError(error, 'UNSUPPORTED');
|
|
165
|
-
await this.repository.append(episode, 'capability_failed', { callId, capabilityId: invocation.id, code: safe.code }).catch(() => {});
|
|
187
|
+
await this.repository.append(episode, 'capability_failed', { callId, capabilityId: invocation.id, ...execution, code: safe.code }).catch(() => {});
|
|
166
188
|
throw safe;
|
|
167
189
|
}
|
|
168
190
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { Worker } from 'node:worker_threads';
|
|
2
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
3
|
+
import { bytes, fail } from './contracts.js';
|
|
4
|
+
|
|
5
|
+
const MAX_WORKERS = 4;
|
|
6
|
+
let active = 0;
|
|
7
|
+
|
|
8
|
+
/** JSON-only values, bounded before crossing the guest boundary. */
|
|
9
|
+
export function scriptInput(value) {
|
|
10
|
+
let remaining = 8192;
|
|
11
|
+
const ancestors = new Set();
|
|
12
|
+
const spend = n => { remaining -= n; if (remaining < 0) fail('INVALID_REQUEST'); };
|
|
13
|
+
function visit(v, depth) {
|
|
14
|
+
if (depth > 32) fail('INVALID_REQUEST');
|
|
15
|
+
if (v === null || typeof v === 'string' || typeof v === 'boolean' || (typeof v === 'number' && Number.isFinite(v))) { spend(bytes(JSON.stringify(v))); return; }
|
|
16
|
+
if (!v || typeof v !== 'object' || ancestors.has(v) || (!Array.isArray(v) && Object.getPrototypeOf(v) !== Object.prototype && Object.getPrototypeOf(v) !== null)) fail('INVALID_REQUEST');
|
|
17
|
+
const array = Array.isArray(v), keys = Object.keys(v);
|
|
18
|
+
if (Reflect.ownKeys(v).length !== keys.length + (array ? 1 : 0) || (array && keys.length !== v.length)) fail('INVALID_REQUEST');
|
|
19
|
+
spend(2 + Math.max(0, keys.length - 1)); ancestors.add(v);
|
|
20
|
+
for (let i = 0; i < keys.length; i++) {
|
|
21
|
+
const key = keys[i], d = Object.getOwnPropertyDescriptor(v, key);
|
|
22
|
+
if (!Object.hasOwn(d, 'value') || (array && key !== String(i))) fail('INVALID_REQUEST');
|
|
23
|
+
if (!array) spend(bytes(JSON.stringify(key)) + 1);
|
|
24
|
+
visit(d.value, depth + 1);
|
|
25
|
+
}
|
|
26
|
+
ancestors.delete(v);
|
|
27
|
+
}
|
|
28
|
+
visit(value, 0);
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Pure JSON -> JSON computation. No permissions can be requested by guest code.
|
|
33
|
+
* Worker is joined on success, failure and cancellation; no user script lives on.
|
|
34
|
+
* This does not claim arbitrary native-code isolation or semantic correctness. */
|
|
35
|
+
export async function runPersonScript(code, input, { signal } = {}) {
|
|
36
|
+
signal?.throwIfAborted();
|
|
37
|
+
if (typeof code !== 'string' || !code.trim() || bytes(code) > 8192) fail('INVALID_REQUEST');
|
|
38
|
+
scriptInput(input);
|
|
39
|
+
if (active >= MAX_WORKERS) return { ok: false, code: 'SCRIPT_BUSY' };
|
|
40
|
+
active++;
|
|
41
|
+
let worker, timer, onAbort;
|
|
42
|
+
try {
|
|
43
|
+
return await new Promise((resolve, reject) => {
|
|
44
|
+
let finished = false;
|
|
45
|
+
const finish = async (result, cancelled = false) => {
|
|
46
|
+
if (finished) return;
|
|
47
|
+
finished = true;
|
|
48
|
+
clearTimeout(timer); signal?.removeEventListener('abort', onAbort);
|
|
49
|
+
await worker?.terminate().catch(() => {});
|
|
50
|
+
if (cancelled) reject(signal.reason); else resolve(result);
|
|
51
|
+
};
|
|
52
|
+
onAbort = () => finish(null, true);
|
|
53
|
+
try {
|
|
54
|
+
worker = new Worker(new URL('./script-worker.js', import.meta.url), {
|
|
55
|
+
workerData: { code, input }, resourceLimits: { maxOldGenerationSizeMb: 64, maxYoungGenerationSizeMb: 16, stackSizeMb: 2 },
|
|
56
|
+
});
|
|
57
|
+
worker.on('message', result => {
|
|
58
|
+
try {
|
|
59
|
+
if (result?.ok === true) scriptInput(result.output);
|
|
60
|
+
else if (result?.ok !== false) throw new Error('Invalid worker result');
|
|
61
|
+
void finish(result);
|
|
62
|
+
} catch { void finish({ ok: false, code: 'SCRIPT_OUTPUT' }); }
|
|
63
|
+
});
|
|
64
|
+
worker.on('error', () => { void finish({ ok: false, code: 'SCRIPT_EXECUTION' }); });
|
|
65
|
+
worker.on('exit', () => { void finish({ ok: false, code: 'SCRIPT_EXECUTION' }); });
|
|
66
|
+
timer = setTimeout(() => { void finish({ ok: false, code: 'SCRIPT_TIMEOUT' }); }, 3000);
|
|
67
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
68
|
+
if (signal?.aborted) onAbort();
|
|
69
|
+
} catch { void finish({ ok: false, code: 'SCRIPT_EXECUTION' }); }
|
|
70
|
+
});
|
|
71
|
+
} finally {
|
|
72
|
+
clearTimeout(timer); signal?.removeEventListener('abort', onAbort);
|
|
73
|
+
active--;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Every case gets a fresh VM; test output is evidence only for the supplied cases. */
|
|
78
|
+
export async function testPersonScript(definition, { signal } = {}) {
|
|
79
|
+
let testsPassed = 0;
|
|
80
|
+
for (const [index, test] of definition.tests.entries()) {
|
|
81
|
+
const result = await runPersonScript(definition.code, test.input, { signal });
|
|
82
|
+
signal?.throwIfAborted();
|
|
83
|
+
if (!result.ok) return { ...result, testsPassed, failedTest: index };
|
|
84
|
+
if (!isDeepStrictEqual(result.output, test.expected)) return { ok: false, code: 'SCRIPT_TEST_FAILED', testsPassed, failedTest: index, actual: result.output };
|
|
85
|
+
testsPassed++;
|
|
86
|
+
}
|
|
87
|
+
return { ok: true, evidence: { engine: 'quickjs', testsPassed, testedAt: new Date().toISOString() } };
|
|
88
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { parentPort, workerData } from 'node:worker_threads';
|
|
2
|
+
import { getQuickJS } from 'quickjs-emscripten';
|
|
3
|
+
|
|
4
|
+
// Separate WASM interpreter, NOT node:vm. No host functions/modules are bound.
|
|
5
|
+
// Parent termination bounds startup, computation, output extraction and cleanup.
|
|
6
|
+
let runtime, context;
|
|
7
|
+
try {
|
|
8
|
+
const engine = await getQuickJS();
|
|
9
|
+
runtime = engine.newRuntime();
|
|
10
|
+
runtime.setMemoryLimit(16 * 1024 * 1024);
|
|
11
|
+
runtime.setMaxStackSize(256 * 1024);
|
|
12
|
+
const deadline = Date.now() + 200;
|
|
13
|
+
runtime.setInterruptHandler(() => Date.now() > deadline);
|
|
14
|
+
context = runtime.newContext();
|
|
15
|
+
const { code, input } = workerData;
|
|
16
|
+
// Capture intrinsics before executing generated code; Function compiles ONLY a
|
|
17
|
+
// function body and has no access to this validation closure. Neither raw code
|
|
18
|
+
// interpolation nor mutable guest serializers can redefine the output boundary.
|
|
19
|
+
const source = `(() => {
|
|
20
|
+
'use strict';
|
|
21
|
+
const compile = Function, parse = JSON.parse, stringify = JSON.stringify;
|
|
22
|
+
const isArray = Array.isArray, finite = Number.isFinite;
|
|
23
|
+
const proto = Object.getPrototypeOf, objectProto = Object.prototype;
|
|
24
|
+
const descriptor = Object.getOwnPropertyDescriptor, keys = Reflect.ownKeys;
|
|
25
|
+
const create = Object.create, setProto = Object.setPrototypeOf, ErrorType = Error;
|
|
26
|
+
const input = parse(${JSON.stringify(JSON.stringify(input))});
|
|
27
|
+
const fn = compile('input', ${JSON.stringify(`'use strict';\n${code}`)});
|
|
28
|
+
const ancestors = create(null);
|
|
29
|
+
let nodes = 0;
|
|
30
|
+
function snapshot(v, depth) {
|
|
31
|
+
if (++nodes > 8192 || depth > 32) throw new ErrorType('JSON limit');
|
|
32
|
+
if (v === null || typeof v === 'string' || typeof v === 'boolean') return v;
|
|
33
|
+
if (typeof v === 'number' && finite(v)) return v;
|
|
34
|
+
if (typeof v !== 'object') throw new ErrorType('JSON only');
|
|
35
|
+
for (let i = 0; i < depth; i++) if (ancestors[i] === v) throw new ErrorType('JSON cycle');
|
|
36
|
+
const array = isArray(v), p = proto(v);
|
|
37
|
+
if (!array && p !== objectProto && p !== null) throw new ErrorType('JSON object only');
|
|
38
|
+
const names = keys(v);
|
|
39
|
+
const length = array ? descriptor(v, 'length').value : names.length;
|
|
40
|
+
if (length > 8192 || names.length !== length + (array ? 1 : 0)) throw new ErrorType('JSON keys');
|
|
41
|
+
const out = array ? setProto([], null) : create(null);
|
|
42
|
+
ancestors[depth] = v;
|
|
43
|
+
for (let i = 0; i < length; i++) {
|
|
44
|
+
const key = array ? '' + i : names[i];
|
|
45
|
+
if (typeof key !== 'string') throw new ErrorType('JSON key');
|
|
46
|
+
const d = descriptor(v, key);
|
|
47
|
+
if (!d || !d.enumerable || !descriptor(d, 'value')) throw new ErrorType('JSON data only');
|
|
48
|
+
out[key] = snapshot(d.value, depth + 1);
|
|
49
|
+
}
|
|
50
|
+
delete ancestors[depth];
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
const value = snapshot(fn(input), 0);
|
|
54
|
+
// Snapshot has null prototypes and data properties only: no guest getters or
|
|
55
|
+
// inherited toJSON are run during serialization, even after global mutation.
|
|
56
|
+
const json = stringify(value);
|
|
57
|
+
if (typeof json !== 'string' || json.length > 8192) throw new ErrorType('JSON output limit');
|
|
58
|
+
return json;
|
|
59
|
+
})()`;
|
|
60
|
+
const result = context.evalCode(source, 'created-capability.js');
|
|
61
|
+
if (result.error) {
|
|
62
|
+
result.error.dispose();
|
|
63
|
+
parentPort.postMessage({ ok: false, code: 'SCRIPT_EXECUTION' });
|
|
64
|
+
} else {
|
|
65
|
+
const json = context.getString(result.value);
|
|
66
|
+
result.value.dispose();
|
|
67
|
+
if (Buffer.byteLength(json, 'utf8') > 8192) parentPort.postMessage({ ok: false, code: 'SCRIPT_OUTPUT' });
|
|
68
|
+
else parentPort.postMessage({ ok: true, output: JSON.parse(json) });
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
// Guest error strings may contain arbitrary input; fixed codes only cross out.
|
|
72
|
+
parentPort.postMessage({ ok: false, code: 'SCRIPT_EXECUTION' });
|
|
73
|
+
} finally {
|
|
74
|
+
context?.dispose();
|
|
75
|
+
runtime?.dispose();
|
|
76
|
+
parentPort.close();
|
|
77
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Worker } from 'node:worker_threads';
|
|
2
2
|
import { join, resolve } from 'node:path';
|
|
3
3
|
import { digest, fail, identifier, PersonError, text } from './contracts.js';
|
|
4
|
+
import { validateCreatedCapability } from './created-capability-contract.js';
|
|
4
5
|
|
|
5
6
|
const storageError = code => {
|
|
6
7
|
const error = new PersonError(code);
|
|
@@ -91,6 +92,8 @@ export class SqlitePersonRepository {
|
|
|
91
92
|
async append(episode, kind, data) { return this.request('append', [episode, kind, data]); }
|
|
92
93
|
async startCall(episode, data) { return this.request('startCall', [episode, data]); }
|
|
93
94
|
async finalizeCall(episode, data) { return this.request('finalizeCall', [episode, data]); }
|
|
95
|
+
async createdCapabilities(episode) { return this.request('createdCapabilities', [episode]); }
|
|
96
|
+
async saveCreatedCapability(episode, input) { return this.request('saveCreatedCapability', [episode, validateCreatedCapability(input)]); }
|
|
94
97
|
async context(episode) { return this.request('context', [episode]); }
|
|
95
98
|
async recall(ownerId, options) { return this.request('recall', [ownerId, options]); }
|
|
96
99
|
async commit(episode, proposal, selection, callId, reportedSources = new Map()) { return this.request('commit', [episode, proposal, selection, callId, reportedSources]); }
|
|
@@ -8,6 +8,8 @@ export const TABLES = Object.freeze({
|
|
|
8
8
|
concept_revisions: { keys: ['id', 'revision'], columns: [] },
|
|
9
9
|
state_commits: { keys: ['version'], columns: ['id'] },
|
|
10
10
|
traces: { keys: ['id'], columns: ['seq'] },
|
|
11
|
+
created_capabilities: { keys: ['id'], columns: ['version'] },
|
|
12
|
+
created_capability_revisions: { keys: ['id', 'version'], columns: [] },
|
|
11
13
|
});
|
|
12
14
|
|
|
13
15
|
export const SCHEMA = `
|
|
@@ -66,6 +68,21 @@ CREATE TABLE IF NOT EXISTS traces (
|
|
|
66
68
|
PRIMARY KEY(namespace, ownerId, personId, id), UNIQUE(namespace, ownerId, personId, seq),
|
|
67
69
|
FOREIGN KEY(namespace, ownerId, personId) REFERENCES persons(namespace, ownerId, personId)
|
|
68
70
|
) STRICT;
|
|
71
|
+
CREATE INDEX IF NOT EXISTS traces_call_output ON traces(namespace, ownerId, personId,
|
|
72
|
+
json_extract(record, '$.episodeId'), json_extract(record, '$.callId'))
|
|
73
|
+
WHERE json_extract(record, '$.kind') = 'call_output';
|
|
74
|
+
CREATE TABLE IF NOT EXISTS created_capabilities (
|
|
75
|
+
namespace TEXT NOT NULL, ownerId TEXT NOT NULL, personId TEXT NOT NULL,
|
|
76
|
+
id TEXT NOT NULL, version INTEGER NOT NULL CHECK(version BETWEEN 1 AND 32),
|
|
77
|
+
record TEXT NOT NULL CHECK(json_valid(record)), PRIMARY KEY(namespace, ownerId, personId, id),
|
|
78
|
+
FOREIGN KEY(namespace, ownerId, personId) REFERENCES persons(namespace, ownerId, personId)
|
|
79
|
+
) STRICT;
|
|
80
|
+
CREATE TABLE IF NOT EXISTS created_capability_revisions (
|
|
81
|
+
namespace TEXT NOT NULL, ownerId TEXT NOT NULL, personId TEXT NOT NULL,
|
|
82
|
+
id TEXT NOT NULL, version INTEGER NOT NULL CHECK(version BETWEEN 1 AND 32),
|
|
83
|
+
record TEXT NOT NULL CHECK(json_valid(record)), PRIMARY KEY(namespace, ownerId, personId, id, version),
|
|
84
|
+
FOREIGN KEY(namespace, ownerId, personId) REFERENCES persons(namespace, ownerId, personId)
|
|
85
|
+
) STRICT;
|
|
69
86
|
-- This journal is authority data, not a disposable search index. A committed seq is
|
|
70
87
|
-- never reused, including after reopening; gaps between owners are intentional.
|
|
71
88
|
CREATE TABLE IF NOT EXISTS memory_changes (
|
|
@@ -4,14 +4,28 @@ import { dirname } from 'node:path';
|
|
|
4
4
|
import { randomUUID } from 'node:crypto';
|
|
5
5
|
import { isMainThread } from 'node:worker_threads';
|
|
6
6
|
import { bytes, digest, fail, identifier, LIMITS, PersonError, text } from './contracts.js';
|
|
7
|
+
import { CREATED_CAPABILITY_LIMITS, createdCapabilityRecord, validateCreatedCapability } from './created-capability-contract.js';
|
|
7
8
|
import { SCHEMA, TABLES } from './sqlite-schema.js';
|
|
9
|
+
import { capabilityExperienceView, recordCapabilityExperience } from './capability-experience.js';
|
|
8
10
|
|
|
9
11
|
const SCOPE = 'namespace = ? AND ownerId = ? AND personId = ?';
|
|
10
12
|
const scopeValues = s => [s.namespace, s.ownerId, s.personId];
|
|
11
13
|
// Query-only projections; record retains the original public Unicode text.
|
|
12
14
|
const SEARCH_COLUMNS = Object.freeze({ messages: 'text', concepts: 'statement' });
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
+
const metadataDates = Object.freeze({
|
|
16
|
+
persons: ['createdAt', 'leaseUntil'], episodes: ['createdAt', 'endedAt', 'callFinalizeUntil'],
|
|
17
|
+
messages: ['createdAt'], states: ['updatedAt'], concepts: ['updatedAt'], concept_revisions: ['updatedAt'],
|
|
18
|
+
state_commits: ['createdAt'], traces: ['createdAt'],
|
|
19
|
+
});
|
|
20
|
+
const decode = (row, table) => {
|
|
21
|
+
if (!row) return null;
|
|
22
|
+
const record = JSON.parse(row.record);
|
|
23
|
+
// Only known storage metadata is a Date. Trace payloads and created-definition
|
|
24
|
+
// JSON may use these names at any depth without changing their string values.
|
|
25
|
+
for (const key of metadataDates[table] ?? []) if (typeof record[key] === 'string') record[key] = new Date(record[key]);
|
|
26
|
+
if (table === 'state_commits' && typeof record.state?.updatedAt === 'string') record.state.updatedAt = new Date(record.state.updatedAt);
|
|
27
|
+
return record;
|
|
28
|
+
};
|
|
15
29
|
const publicDoc = doc => {
|
|
16
30
|
if (!doc) return null;
|
|
17
31
|
const { _id, ownerId, namespace, personId, ...rest } = doc;
|
|
@@ -31,8 +45,8 @@ const publicOutput = (output, failed) => {
|
|
|
31
45
|
return failed ? { text: output.text, retainedBytes, observedBytes, complete: false, accepted: false, availability: 'captured', usage, stopReason }
|
|
32
46
|
: { text: output.text, bytes: retainedBytes, complete: true, usage, stopReason };
|
|
33
47
|
};
|
|
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']);
|
|
48
|
+
const READS = new Set(['getPerson', 'context', 'recall', 'list', 'searchChanges', 'resolveMemories', 'createdCapabilities']);
|
|
49
|
+
const WRITES = new Set(['open', 'recover', 'admit', 'heartbeat', 'append', 'startCall', 'finalizeCall', 'commit', 'finish', 'cancel', 'settings', 'snapshot', 'saveCreatedCapability']);
|
|
36
50
|
const memoryKind = kind => { if (!['messages', 'concepts'].includes(kind)) fail('INVALID_REQUEST'); return kind; };
|
|
37
51
|
const boundedLimit = (limit, max = 100) => { if (!Number.isSafeInteger(limit) || limit < 1 || limit > max) fail('INVALID_REQUEST'); return limit; };
|
|
38
52
|
const sequence = value => {
|
|
@@ -100,7 +114,8 @@ export class SqlitePersonStore {
|
|
|
100
114
|
// All clauses at call sites below are fixed application strings; values are bound.
|
|
101
115
|
rows(table, scope, clause = '', params = []) {
|
|
102
116
|
this.table(table);
|
|
103
|
-
return this.sql(`SELECT record FROM ${table} WHERE ${SCOPE}${clause}`).all(...scopeValues(scope), ...params)
|
|
117
|
+
return this.sql(`SELECT record FROM ${table} WHERE ${SCOPE}${clause}`).all(...scopeValues(scope), ...params)
|
|
118
|
+
.map(row => decode(row, table));
|
|
104
119
|
}
|
|
105
120
|
one(table, scope, clause = '', params = []) { return this.rows(table, scope, `${clause} LIMIT 1`, params)[0] ?? null; }
|
|
106
121
|
put(table, record, insert = false) {
|
|
@@ -219,7 +234,14 @@ export class SqlitePersonStore {
|
|
|
219
234
|
const p = this.own(episode); p.leaseUntil = new Date(this.now.getTime() + this.leaseMs); p.writeSerial++; this.put('persons', p);
|
|
220
235
|
}
|
|
221
236
|
append(episode, kind, data) {
|
|
222
|
-
|
|
237
|
+
// Call proof/publication events are emitted only by their transactional methods.
|
|
238
|
+
if (['call_started', 'call_output', 'call_failed', 'capability_created'].includes(kind)) fail('INVALID_REQUEST');
|
|
239
|
+
const p = this.own(episode);
|
|
240
|
+
const record = this.one('episodes', this.episodeScope(episode), ' AND id = ?', [episode.id]);
|
|
241
|
+
if (!record || record.status !== 'running') fail('STALE');
|
|
242
|
+
const experience = recordCapabilityExperience(p.capabilityExperience, record, kind, data, this.now.toISOString());
|
|
243
|
+
if (experience) p.capabilityExperience = experience;
|
|
244
|
+
p.writeSerial++; this.put('persons', p);
|
|
223
245
|
return this.trace(episode.ownerId, episode.id, kind, data);
|
|
224
246
|
}
|
|
225
247
|
startCall(episode, data) {
|
|
@@ -249,6 +271,28 @@ export class SqlitePersonStore {
|
|
|
249
271
|
});
|
|
250
272
|
return live;
|
|
251
273
|
}
|
|
274
|
+
createdCapabilities(episode) {
|
|
275
|
+
this.own(episode);
|
|
276
|
+
return this.rows('created_capabilities', this.episodeScope(episode), ' ORDER BY id ASC LIMIT 32').map(publicDoc);
|
|
277
|
+
}
|
|
278
|
+
saveCreatedCapability(episode, input) {
|
|
279
|
+
const { definition, evidence, callId } = validateCreatedCapability(input);
|
|
280
|
+
this.own(episode);
|
|
281
|
+
const scope = this.episodeScope(episode), active = this.one('episodes', scope, ' AND id = ?', [episode.id]);
|
|
282
|
+
if (!active || active.status !== 'running' || active.epoch !== episode.epoch || active.workerId !== episode.workerId) fail('STALE');
|
|
283
|
+
const proof = this.one('traces', scope, " AND json_extract(record, '$.episodeId') = ? AND json_extract(record, '$.kind') = 'call_output' AND json_extract(record, '$.callId') = ? AND json_extract(record, '$.output.complete') = 1", [episode.id, callId]);
|
|
284
|
+
if (!proof || active.openCall?.callId === callId) fail('INVALID_REQUEST');
|
|
285
|
+
const current = this.one('created_capabilities', scope, ' AND id = ?', [definition.id]);
|
|
286
|
+
if ((current?.version ?? 0) !== definition.expectedVersion) fail('STALE');
|
|
287
|
+
if (definition.expectedVersion >= CREATED_CAPABILITY_LIMITS.versions || (!current &&
|
|
288
|
+
this.sql(`SELECT count(*) AS n FROM created_capabilities WHERE ${SCOPE}`).get(...scopeValues(scope)).n >= CREATED_CAPABILITY_LIMITS.ids)) fail('CONTEXT_LIMIT');
|
|
289
|
+
const record = this.doc(scope, createdCapabilityRecord(definition, evidence, { episode, callId, now: this.now }));
|
|
290
|
+
this.put('created_capability_revisions', record, true);
|
|
291
|
+
this.put('created_capabilities', record);
|
|
292
|
+
this.trace(episode.ownerId, episode.id, 'capability_created', { callId, capabilityId: record.id,
|
|
293
|
+
capabilityManifest: { id: record.id, version: record.version, revision: record.revision }, evidence: record.evidence });
|
|
294
|
+
return publicDoc(record);
|
|
295
|
+
}
|
|
252
296
|
focused(scope, ids) {
|
|
253
297
|
return ids.map(id => this.one('concepts', scope, ' AND id = ?', [id])).filter(Boolean);
|
|
254
298
|
}
|
|
@@ -256,7 +300,8 @@ export class SqlitePersonStore {
|
|
|
256
300
|
const p = this.own(episode), scope = this.episodeScope(episode), state = this.one('states', scope);
|
|
257
301
|
const focused = this.focused(scope, state.focusConceptIds).slice(0, 12);
|
|
258
302
|
const recent = this.rows('concepts', scope, ' ORDER BY updatedAt DESC, id ASC LIMIT 12');
|
|
259
|
-
return { person: this.personView(p),
|
|
303
|
+
return { person: this.personView(p), capabilityExperience: capabilityExperienceView(p.capabilityExperience),
|
|
304
|
+
state: publicDoc(state), messages: this.rows('messages', scope, ' ORDER BY seq DESC LIMIT 12').reverse().map(publicDoc),
|
|
260
305
|
concepts: [...new Map([...focused, ...recent].map(c => [c.id, publicDoc(c)])).values()] };
|
|
261
306
|
}
|
|
262
307
|
commit(episode, proposal, selection, callId, reportedSources = new Map()) {
|
|
@@ -359,7 +404,7 @@ export class SqlitePersonStore {
|
|
|
359
404
|
this.getPerson(ownerId); after = sequence(after); boundedLimit(limit, 1000);
|
|
360
405
|
const rows = this.sql(`SELECT seq, kind, id, revision, record FROM memory_changes WHERE ${SCOPE} AND seq > ? ORDER BY seq ASC LIMIT ?`)
|
|
361
406
|
.all(...scopeValues(this.scope(ownerId)), after, limit + 1);
|
|
362
|
-
const items = rows.slice(0, limit).map(row => ({ ...row, record: decode(row) }));
|
|
407
|
+
const items = rows.slice(0, limit).map(row => ({ ...row, record: decode(row, row.kind) }));
|
|
363
408
|
return { items, lastSeq: items.at(-1)?.seq ?? after, hasMore: rows.length > limit };
|
|
364
409
|
}
|
|
365
410
|
resolveMemories(ownerId, kind, refs) {
|