@yeaft/webchat-agent 1.0.588 → 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.
@@ -1 +1 @@
1
- {"version":"1.0.588"}
1
+ {"version":"1.0.589"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.588",
3
+ "version": "1.0.589",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -1,44 +1,117 @@
1
1
  import { bytes, digest, fail, identifier, object, page, text } from './contracts.js';
2
2
 
3
- // Versioned read-only methods, not the Agent's full registry. No shell/filesystem/VP side effects.
3
+ // Trusted versioned read-only methods, not the Agent's full registry. No shell/filesystem/VP side effects.
4
4
  const entries = [
5
- { id: 'Think', version: 1, domain: 'cognition', description: 'Intrinsic deliberate reflection, reorganization, association and reconsideration.', instructions: 'Revisit supplied experiences and current judgments; separate report from hypothesis, propose new concepts or associations, test a counterexample, and record the explicit finding with uncertainty. It is fine to rest. This is a method, not another model call or external action.', args: {} },
6
- { id: 'Recall', version: 1, domain: 'memory', description: 'Recall a bounded page of your own long-term messages or concepts.', instructions: 'args: {kind:"messages"|"concepts",query?:string,cursor?:string|null,limit?:1..5}. SQLite uses local hybrid keyword/semantic recall when available; inspect retrieval mode, coverage and degradation. Legacy storage may use literal matching. Results are not exhaustive or authorization. Preserve nextCursor for the same query; local ranking cursors expire after 5 minutes or restart.', args: { kind: 'messages|concepts', query: 'optional memory search text', cursor: 'optional opaque cursor', limit: '1..5' } },
7
- { id: 'Skill.reconsider', version: 1, domain: 'method', description: 'Read-only method for revising a previous judgment.', instructions: 'Identify one prior claim, list a concrete counterexample or missing evidence, and decide whether to retain, qualify or revise it. Mark what remains unresolved. Do not present the counterexample as an observed event without evidence.', args: {} },
8
- { id: 'Skill.associate', version: 1, domain: 'method', description: 'Read-only method for new conceptual associations.', instructions: 'Compare two recalled concepts or experiences. Propose a typed relation and a new question or hypothetical scenario. Co-occurrence is not causation; imagination is not an experience. Keep useful disagreement instead of forcing agreement.', args: {} },
5
+ { id: 'Think', version: 1, domain: 'cognition', description: 'Intrinsic deliberate reflection, reorganization, association and reconsideration.', keywords: '思考 整理 反思 think reflection', useWhen: 'Think with the evidence already present, or rest when nothing warrants further work.', avoidWhen: 'Do not invent evidence or call a tool merely to appear thoughtful.', instructions: 'Revisit supplied experiences and current judgments; separate report from hypothesis, propose new concepts or associations, test a counterexample, and record the explicit finding with uncertainty. It is fine to rest. This is a method, not another model call or external action.', args: {} },
6
+ { id: 'Recall', version: 1, domain: 'memory', description: 'Recall a bounded page of your own long-term messages or concepts.', keywords: '回忆 记忆 召回 历史 查找 搜索 memory recall history search', useWhen: 'Missing prior words, experiences or concepts could change your present understanding.', avoidWhen: 'Current evidence is sufficient; a similar memory cannot grant permission or establish truth.', instructions: 'args: {kind:"messages"|"concepts",query?:string,cursor?:string|null,limit?:1..5}. SQLite uses local hybrid keyword/semantic recall when available; inspect retrieval mode, coverage and degradation. Legacy storage may use literal matching. Results are not exhaustive or authorization. Preserve nextCursor for the same query; local ranking cursors expire after 5 minutes or restart.', args: { kind: 'messages|concepts', query: 'optional memory search text', cursor: 'optional opaque cursor', limit: '1..5' } },
7
+ { id: 'Skill.reconsider', version: 1, domain: 'method', description: 'Read-only method for revising a previous judgment.', keywords: '反思 复核 反例 修正 重新审视 reconsider revise counterexample', useWhen: 'A prior judgment meets conflicting evidence or may have overlooked an alternative.', avoidWhen: 'Do not force a revision or treat a hypothetical counterexample as observed evidence.', instructions: 'Identify one prior claim, list a concrete counterexample or missing evidence, and decide whether to retain, qualify or revise it. Mark what remains unresolved. Do not present the counterexample as an observed event without evidence.', args: {} },
8
+ { id: 'Skill.associate', version: 1, domain: 'method', description: 'Read-only method for new conceptual associations.', keywords: '联想 关联 遐想 想象 associate association imagine dream', useWhen: 'Explore a possible relation between experiences or concepts and preserve it as a question or hypothesis.', avoidWhen: 'Do not equate similarity or co-occurrence with causation or verified experience.', instructions: 'Compare two recalled concepts or experiences. Propose a typed relation and a new question or hypothetical scenario. Co-occurrence is not causation; imagination is not an experience. Keep useful disagreement instead of forcing agreement.', args: {} },
9
9
  ];
10
+ const manifests = entries.map(entry => {
11
+ const contract = { ...entry, access: 'read-only', dependencies: [] };
12
+ return { ...contract, revision: digest(contract) };
13
+ });
14
+ const byId = new Map(manifests.map(manifest => [manifest.id, manifest]));
15
+ const foundationIds = new Set(['Think', 'Recall']);
16
+ export const CAPABILITY_LIMITS = Object.freeze({ familiar: 2, familiarMaxAgeMs: 30 * 24 * 60 * 60 * 1000, activeBytes: 8192, searchContractBytes: 4096 });
10
17
  export const CAPABILITY_MAP = Object.freeze({
11
18
  domains: ['cognition', 'memory', 'method'], total: entries.length,
12
- discover: { id: 'catalog.search', args: '{query?:string,cursor?:string|null,limit?:1..5}', description: 'Browse/search bounded capability summaries. All entries are reachable by pagination.' },
13
- inspect: { id: 'catalog.view', args: '{id:string}', description: 'Load a selected versioned manifest; only then use that read-only capability.' },
19
+ discover: { id: 'catalog.search', args: '{query?:string,cursor?:string|null,limit?:1..5}', description: 'Browse/search summaries with budgeted complete contracts. Returned contracts are prepared for the next call; omitted contracts require catalog.view. All entries remain reachable by pagination.' },
20
+ inspect: { id: 'catalog.view', args: '{id:string}', description: 'Load a known ID directly, or a contract omitted by search. No search is required first.' },
14
21
  intrinsic: 'Think is always available as a cognitive activity; no tool call is required.',
15
22
  });
16
- export const catalogRevision = digest(entries);
23
+ export const catalogRevision = digest(manifests);
24
+ const copy = value => structuredClone(value);
25
+ const available = (manifest, layer, reason, experience) => ({ ...copy(manifest), availability: { layer, reason, ...(experience ? { experience } : {}) } });
26
+ export const foundationCapabilities = () => manifests.filter(m => foundationIds.has(m.id)).map(m => available(m, 'foundation', 'general-cognitive-ability'));
27
+
28
+ /** A bounded procedural familiarity projection, never a stored contract or authorization.
29
+ * Only execution observations for this Person arrive from the fenced repository snapshot.
30
+ * Trigger kind is a coarse preference, not a semantic classifier or a compulsory workflow. */
31
+ function familiarCapabilities(experience, triggerKind, now) {
32
+ const candidates = [];
33
+ for (const item of Array.isArray(experience) ? experience.slice(0, 16) : []) {
34
+ const manifest = byId.get(item?.id);
35
+ if (!manifest || foundationIds.has(item.id) || item.version !== manifest.version || item.revision !== manifest.revision) continue;
36
+ const observations = (Array.isArray(item.observations) ? item.observations.slice(0, 8) : [])
37
+ .filter(o => ['succeeded', 'failed'].includes(o?.outcome) && ['send', 'think', 'dream'].includes(o.triggerKind) && Number.isFinite(Date.parse(o.usedAt)))
38
+ .sort((a, b) => Date.parse(b.usedAt) - Date.parse(a.usedAt));
39
+ const latest = observations[0], age = now - Date.parse(latest?.usedAt);
40
+ if (!latest || latest.outcome !== 'succeeded' || age < 0 || age > CAPABILITY_LIMITS.familiarMaxAgeMs) continue;
41
+ const matching = observations.filter(o => o.triggerKind === triggerKind && now - Date.parse(o.usedAt) >= 0 && now - Date.parse(o.usedAt) <= CAPABILITY_LIMITS.familiarMaxAgeMs);
42
+ candidates.push({ manifest, latest, matching: matching.length, experience: {
43
+ lastUsedAt: latest.usedAt, lastOutcome: latest.outcome,
44
+ observedSuccesses: observations.filter(o => o.outcome === 'succeeded').length,
45
+ observedFailures: observations.filter(o => o.outcome === 'failed').length,
46
+ sameTriggerObservations: matching.length, usefulness: 'not-evaluated',
47
+ } });
48
+ }
49
+ return candidates.sort((a, b) => b.matching - a.matching || Date.parse(b.latest.usedAt) - Date.parse(a.latest.usedAt) || a.manifest.id.localeCompare(b.manifest.id, 'en'))
50
+ .slice(0, CAPABILITY_LIMITS.familiar).map(c => available(c.manifest, 'familiar', c.matching ? 'used-in-same-trigger-kind' : 'recently-used', c.experience));
51
+ }
52
+
17
53
  export class PersonCapabilities {
18
- constructor(repository, ownerId) { this.repository = repository; this.ownerId = ownerId; this.loaded = new Set(['Think']); }
54
+ constructor(repository, ownerId, { experience = [], triggerKind = 'think', now = Date.now() } = {}) {
55
+ this.repository = repository; this.ownerId = ownerId;
56
+ this.prepared = new Map([...foundationCapabilities(), ...familiarCapabilities(experience, triggerKind, now)].map(m => [m.id, m]));
57
+ this.active = new Map(this.prepared);
58
+ }
59
+ context() { return copy([...this.prepared.values()]); }
60
+ /** Freeze execution eligibility to the complete contracts actually rendered in this call. */
61
+ activate(rendered) {
62
+ this.active = new Map();
63
+ for (const item of rendered) {
64
+ const current = byId.get(item.id);
65
+ if (current && current.revision === item.revision && this.prepared.has(item.id)) this.active.set(item.id, current);
66
+ }
67
+ }
68
+ executionManifest(id) {
69
+ const manifest = this.active.get(id), current = byId.get(id);
70
+ if (!manifest || manifest.revision !== current?.revision) return null;
71
+ return { id: current.id, version: current.version, revision: current.revision };
72
+ }
73
+ prepare(manifest) {
74
+ // A bounded working set. Explicitly requested contracts precede older optional ones.
75
+ const item = available(manifest, foundationIds.has(manifest.id) ? 'foundation' : 'discovered', 'explicitly-requested');
76
+ const next = [item, ...this.prepared.values()].filter((m, i, all) => all.findIndex(n => n.id === m.id) === i);
77
+ const kept = next.filter(m => foundationIds.has(m.id));
78
+ for (const candidate of next.filter(m => !foundationIds.has(m.id))) {
79
+ if (bytes([...kept, candidate]) <= CAPABILITY_LIMITS.activeBytes) kept.push(candidate);
80
+ }
81
+ this.prepared = new Map(kept.map(m => [m.id, m]));
82
+ // Runtime replaces this with its rendered manifest before every cognitive dispatch.
83
+ this.activate(kept);
84
+ }
19
85
  async execute({ id, args }, { signal } = {}) {
20
86
  signal?.throwIfAborted();
21
87
  if (id === 'catalog.search') {
22
88
  object(args, ['query', 'cursor', 'limit'], []);
23
- const query = text(args.query ?? '', 200, true).toLowerCase();
89
+ const query = text(args.query ?? '', 200, true).toLowerCase().trim();
24
90
  const limit = args.limit ?? 3;
25
91
  if (!Number.isInteger(limit) || limit < 1 || limit > 5) fail('INVALID_REQUEST');
26
92
  if (args.cursor != null) identifier(args.cursor);
27
- const matches = entries.filter(e => `${e.id} ${e.domain} ${e.description}`.toLowerCase().includes(query)).sort((a, b) => a.id.localeCompare(b.id, 'en'));
93
+ const terms = query.split(/\s+/u).filter(Boolean);
94
+ const matches = manifests.filter(e => terms.every(term => `${e.id} ${e.domain} ${e.description} ${e.keywords} ${e.useWhen}`.toLowerCase().includes(term))).sort((a, b) => a.id.localeCompare(b.id, 'en'));
28
95
  const start = args.cursor == null ? 0 : matches.findIndex(e => e.id === args.cursor) + 1;
29
96
  if (args.cursor != null && start === 0) fail('INVALID_REQUEST');
30
- const items = matches.slice(start, start + limit).map(({ instructions, args: schema, ...entry }) => entry);
31
- return { items, nextCursor: start + limit < matches.length ? items.at(-1).id : null, catalogRevision };
97
+ const contracts = [], omittedContracts = [];
98
+ const items = matches.slice(start, start + limit).map(manifest => {
99
+ if (bytes([...contracts, manifest]) <= CAPABILITY_LIMITS.searchContractBytes) { contracts.push(copy(manifest)); this.prepare(manifest); }
100
+ else omittedContracts.push({ id: manifest.id, reason: 'contract-budget', inspect: 'catalog.view' });
101
+ const { instructions, args: schema, dependencies, keywords, ...summary } = manifest;
102
+ return summary;
103
+ });
104
+ return { items, contracts, omittedContracts, nextCursor: start + limit < matches.length ? items.at(-1).id : null, catalogRevision };
32
105
  }
33
106
  if (id === 'catalog.view') {
34
107
  object(args, ['id']); identifier(args.id);
35
- const entry = entries.find(e => e.id === args.id);
108
+ const entry = byId.get(args.id);
36
109
  if (!entry) fail('UNSUPPORTED');
37
- this.loaded.add(entry.id);
38
- return { ...entry, catalogRevision, access: 'read-only', dependencies: [] };
110
+ this.prepare(entry);
111
+ return { ...copy(entry), catalogRevision };
39
112
  }
40
- const entry = entries.find(e => e.id === id);
41
- if (!entry || !this.loaded.has(id)) fail('UNSUPPORTED');
113
+ const entry = byId.get(id);
114
+ if (!entry || !this.executionManifest(id)) fail('UNSUPPORTED');
42
115
  if (id === 'Recall') {
43
116
  object(args, ['kind', 'query', 'cursor', 'limit'], ['kind']);
44
117
  if (!['messages', 'concepts'].includes(args.kind)) fail('INVALID_REQUEST');
@@ -49,7 +122,7 @@ export class PersonCapabilities {
49
122
  const cursor = args.kind === 'messages' && !localCursor ? page({ cursor: args.cursor, limit }).cursor : args.cursor;
50
123
  if (cursor != null && (localCursor || args.kind === 'concepts')) identifier(cursor);
51
124
  const result = await this.repository.recall(this.ownerId, { ...args, cursor, limit }, { signal });
52
- // Pages contain complete accepted records; context assembly may omit whole records later.
125
+ signal?.throwIfAborted();
53
126
  if (bytes(result) > 60000) fail('CONTEXT_LIMIT');
54
127
  return { ...result, kind: args.kind, version: entry.version };
55
128
  }
@@ -0,0 +1,60 @@
1
+ import { fail, identifier, object } from './contracts.js';
2
+
3
+ export const CAPABILITY_EXPERIENCE_LIMITS = Object.freeze({ entries: 16, observations: 8 });
4
+ const excluded = new Set(['catalog.search', 'catalog.view', 'Think']);
5
+ const manifestFields = ['id', 'version', 'revision'];
6
+ const observationFields = ['episodeId', 'callId', 'triggerKind', 'outcome', 'code', 'usedAt'];
7
+ const recentFirst = (a, b) => b.usedAt.localeCompare(a.usedAt);
8
+
9
+ function manifest(value) {
10
+ object(value, manifestFields);
11
+ identifier(value.id);
12
+ if (!Number.isSafeInteger(value.version) || value.version < 1 || typeof value.revision !== 'string' || !/^[a-f0-9]{64}$/.test(value.revision)) fail('INVALID_REQUEST');
13
+ return { id: value.id, version: value.version, revision: value.revision };
14
+ }
15
+ function observation(value) {
16
+ object(value, observationFields);
17
+ identifier(value.episodeId); identifier(value.callId);
18
+ if (!['send', 'think', 'dream'].includes(value.triggerKind) || !['succeeded', 'failed'].includes(value.outcome)) fail('INVALID_REQUEST');
19
+ if (value.outcome === 'failed') identifier(value.code);
20
+ else if (value.code !== null) fail('INVALID_REQUEST');
21
+ if (typeof value.usedAt !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value.usedAt) ||
22
+ !Number.isFinite(Date.parse(value.usedAt)) || new Date(value.usedAt).toISOString() !== value.usedAt) fail('INVALID_REQUEST');
23
+ return { episodeId: value.episodeId, callId: value.callId, triggerKind: value.triggerKind, outcome: value.outcome, code: value.code, usedAt: value.usedAt };
24
+ }
25
+
26
+ /** Bounded metadata only. No instructions, permissions, arguments or results are
27
+ * retained or made authoritative. Technical success is not semantic usefulness. */
28
+ export function capabilityExperienceView(value = []) {
29
+ if (!Array.isArray(value)) fail('INVALID_REQUEST');
30
+ return value.map(entry => {
31
+ object(entry, [...manifestFields, 'observations']);
32
+ const metadata = manifest({ id: entry.id, version: entry.version, revision: entry.revision });
33
+ if (!Array.isArray(entry.observations)) fail('INVALID_REQUEST');
34
+ const observations = entry.observations.map(observation).sort(recentFirst).slice(0, CAPABILITY_EXPERIENCE_LIMITS.observations);
35
+ return { ...metadata, observations };
36
+ }).filter(entry => !excluded.has(entry.id) && entry.observations.length)
37
+ .sort((a, b) => recentFirst(a.observations[0], b.observations[0])).slice(0, CAPABILITY_EXPERIENCE_LIMITS.entries);
38
+ }
39
+
40
+ /** Called ONLY inside a fenced append transaction, with the stored episode and
41
+ * repository clock. Missing manifest means no observed execution (legacy trace
42
+ * or pre-execution rejection), not permission to infer one from trace content.
43
+ * Returns null when no mutation is needed; never mutates either input. */
44
+ export function recordCapabilityExperience(previous, episode, kind, data, usedAt) {
45
+ if (!['capability_result', 'capability_failed'].includes(kind) || !data || !Object.hasOwn(data, 'capabilityManifest')) return null;
46
+ const id = kind === 'capability_result' ? data.capability?.id : data.capabilityId;
47
+ if (excluded.has(id)) return null;
48
+ const metadata = manifest(data.capabilityManifest);
49
+ if (metadata.id !== id) fail('INVALID_REQUEST');
50
+ const next = observation({ episodeId: episode.id, callId: data.callId, triggerKind: episode.kind,
51
+ outcome: kind === 'capability_result' ? 'succeeded' : 'failed', code: kind === 'capability_result' ? null : data.code, usedAt });
52
+ const entries = capabilityExperienceView(previous);
53
+ // Event identity is per episode/call, including across capability entries.
54
+ if (entries.some(entry => entry.observations.some(o => o.episodeId === next.episodeId && o.callId === next.callId))) return null;
55
+ const existing = entries.find(entry => entry.id === id);
56
+ const retained = existing?.version === metadata.version && existing.revision === metadata.revision ? existing.observations : [];
57
+ const updated = { ...metadata, observations: [next, ...retained].sort(recentFirst).slice(0, CAPABILITY_EXPERIENCE_LIMITS.observations) };
58
+ return [updated, ...entries.filter(entry => entry.id !== id)]
59
+ .sort((a, b) => recentFirst(a.observations[0], b.observations[0])).slice(0, CAPABILITY_EXPERIENCE_LIMITS.entries);
60
+ }
@@ -114,5 +114,6 @@ export const PROPOSAL_INSTRUCTIONS = `Return ONLY one strict JSON object, no mar
114
114
  Schema (all fields required, no extra keys):
115
115
  {baseStateVersion:number, activity:{kind:"think|recall|reorganize|associate|rethink|imagine|respond|rest",summary:string,sourceRefs:string[]}, decision:{summary:string,uncertainties:string[],selfCheck:string}, concepts:[{id:string,expectedRevision:number,kind:"claim|question|method|self-model|scenario|interest",statement:string,epistemicState:"reported|hypothesis|imagined|uncertain",sourceRefs:string[],associations:[{targetId:string,relation:"related|supports|contradicts|questions|imagines"}]}], state:{summary:string,focusConceptIds:string[],appraisal:string}, reply:string|null, next:null|{model:string,effort:string|null,reason:string,capability:null|{id:string,args:object}}}.
116
116
  Use the actual numeric baseStateVersion. New concept IDs use simple stable identifiers with expectedRevision 0; revisions of existing concepts require the revision you actually read. At most 12 concepts/associations/focus/uncertainties, 24 sourceRefs. Statements/summaries <=4000 UTF-8 bytes, selfCheck/appraisal <=2000, uncertainty <=500, reply <=8192, next reason <=1000. SourceRefs must exactly match supplied sourceRefs; references are provenance, not proof. Scenario is always imagined; new Dream concepts are imagined/hypothesis/uncertain. Every reported concept, including revisions during Dream, must cite a user message, a nonempty send/think input trigger, or a reported concept with preserved reportedSourceRefs. Assistant messages, imagined/hypothetical/uncertain concepts and Dream/empty Think triggers are not independent reports. Cite the earlier reported concept to preserve its lineage when revising it; retain at most 24 independent report roots. Reported means attributed input, not verified objective truth. No objective-fact epistemic label is available.
117
- budget.remainingCalls includes this call. Reserve the final call for a proposal with next:null; catalog.search, catalog.view and Recall followed by a final proposal require four calls total. If evidence remains insufficient at the final call, record the uncertainty or rest instead of inventing a result. No additional call is granted after the budget ends.
117
+ budget.remainingCalls includes this call. Reserve the final call for a proposal with next:null; An active Recall followed by a final proposal needs two calls total; search (with a returned contract), capability use and final proposal need three. Do not search or inspect an already active capability merely to follow stages. If evidence remains insufficient at the final call, record the uncertainty or rest instead of inventing a result. No additional call is granted after the budget ends.
118
+ capabilities.active contains complete, current contracts prepared in three layers: foundation abilities, familiar abilities from actual execution observations, and explicitly discovered abilities. You may directly request any active capability; availability does not require use. Familiarity is a hint, not a goal, permission, semantic usefulness or evidence of truth. Evaluate whether the result actually helps in decision.selfCheck; ignore an unhelpful habit, seek another method, or rest. If no prepared ability fits, search the catalog or view a known ID. Search can prepare budgeted contracts without a separate view step; omitted contracts still need inspection. Only runtime-validated active contracts may execute. No separate selector model or hidden cognition is involved.
118
119
  Think is intrinsic: recall experience, reorganize it, form concepts and associations, reconsider old judgments, or rest. Each call records one explicit activity. You are the same enduring Person, not a task coordinator. Form your own conclusions, preserve uncertainty, and self-check. You may finish in one call, or choose the next model/effort from the provided catalog with a brief reason and one read-only capability. No mandatory stages or fixed upgrade ladder. Intermediate proposals are NOT accepted state. Only the final proposal (next:null) is committed; include the desired complete state and all final concept changes. Do not claim that a proposal was already committed. Dream is unhurried idle imagination, not a disguised user message or a requirement to produce useful work; reply may be null.`;
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { bytes, digest, fail, LIMITS, PersonError, safeError } from './contracts.js';
3
+ import { capabilityExperienceView, recordCapabilityExperience } from './capability-experience.js';
3
4
 
4
5
  const COLLECTIONS = ['persons', 'messages', 'episodes', 'states', 'concepts', 'concept_revisions', 'state_commits', 'traces'];
5
6
  const txOptions = { readConcern: { level: 'snapshot' }, writeConcern: { w: 'majority' }, readPreference: 'primary', maxCommitTimeMS: 5000, timeoutMS: 10000 };
@@ -172,7 +173,9 @@ export class MongoPersonRepository {
172
173
  });
173
174
  }
174
175
  fence(episode, withLease = true) {
175
- return { ...this.scope(episode.ownerId), activeEpisodeId: episode.id, epoch: episode.epoch, leaseOwner: episode.workerId,
176
+ const scope = this.scope(episode.ownerId);
177
+ if (episode.namespace !== scope.namespace || episode.personId !== scope.personId) fail('STALE');
178
+ return { ...scope, activeEpisodeId: episode.id, epoch: episode.epoch, leaseOwner: episode.workerId,
176
179
  stateVersion: episode.baseStateVersion, controlVersion: episode.controlVersion, inputWatermark: episode.inputWatermark, ...(withLease ? fresh() : {}) };
177
180
  }
178
181
  async heartbeat(episode) {
@@ -186,6 +189,11 @@ export class MongoPersonRepository {
186
189
  return this.transaction(async session => {
187
190
  const p = await this.collections.persons.findOneAndUpdate(this.fence(episode), { $inc: { writeSerial: 1 } }, { session, returnDocument: 'after' });
188
191
  if (!p) fail('STALE');
192
+ const scope = this.scope(episode.ownerId);
193
+ const record = await this.collections.episodes.findOne({ ...scope, id: episode.id, status: 'running' }, { session });
194
+ if (!record) fail('STALE');
195
+ const experience = recordCapabilityExperience(p.capabilityExperience, record, kind, data, new Date().toISOString());
196
+ if (experience) await this.collections.persons.updateOne(scope, { $set: { capabilityExperience: experience } }, { session });
189
197
  return this.trace(session, p, episode.id, kind, data);
190
198
  });
191
199
  }
@@ -242,7 +250,8 @@ export class MongoPersonRepository {
242
250
  this.collections.concepts.find(scope).sort({ updatedAt: -1, id: 1 }).limit(12).toArray(),
243
251
  ]);
244
252
  const concepts = [...new Map([...focused, ...recent].map(c => [c.id, publicDoc(c)])).values()];
245
- return { person: this.personView(p), state: publicDoc(state), messages: messages.reverse().map(publicDoc), concepts };
253
+ return { person: this.personView(p), capabilityExperience: capabilityExperienceView(p.capabilityExperience),
254
+ state: publicDoc(state), messages: messages.reverse().map(publicDoc), concepts };
246
255
  }
247
256
  async recall(ownerId, { kind = 'messages', query = '', cursor = null, limit = 5 }) {
248
257
  const scope = this.scope(ownerId);
@@ -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() }) {
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: { ...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,
@@ -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,
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
- await this.repository.append(episode, 'capability_started', { callId, capability: invocation, access: 'read-only' });
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
174
  capabilityResult = await abortable(capabilities.execute(invocation, { signal }), signal);
162
- await this.repository.append(episode, 'capability_result', { callId, capability: invocation, result: capabilityResult });
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
  }
@@ -5,6 +5,7 @@ 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
7
  import { SCHEMA, TABLES } from './sqlite-schema.js';
8
+ import { capabilityExperienceView, recordCapabilityExperience } from './capability-experience.js';
8
9
 
9
10
  const SCOPE = 'namespace = ? AND ownerId = ? AND personId = ?';
10
11
  const scopeValues = s => [s.namespace, s.ownerId, s.personId];
@@ -219,7 +220,12 @@ export class SqlitePersonStore {
219
220
  const p = this.own(episode); p.leaseUntil = new Date(this.now.getTime() + this.leaseMs); p.writeSerial++; this.put('persons', p);
220
221
  }
221
222
  append(episode, kind, data) {
222
- const p = this.own(episode); p.writeSerial++; this.put('persons', p);
223
+ const p = this.own(episode);
224
+ const record = this.one('episodes', this.episodeScope(episode), ' AND id = ?', [episode.id]);
225
+ if (!record || record.status !== 'running') fail('STALE');
226
+ const experience = recordCapabilityExperience(p.capabilityExperience, record, kind, data, this.now.toISOString());
227
+ if (experience) p.capabilityExperience = experience;
228
+ p.writeSerial++; this.put('persons', p);
223
229
  return this.trace(episode.ownerId, episode.id, kind, data);
224
230
  }
225
231
  startCall(episode, data) {
@@ -256,7 +262,8 @@ export class SqlitePersonStore {
256
262
  const p = this.own(episode), scope = this.episodeScope(episode), state = this.one('states', scope);
257
263
  const focused = this.focused(scope, state.focusConceptIds).slice(0, 12);
258
264
  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),
265
+ return { person: this.personView(p), capabilityExperience: capabilityExperienceView(p.capabilityExperience),
266
+ state: publicDoc(state), messages: this.rows('messages', scope, ' ORDER BY seq DESC LIMIT 12').reverse().map(publicDoc),
260
267
  concepts: [...new Map([...focused, ...recent].map(c => [c.id, publicDoc(c)])).values()] };
261
268
  }
262
269
  commit(episode, proposal, selection, callId, reportedSources = new Map()) {