@yeaft/webchat-agent 1.0.632 → 1.0.634

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.
@@ -12,6 +12,7 @@ import { capabilityExperienceView, recordCapabilityExperience } from './capabili
12
12
  import { chronologicalCursor, conceptView, inspectionCursor, inspectionPage, inspectRequest, messageView, personName, searchRequest, settingsView, stateView } from './inspection.js';
13
13
  import { TOKEN_FIELDS, turnsPage, turnView } from './turn-diagnostics.js';
14
14
  import { personPresentation } from './presentation.js';
15
+ import { executionOrientation } from './behavior.js';
15
16
  import { inspectCapabilities } from './capabilities.js';
16
17
  import { executionEvidencePage, executionReadRequest, nativeInvocationMatches } from './execution-evidence.js';
17
18
  import { OUTPUT_LIMITS, outputArgs, outputMime, outputReadRequest, outputsPage, outputUrl, outputView } from './outputs.js';
@@ -92,8 +93,8 @@ const objectOutput = (record, data) => {
92
93
  if (!mimes.includes(record.mimeType) || !(data instanceof Uint8Array) || data.length !== record.size) fail('INVALID_REQUEST');
93
94
  }
94
95
  };
95
- const READS = new Set(['executionRead', 'outputs', 'outputRead', 'checkOutputPublication', 'receipt', 'turns', 'inspect', 'search', 'getPerson', 'context', 'recall', 'list', 'searchChanges', 'resolveMemories', 'createdCapabilities', 'episodeAttachments']);
96
- const WRITES = new Set(['executionControl', 'savePresentation', 'saveOutput', 'open', 'recover', 'admit', 'heartbeat', 'append', 'startCall', 'finalizeCall', 'startCapability', 'finalizeCapability', 'commit', 'publishProgress', 'finish', 'cancel', 'settings', 'snapshot', 'saveCreatedCapability']);
96
+ const READS = new Set(['autonomyOwners', 'checkExecutionRead', 'executionRead', 'outputs', 'outputRead', 'checkOutputPublication', 'receipt', 'turns', 'inspect', 'search', 'getPerson', 'context', 'recall', 'list', 'searchChanges', 'resolveMemories', 'createdCapabilities', 'episodeAttachments']);
97
+ const WRITES = new Set(['autonomyObserve', 'autonomyAdmit', 'executionControl', 'savePresentation', 'saveOutput', 'open', 'recover', 'admit', 'heartbeat', 'append', 'startCall', 'finalizeCall', 'startCapability', 'finalizeCapability', 'commit', 'publishProgress', 'finish', 'cancel', 'settings', 'snapshot', 'saveCreatedCapability']);
97
98
  const memoryKind = kind => { if (!['messages', 'concepts'].includes(kind)) fail('INVALID_REQUEST'); return kind; };
98
99
  const boundedLimit = (limit, max = 100) => { if (!Number.isSafeInteger(limit) || limit < 1 || limit > max) fail('INVALID_REQUEST'); return limit; };
99
100
  const sequence = value => {
@@ -181,7 +182,7 @@ export class SqlitePersonStore {
181
182
  .run(...scopeValues(scope), kind, record.id, record.revision, JSON.stringify(publicDoc(record)));
182
183
  }
183
184
  personView(p) {
184
- return { id: p.personId, name: p.name, soul: p.soul, soulRevision: p.soulRevision, createdAt: p.createdAt.toISOString(), settings: settingsView(p.settings) };
185
+ return { id: p.personId, name: p.name, soul: p.soul, soulRevision: p.soulRevision, createdAt: p.createdAt.toISOString(), settings: settingsView({ ...p.settings, autonomyEnabled: p.settings.autonomyEnabled === true && p.autonomy?.version === 1 }) };
185
186
  }
186
187
  getPerson(ownerId) {
187
188
  const p = this.one('persons', this.scope(ownerId));
@@ -224,6 +225,10 @@ export class SqlitePersonStore {
224
225
  if (expired) {
225
226
  const id = p.activeEpisodeId;
226
227
  p.epoch++; p.writeSerial++; p.activeEpisodeId = null; p.leaseOwner = null; p.leaseUntil = new Date(0);
228
+ if (p.settings.autonomyEnabled === true && p.autonomy?.version === 1) {
229
+ p.settings.autonomyEnabled = false;
230
+ p.autonomy = { ...p.autonomy, generation: randomUUID(), pending: [], nextAt: null };
231
+ }
227
232
  this.put('persons', p);
228
233
  const episode = this.one('episodes', scope, ' AND id = ?', [id]);
229
234
  if (episode?.status === 'running') {
@@ -243,8 +248,82 @@ export class SqlitePersonStore {
243
248
  const episode = this.one('episodes', this.scope(ownerId), ' AND clientMessageId = ?', [clientMessageId]);
244
249
  return admissionReceipt(episode, clientMessageId, requestHash);
245
250
  }
251
+ /** Only explicit v1 opt-in is schedulable. Legacy settings never acquire consent. */
252
+ autonomyOwners({ after = '', limit = 50 } = {}) {
253
+ boundedLimit(limit, 100);
254
+ const rows = this.sql(`SELECT record FROM persons WHERE namespace = ? AND ownerId > ?
255
+ AND json_extract(record, '$.settings.autonomyEnabled') = 1
256
+ AND json_extract(record, '$.autonomy.version') = 1 ORDER BY ownerId LIMIT ?`)
257
+ .all(this.namespace, after, limit).map(row => JSON.parse(row.record));
258
+ return { owners: rows.map(p => p.ownerId), next: rows.length === limit ? rows.at(-1).ownerId : null };
259
+ }
260
+ autonomyObserve(ownerId, event) {
261
+ const p = this.getPerson(ownerId), a = p.autonomy;
262
+ // No raw task text enters scheduling metadata. Events are untrusted observations,
263
+ // and only tasks from the current consent generation may wake this Person.
264
+ if (p.settings.autonomyEnabled !== true || a?.version !== 1 || !event ||
265
+ !['succeeded', 'failed'].includes(event.status)) return false;
266
+ identifier(event.taskId); identifier(event.episodeId); text(event.sourceRef, 512);
267
+ const source = this.one('episodes', this.scope(ownerId), ' AND id = ?', [event.episodeId]);
268
+ if (source?.autonomyGeneration !== a.generation) return false;
269
+ const key = digest([event.taskId, event.status]);
270
+ if ((a.seen ?? []).includes(key) || (a.integratedSourceRefs ?? []).includes(event.sourceRef)) return false;
271
+ a.seen = [...(a.seen ?? []), key].slice(-64);
272
+ a.pending = [...(a.pending ?? []), { taskId: event.taskId, status: event.status, sourceRef: event.sourceRef }].slice(-8);
273
+ a.nextAt = this.now.getTime() + 5000; a.backoffMs = 60000;
274
+ p.writeSerial++; this.put('persons', p);
275
+ return true;
276
+ }
277
+ autonomyAdmit(ownerId, { workerId, budget }) {
278
+ let p = this.getPerson(ownerId), a = p.autonomy;
279
+ if (p.settings.autonomyEnabled !== true || a?.version !== 1) return null;
280
+ if (p.activeEpisodeId) {
281
+ if (p.leaseUntil <= this.now) {
282
+ // Lost execution control is not permission to replay host effects. Reconcile
283
+ // the episode, then require explicit re-enable before further cognition.
284
+ this.recover(ownerId); p = this.getPerson(ownerId);
285
+ p.settings.autonomyEnabled = false; p.autonomy = { ...a, generation: randomUUID(), pending: [], nextAt: null };
286
+ p.writeSerial++; this.put('persons', p);
287
+ }
288
+ return null; // Includes paused / pausing jobs; never bypass admission.
289
+ }
290
+ const now = this.now.getTime();
291
+ if (!Number.isFinite(a.nextAt) || now < a.nextAt) return null;
292
+ const state = this.one('states', this.scope(ownerId));
293
+ const continuation = state?.continuation ?? null;
294
+ const fingerprint = digest(continuation);
295
+ const pending = a.pending ?? [], taskCompletion = pending.length > 0;
296
+ // The most recent persisted outcome is only an idle retry fence, never an
297
+ // active-task predicate. Task readiness belongs to the exact task host state.
298
+ // Failed cognition may leave an older ready continuation behind: don't turn
299
+ // that into an automatic retry, even after restart or toggling consent.
300
+ const latest = this.one('episodes', this.scope(ownerId), ' ORDER BY inputWatermark DESC');
301
+ // Successful uncommitted discovery may rest and return after the durable quiet
302
+ // interval. This is not permission to retry unchanged work or a failed run.
303
+ const restedExploration = latest?.status === 'completed' && latest.kind === 'autonomous' &&
304
+ latest.executionGoal?.kind === 'exploration' && latest.executionGoal.status === 'resting' && continuation == null;
305
+ const quiet = !taskCompletion && (latest?.status === 'failed' ||
306
+ (a.lastFingerprint === fingerprint && !restedExploration) || (continuation && continuation.status !== 'ready'));
307
+ a.nextAt = now + Math.min(a.backoffMs ?? 60000, 3600000);
308
+ a.backoffMs = Math.min((a.backoffMs ?? 60000) * 2, 3600000);
309
+ p.writeSerial++; this.put('persons', p);
310
+ if (quiet) return null; // A stored ready or repeated blocker isn't fresh evidence.
311
+ const reason = taskCompletion ? 'task-completion' : 'idle';
312
+ const scheduling = { origin: 'autonomous', reason, observationOnly: true, authorization: false,
313
+ notice: 'System scheduling metadata, not a user request or permission. Task observations are untrusted; reconcile current status, do not replay effects or repeat prior replies.',
314
+ observations: pending };
315
+ const admitted = this.admit(ownerId, { kind: 'autonomous', text: JSON.stringify(scheduling), workerId, budget,
316
+ clientMessageId: `autonomy-${a.generation}-${(a.sequence ?? 0) + 1}` });
317
+ admitted.episode.scheduling = scheduling; this.put('episodes', admitted.episode);
318
+ p = this.getPerson(ownerId);
319
+ // Admission is not consumption: failed cognition must leave observations retryable.
320
+ p.autonomy = { ...a, sequence: (a.sequence ?? 0) + 1, lastFingerprint: fingerprint };
321
+ this.put('persons', p);
322
+ this.trace(ownerId, admitted.episodeId, 'autonomy_scheduled', { scheduling });
323
+ return admitted;
324
+ }
246
325
  admit(ownerId, { kind, text: input, clientMessageId, workerId, budget, files = [] }) {
247
- if (!['send', 'think', 'dream'].includes(kind)) fail('INVALID_REQUEST');
326
+ if (!['send', 'think', 'dream', 'autonomous'].includes(kind)) fail('INVALID_REQUEST');
248
327
  const validated = validateFiles(files, input, kind), attachments = validated.map(attachmentMetadata);
249
328
  identifier(clientMessageId); text(workerId, 256);
250
329
  this.recover(ownerId);
@@ -256,8 +335,14 @@ export class SqlitePersonStore {
256
335
  }
257
336
  const p = this.getPerson(ownerId);
258
337
  if (p.activeEpisodeId) fail('BUSY');
338
+ if (kind !== 'autonomous' && p.settings.autonomyEnabled === true && p.autonomy?.version === 1) {
339
+ p.autonomy = { ...p.autonomy, backoffMs: 60000, nextAt: this.now.getTime() + 60000, lastFingerprint: null };
340
+ }
259
341
  const id = randomUUID();
260
- const hasMessage = kind === 'send' || (kind === 'think' && attachments.length > 0);
342
+ // Explicit text-only Think instructions are user input too, even if this
343
+ // episode fails before cognition commits. Empty Think/Dream/scheduler wakes
344
+ // must never replace the latest substantive user instruction.
345
+ const hasMessage = kind === 'send' || (kind === 'think' && (input.trim().length > 0 || attachments.length > 0));
261
346
  p.activeEpisodeId = id; p.leaseOwner = workerId; p.leaseUntil = new Date(this.now.getTime() + this.leaseMs);
262
347
  p.epoch++; p.writeSerial++; p.inputWatermark++; if (hasMessage) p.messageSeq++;
263
348
  this.put('persons', p);
@@ -269,7 +354,7 @@ export class SqlitePersonStore {
269
354
  this.put('messages', message, true); this.journal(scope, 'messages', message);
270
355
  }
271
356
  const episode = this.doc(scope, { id, clientMessageId, requestHash, kind, text: input, messageId, attachments, modelCandidates: [...(p.settings.modelCandidates ?? [])], defaultModel: p.settings.defaultModel ?? null, status: 'running', workerId, epoch: p.epoch,
272
- baseStateVersion: p.stateVersion, inputWatermark: p.inputWatermark, controlVersion: p.controlVersion, budget, createdAt: this.now });
357
+ baseStateVersion: p.stateVersion, inputWatermark: p.inputWatermark, controlVersion: p.controlVersion, autonomyGeneration: p.settings.autonomyEnabled === true ? p.autonomy?.generation ?? null : null, budget, createdAt: this.now });
273
358
  this.put('episodes', episode, true);
274
359
  this.trace(ownerId, id, 'accepted', { trigger: { kind, text: input, messageId, attachments }, baseStateVersion: p.stateVersion, budget });
275
360
  return { episodeId: id, duplicate: false, status: 'running', episode };
@@ -326,6 +411,7 @@ export class SqlitePersonStore {
326
411
  if (!record || record.status !== 'running') fail('STALE');
327
412
  const experience = recordCapabilityExperience(p.capabilityExperience, record, kind, data, this.now.toISOString());
328
413
  if (experience) p.capabilityExperience = experience;
414
+ if (kind === 'execution_goal') { record.executionGoal = data.goal; this.put('episodes', record); }
329
415
  p.writeSerial++; this.put('persons', p);
330
416
  return this.trace(episode.ownerId, episode.id, kind, data);
331
417
  }
@@ -352,6 +438,10 @@ export class SqlitePersonStore {
352
438
  const safeEffective = { model: call.requested.model, effort: typeof effective?.effort === 'string' && effective.effort.length <= 128 ? effective.effort : null,
353
439
  effortObserved: effective?.effortObserved === true };
354
440
  if (typeof effective?.wireMode === 'string' && effective.wireMode.length <= 128) safeEffective.wireMode = effective.wireMode;
441
+ // Only evidence actually rendered to a successful model call can be
442
+ // acknowledged at commit. Scheduling metadata and inherited refs are not reads.
443
+ if (!failed) record.integratedTaskSourceRefs = [...new Set([...(record.integratedTaskSourceRefs ?? []),
444
+ ...(call.manifest?.renderedSourceRefs ?? []).filter(ref => typeof ref === 'string' && ref.startsWith('person-task:'))])].slice(-64);
355
445
  delete record.openCall; delete record.callFinalizeUntil; this.put('episodes', record);
356
446
  this.trace(episode.ownerId, episode.id, failed ? 'call_failed' : 'call_output', {
357
447
  callId, requested: call.requested, effective: safeEffective, output: publicOutput(output, failed),
@@ -413,6 +503,7 @@ export class SqlitePersonStore {
413
503
  const data = { callId, ...(started.modelCallId !== undefined ? { modelCallId: started.modelCallId, toolUseId: started.toolUseId, toolIndex: started.toolIndex } : {}), ...(started.capabilityManifest ? { capabilityManifest: started.capabilityManifest } : {}),
414
504
  ...(failed ? { capabilityId: started.capability.id, code: code || result.code } : { capability: started.capability }),
415
505
  ...(result !== undefined ? { result } : {}) };
506
+ if (live && started.capability.id === 'Execution.goal' && result?.ok === true) record.executionGoal = result.goal;
416
507
  delete record.openCapability; this.put('episodes', record);
417
508
  if (live) {
418
509
  const experience = recordCapabilityExperience(p.capabilityExperience, record, outcome, data, this.now.toISOString());
@@ -425,14 +516,18 @@ export class SqlitePersonStore {
425
516
  }
426
517
  /** Exact scoped read in one read transaction; historical evidence grants no lease,
427
518
  * publication, capability activation or current behavioral authority. */
428
- executionRead(episode, args) {
429
- const request = executionReadRequest(args);
519
+ checkExecutionRead(episode, episodeId = episode.id) {
430
520
  this.own(episode);
431
- const scope = this.episodeScope(episode);
432
- const live = this.one('episodes', scope, ' AND id = ?', [episode.id]);
521
+ const scope = this.episodeScope(episode), live = this.one('episodes', scope, ' AND id = ?', [episode.id]);
433
522
  if (!live || live.status !== 'running' || live.epoch !== episode.epoch || live.workerId !== episode.workerId) fail('STALE');
434
- const episodeId = request.episodeId ?? episode.id;
435
523
  if (!this.one('episodes', scope, ' AND id = ?', [episodeId])) fail('NOT_FOUND');
524
+ return true;
525
+ }
526
+ executionRead(episode, args) {
527
+ const request = executionReadRequest(args);
528
+ this.checkExecutionRead(episode, request.episodeId);
529
+ const scope = this.episodeScope(episode), episodeId = request.episodeId ?? episode.id;
530
+ if (request.sourceRef !== undefined) fail('INVALID_REQUEST');
436
531
  const trace = this.one('traces', scope,
437
532
  " AND json_extract(record, '$.episodeId') = ? AND json_extract(record, '$.callId') = ? AND (json_extract(record, '$.kind') IN ('capability_result', 'capability_failed') OR (json_extract(record, '$.kind') = 'capability_finalized' AND json_extract(record, '$.outcome') IN ('capability_result', 'capability_failed'))) ORDER BY seq DESC", [episodeId, request.callId]);
438
533
  if (!trace) fail('NOT_FOUND');
@@ -469,11 +564,54 @@ export class SqlitePersonStore {
469
564
  }
470
565
  context(episode) {
471
566
  const p = this.own(episode), scope = this.episodeScope(episode), state = this.one('states', scope);
472
- const focused = this.focused(scope, state.focusConceptIds).slice(0, 12);
473
- const recent = this.rows('concepts', scope, ' ORDER BY updatedAt DESC, id ASC LIMIT 12');
567
+ const focused = this.focused(scope, state.focusConceptIds.slice(0, 12));
568
+ const latestUser = this.one('messages', scope, " AND json_extract(record, '$.role') = 'user' ORDER BY seq DESC");
569
+ const dream = executionOrientation(episode.kind, episode.scheduling) === 'dream', recentLimit = dream ? 6 : 12;
570
+ const recent = this.rows('concepts', scope, ` ORDER BY updatedAt DESC, id ASC LIMIT ${recentLimit}`);
571
+ let messages = dream ? [] : this.rows('messages', scope, ' ORDER BY seq DESC LIMIT 12');
572
+ let older = [], olderMessages = [];
573
+ if (dream) {
574
+ // Rotate complete records, not LLM summaries or random full-database loads.
575
+ // SQL counts/offsets may scan indexes; only the small selected pages are decoded.
576
+ // inputWatermark advances on admission, including episodes that never commit.
577
+ const rotation = Math.max(0, episode.inputWatermark - 1);
578
+ const recentUsers = this.rows('messages', scope, " AND json_extract(record, '$.role') = 'user' ORDER BY seq DESC LIMIT 6");
579
+ const boundary = recentUsers.at(-1)?.seq ?? 0;
580
+ const userClause = " AND json_extract(record, '$.role') = 'user' AND seq < ?";
581
+ const count = this.sql(`SELECT count(*) AS n FROM messages WHERE ${SCOPE}${userClause}`).get(...scopeValues(scope), boundary).n;
582
+ if (count) {
583
+ const offset = rotation * 4 % count;
584
+ olderMessages = this.rows('messages', scope, `${userClause} ORDER BY seq ASC LIMIT 4 OFFSET ?`, [boundary, offset]);
585
+ if (olderMessages.length < Math.min(4, count)) olderMessages.push(...this.rows('messages', scope,
586
+ `${userClause} ORDER BY seq ASC LIMIT ?`, [boundary, Math.min(4, count) - olderMessages.length]));
587
+ }
588
+ // Assistant progress is not evidence of user interests. Keep at most two
589
+ // substantive replies for conversational grounding, independent of progress volume.
590
+ const replies = this.rows('messages', scope,
591
+ " AND json_extract(record, '$.role') = 'assistant' AND coalesce(json_extract(record, '$.replyKind'), '') != 'progress' ORDER BY seq DESC LIMIT 2");
592
+ messages = [...recentUsers, ...olderMessages, ...replies];
593
+ const excluded = [...new Set([...focused, ...recent].map(c => c.id))];
594
+ const exclusion = excluded.length ? ` AND id NOT IN (${excluded.map(() => '?').join(',')})` : '';
595
+ for (const kind of ['claim', 'question', 'method', 'self-model', 'scenario', 'interest']) {
596
+ const clause = ` AND json_extract(record, '$.kind') = ?${exclusion}`;
597
+ const params = [kind, ...excluded];
598
+ const total = this.sql(`SELECT count(*) AS n FROM concepts WHERE ${SCOPE}${clause}`).get(...scopeValues(scope), ...params).n;
599
+ if (total) older.push(...this.rows('concepts', scope, `${clause} ORDER BY updatedAt ASC, id ASC LIMIT 1 OFFSET ?`, [...params, rotation % total]));
600
+ }
601
+ }
602
+ // A long assistant progress stream must never erase the newest user stop or
603
+ // prohibition. Runtime also receives it separately for mandatory admission.
604
+ if (latestUser && !messages.some(m => m.id === latestUser.id)) messages = [...messages.slice(0, 11), latestUser];
605
+ messages.sort((a, b) => a.seq - b.seq);
474
606
  return { person: this.personView(p), capabilityExperience: capabilityExperienceView(p.capabilityExperience),
475
- state: stateView(state), messages: this.rows('messages', scope, ' ORDER BY seq DESC LIMIT 12').reverse().map(messageView),
476
- concepts: [...new Map([...focused, ...recent].map(c => [c.id, conceptView(c)])).values()] };
607
+ state: stateView(state), latestUserInstruction: messageView(latestUser), messages: messages.map(messageView),
608
+ concepts: [...new Map([...focused, ...recent, ...older].map(c => [c.id, conceptView(c)])).values()],
609
+ contextSelection: { strategy: dream ? 'dream-diverse' : 'recent', rotation: dream ? episode.inputWatermark - 1 : null,
610
+ limits: { messages: 12, focusedConcepts: 12, recentConcepts: recentLimit, olderConcepts: dream ? 6 : 0 },
611
+ recentUserMessageRefs: dream ? messages.filter(m => m.role === 'user' && !olderMessages.some(old => old.id === m.id)).map(m => `message:${m.id}:${m.revision}`) : [],
612
+ olderUserMessageRefs: olderMessages.map(m => `message:${m.id}:${m.revision}`),
613
+ focusedConceptRefs: focused.map(c => `concept:${c.id}:${c.revision}`), recentConceptRefs: recent.map(c => `concept:${c.id}:${c.revision}`),
614
+ olderConceptRefs: older.map(c => `concept:${c.id}:${c.revision}`) } };
477
615
  }
478
616
  /** Atomic capability publication: exact started invocation + live lease, never
479
617
  * publishProgress's no-open-capability path. JSON carries the canonical payload;
@@ -497,7 +635,10 @@ export class SqlitePersonStore {
497
635
  if (!record || record.status !== 'running' || record.openCall || !proof || record.openCapability?.callId !== callId ||
498
636
  record.openCapability.invocationHash !== invocationHash) fail('STALE');
499
637
  // Repeating an identical delivery in this episode adds no new visible answer.
500
- const repeated = prior.find(m => digest(m.presentation) === digest(presentation));
638
+ const repeated = prior.find(m => digest(m.presentation) === digest(presentation)) ??
639
+ (episode.kind === 'autonomous' ? this.rows('messages', scope,
640
+ " AND json_type(record, '$.presentation') = 'object' ORDER BY seq DESC LIMIT 32")
641
+ .find(m => digest(m.presentation) === digest(presentation)) : null);
501
642
  if (repeated) {
502
643
  this.trace(episode.ownerId, episode.id, 'presentation_published', { callId, invocationHash, messageId: repeated.id, phase: presentation.phase, duplicate: true });
503
644
  return messageView(repeated);
@@ -511,6 +652,11 @@ export class SqlitePersonStore {
511
652
  this.trace(episode.ownerId, episode.id, 'presentation_published', { callId, invocationHash, messageId: message.id, phase: presentation.phase });
512
653
  return messageView(message);
513
654
  }
655
+ repeatsAutonomousReply(scope, episode, reply) {
656
+ return episode.kind === 'autonomous' && typeof reply === 'string' &&
657
+ this.rows('messages', scope, " AND json_extract(record, '$.role') = 'assistant' ORDER BY seq DESC LIMIT 32")
658
+ .some(message => message.text.trim() === reply.trim());
659
+ }
514
660
  repeatsPresentation(scope, episodeId, reply) {
515
661
  return typeof reply === 'string' && this.rows('messages', scope,
516
662
  " AND json_extract(record, '$.episodeId') = ? AND json_type(record, '$.presentation') = 'object'", [episodeId])
@@ -541,9 +687,12 @@ export class SqlitePersonStore {
541
687
  // cognition does not interpret dedupe as a missing public result.
542
688
  const sameReply = prior.find(m => m.text.trim() === reply.trim());
543
689
  if (final && sameReply) return messageView(sameReply);
690
+ if (final && this.repeatsAutonomousReply(scope, episode, reply)) {
691
+ return messageView(this.rows('messages', scope, " AND json_extract(record, '$.role') = 'assistant' ORDER BY seq DESC LIMIT 32").find(message => message.text.trim() === reply.trim()));
692
+ }
544
693
  // Provider latency counts too: a call started before 30s can finish after it.
545
694
  if ((!final && this.now.getTime() - new Date(record.lastReplyAt ?? record.createdAt).getTime() < FEEDBACK.minMs) ||
546
- sameReply || this.repeatsPresentation(scope, episode.id, reply)) return null;
695
+ sameReply || this.repeatsAutonomousReply(scope, episode, reply) || this.repeatsPresentation(scope, episode.id, reply)) return null;
547
696
  p.messageSeq++; p.writeSerial++; this.put('persons', p);
548
697
  const message = this.doc(scope, { id: randomUUID(), revision: 1, seq: p.messageSeq, episodeId: episode.id,
549
698
  callId, replyKind: final ? 'execution-final' : 'progress', role: 'assistant', text: reply, createdAt: this.now });
@@ -554,11 +703,12 @@ export class SqlitePersonStore {
554
703
  }
555
704
  commit(episode, proposal, selection, callId, reportedSources = new Map()) {
556
705
  const p = this.own(episode), scope = this.episodeScope(episode);
557
- if (proposal.baseStateVersion !== episode.baseStateVersion) fail('STALE');
706
+ const record = this.one('episodes', scope, ' AND id = ?', [episode.id]);
707
+ if (!record || record.status !== 'running' || proposal.baseStateVersion !== episode.baseStateVersion) fail('STALE');
558
708
  const repeatsExecutionReply = proposal.reply && this.rows('messages', scope,
559
709
  " AND json_extract(record, '$.episodeId') = ? AND json_extract(record, '$.replyKind') IN ('progress', 'execution-final')", [episode.id])
560
710
  .some(message => message.text.trim() === proposal.reply.trim());
561
- const publishReply = proposal.reply && !repeatsExecutionReply && !this.repeatsPresentation(scope, episode.id, proposal.reply);
711
+ const publishReply = proposal.reply && !this.repeatsAutonomousReply(scope, episode, proposal.reply) && !repeatsExecutionReply && !this.repeatsPresentation(scope, episode.id, proposal.reply);
562
712
  p.stateVersion++; p.writeSerial++; if (publishReply) p.messageSeq++;
563
713
  p.activeEpisodeId = null; p.leaseOwner = null; p.leaseUntil = new Date(0);
564
714
  this.put('persons', p);
@@ -576,18 +726,38 @@ export class SqlitePersonStore {
576
726
  if (new Set(proposal.state.focusConceptIds).size !== proposal.state.focusConceptIds.length || focus.length !== proposal.state.focusConceptIds.length) fail('STALE');
577
727
  const priorState = this.one('states', scope);
578
728
  const continuation = Object.hasOwn(proposal.state, 'continuation') ? proposal.state.continuation : priorState?.continuation;
579
- const state = this.doc(scope, { ...proposal.state, ...(continuation !== undefined ? { continuation } : {}), version: p.stateVersion, conceptRefs: focus.map(c => ({ id: c.id, revision: c.revision })),
729
+ const understanding = Object.hasOwn(proposal.state, 'understanding') ? proposal.state.understanding : priorState?.understanding;
730
+ const state = this.doc(scope, { ...proposal.state, ...(continuation !== undefined ? { continuation } : {}),
731
+ ...(understanding !== undefined ? { understanding } : {}), version: p.stateVersion, conceptRefs: focus.map(c => ({ id: c.id, revision: c.revision })),
580
732
  decision: proposal.decision, lastSelection: { model: selection.model, effort: selection.effort }, updatedAt: this.now, episodeId: episode.id });
581
733
  if (this.one('states', scope)?.version !== episode.baseStateVersion) fail('STALE');
582
734
  this.put('states', state);
735
+ if (episode.kind === 'autonomous' && p.autonomy?.generation === episode.autonomyGeneration) {
736
+ p.autonomy.lastFingerprint = digest(continuation ?? null);
737
+ // Backoff starts when discovery actually rests, not when a potentially long
738
+ // episode was admitted. Never immediately wake after a long execution.
739
+ if (record.executionGoal?.kind === 'exploration' && record.executionGoal.status === 'resting' && continuation == null) {
740
+ p.autonomy.nextAt = this.now.getTime() + (p.autonomy.backoffMs ?? 60000);
741
+ }
742
+ this.put('persons', p);
743
+ }
583
744
  this.put('state_commits', this.doc(scope, { id: randomUUID(), version: p.stateVersion, parentVersion: episode.baseStateVersion,
584
745
  episodeId: episode.id, callId, conceptRevisions: revisions, state: stateView(state), createdAt: this.now }), true);
585
746
  if (publishReply) {
586
747
  const message = this.doc(scope, { id: randomUUID(), revision: 1, seq: p.messageSeq, episodeId: episode.id, callId, replyKind: 'final', role: 'assistant', text: proposal.reply, createdAt: this.now });
587
748
  this.put('messages', message, true); this.journal(scope, 'messages', message);
588
749
  }
589
- const record = this.one('episodes', scope, ' AND id = ?', [episode.id]);
590
- if (!record || record.status !== 'running') fail('STALE');
750
+ if (p.settings.autonomyEnabled === true && p.autonomy?.generation === record.autonomyGeneration) {
751
+ const integrated = new Set(record.integratedTaskSourceRefs ?? []);
752
+ if (integrated.size) {
753
+ // Exact event identity only. A completion arriving after the last model
754
+ // read survives; rollback of any rejected commit preserves every event.
755
+ p.autonomy.pending = (p.autonomy.pending ?? []).filter(event => !integrated.has(event.sourceRef));
756
+ p.autonomy.integratedSourceRefs = [...new Set([...(p.autonomy.integratedSourceRefs ?? []), ...integrated])].slice(-64);
757
+ p.autonomy.lastFingerprint = digest(continuation ?? null);
758
+ this.put('persons', p);
759
+ }
760
+ }
591
761
  delete record.executionControl;
592
762
  Object.assign(record, { status: 'completed', endedAt: this.now, stateVersion: p.stateVersion }); this.put('episodes', record);
593
763
  this.trace(episode.ownerId, episode.id, 'committed', { callId, parentVersion: episode.baseStateVersion, stateVersion: p.stateVersion, conceptRevisions: revisions, decision: proposal.decision });
@@ -607,7 +777,17 @@ export class SqlitePersonStore {
607
777
  }
608
778
  cancel(ownerId, episodeId = null) {
609
779
  const scope = this.scope(ownerId), p = this.getPerson(ownerId);
610
- if (!p.activeEpisodeId || (episodeId && p.activeEpisodeId !== episodeId)) return { cancelled: false, episodeId };
780
+ // Cancel is also a durable autonomy stop, even when the target already ended.
781
+ p.settings = { ...p.settings, autonomyEnabled: false };
782
+ p.autonomy = { version: 1, generation: randomUUID(), pending: [], nextAt: null };
783
+ p.writeSerial++; this.put('persons', p);
784
+ if (!p.activeEpisodeId) return { cancelled: false, episodeId };
785
+ if (episodeId && p.activeEpisodeId !== episodeId) {
786
+ const active = this.one('episodes', scope, ' AND id = ?', [p.activeEpisodeId]);
787
+ if (active?.kind !== 'autonomous') return { cancelled: false, episodeId };
788
+ // The target may finish just before a queued autonomous admission. Cancel
789
+ // that follow-up too; never let a stale target bypass the owner's stop.
790
+ }
611
791
  const id = p.activeEpisodeId, until = p.leaseUntil;
612
792
  p.epoch++; p.controlVersion++; p.writeSerial++; p.activeEpisodeId = null; p.leaseOwner = null; p.leaseUntil = new Date(0); this.put('persons', p);
613
793
  const record = this.one('episodes', scope, ' AND id = ?', [id]);
@@ -618,14 +798,21 @@ export class SqlitePersonStore {
618
798
  this.trace(ownerId, id, 'cancelled', { code: 'CANCELLED' });
619
799
  return { cancelled: true, episodeId: id };
620
800
  }
621
- settings(ownerId, settings, expectedControlVersion) {
801
+ settings(ownerId, settings, expectedControlVersion, expectedAutonomyGeneration) {
622
802
  const p = this.getPerson(ownerId);
623
- if (p.activeEpisodeId) fail('BUSY');
803
+ const autonomyOnly = Object.keys(settings).length === 1 && Object.hasOwn(settings, 'autonomyEnabled');
804
+ if (p.activeEpisodeId && !autonomyOnly) fail('BUSY');
805
+ if (expectedAutonomyGeneration !== undefined && (p.autonomy?.generation ?? null) !== expectedAutonomyGeneration) fail('STALE');
806
+ if (Object.hasOwn(settings, 'autonomyEnabled')) {
807
+ if (typeof settings.autonomyEnabled !== 'boolean') fail('INVALID_REQUEST');
808
+ p.autonomy = { version: 1, generation: randomUUID(), pending: [], backoffMs: 60000,
809
+ nextAt: settings.autonomyEnabled ? this.now.getTime() + 5000 : null };
810
+ }
624
811
  if (expectedControlVersion !== undefined && p.controlVersion !== expectedControlVersion) fail('STALE');
625
812
  const { name, ...patch } = settings;
626
813
  if (Object.hasOwn(settings, 'name')) p.name = personName(name);
627
814
  // A partial update must preserve durable fields omitted from the public view.
628
- p.settings = { ...p.settings, ...patch }; p.controlVersion++; p.writeSerial++; this.put('persons', p);
815
+ p.settings = { ...p.settings, ...patch }; if (!autonomyOnly) p.controlVersion++; p.writeSerial++; this.put('persons', p);
629
816
  if (Object.keys(settings).length) this.trace(ownerId, null, 'settings', { settings: settingsView(p.settings), ...(name !== undefined ? { name: p.name } : {}) });
630
817
  return { settings: settingsView(p.settings), person: this.personView(p) };
631
818
  }
@@ -4,7 +4,7 @@ import { randomUUID } from 'node:crypto';
4
4
  import { TaskManager } from '../tasks/manager.js';
5
5
  import { TASK_RESULT_DELIVERY, isTerminalTaskStatus } from '../tasks/store.js';
6
6
  import { createFullRegistry } from '../tools/index.js';
7
- import { ToolRegistry, normalizeToolOutput } from '../tools/registry.js';
7
+ import { ToolRegistry, normalizeToolOutput, isToolErrorOutput, toolErrorEffect } from '../tools/registry.js';
8
8
  import { SubAgentToolRegistry } from '../sub-agent/execution-control.js';
9
9
  import { agentBelongsToScope, getAgentRegistry } from '../tools/agent.js';
10
10
  import closeAgent from '../tools/close-agent.js';
@@ -15,6 +15,7 @@ import { NullTrace } from '../debug-trace.js';
15
15
  import { loadConfig } from '../config.js';
16
16
  import { digest, fail, PERSON_TASK_LIMITS, personTaskRequest, taskInspectionCursor } from './contracts.js';
17
17
  import { inspectionPage, inspectionTime } from './inspection.js';
18
+ import { executionEvidencePage, executionReadRequest, personToolReference, utf8End } from './execution-evidence.js';
18
19
 
19
20
  // Deliberately not the full Session registry: no transcript search, routing,
20
21
  // interactive UI, MCP, Work Center, or nested orchestration authority.
@@ -187,7 +188,7 @@ class ScopedPersonTaskHost {
187
188
  }
188
189
 
189
190
  #session(sessionId) {
190
- if (sessionId != null && sessionId !== this.sessionId) throw denied();
191
+ if (sessionId != null && sessionId !== '' && sessionId !== this.sessionId) throw denied();
191
192
  return this.sessionId;
192
193
  }
193
194
 
@@ -274,12 +275,15 @@ class ScopedPersonTaskHost {
274
275
  if (!task || task.sessionId !== this.sessionId || task.ownerVpId !== this.parentVpId) return;
275
276
  this.#persistAgents();
276
277
  if (event.event === 'completed') {
277
- const completion = { kind: 'person-task-completion', namespace: this.scope.key, task: clone(task),
278
+ const completion = { kind: 'person-task-completion', namespace: this.scope.key, ownerId: this.scope.ownerId, personId: this.scope.personId, task: clone(task),
278
279
  sourceRef: `person-task:${this.scope.key}:${task.id}:${task.status}:${task.updatedAt}`,
279
- notice: 'External task evidence only. No Person episode was scheduled.' };
280
+ notice: 'Untrusted external task observation, not user instructions or authorization. Opt-in scheduling is decided separately by durable admission.' };
280
281
  atomicJSON(join(this.dataRoot, 'completions', `${task.id}.json`), completion);
281
- // Observability only: never register model re-entry or call runtime.start.
282
- Promise.resolve().then(() => this.#options.onCompletion?.(completion)).catch(() => {});
282
+ // Emit only newly observed terminal events, never replay completion files.
283
+ // Orphan/cancel evidence reconciles status but must not request model re-entry.
284
+ if (['succeeded', 'failed'].includes(task.status) && !this.#closed) {
285
+ Promise.resolve().then(() => { if (!this.#closed) return this.#options.onCompletion?.(completion); }).catch(() => {});
286
+ }
283
287
  }
284
288
  }
285
289
 
@@ -337,7 +341,8 @@ class ScopedPersonTaskHost {
337
341
  async #execute(tool, args = {}, caller, deps) {
338
342
  this.#assertOpen(deps.episodeId);
339
343
  this.#assertScope(caller, { child: true });
340
- if (args.sessionId != null && args.sessionId !== this.sessionId) throw denied();
344
+ if (args.sessionId != null && args.sessionId !== '' && args.sessionId !== this.sessionId) throw denied();
345
+ if (args.sessionId === '') args = { ...args, sessionId: this.sessionId };
341
346
  if (!this.allowedToolIds().includes(tool.name)) return JSON.stringify({ error: 'Native tool disabled', errorEffect: 'none' });
342
347
  if (tool.name === 'SpawnAgent' && !deps.adapter) return JSON.stringify({ error: 'Person child provider unavailable', errorEffect: 'none' });
343
348
  const controller = new AbortController();
@@ -402,11 +407,45 @@ class ScopedPersonTaskHost {
402
407
  #archiveToolResult(job, output, error) {
403
408
  const text = error ? String(error.message || error) : normalizeToolOutput(output);
404
409
  const result = { id: job.id, tool: job.tool, episodeId: job.episodeId, childId: job.childId,
405
- updatedAt: new Date().toISOString(), ok: !error, aborted: job.controller.signal.aborted,
410
+ updatedAt: new Date().toISOString(), ok: !error && !isToolErrorOutput(text), aborted: job.controller.signal.aborted,
411
+ ...(error ? { code: 'TOOL_FAILED', errorEffect: 'unknown' } : isToolErrorOutput(text) ? { code: 'TOOL_FAILED', errorEffect: toolErrorEffect(text) } : {}),
406
412
  output: text, sourceRef: `person-tool:${this.scope.key}:${job.id}` };
407
413
  atomicJSON(join(this.dataRoot, 'tool-results', `${job.id}.json`), result);
408
414
  }
409
415
 
416
+ /** Exact owned archive only. Repository authorization is required before IO
417
+ * and again before returning bytes; neither model paths nor scopes authorize it. */
418
+ async readToolResult(episode, args, { repository, signal } = {}) {
419
+ signal?.throwIfAborted();
420
+ this.#assertOpen(episode?.id);
421
+ if (!episode?.id || episode.ownerId !== this.scope.ownerId || episode.personId !== this.scope.personId ||
422
+ episode.namespace !== this.scope.namespace) throw denied();
423
+ const request = executionReadRequest(args);
424
+ const { scope, id } = personToolReference(request.sourceRef);
425
+ if (scope !== this.scope.key) throw denied();
426
+ if (!repository?.checkExecutionRead) fail('UNSUPPORTED');
427
+ await repository.checkExecutionRead(episode, request.episodeId);
428
+ signal?.throwIfAborted();
429
+ const path = privatePath(this.yeaftDir, join(this.dataRoot, 'tool-results', `${id}.json`));
430
+ let result;
431
+ try {
432
+ if (!lstatSync(path).isFile()) throw denied();
433
+ result = JSON.parse(readFileSync(path, 'utf8'));
434
+ } catch (error) {
435
+ if (error.code === 'ENOENT') fail('NOT_FOUND');
436
+ if (error.code === 'TASK_SCOPE_DENIED') throw error;
437
+ fail('STORAGE_UNAVAILABLE');
438
+ }
439
+ if (!result || typeof result !== 'object' || Array.isArray(result)) fail('STORAGE_UNAVAILABLE');
440
+ if (result.id !== id || result.sourceRef !== request.sourceRef || result.episodeId !== (request.episodeId ?? episode.id)) throw denied();
441
+ if (typeof result.output !== 'string' || typeof result.tool !== 'string' || typeof result.ok !== 'boolean') fail('STORAGE_UNAVAILABLE');
442
+ const page = executionEvidencePage({ episodeId: result.episodeId, result, capabilityId: result.tool }, null, request, episode.id);
443
+ await repository.checkExecutionRead(episode, request.episodeId);
444
+ signal?.throwIfAborted();
445
+ this.#assertOpen(episode.id);
446
+ return page;
447
+ }
448
+
410
449
  #instrumentChild(agent, deps) {
411
450
  if (!agent || !agentBelongsToScope(agent, this.agentScope)) return;
412
451
  agent.personEpisodeId = deps.episodeId;
@@ -571,6 +610,17 @@ class ScopedPersonTaskHost {
571
610
  }
572
611
  }
573
612
 
613
+ /** Exact owner-scoped control state, never the capped model evidence page. */
614
+ goalState(episodeId) {
615
+ this.#persistAgents();
616
+ return {
617
+ tasks: this.#manager.listActiveTasks(this.sessionId, this.parentVpId).map(task => ({ id: task.id, status: task.status,
618
+ currentEpisode: task.source?.episodeId === episodeId })),
619
+ agents: [...this.#agents.values()].map(agent => ({ id: agent.id, status: agent.status,
620
+ executionPending: agent.executionPending, currentEpisode: agent.personEpisodeId === episodeId })),
621
+ };
622
+ }
623
+
574
624
  /** Completion records remain facts, not user messages or automatic triggers. */
575
625
  evidence({ limit = 50 } = {}) {
576
626
  if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw new Error('Invalid evidence limit');
@@ -579,8 +629,11 @@ class ScopedPersonTaskHost {
579
629
  return { namespace: this.scope.key, sessionId: this.sessionId,
580
630
  tasks: recent(jsonFiles(this.#manager.store.sessionDir(this.sessionId), { limit })),
581
631
  agents: clone([...this.#agents.values()].slice(-limit)), completions: recent(jsonFiles(join(this.dataRoot, 'completions'), { limit })),
582
- toolResults: recent(jsonFiles(join(this.dataRoot, 'tool-results'), { limit })).map(result => ({ ...result,
583
- output: result.output.slice(0, 8192), rawPath: join(this.dataRoot, 'tool-results', `${result.id}.json`) })) };
632
+ toolResults: recent(jsonFiles(join(this.dataRoot, 'tool-results'), { limit })).map(result => {
633
+ const raw = Buffer.from(result.output, 'utf8'), end = utf8End(raw, Math.min(raw.length, 8192));
634
+ return { ...result, rawBytes: raw.length, sha256: digest(result.output), output: raw.subarray(0, end).toString('utf8'),
635
+ ...(end < raw.length ? { outputTruncated: true, outputNextOffset: end } : {}), rawPath: join(this.dataRoot, 'tool-results', `${result.id}.json`) };
636
+ }) };
584
637
  }
585
638
 
586
639
  /** Explicit cancellation stops and joins this Person's effects; normal episode
@@ -672,8 +725,25 @@ export class PersonTaskHost {
672
725
  return this.#host(episode.ownerId, episode.personId).context(input);
673
726
  }
674
727
 
728
+ goalState(ownerId, episodeId) {
729
+ return this.#host(ownerId).goalState(episodeId);
730
+ }
731
+
675
732
  snapshot(ownerId) { return this.#host(ownerId).snapshot(ownerId); }
676
733
 
734
+ async readToolResult(episode, args, options) {
735
+ if (!episode?.id || episode.namespace !== this.#options.namespace) throw denied();
736
+ return this.#host(episode.ownerId, episode.personId).readToolResult(episode, args, options);
737
+ }
738
+
739
+ toolResultRef(episode, callId) {
740
+ if (!episode?.id || episode.namespace !== this.#options.namespace) throw denied();
741
+ const host = this.#host(episode.ownerId, episode.personId);
742
+ const ref = `person-tool:${host.scope.key}:${callId}`;
743
+ personToolReference(ref);
744
+ return ref;
745
+ }
746
+
677
747
  request(input = {}) {
678
748
  if (typeof input.ownerId !== 'string' || !input.ownerId || typeof input.personId !== 'string' || !input.personId
679
749
  || input.namespace !== this.#options.namespace) throw denied();
@@ -4,6 +4,7 @@ import { executionReadRequest } from './execution-evidence.js';
4
4
  import { outputArgs } from './outputs.js';
5
5
  import { personPresentation } from './presentation.js';
6
6
  import { scriptInput } from './script-executor.js';
7
+ import { validateExecutionGoal } from './execution-goal.js';
7
8
 
8
9
  /** Pre-dispatch schema check, deliberately separate from execution: rejection
9
10
  * here proves that no host effect has begun and can safely become tool feedback.
@@ -50,7 +51,8 @@ export function validateToolArguments(value, schema) {
50
51
  * callers must not wrap capability execution in the same catch. */
51
52
  export function validateCapabilityArguments({ id, args }) {
52
53
  try {
53
- if (id === 'Capability.create') validateCreatedDefinition(args);
54
+ if (id === 'Execution.goal') validateExecutionGoal(args);
55
+ else if (id === 'Capability.create') validateCreatedDefinition(args);
54
56
  else if (id === 'Execution.read') executionReadRequest(args);
55
57
  else if (id === 'Output.publish') outputArgs(args);
56
58
  else if (id === 'PresentResponse') personPresentation(args);