@yeaft/webchat-agent 1.0.582 → 1.0.584

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.
@@ -0,0 +1,352 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { bytes, digest, fail, LIMITS, PersonError, safeError } from './contracts.js';
3
+
4
+ const COLLECTIONS = ['persons', 'messages', 'episodes', 'states', 'concepts', 'concept_revisions', 'state_commits', 'traces'];
5
+ const txOptions = { readConcern: { level: 'snapshot' }, writeConcern: { w: 'majority' }, readPreference: 'primary', maxCommitTimeMS: 5000, timeoutMS: 10000 };
6
+ const publicDoc = doc => {
7
+ if (!doc) return null;
8
+ const { _id, ownerId, namespace, personId, ...rest } = doc;
9
+ return rest;
10
+ };
11
+ const iso = date => date instanceof Date ? date.toISOString() : date;
12
+ const scopeFor = (ownerId, namespace) => ({ ownerId, namespace, personId: `person-${digest([namespace, ownerId]).slice(0, 32)}` });
13
+ const fresh = () => ({ $expr: { $gt: ['$leaseUntil', '$$NOW'] } });
14
+ const expired = () => ({ $expr: { $lte: ['$leaseUntil', '$$NOW'] } });
15
+ const unavailableOutput = () => ({ text: '', retainedBytes: 0, observedBytes: null, complete: false, accepted: false, availability: 'unavailable', reason: 'worker-unavailable' });
16
+ // This terminal-only path accepts a bounded public-output shape, never arbitrary trace/state data.
17
+ const publicOutput = (output, failed) => {
18
+ if (!output) return unavailableOutput();
19
+ if (typeof output.text !== 'string' || bytes(output.text) > LIMITS.outputBytes) fail('OUTPUT_LIMIT');
20
+ const retainedBytes = bytes(output.text);
21
+ const observedBytes = output.observedBytes ?? output.bytes ?? retainedBytes;
22
+ if (!Number.isSafeInteger(observedBytes) || observedBytes < retainedBytes) fail('INVALID_REQUEST');
23
+ const usage = output.usage ? {} : null;
24
+ if (usage) for (const key of ['inputTokens', 'outputTokens', 'reasoningTokens', 'cacheReadTokens', 'cacheWriteTokens']) {
25
+ if (Number.isFinite(output.usage[key]) && output.usage[key] >= 0) usage[key] = output.usage[key];
26
+ }
27
+ const stopReason = typeof output.stopReason === 'string' && bytes(output.stopReason) <= 128 ? output.stopReason : null;
28
+ return failed ? { text: output.text, retainedBytes, observedBytes, complete: false, accepted: false, availability: 'captured', usage, stopReason }
29
+ : { text: output.text, bytes: retainedBytes, complete: true, usage, stopReason };
30
+ };
31
+
32
+ /** MongoDB is the sole authority. No Session, local transcript or memory-file fallback. */
33
+ export class MongoPersonRepository {
34
+ constructor({ uri, dbName = 'yeaft_person', namespace, MongoClient, leaseMs = 15000 }) {
35
+ this.uri = uri; this.dbName = dbName; this.namespace = namespace; this.MongoClient = MongoClient; this.leaseMs = leaseMs;
36
+ }
37
+ async init() {
38
+ if (!this.initializing) this.initializing = this.connect().catch(async error => {
39
+ await this.client?.close().catch(() => {}); this.initializing = null;
40
+ throw safeError(error);
41
+ });
42
+ return this.initializing;
43
+ }
44
+ async connect() {
45
+ const Client = this.MongoClient || (await import('mongodb')).MongoClient;
46
+ this.client = new Client(this.uri, { serverSelectionTimeoutMS: 5000, connectTimeoutMS: 5000, socketTimeoutMS: 10000, maxPoolSize: 8, retryWrites: true });
47
+ await this.client.connect(); this.db = this.client.db(this.dbName);
48
+ const hello = await this.db.command({ hello: 1 });
49
+ if (!hello.setName && hello.msg !== 'isdbgrid') fail('STORAGE_UNAVAILABLE');
50
+ if (!hello.logicalSessionTimeoutMinutes) fail('STORAGE_UNAVAILABLE');
51
+ this.collections = {};
52
+ for (const name of COLLECTIONS) {
53
+ const fullName = `person_${name}`;
54
+ try {
55
+ await this.db.createCollection(fullName, { validator: { $jsonSchema: {
56
+ bsonType: 'object', required: ['schemaVersion', 'ownerId', 'namespace', 'personId'],
57
+ properties: { schemaVersion: { enum: [1] }, ownerId: { bsonType: 'string' }, namespace: { bsonType: 'string' }, personId: { bsonType: 'string' } },
58
+ } }, validationLevel: 'strict' });
59
+ } catch (error) { if (error.code !== 48) throw error; }
60
+ this.collections[name] = this.db.collection(fullName);
61
+ }
62
+ const scope = { namespace: 1, ownerId: 1, personId: 1 };
63
+ const unique = (name, fields) => this.collections[name].createIndex({ ...scope, ...fields }, { unique: true });
64
+ await Promise.all([
65
+ unique('persons', {}), unique('states', {}), unique('episodes', { clientMessageId: 1 }), unique('episodes', { id: 1 }),
66
+ unique('messages', { seq: 1 }), unique('traces', { seq: 1 }), unique('concepts', { id: 1 }),
67
+ this.collections.episodes.createIndex({ ...scope, inputWatermark: -1 }),
68
+ unique('concept_revisions', { id: 1, revision: 1 }), unique('state_commits', { version: 1 }),
69
+ this.collections.concepts.createIndex({ ...scope, updatedAt: -1, id: 1 }),
70
+ ]);
71
+ }
72
+ scope(ownerId) { return scopeFor(ownerId, this.namespace); }
73
+ doc(scope, values) { return { schemaVersion: 1, ...scope, ...values }; }
74
+ async transaction(fn) {
75
+ await this.init();
76
+ const session = this.client.startSession();
77
+ try { return await session.withTransaction(() => fn(session), txOptions); }
78
+ catch (error) { throw safeError(error); }
79
+ finally { await session.endSession(); }
80
+ }
81
+ personView(p) {
82
+ return { id: p.personId, name: p.name, soul: p.soul, soulRevision: p.soulRevision, createdAt: iso(p.createdAt), settings: p.settings };
83
+ }
84
+ async open(ownerId, name = 'Digital Person') {
85
+ const scope = this.scope(ownerId);
86
+ try {
87
+ return await this.transaction(async session => {
88
+ let p = await this.collections.persons.findOne(scope, { session });
89
+ if (!p) {
90
+ p = this.doc(scope, {
91
+ name, soul: 'A continuous, curious and honest digital person. Preserve uncertainty, reconsider your judgments, distinguish imagination from experience, and respect permissions. You may disagree without acting without authority.',
92
+ soulRevision: 1, createdAt: new Date(), settings: { autonomyEnabled: false },
93
+ epoch: 0, writeSerial: 0, inputWatermark: 0, controlVersion: 0, stateVersion: 0,
94
+ traceSeq: 0, messageSeq: 0, activeEpisodeId: null, leaseOwner: null, leaseUntil: new Date(0),
95
+ });
96
+ await this.collections.persons.insertOne(p, { session });
97
+ await this.collections.states.insertOne(this.doc(scope, { version: 0, summary: '', appraisal: '', focusConceptIds: [], conceptRefs: [], lastSelection: null, updatedAt: p.createdAt }), { session });
98
+ }
99
+ return { person: this.personView(p) };
100
+ });
101
+ } catch (error) {
102
+ // Two explicit opens may race on the deterministic unique identity.
103
+ const p = await this.collections?.persons.findOne(scope).catch(() => null);
104
+ if (p) return { person: this.personView(p) };
105
+ throw error;
106
+ }
107
+ }
108
+ async getPerson(ownerId) {
109
+ await this.init();
110
+ const p = await this.collections.persons.findOne(this.scope(ownerId));
111
+ if (!p) fail('NOT_OPEN');
112
+ return p;
113
+ }
114
+ async trace(session, p, episodeId, kind, data = {}) {
115
+ const scope = this.scope(p.ownerId);
116
+ const updated = await this.collections.persons.findOneAndUpdate(scope, { $inc: { traceSeq: 1, writeSerial: 1 } }, { session, returnDocument: 'after' });
117
+ const record = this.doc(scope, { id: randomUUID(), episodeId, kind, seq: updated.traceSeq, createdAt: new Date(), ...data });
118
+ await this.collections.traces.insertOne(record, { session });
119
+ return publicDoc(record);
120
+ }
121
+ async recover(ownerId) {
122
+ return this.transaction(async session => {
123
+ const scope = this.scope(ownerId);
124
+ const p = await this.collections.persons.findOneAndUpdate({ ...scope, activeEpisodeId: { $ne: null }, ...expired() }, {
125
+ $inc: { epoch: 1, writeSerial: 1 }, $set: { activeEpisodeId: null, leaseOwner: null, leaseUntil: new Date(0) },
126
+ }, { session, returnDocument: 'before' });
127
+ if (p) {
128
+ const episode = await this.collections.episodes.findOneAndUpdate({ ...scope, id: p.activeEpisodeId, status: 'running' },
129
+ { $set: { status: 'interrupted', terminalCode: 'INTERRUPTED', endedAt: new Date() } }, { session, returnDocument: 'before' });
130
+ await this.abandonCall(session, p, episode, 'INTERRUPTED');
131
+ await this.trace(session, p, p.activeEpisodeId, 'interrupted', { code: 'INTERRUPTED', reason: 'lease-expired', baseStateVersion: p.stateVersion });
132
+ }
133
+ // Cancellation leaves a bounded drain window for the original worker's consumed prefix.
134
+ // If that worker crashed, the next access closes the call with explicit unavailable output.
135
+ const abandoned = await this.collections.episodes.find({ ...scope, status: 'cancelled', 'openCall.callId': { $exists: true },
136
+ $expr: { $lte: ['$callFinalizeUntil', '$$NOW'] } }, { session }).toArray();
137
+ for (const episode of abandoned) await this.abandonCall(session, { ownerId }, episode, 'CANCELLED');
138
+ return Boolean(p);
139
+ });
140
+ }
141
+ async admit(ownerId, { kind, text, clientMessageId, workerId, budget }) {
142
+ await this.recover(ownerId);
143
+ const scope = this.scope(ownerId), requestHash = digest([kind, text]);
144
+ return this.transaction(async session => {
145
+ const existing = await this.collections.episodes.findOne({ ...scope, clientMessageId }, { session });
146
+ if (existing) {
147
+ if (existing.requestHash !== requestHash) fail('IDEMPOTENCY_CONFLICT');
148
+ return { episodeId: existing.id, duplicate: true, status: existing.status };
149
+ }
150
+ let p = await this.collections.persons.findOne(scope, { session });
151
+ if (!p) fail('NOT_OPEN');
152
+ if (p.activeEpisodeId) fail('BUSY');
153
+ // Every admission is explicit. A manual Dream request authorizes this episode
154
+ // only; it neither requires nor enables unsolicited background cognition.
155
+ const id = randomUUID();
156
+ p = await this.collections.persons.findOneAndUpdate({ ...scope, activeEpisodeId: null, epoch: p.epoch }, [{ $set: {
157
+ activeEpisodeId: id, leaseOwner: workerId, leaseUntil: { $add: ['$$NOW', this.leaseMs] },
158
+ epoch: { $add: ['$epoch', 1] }, writeSerial: { $add: ['$writeSerial', 1] },
159
+ inputWatermark: { $add: ['$inputWatermark', 1] }, messageSeq: { $add: ['$messageSeq', kind === 'send' ? 1 : 0] },
160
+ } }], { session, returnDocument: 'after' });
161
+ if (!p) fail('BUSY');
162
+ let messageId = null;
163
+ if (kind === 'send') {
164
+ messageId = randomUUID();
165
+ await this.collections.messages.insertOne(this.doc(scope, { id: messageId, revision: 1, seq: p.messageSeq, episodeId: id, role: 'user', text, createdAt: new Date(), clientMessageId }), { session });
166
+ }
167
+ const episode = this.doc(scope, { id, clientMessageId, requestHash, kind, text, messageId, status: 'running', workerId, epoch: p.epoch,
168
+ baseStateVersion: p.stateVersion, inputWatermark: p.inputWatermark, controlVersion: p.controlVersion, budget, createdAt: new Date() });
169
+ await this.collections.episodes.insertOne(episode, { session });
170
+ await this.trace(session, p, id, 'accepted', { trigger: { kind, text, messageId }, baseStateVersion: p.stateVersion, budget });
171
+ return { episodeId: id, duplicate: false, status: 'running', episode };
172
+ });
173
+ }
174
+ fence(episode, withLease = true) {
175
+ return { ...this.scope(episode.ownerId), activeEpisodeId: episode.id, epoch: episode.epoch, leaseOwner: episode.workerId,
176
+ stateVersion: episode.baseStateVersion, controlVersion: episode.controlVersion, inputWatermark: episode.inputWatermark, ...(withLease ? fresh() : {}) };
177
+ }
178
+ async heartbeat(episode) {
179
+ await this.init();
180
+ const p = await this.collections.persons.findOneAndUpdate(this.fence(episode), [{ $set: {
181
+ leaseUntil: { $add: ['$$NOW', this.leaseMs] }, writeSerial: { $add: ['$writeSerial', 1] },
182
+ } }], { returnDocument: 'after', writeConcern: { w: 'majority' } });
183
+ if (!p) fail('STALE');
184
+ }
185
+ async append(episode, kind, data) {
186
+ return this.transaction(async session => {
187
+ const p = await this.collections.persons.findOneAndUpdate(this.fence(episode), { $inc: { writeSerial: 1 } }, { session, returnDocument: 'after' });
188
+ if (!p) fail('STALE');
189
+ return this.trace(session, p, episode.id, kind, data);
190
+ });
191
+ }
192
+ async startCall(episode, data) {
193
+ return this.transaction(async session => {
194
+ const p = await this.collections.persons.findOneAndUpdate(this.fence(episode), { $inc: { writeSerial: 1 } }, { session, returnDocument: 'after' });
195
+ if (!p) fail('STALE');
196
+ const updated = await this.collections.episodes.updateOne({ ...this.scope(episode.ownerId), id: episode.id, status: 'running', openCall: { $exists: false } },
197
+ { $set: { openCall: { callId: data.callId, requested: data.requested, effective: data.effective, ...(data.manifest ? { manifest: data.manifest } : {}) } } }, { session });
198
+ if (!updated.modifiedCount) fail('STALE');
199
+ return this.trace(session, p, episode.id, 'call_started', data);
200
+ });
201
+ }
202
+ /** Close exactly the original worker's started call, even after cancel; never regain state ownership. */
203
+ async finalizeCall(episode, { callId, effective, output, code = null }) {
204
+ return this.transaction(async session => {
205
+ const scope = this.scope(episode.ownerId);
206
+ const record = await this.collections.episodes.findOne({ ...scope, id: episode.id, workerId: episode.workerId,
207
+ epoch: episode.epoch, 'openCall.callId': callId }, { session });
208
+ if (!record) return false;
209
+ const live = record.status === 'running' && await this.collections.persons.findOne(this.fence(episode), { session });
210
+ const draining = record.status === 'cancelled' && await this.collections.episodes.findOne({ ...scope, id: episode.id,
211
+ $expr: { $gt: ['$callFinalizeUntil', '$$NOW'] } }, { session });
212
+ if (!live && !draining) return false;
213
+ const failed = Boolean(code || !live || !output);
214
+ const terminalCode = !live ? 'CANCELLED' : code || 'INTERRUPTED';
215
+ const safeEffective = { model: record.openCall.requested.model, effort: typeof effective?.effort === 'string' && effective.effort.length <= 128 ? effective.effort : null,
216
+ effortObserved: effective?.effortObserved === true };
217
+ if (typeof effective?.wireMode === 'string' && effective.wireMode.length <= 128) safeEffective.wireMode = effective.wireMode;
218
+ // The episode update serializes finalizers, cancellation/recovery and duplicate workers.
219
+ await this.collections.episodes.updateOne({ ...scope, id: episode.id, 'openCall.callId': callId }, { $unset: { openCall: '', callFinalizeUntil: '' } }, { session });
220
+ await this.trace(session, { ownerId: episode.ownerId }, episode.id, failed ? 'call_failed' : 'call_output', {
221
+ callId, requested: record.openCall.requested, effective: safeEffective, output: publicOutput(output, failed),
222
+ ...(!failed && record.openCall.manifest ? { manifest: record.openCall.manifest } : {}),
223
+ ...(failed ? { code: new PersonError(terminalCode).code } : {}),
224
+ });
225
+ return Boolean(live);
226
+ });
227
+ }
228
+ async abandonCall(session, p, episode, code) {
229
+ if (!episode?.openCall) return;
230
+ await this.collections.episodes.updateOne({ ...this.scope(p.ownerId), id: episode.id, 'openCall.callId': episode.openCall.callId },
231
+ { $unset: { openCall: '', callFinalizeUntil: '' } }, { session });
232
+ await this.trace(session, p, episode.id, 'call_failed', { ...episode.openCall, code, output: unavailableOutput() });
233
+ }
234
+ async context(episode) {
235
+ await this.init();
236
+ const scope = this.scope(episode.ownerId);
237
+ const p = await this.collections.persons.findOne(this.fence(episode));
238
+ if (!p) fail('STALE');
239
+ const [state, messages, focused, recent] = await Promise.all([
240
+ this.collections.states.findOne(scope), this.collections.messages.find(scope).sort({ seq: -1 }).limit(12).toArray(),
241
+ this.collections.states.findOne(scope).then(s => this.collections.concepts.find({ ...scope, id: { $in: s.focusConceptIds } }).limit(12).toArray()),
242
+ this.collections.concepts.find(scope).sort({ updatedAt: -1, id: 1 }).limit(12).toArray(),
243
+ ]);
244
+ 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 };
246
+ }
247
+ async recall(ownerId, { kind = 'messages', query = '', cursor = null, limit = 5 }) {
248
+ const scope = this.scope(ownerId);
249
+ const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
250
+ if (kind === 'messages') return this.list(ownerId, 'messages', { cursor, limit }, query ? { text: { $regex: escaped, $options: 'i' } } : {});
251
+ await this.init();
252
+ const filter = { ...scope, ...(query ? { statement: { $regex: escaped, $options: 'i' } } : {}), ...(cursor ? { id: { $gt: cursor } } : {}) };
253
+ const docs = await this.collections.concepts.find(filter).sort({ id: 1 }).limit(limit + 1).maxTimeMS(2000).toArray();
254
+ return { items: docs.slice(0, limit).map(publicDoc), nextCursor: docs.length > limit ? docs[limit - 1].id : null };
255
+ }
256
+ async commit(episode, proposal, selection, callId, reportedSources = new Map()) {
257
+ return this.transaction(async session => {
258
+ const scope = this.scope(episode.ownerId);
259
+ const p = await this.collections.persons.findOneAndUpdate(this.fence(episode), { $inc: { stateVersion: 1, writeSerial: 1, messageSeq: proposal.reply ? 1 : 0 },
260
+ $set: { activeEpisodeId: null, leaseOwner: null, leaseUntil: new Date(0) } }, { session, returnDocument: 'after' });
261
+ if (!p) fail('STALE');
262
+ const revisions = [];
263
+ for (const patch of proposal.concepts) {
264
+ const { expectedRevision, ...fields } = patch;
265
+ const reportedSourceRefs = reportedSources.get(patch.id) ?? [];
266
+ if (patch.epistemicState === 'reported' && !reportedSourceRefs.length) fail('INVALID_PROPOSAL');
267
+ const record = this.doc(scope, { ...fields, reportedSourceRefs, revision: expectedRevision + 1, episodeId: episode.id, callId, stateVersion: p.stateVersion, updatedAt: new Date() });
268
+ if (expectedRevision === 0) {
269
+ if (await this.collections.concepts.findOne({ ...scope, id: patch.id }, { session })) fail('STALE');
270
+ await this.collections.concepts.insertOne(record, { session });
271
+ } else {
272
+ const update = await this.collections.concepts.replaceOne({ ...scope, id: patch.id, revision: expectedRevision }, record, { session });
273
+ if (update.matchedCount !== 1) fail('STALE');
274
+ }
275
+ await this.collections.concept_revisions.insertOne({ ...record, _id: randomUUID() }, { session });
276
+ revisions.push({ id: patch.id, revision: record.revision });
277
+ }
278
+ const focus = await this.collections.concepts.find({ ...scope, id: { $in: proposal.state.focusConceptIds } }, { session }).toArray();
279
+ if (focus.length !== proposal.state.focusConceptIds.length) fail('STALE');
280
+ const state = this.doc(scope, { ...proposal.state, version: p.stateVersion, conceptRefs: focus.map(c => ({ id: c.id, revision: c.revision })),
281
+ decision: proposal.decision, lastSelection: { model: selection.model, effort: selection.effort }, updatedAt: new Date(), episodeId: episode.id });
282
+ const updated = await this.collections.states.replaceOne({ ...scope, version: episode.baseStateVersion }, state, { session });
283
+ if (updated.matchedCount !== 1) fail('STALE');
284
+ await this.collections.state_commits.insertOne(this.doc(scope, { id: randomUUID(), version: p.stateVersion, parentVersion: episode.baseStateVersion,
285
+ episodeId: episode.id, callId, conceptRevisions: revisions, state: publicDoc(state), createdAt: new Date() }), { session });
286
+ if (proposal.reply) await this.collections.messages.insertOne(this.doc(scope, { id: randomUUID(), revision: 1, seq: p.messageSeq, episodeId: episode.id,
287
+ role: 'assistant', text: proposal.reply, createdAt: new Date() }), { session });
288
+ await this.collections.episodes.updateOne({ ...scope, id: episode.id, status: 'running' }, { $set: { status: 'completed', endedAt: new Date(), stateVersion: p.stateVersion } }, { session });
289
+ await this.trace(session, p, episode.id, 'committed', { callId, parentVersion: episode.baseStateVersion, stateVersion: p.stateVersion, conceptRevisions: revisions, decision: proposal.decision });
290
+ return { state: publicDoc(state) };
291
+ });
292
+ }
293
+ async finish(episode, status, code) {
294
+ return this.transaction(async session => {
295
+ const p = await this.collections.persons.findOneAndUpdate(this.fence(episode, false), { $inc: { writeSerial: 1, epoch: 1 },
296
+ $set: { activeEpisodeId: null, leaseOwner: null, leaseUntil: new Date(0) } }, { session, returnDocument: 'before' });
297
+ if (!p) return false; // A cancel, takeover or earlier terminal transaction already won.
298
+ const ended = await this.collections.episodes.findOneAndUpdate({ ...this.scope(episode.ownerId), id: episode.id, status: 'running' }, { $set: { status, terminalCode: code, endedAt: new Date() } }, { session, returnDocument: 'before' });
299
+ await this.abandonCall(session, p, ended, code);
300
+ await this.trace(session, p, episode.id, status, { code, baseStateVersion: episode.baseStateVersion });
301
+ return true;
302
+ });
303
+ }
304
+ async cancel(ownerId, episodeId = null) {
305
+ return this.transaction(async session => {
306
+ const scope = this.scope(ownerId);
307
+ const current = await this.collections.persons.findOne(scope, { session });
308
+ if (!current) fail('NOT_OPEN');
309
+ if (!current.activeEpisodeId || (episodeId && current.activeEpisodeId !== episodeId)) return { cancelled: false, episodeId };
310
+ const p = await this.collections.persons.findOneAndUpdate({ ...scope, activeEpisodeId: current.activeEpisodeId, epoch: current.epoch }, {
311
+ $inc: { epoch: 1, controlVersion: 1, writeSerial: 1 }, $set: { activeEpisodeId: null, leaseOwner: null, leaseUntil: new Date(0) },
312
+ }, { session, returnDocument: 'before' });
313
+ if (!p) fail('STALE');
314
+ await this.collections.episodes.updateOne({ ...scope, id: p.activeEpisodeId, status: 'running' }, { $set: { status: 'cancelled', terminalCode: 'CANCELLED', endedAt: new Date(), callFinalizeUntil: p.leaseUntil } }, { session });
315
+ await this.trace(session, p, p.activeEpisodeId, 'cancelled', { code: 'CANCELLED' });
316
+ return { cancelled: true, episodeId: p.activeEpisodeId };
317
+ });
318
+ }
319
+ async settings(ownerId, settings) {
320
+ return this.transaction(async session => {
321
+ const scope = this.scope(ownerId);
322
+ const p = await this.collections.persons.findOne(scope, { session });
323
+ if (!p) fail('NOT_OPEN');
324
+ if (p.activeEpisodeId) fail('BUSY'); // No implicit background API or deferred control effects.
325
+ const next = { ...p.settings, ...settings };
326
+ await this.collections.persons.updateOne(scope, { $set: { settings: next }, $inc: { controlVersion: 1, writeSerial: 1 } }, { session });
327
+ if (Object.keys(settings).length) await this.trace(session, p, null, 'settings', { settings: next });
328
+ return { settings: next };
329
+ });
330
+ }
331
+ async list(ownerId, collection, { cursor = null, limit = 20 }, filter = {}) {
332
+ await this.getPerson(ownerId);
333
+ const docs = await this.collections[collection].find({ ...this.scope(ownerId), ...filter, ...(cursor ? { seq: { $lt: cursor } } : {}) }).sort({ seq: -1 }).limit(limit + 1).maxTimeMS(2000).toArray();
334
+ return { items: docs.slice(0, limit).map(publicDoc), nextCursor: docs.length > limit ? String(docs[limit - 1].seq) : null };
335
+ }
336
+ async snapshot(ownerId) {
337
+ await this.recover(ownerId);
338
+ return this.transaction(async session => {
339
+ const scope = this.scope(ownerId);
340
+ const p = await this.collections.persons.findOne(scope, { session });
341
+ if (!p) fail('NOT_OPEN');
342
+ const state = await this.collections.states.findOne(scope, { session });
343
+ const messages = await this.collections.messages.find(scope, { session }).sort({ seq: -1 }).limit(21).toArray();
344
+ const concepts = await this.collections.concepts.find({ ...scope, id: { $in: state.focusConceptIds } }, { session }).limit(12).toArray();
345
+ const episode = await this.collections.episodes.findOne(scope, { session, sort: { inputWatermark: -1 },
346
+ projection: { _id: 0, id: 1, status: 1, terminalCode: 1, endedAt: 1 } });
347
+ return { latestEpisode: episode, person: this.personView(p), state: publicDoc(state), concepts: concepts.map(publicDoc), messages: messages.slice(0, 20).reverse().map(publicDoc),
348
+ nextMessagesCursor: messages.length > 20 ? String(messages[19].seq) : null, busy: Boolean(p.activeEpisodeId), episodeId: p.activeEpisodeId };
349
+ });
350
+ }
351
+ async close() { await this.client?.close(); }
352
+ }
@@ -0,0 +1,186 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { CAPABILITY_MAP, PersonCapabilities, catalogRevision as capabilityCatalogRevision } from './capabilities.js';
3
+ import { abortable, collectOutput } from './provider.js';
4
+ import { bytes, fail, LIMITS, PersonError, PROPOSAL_INSTRUCTIONS, reportedLineage, safeError, validateProposal, validateSelection } from './contracts.js';
5
+
6
+ const messageRef = m => `message:${m.id}:${m.revision}`;
7
+ const conceptRef = c => `concept:${c.id}:${c.revision}`;
8
+
9
+ /** Assemble bounded request copies. Omitting a record never deletes or truncates its MongoDB original. */
10
+ export function assembleContext({ snapshot, episode, provider, selection, previous, capabilityResult, remainingCalls, dependencyRefs = [] }) {
11
+ const model = validateSelection(selection, provider.catalog);
12
+ // UTF-8 bytes is a conservative text-token bound; reserve explicit envelope/output overhead.
13
+ const contextCap = Math.min(LIMITS.contextBytes, model.contextWindow - model.maxOutput - 1024);
14
+ const system = `${snapshot.person.soul}\n\n${PROPOSAL_INSTRUCTIONS}`;
15
+ const triggerRef = `trigger:${episode.id}`;
16
+ const context = {
17
+ person: { id: snapshot.person.id, name: snapshot.person.name, soulRevision: snapshot.person.soulRevision },
18
+ state: snapshot.state, trigger: { kind: episode.kind, text: episode.text, ref: triggerRef },
19
+ models: provider.catalog, modelCatalogRevision: provider.catalogRevision,
20
+ capabilities: CAPABILITY_MAP, capabilityCatalogRevision,
21
+ budget: { remainingCalls, maxOutputBytes: LIMITS.outputBytes },
22
+ previousProposal: previous ?? null, capabilityResult: capabilityResult ?? null,
23
+ messages: [], concepts: [], sourceRefs: [triggerRef], inheritedSourceRefs: dependencyRefs,
24
+ contextNotice: 'This is bounded short-term context, not all memory. Omitted records remain in MongoDB. Recall pages are scoped to this Person. A previous proposal is not committed state. Inherited source refs were read by an earlier call of this episode, not necessarily rendered here; recall again to check their content.',
25
+ };
26
+ const sourceRefs = new Set([triggerRef, ...dependencyRefs]);
27
+ const renderedRefs = new Set([triggerRef]);
28
+ const conceptMap = new Map();
29
+ const sources = new Map([[triggerRef, { kind: 'trigger', reportedSourceRefs:
30
+ ['send', 'think'].includes(episode.kind) && episode.text?.trim() ? [triggerRef] : [] }]]);
31
+ const seenMessage = m => sources.set(messageRef(m), { kind: 'message', role: m.role, reportedSourceRefs: m.role === 'user' ? [messageRef(m)] : [] });
32
+ const seenConcept = c => {
33
+ conceptMap.set(c.id, c);
34
+ sources.set(conceptRef(c), { kind: 'concept', epistemicState: c.epistemicState,
35
+ reportedSourceRefs: c.epistemicState === 'reported' ? c.reportedSourceRefs ?? [] : [] });
36
+ };
37
+ const omitted = [];
38
+ // A selected recall result is a full bounded page. Never silently shorten it after recording tool success.
39
+ if (capabilityResult?.kind === 'messages') for (const m of capabilityResult.items) { sourceRefs.add(messageRef(m)); renderedRefs.add(messageRef(m)); seenMessage(m); }
40
+ if (capabilityResult?.kind === 'concepts') for (const c of capabilityResult.items) { sourceRefs.add(conceptRef(c)); renderedRefs.add(conceptRef(c)); seenConcept(c); }
41
+ context.sourceRefs = [...sourceRefs];
42
+ const fits = () => bytes(system) + bytes(context) <= contextCap;
43
+ if (!fits()) fail('CONTEXT_LIMIT');
44
+ const add = (field, item, ref) => {
45
+ if (renderedRefs.has(ref)) return;
46
+ const inherited = sourceRefs.has(ref);
47
+ context[field].push(item); if (!inherited) context.sourceRefs.push(ref);
48
+ if (!fits()) { context[field].pop(); if (!inherited) context.sourceRefs.pop(); omitted.push({ ref, reason: 'context-budget' }); }
49
+ else { sourceRefs.add(ref); renderedRefs.add(ref); if (field === 'concepts') seenConcept(item); else seenMessage(item); }
50
+ };
51
+ for (const m of [...snapshot.messages].reverse()) add('messages', m, messageRef(m));
52
+ context.messages.reverse();
53
+ for (const c of snapshot.concepts) add('concepts', c, conceptRef(c));
54
+ return {
55
+ system, messages: [{ role: 'user', content: JSON.stringify(context) }], sourceRefs, concepts: conceptMap, sources,
56
+ manifest: { stateVersion: snapshot.state.version, sourceRefs: [...sourceRefs], renderedSourceRefs: [...renderedRefs], inputDependencyRefs: [...sourceRefs], omitted,
57
+ boundedRecentWindow: { messages: 12, recentConcepts: 12, focusedConcepts: 12 },
58
+ contextBytes: bytes(system) + bytes(context), contextBudgetBytes: contextCap, outputTokensReserved: model.maxOutput,
59
+ modelCatalogRevision: provider.catalogRevision, capabilityCatalogRevision }, maxTokens: model.maxOutput,
60
+ };
61
+ }
62
+
63
+ export class PersonRuntime {
64
+ constructor({ repository, getProvider, budget }) {
65
+ this.repository = repository; this.getProvider = getProvider; this.budget = budget;
66
+ this.running = new Map(); this.workerId = randomUUID(); this.closed = false;
67
+ }
68
+ start(episode) {
69
+ if (this.closed) return;
70
+ const controller = new AbortController();
71
+ const job = { controller, episode, promise: null };
72
+ this.running.set(episode.id, job);
73
+ // Admission resolves independently of provider latency. No model work occurs without an explicit admission.
74
+ job.promise = Promise.resolve().then(() => this.run(episode, controller)).catch(() => {}).finally(() => this.running.delete(episode.id));
75
+ }
76
+ cancel(episodeId, code = 'CANCELLED') { this.running.get(episodeId)?.controller.abort(new PersonError(code)); }
77
+ async run(episode, controller) {
78
+ const { signal } = controller;
79
+ const timeout = setTimeout(() => controller.abort(new PersonError('TIMEOUT')), episode.budget.timeoutMs);
80
+ timeout.unref?.();
81
+ let heartbeatRunning = false;
82
+ const heartbeat = setInterval(async () => {
83
+ if (heartbeatRunning || signal.aborted) return;
84
+ heartbeatRunning = true;
85
+ try { await this.repository.heartbeat(episode); }
86
+ catch (error) { controller.abort(safeError(error)); }
87
+ finally { heartbeatRunning = false; }
88
+ }, Math.max(100, Math.floor(this.repository.leaseMs / 3)));
89
+ heartbeat.unref?.();
90
+ try {
91
+ const provider = await abortable(this.getProvider(), signal);
92
+ const snapshot = await abortable(this.repository.context(episode), signal);
93
+ let selection = { ...provider.defaultSelection, reason: 'configured-default', origin: 'bootstrap' };
94
+ if (snapshot.state.lastSelection) {
95
+ try { validateSelection(snapshot.state.lastSelection, provider.catalog); selection = { ...snapshot.state.lastSelection, reason: 'last-accepted-choice', origin: 'persisted' }; }
96
+ catch { await this.repository.append(episode, 'selection_rejected', { requested: snapshot.state.lastSelection, code: 'MODEL_SELECTION', fallback: 'configured-default' }); }
97
+ }
98
+ const capabilities = new PersonCapabilities(this.repository, episode.ownerId);
99
+ let previous = null, capabilityResult = null, dependencyRefs = [];
100
+ // Validation retains actual reads across calls, independently of the bounded rendered request.
101
+ // Candidate proposals never enter this read-set or establish new provenance.
102
+ const readConcepts = new Map(), readSources = new Map();
103
+ for (let index = 0; index < episode.budget.calls; index++) {
104
+ signal.throwIfAborted();
105
+ const callId = randomUUID();
106
+ const context = assembleContext({ snapshot, episode, provider, selection, previous, capabilityResult, dependencyRefs, remainingCalls: episode.budget.calls - index });
107
+ dependencyRefs = context.manifest.inputDependencyRefs;
108
+ for (const [id, concept] of context.concepts) readConcepts.set(id, concept);
109
+ for (const [ref, source] of context.sources) readSources.set(ref, source);
110
+ await this.repository.heartbeat(episode); // Revalidate distributed ownership before each dispatch.
111
+ const requested = { model: selection.model, effort: selection.effort };
112
+ const effective = { model: selection.model, effort: null, effortObserved: false };
113
+ await this.repository.startCall(episode, {
114
+ callId, callIndex: index, requested, effective, selectionOrigin: selection.origin, reason: selection.reason,
115
+ manifest: context.manifest, request: { system: context.system, messages: context.messages, maxTokens: context.maxTokens, tools: [] },
116
+ capability: previous?.next?.capability ?? null,
117
+ });
118
+ let output;
119
+ try {
120
+ signal.throwIfAborted();
121
+ output = await collectOutput(provider.adapter, {
122
+ model: selection.model, effort: selection.effort ?? undefined, effortSource: 'auto',
123
+ system: context.system, messages: context.messages, maxTokens: context.maxTokens, signal,
124
+ }, decision => { effective.effort = decision.effective; effective.effortObserved = true; effective.wireMode = decision.wireMode; });
125
+ } catch (error) {
126
+ const safe = safeError(error, 'PROVIDER_FAILED');
127
+ await this.repository.finalizeCall(episode, { callId, effective, code: safe.code, output: error.partialOutput }).catch(() => {});
128
+ throw safe;
129
+ }
130
+ // A narrow once-only finalizer can retain consumed output after cancellation, never state.
131
+ // Complete public output remains durable even if proposal validation subsequently rejects it.
132
+ if (!await this.repository.finalizeCall(episode, { callId, effective, output })) fail('STALE');
133
+ signal.throwIfAborted();
134
+ let proposal;
135
+ try {
136
+ try { proposal = JSON.parse(output.text); } catch { fail('INVALID_PROPOSAL'); }
137
+ validateProposal(proposal, { stateVersion: episode.baseStateVersion, sourceRefs: context.sourceRefs, concepts: readConcepts, sources: readSources, catalog: provider.catalog, dream: episode.kind === 'dream' });
138
+ } catch (error) {
139
+ const safe = safeError(error, 'INVALID_PROPOSAL');
140
+ await this.repository.append(episode, 'proposal_rejected', { callId, code: safe.code });
141
+ throw safe;
142
+ }
143
+ await this.repository.append(episode, 'activity', { callId, activity: proposal.activity, decision: proposal.decision, disposition: proposal.next ? 'candidate' : 'proposed-commit' });
144
+ if (!proposal.next) {
145
+ signal.throwIfAborted();
146
+ await this.repository.commit(episode, proposal, selection, callId, new Map(proposal.concepts.map(c => [c.id, c.epistemicState === 'reported' ? reportedLineage(c, readSources) : []])));
147
+ return;
148
+ }
149
+ if (index + 1 >= episode.budget.calls) {
150
+ await this.repository.finish(episode, 'budget_exhausted', 'CALL_BUDGET');
151
+ return;
152
+ }
153
+ previous = proposal;
154
+ selection = { model: proposal.next.model, effort: proposal.next.effort, reason: proposal.next.reason, origin: 'person' };
155
+ capabilityResult = null;
156
+ if (proposal.next.capability) {
157
+ const invocation = proposal.next.capability;
158
+ await this.repository.append(episode, 'capability_started', { callId, capability: invocation, access: 'read-only' });
159
+ try {
160
+ signal.throwIfAborted();
161
+ capabilityResult = await abortable(capabilities.execute(invocation), signal);
162
+ await this.repository.append(episode, 'capability_result', { callId, capability: invocation, result: capabilityResult });
163
+ } catch (error) {
164
+ const safe = safeError(error, 'UNSUPPORTED');
165
+ await this.repository.append(episode, 'capability_failed', { callId, capabilityId: invocation.id, code: safe.code }).catch(() => {});
166
+ throw safe;
167
+ }
168
+ }
169
+ }
170
+ } catch (error) {
171
+ const safe = safeError(signal.aborted ? signal.reason : error, 'PROVIDER_FAILED');
172
+ const status = safe.code === 'CANCELLED' ? 'cancelled' : safe.code === 'INTERRUPTED' || safe.code === 'STALE' ? 'interrupted' : 'failed';
173
+ // On database outage no local fallback is possible. Expired leases become durable interrupted records on next access.
174
+ await this.repository.finish(episode, status, safe.code).catch(() => {});
175
+ } finally {
176
+ clearTimeout(timeout); clearInterval(heartbeat);
177
+ if (!signal.aborted) controller.abort(new PersonError('INTERRUPTED'));
178
+ }
179
+ }
180
+ async close() {
181
+ this.closed = true;
182
+ const jobs = [...this.running.values()];
183
+ for (const job of jobs) job.controller.abort(new PersonError('INTERRUPTED'));
184
+ await Promise.all(jobs.map(job => job.promise));
185
+ }
186
+ }
@@ -0,0 +1,101 @@
1
+ import { MongoPersonRepository } from './repository.js';
2
+ import { PersonRuntime } from './runtime.js';
3
+ import { createPersonProvider } from './provider.js';
4
+ import { fail, identifier, LIMITS, object, page, safeError, text } from './contracts.js';
5
+
6
+ /**
7
+ * One Mongo-backed Person per (namespace, authenticated ownerId). The transport MUST
8
+ * supply authenticated ownerId, never accept it from a browser payload. URI/provider
9
+ * secrets stay inside this Agent. Constructor and status never initiate model calls.
10
+ * Native provider configuration is read from yeaftDir only (no Session initialization).
11
+ * Inject MongoClient/config/adapter for isolated integration tests; no persistence fallback.
12
+ */
13
+ export function createPersonService(options = {}) {
14
+ const { uri, dbName = 'yeaft_person', namespace = 'default', yeaftDir, MongoClient, config, adapter, allowedModels } = options;
15
+ identifier(namespace);
16
+ if (typeof dbName !== 'string' || !/^[a-zA-Z0-9_-]{1,63}$/.test(dbName)) fail('INVALID_REQUEST');
17
+ if (uri != null && typeof uri !== 'string') fail('INVALID_REQUEST');
18
+ const configured = Boolean(uri?.trim());
19
+ const calls = options.maxCalls ?? LIMITS.calls;
20
+ const timeoutMs = options.timeoutMs ?? LIMITS.timeoutMs;
21
+ const leaseMs = options.leaseMs ?? LIMITS.leaseMs;
22
+ if (!Number.isInteger(calls) || calls < 1 || calls > 8 || !Number.isInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > 300000 ||
23
+ !Number.isInteger(leaseMs) || leaseMs < 300 || leaseMs > 60000) fail('INVALID_REQUEST');
24
+ if (allowedModels != null && (!Array.isArray(allowedModels) || !allowedModels.length || allowedModels.length > 8 || allowedModels.some(m => typeof m !== 'string'))) fail('INVALID_REQUEST');
25
+ const repository = new MongoPersonRepository({ uri, dbName, namespace, MongoClient, leaseMs });
26
+ // Config/adapter are loaded per explicit episode, not a permanent stale cache.
27
+ const getProvider = () => createPersonProvider({ yeaftDir, config, adapter, allowedModels, effortEnabled: options.effortEnabled });
28
+ const runtime = new PersonRuntime({ repository, getProvider, budget: { calls, timeoutMs } });
29
+ let closed = false;
30
+ const requests = new Set();
31
+ async function handle({ ownerId, op, payload = {} } = {}) {
32
+ if (closed) fail('CLOSED');
33
+ text(ownerId, 256); // Owners are opaque authenticated IDs, not database selectors.
34
+ if (typeof op !== 'string') fail('INVALID_REQUEST');
35
+ if (op === 'status') {
36
+ object(payload, []);
37
+ if (!configured) return { configured: false, reason: 'MongoDB is not configured for digital person.', storageReady: false, modelReady: false };
38
+ try { await repository.init(); }
39
+ catch { return { configured: true, reason: 'Digital person storage is unavailable; a transaction-capable MongoDB replica set is required.', storageReady: false, modelReady: false }; }
40
+ try {
41
+ const provider = await getProvider();
42
+ return { configured: true, reason: null, storageReady: true, modelReady: true, models: provider.catalog, autonomySupported: false };
43
+ } catch { return { configured: true, reason: 'No permitted native model is configured for digital person.', storageReady: true, modelReady: false }; }
44
+ }
45
+ if (!configured) fail('NOT_CONFIGURED');
46
+ switch (op) {
47
+ case 'open': {
48
+ object(payload, ['name'], []);
49
+ if (payload.name != null) text(payload.name, 160);
50
+ return repository.open(ownerId, payload.name);
51
+ }
52
+ case 'snapshot': object(payload, []); return repository.snapshot(ownerId);
53
+ case 'send':
54
+ case 'think':
55
+ case 'dream': {
56
+ object(payload, op === 'dream' ? ['clientMessageId'] : ['text', 'clientMessageId'], op === 'dream' ? ['clientMessageId'] : ['text', 'clientMessageId']);
57
+ identifier(payload.clientMessageId);
58
+ if (op !== 'dream') text(payload.text, LIMITS.inputBytes, op === 'think');
59
+ const admitted = await repository.admit(ownerId, { kind: op, text: payload.text ?? '', clientMessageId: payload.clientMessageId,
60
+ workerId: runtime.workerId, budget: runtime.budget });
61
+ if (!admitted.duplicate) {
62
+ if (closed) await repository.finish(admitted.episode, 'interrupted', 'INTERRUPTED');
63
+ else runtime.start(admitted.episode);
64
+ }
65
+ return { episodeId: admitted.episodeId, duplicate: admitted.duplicate, status: admitted.status };
66
+ }
67
+ case 'traces': await repository.recover(ownerId); return repository.list(ownerId, 'traces', page(payload));
68
+ case 'messages': return repository.list(ownerId, 'messages', page(payload));
69
+ case 'cancel': {
70
+ object(payload, ['episodeId'], []);
71
+ if (payload.episodeId != null) identifier(payload.episodeId);
72
+ const result = await repository.cancel(ownerId, payload.episodeId);
73
+ if (result.cancelled) runtime.cancel(result.episodeId);
74
+ return result;
75
+ }
76
+ case 'settings': {
77
+ object(payload, ['autonomyEnabled'], []);
78
+ if (Object.values(payload).some(v => typeof v !== 'boolean')) fail('INVALID_REQUEST');
79
+ // Timer-driven autonomy is deliberately not claimed or silently enabled.
80
+ if (payload.autonomyEnabled === true) fail('UNSUPPORTED');
81
+ return repository.settings(ownerId, payload);
82
+ }
83
+ default: fail('INVALID_REQUEST');
84
+ }
85
+ }
86
+ return {
87
+ request(input) {
88
+ const pending = Promise.resolve().then(() => handle(input)).catch(error => { throw safeError(error); });
89
+ requests.add(pending); pending.then(() => requests.delete(pending), () => requests.delete(pending));
90
+ return pending;
91
+ },
92
+ async close() {
93
+ closed = true;
94
+ await Promise.allSettled([...requests]);
95
+ await runtime.close();
96
+ await repository.close().catch(() => {});
97
+ },
98
+ };
99
+ }
100
+
101
+ export { PersonError } from './contracts.js';