@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.
@@ -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: CAPABILITY_MAP, capabilityCatalogRevision,
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 }, maxTokens: model.maxOutput,
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 capabilities = new PersonCapabilities(this.repository, episode.ownerId);
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
- await this.repository.append(episode, 'capability_started', { callId, capability: invocation, access: 'read-only' });
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
- capabilityResult = await abortable(capabilities.execute(invocation, { signal }), signal);
162
- await this.repository.append(episode, 'capability_result', { callId, capability: invocation, result: capabilityResult });
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 dates = new Set(['createdAt', 'updatedAt', 'endedAt', 'leaseUntil', 'callFinalizeUntil']);
14
- const decode = row => row ? JSON.parse(row.record, (key, value) => dates.has(key) && typeof value === 'string' ? new Date(value) : value) : null;
15
+ const 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).map(decode);
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
- const p = this.own(episode); p.writeSerial++; this.put('persons', p);
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), state: publicDoc(state), messages: this.rows('messages', scope, ' ORDER BY seq DESC LIMIT 12').reverse().map(publicDoc),
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) {