@yeaft/webchat-agent 1.0.633 → 1.0.635

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.
Files changed (35) hide show
  1. package/local-runtime/version.json +1 -1
  2. package/local-runtime/web/app.bundle.js +145 -127
  3. package/local-runtime/web/app.bundle.js.gz +0 -0
  4. package/local-runtime/web/index.html +2 -2
  5. package/local-runtime/web/style.bundle.css +1 -1
  6. package/local-runtime/web/style.bundle.css.gz +0 -0
  7. package/package.json +1 -1
  8. package/yeaft/conversation/search.js +25 -9
  9. package/yeaft/engine.js +116 -14
  10. package/yeaft/person/autonomy.js +59 -0
  11. package/yeaft/person/behavior.js +30 -0
  12. package/yeaft/person/capabilities.js +4 -2
  13. package/yeaft/person/capability-experience.js +1 -1
  14. package/yeaft/person/contracts.js +25 -4
  15. package/yeaft/person/execution-goal.js +119 -0
  16. package/yeaft/person/execution-protocol.js +6 -3
  17. package/yeaft/person/inspection.js +1 -0
  18. package/yeaft/person/runtime.js +69 -12
  19. package/yeaft/person/service.js +25 -8
  20. package/yeaft/person/sqlite-repository.js +4 -1
  21. package/yeaft/person/sqlite-store.js +204 -21
  22. package/yeaft/person/task-host.js +25 -6
  23. package/yeaft/person/tool-arguments.js +3 -1
  24. package/yeaft/query-progress.js +201 -0
  25. package/yeaft/sub-agent/prompt-queue.js +3 -0
  26. package/yeaft/sub-agent/result-reference.js +13 -0
  27. package/yeaft/sub-agent/runner.js +11 -6
  28. package/yeaft/sub-agent/task-identity.js +46 -0
  29. package/yeaft/tools/agent.js +73 -2
  30. package/yeaft/tools/history-search.js +28 -8
  31. package/yeaft/tools/types.js +2 -0
  32. package/yeaft/tools/wait-agent.js +21 -1
  33. package/yeaft/tools/web-fetch.js +9 -0
  34. package/yeaft/web-bridge.js +25 -2
  35. package/yeaft/work-center/runner.js +1 -1
@@ -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(['checkExecutionRead', '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());
@@ -473,11 +564,54 @@ export class SqlitePersonStore {
473
564
  }
474
565
  context(episode) {
475
566
  const p = this.own(episode), scope = this.episodeScope(episode), state = this.one('states', scope);
476
- const focused = this.focused(scope, state.focusConceptIds).slice(0, 12);
477
- 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);
478
606
  return { person: this.personView(p), capabilityExperience: capabilityExperienceView(p.capabilityExperience),
479
- state: stateView(state), messages: this.rows('messages', scope, ' ORDER BY seq DESC LIMIT 12').reverse().map(messageView),
480
- 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}`) } };
481
615
  }
482
616
  /** Atomic capability publication: exact started invocation + live lease, never
483
617
  * publishProgress's no-open-capability path. JSON carries the canonical payload;
@@ -501,7 +635,10 @@ export class SqlitePersonStore {
501
635
  if (!record || record.status !== 'running' || record.openCall || !proof || record.openCapability?.callId !== callId ||
502
636
  record.openCapability.invocationHash !== invocationHash) fail('STALE');
503
637
  // Repeating an identical delivery in this episode adds no new visible answer.
504
- 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);
505
642
  if (repeated) {
506
643
  this.trace(episode.ownerId, episode.id, 'presentation_published', { callId, invocationHash, messageId: repeated.id, phase: presentation.phase, duplicate: true });
507
644
  return messageView(repeated);
@@ -515,6 +652,11 @@ export class SqlitePersonStore {
515
652
  this.trace(episode.ownerId, episode.id, 'presentation_published', { callId, invocationHash, messageId: message.id, phase: presentation.phase });
516
653
  return messageView(message);
517
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
+ }
518
660
  repeatsPresentation(scope, episodeId, reply) {
519
661
  return typeof reply === 'string' && this.rows('messages', scope,
520
662
  " AND json_extract(record, '$.episodeId') = ? AND json_type(record, '$.presentation') = 'object'", [episodeId])
@@ -545,9 +687,12 @@ export class SqlitePersonStore {
545
687
  // cognition does not interpret dedupe as a missing public result.
546
688
  const sameReply = prior.find(m => m.text.trim() === reply.trim());
547
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
+ }
548
693
  // Provider latency counts too: a call started before 30s can finish after it.
549
694
  if ((!final && this.now.getTime() - new Date(record.lastReplyAt ?? record.createdAt).getTime() < FEEDBACK.minMs) ||
550
- sameReply || this.repeatsPresentation(scope, episode.id, reply)) return null;
695
+ sameReply || this.repeatsAutonomousReply(scope, episode, reply) || this.repeatsPresentation(scope, episode.id, reply)) return null;
551
696
  p.messageSeq++; p.writeSerial++; this.put('persons', p);
552
697
  const message = this.doc(scope, { id: randomUUID(), revision: 1, seq: p.messageSeq, episodeId: episode.id,
553
698
  callId, replyKind: final ? 'execution-final' : 'progress', role: 'assistant', text: reply, createdAt: this.now });
@@ -558,11 +703,12 @@ export class SqlitePersonStore {
558
703
  }
559
704
  commit(episode, proposal, selection, callId, reportedSources = new Map()) {
560
705
  const p = this.own(episode), scope = this.episodeScope(episode);
561
- 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');
562
708
  const repeatsExecutionReply = proposal.reply && this.rows('messages', scope,
563
709
  " AND json_extract(record, '$.episodeId') = ? AND json_extract(record, '$.replyKind') IN ('progress', 'execution-final')", [episode.id])
564
710
  .some(message => message.text.trim() === proposal.reply.trim());
565
- 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);
566
712
  p.stateVersion++; p.writeSerial++; if (publishReply) p.messageSeq++;
567
713
  p.activeEpisodeId = null; p.leaseOwner = null; p.leaseUntil = new Date(0);
568
714
  this.put('persons', p);
@@ -580,18 +726,38 @@ export class SqlitePersonStore {
580
726
  if (new Set(proposal.state.focusConceptIds).size !== proposal.state.focusConceptIds.length || focus.length !== proposal.state.focusConceptIds.length) fail('STALE');
581
727
  const priorState = this.one('states', scope);
582
728
  const continuation = Object.hasOwn(proposal.state, 'continuation') ? proposal.state.continuation : priorState?.continuation;
583
- 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 })),
584
732
  decision: proposal.decision, lastSelection: { model: selection.model, effort: selection.effort }, updatedAt: this.now, episodeId: episode.id });
585
733
  if (this.one('states', scope)?.version !== episode.baseStateVersion) fail('STALE');
586
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
+ }
587
744
  this.put('state_commits', this.doc(scope, { id: randomUUID(), version: p.stateVersion, parentVersion: episode.baseStateVersion,
588
745
  episodeId: episode.id, callId, conceptRevisions: revisions, state: stateView(state), createdAt: this.now }), true);
589
746
  if (publishReply) {
590
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 });
591
748
  this.put('messages', message, true); this.journal(scope, 'messages', message);
592
749
  }
593
- const record = this.one('episodes', scope, ' AND id = ?', [episode.id]);
594
- 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
+ }
595
761
  delete record.executionControl;
596
762
  Object.assign(record, { status: 'completed', endedAt: this.now, stateVersion: p.stateVersion }); this.put('episodes', record);
597
763
  this.trace(episode.ownerId, episode.id, 'committed', { callId, parentVersion: episode.baseStateVersion, stateVersion: p.stateVersion, conceptRevisions: revisions, decision: proposal.decision });
@@ -611,7 +777,17 @@ export class SqlitePersonStore {
611
777
  }
612
778
  cancel(ownerId, episodeId = null) {
613
779
  const scope = this.scope(ownerId), p = this.getPerson(ownerId);
614
- 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
+ }
615
791
  const id = p.activeEpisodeId, until = p.leaseUntil;
616
792
  p.epoch++; p.controlVersion++; p.writeSerial++; p.activeEpisodeId = null; p.leaseOwner = null; p.leaseUntil = new Date(0); this.put('persons', p);
617
793
  const record = this.one('episodes', scope, ' AND id = ?', [id]);
@@ -622,14 +798,21 @@ export class SqlitePersonStore {
622
798
  this.trace(ownerId, id, 'cancelled', { code: 'CANCELLED' });
623
799
  return { cancelled: true, episodeId: id };
624
800
  }
625
- settings(ownerId, settings, expectedControlVersion) {
801
+ settings(ownerId, settings, expectedControlVersion, expectedAutonomyGeneration) {
626
802
  const p = this.getPerson(ownerId);
627
- 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
+ }
628
811
  if (expectedControlVersion !== undefined && p.controlVersion !== expectedControlVersion) fail('STALE');
629
812
  const { name, ...patch } = settings;
630
813
  if (Object.hasOwn(settings, 'name')) p.name = personName(name);
631
814
  // A partial update must preserve durable fields omitted from the public view.
632
- 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);
633
816
  if (Object.keys(settings).length) this.trace(ownerId, null, 'settings', { settings: settingsView(p.settings), ...(name !== undefined ? { name: p.name } : {}) });
634
817
  return { settings: settingsView(p.settings), person: this.personView(p) };
635
818
  }
@@ -188,7 +188,7 @@ class ScopedPersonTaskHost {
188
188
  }
189
189
 
190
190
  #session(sessionId) {
191
- if (sessionId != null && sessionId !== this.sessionId) throw denied();
191
+ if (sessionId != null && sessionId !== '' && sessionId !== this.sessionId) throw denied();
192
192
  return this.sessionId;
193
193
  }
194
194
 
@@ -275,12 +275,15 @@ class ScopedPersonTaskHost {
275
275
  if (!task || task.sessionId !== this.sessionId || task.ownerVpId !== this.parentVpId) return;
276
276
  this.#persistAgents();
277
277
  if (event.event === 'completed') {
278
- 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),
279
279
  sourceRef: `person-task:${this.scope.key}:${task.id}:${task.status}:${task.updatedAt}`,
280
- 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.' };
281
281
  atomicJSON(join(this.dataRoot, 'completions', `${task.id}.json`), completion);
282
- // Observability only: never register model re-entry or call runtime.start.
283
- 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
+ }
284
287
  }
285
288
  }
286
289
 
@@ -338,7 +341,8 @@ class ScopedPersonTaskHost {
338
341
  async #execute(tool, args = {}, caller, deps) {
339
342
  this.#assertOpen(deps.episodeId);
340
343
  this.#assertScope(caller, { child: true });
341
- 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 };
342
346
  if (!this.allowedToolIds().includes(tool.name)) return JSON.stringify({ error: 'Native tool disabled', errorEffect: 'none' });
343
347
  if (tool.name === 'SpawnAgent' && !deps.adapter) return JSON.stringify({ error: 'Person child provider unavailable', errorEffect: 'none' });
344
348
  const controller = new AbortController();
@@ -606,6 +610,17 @@ class ScopedPersonTaskHost {
606
610
  }
607
611
  }
608
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
+
609
624
  /** Completion records remain facts, not user messages or automatic triggers. */
610
625
  evidence({ limit = 50 } = {}) {
611
626
  if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw new Error('Invalid evidence limit');
@@ -710,6 +725,10 @@ export class PersonTaskHost {
710
725
  return this.#host(episode.ownerId, episode.personId).context(input);
711
726
  }
712
727
 
728
+ goalState(ownerId, episodeId) {
729
+ return this.#host(ownerId).goalState(episodeId);
730
+ }
731
+
713
732
  snapshot(ownerId) { return this.#host(ownerId).snapshot(ownerId); }
714
733
 
715
734
  async readToolResult(episode, args, options) {
@@ -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);