@yeaft/webchat-agent 1.0.622 → 1.0.624

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.
@@ -10,6 +10,7 @@ import { attachmentMetadata, attachmentRequestHash, validateFiles } from './atta
10
10
  import { capabilityExperienceView, recordCapabilityExperience } from './capability-experience.js';
11
11
  import { chronologicalCursor, conceptView, inspectionCursor, inspectionPage, inspectRequest, messageView, personName, searchRequest, settingsView, stateView } from './inspection.js';
12
12
  import { TOKEN_FIELDS, turnsPage, turnView } from './turn-diagnostics.js';
13
+ import { personPresentation } from './presentation.js';
13
14
  import { inspectCapabilities } from './capabilities.js';
14
15
  import { OUTPUT_LIMITS, outputArgs, outputMime, outputReadRequest, outputsPage, outputUrl, outputView } from './outputs.js';
15
16
 
@@ -65,7 +66,7 @@ const objectOutput = (record, data) => {
65
66
  }
66
67
  };
67
68
  const READS = new Set(['outputs', 'outputRead', 'checkOutputPublication', 'receipt', 'turns', 'inspect', 'search', 'getPerson', 'context', 'recall', 'list', 'searchChanges', 'resolveMemories', 'createdCapabilities', 'episodeAttachments']);
68
- const WRITES = new Set(['saveOutput', 'open', 'recover', 'admit', 'heartbeat', 'append', 'startCall', 'finalizeCall', 'startCapability', 'finalizeCapability', 'commit', 'publishProgress', 'finish', 'cancel', 'settings', 'snapshot', 'saveCreatedCapability']);
69
+ const WRITES = new Set(['executionControl', 'savePresentation', 'saveOutput', 'open', 'recover', 'admit', 'heartbeat', 'append', 'startCall', 'finalizeCall', 'startCapability', 'finalizeCapability', 'commit', 'publishProgress', 'finish', 'cancel', 'settings', 'snapshot', 'saveCreatedCapability']);
69
70
  const memoryKind = kind => { if (!['messages', 'concepts'].includes(kind)) fail('INVALID_REQUEST'); return kind; };
70
71
  const boundedLimit = (limit, max = 100) => { if (!Number.isSafeInteger(limit) || limit < 1 || limit > max) fail('INVALID_REQUEST'); return limit; };
71
72
  const sequence = value => {
@@ -199,6 +200,7 @@ export class SqlitePersonStore {
199
200
  this.put('persons', p);
200
201
  const episode = this.one('episodes', scope, ' AND id = ?', [id]);
201
202
  if (episode?.status === 'running') {
203
+ delete episode.executionControl;
202
204
  Object.assign(episode, { status: 'interrupted', terminalCode: 'INTERRUPTED', endedAt: this.now });
203
205
  this.put('episodes', episode); this.abandonCall(ownerId, episode, 'INTERRUPTED');
204
206
  }
@@ -267,6 +269,14 @@ export class SqlitePersonStore {
267
269
  (!withLease || p.leaseUntil > this.now) ? p : null;
268
270
  }
269
271
  own(episode) { const p = this.fenced(episode); if (!p) fail('STALE'); return p; }
272
+ executionControl(episode, control) {
273
+ this.own(episode);
274
+ const record = this.one('episodes', this.episodeScope(episode), ' AND id = ?', [episode.id]);
275
+ if (!record || record.status !== 'running' || !['running', 'pausing', 'paused'].includes(control.status)) fail('STALE');
276
+ record.executionControl = { status: control.status, updatedAt: this.now.toISOString() };
277
+ this.put('episodes', record);
278
+ return { episodeId: episode.id, ...record.executionControl };
279
+ }
270
280
  heartbeat(episode) {
271
281
  const p = this.own(episode); p.leaseUntil = new Date(this.now.getTime() + this.leaseMs); p.writeSerial++; this.put('persons', p);
272
282
  const record = this.one('episodes', this.episodeScope(episode), ' AND id = ?', [episode.id]);
@@ -275,7 +285,7 @@ export class SqlitePersonStore {
275
285
  // Heartbeats are at most 5s apart: publish the fallback between 55–60s.
276
286
  // This updates one snapshot field, never adds messages or schedules cognition.
277
287
  if (this.now.getTime() - lastAt >= FEEDBACK.maxMs - 5000 || record.feedback) {
278
- const phase = record.openCall ? 'model' : record.openCapability ? 'capability' : 'preparing';
288
+ const phase = record.executionControl?.status === 'paused' ? 'paused' : record.openCall ? 'model' : record.openCapability ? 'capability' : 'preparing';
279
289
  record.feedback = { at: this.now.getTime() - lastAt >= FEEDBACK.maxMs - 5000 ? this.now.toISOString() : record.feedback.at, phase };
280
290
  if (phase === 'capability') record.feedback.capabilityId = record.openCapability.capability?.id;
281
291
  this.put('episodes', record);
@@ -283,7 +293,7 @@ export class SqlitePersonStore {
283
293
  }
284
294
  append(episode, kind, data) {
285
295
  // Call proof/publication events are emitted only by their transactional methods.
286
- if (['call_started', 'call_output', 'call_failed', 'capability_created', 'capability_finalized', 'output_published'].includes(kind)) fail('INVALID_REQUEST');
296
+ if (['call_started', 'call_output', 'call_failed', 'capability_created', 'capability_finalized', 'output_published', 'presentation_published'].includes(kind)) fail('INVALID_REQUEST');
287
297
  const p = this.own(episode);
288
298
  const record = this.one('episodes', this.episodeScope(episode), ' AND id = ?', [episode.id]);
289
299
  if (!record || record.status !== 'running') fail('STALE');
@@ -301,7 +311,7 @@ export class SqlitePersonStore {
301
311
  this.put('episodes', record);
302
312
  return this.trace(episode.ownerId, episode.id, 'call_started', data);
303
313
  }
304
- finalizeCall(episode, { callId, effective, output, code = null }) {
314
+ finalizeCall(episode, { callId, effective, output, requestElapsedMs, code = null }) {
305
315
  const scope = this.episodeScope(episode), record = this.one('episodes', scope, ' AND id = ?', [episode.id]);
306
316
  if (!record || record.workerId !== episode.workerId || record.epoch !== episode.epoch || record.openCall?.callId !== callId) return false;
307
317
  const live = record.status === 'running' && Boolean(this.fenced(episode));
@@ -315,6 +325,8 @@ export class SqlitePersonStore {
315
325
  delete record.openCall; delete record.callFinalizeUntil; this.put('episodes', record);
316
326
  this.trace(episode.ownerId, episode.id, failed ? 'call_failed' : 'call_output', {
317
327
  callId, requested: call.requested, effective: safeEffective, output: publicOutput(output, failed),
328
+ // Actual provider time excludes any pause between journal start and dispatch.
329
+ ...(Number.isSafeInteger(requestElapsedMs) && requestElapsedMs >= 0 ? { requestElapsedMs } : {}),
318
330
  ...(!failed && call.manifest ? { manifest: call.manifest } : {}), ...(failed ? { code: new PersonError(terminalCode).code } : {}),
319
331
  });
320
332
  return live;
@@ -389,6 +401,48 @@ export class SqlitePersonStore {
389
401
  state: stateView(state), messages: this.rows('messages', scope, ' ORDER BY seq DESC LIMIT 12').reverse().map(messageView),
390
402
  concepts: [...new Map([...focused, ...recent].map(c => [c.id, conceptView(c)])).values()] };
391
403
  }
404
+ /** Atomic capability publication: exact started invocation + live lease, never
405
+ * publishProgress's no-open-capability path. JSON carries the canonical payload;
406
+ * text remains the searchable/model/legacy fallback. phase is delivery intent. */
407
+ savePresentation(episode, { callId, args }) {
408
+ identifier(callId);
409
+ const presentation = personPresentation(args);
410
+ const p = this.own(episode), scope = this.episodeScope(episode);
411
+ const record = this.one('episodes', scope, ' AND id = ?', [episode.id]);
412
+ const invocationHash = digest({ id: 'PresentResponse', args });
413
+ const prior = this.rows('messages', scope, " AND json_extract(record, '$.episodeId') = ? AND json_type(record, '$.presentation') = 'object'", [episode.id]);
414
+ const receipt = this.one('traces', scope,
415
+ " AND json_extract(record, '$.episodeId') = ? AND json_extract(record, '$.kind') = 'presentation_published' AND json_extract(record, '$.callId') = ?", [episode.id, callId]);
416
+ if (receipt) {
417
+ if (receipt.invocationHash !== invocationHash) fail('INVALID_REQUEST');
418
+ const message = prior.find(m => m.id === receipt.messageId);
419
+ if (!message) fail('STORAGE_UNAVAILABLE');
420
+ return messageView(message);
421
+ }
422
+ const proof = this.one('traces', scope,
423
+ " AND json_extract(record, '$.episodeId') = ? AND json_extract(record, '$.kind') = 'call_output' AND json_extract(record, '$.callId') = ? AND json_extract(record, '$.output.complete') = 1", [episode.id, callId]);
424
+ if (!record || record.status !== 'running' || record.openCall || !proof || record.openCapability?.callId !== callId ||
425
+ record.openCapability.invocationHash !== invocationHash) fail('STALE');
426
+ // Repeating an identical delivery in this episode adds no new visible answer.
427
+ const repeated = prior.find(m => digest(m.presentation) === digest(presentation));
428
+ if (repeated) {
429
+ this.trace(episode.ownerId, episode.id, 'presentation_published', { callId, invocationHash, messageId: repeated.id, phase: presentation.phase, duplicate: true });
430
+ return messageView(repeated);
431
+ }
432
+ p.messageSeq++; p.writeSerial++; this.put('persons', p);
433
+ const message = this.doc(scope, { id: randomUUID(), revision: 1, seq: p.messageSeq, episodeId: episode.id,
434
+ callId, publicationHash: invocationHash, replyKind: 'presentation', role: 'assistant',
435
+ text: presentation.summary, presentation, createdAt: this.now });
436
+ this.put('messages', message, true); this.journal(scope, 'messages', message);
437
+ record.lastReplyAt = this.now.toISOString(); delete record.feedback; this.put('episodes', record);
438
+ this.trace(episode.ownerId, episode.id, 'presentation_published', { callId, invocationHash, messageId: message.id, phase: presentation.phase });
439
+ return messageView(message);
440
+ }
441
+ repeatsPresentation(scope, episodeId, reply) {
442
+ return typeof reply === 'string' && this.rows('messages', scope,
443
+ " AND json_extract(record, '$.episodeId') = ? AND json_type(record, '$.presentation') = 'object'", [episodeId])
444
+ .some(message => message.text.trim() === reply.trim());
445
+ }
392
446
  /** Publish only a validated, complete call's reply. This is communication, not
393
447
  * a state/Concept commit. Fencing, dedupe and the memory journal share one transaction. */
394
448
  publishProgress(episode, { callId, reply }) {
@@ -406,7 +460,7 @@ export class SqlitePersonStore {
406
460
  if (!accepted || !proposal.next || proposal.reply !== reply) fail('INVALID_REQUEST');
407
461
  // Provider latency counts too: a call started before 30s can finish after it.
408
462
  if (this.now.getTime() - new Date(record.lastReplyAt ?? record.createdAt).getTime() < FEEDBACK.minMs ||
409
- prior.some(m => m.text.trim() === reply.trim())) return null;
463
+ prior.some(m => m.text.trim() === reply.trim()) || this.repeatsPresentation(scope, episode.id, reply)) return null;
410
464
  p.messageSeq++; p.writeSerial++; this.put('persons', p);
411
465
  const message = this.doc(scope, { id: randomUUID(), revision: 1, seq: p.messageSeq, episodeId: episode.id,
412
466
  callId, replyKind: 'progress', role: 'assistant', text: reply, createdAt: this.now });
@@ -418,7 +472,8 @@ export class SqlitePersonStore {
418
472
  commit(episode, proposal, selection, callId, reportedSources = new Map()) {
419
473
  const p = this.own(episode), scope = this.episodeScope(episode);
420
474
  if (proposal.baseStateVersion !== episode.baseStateVersion) fail('STALE');
421
- p.stateVersion++; p.writeSerial++; if (proposal.reply) p.messageSeq++;
475
+ const publishReply = proposal.reply && !this.repeatsPresentation(scope, episode.id, proposal.reply);
476
+ p.stateVersion++; p.writeSerial++; if (publishReply) p.messageSeq++;
422
477
  p.activeEpisodeId = null; p.leaseOwner = null; p.leaseUntil = new Date(0);
423
478
  this.put('persons', p);
424
479
  const revisions = [];
@@ -441,12 +496,13 @@ export class SqlitePersonStore {
441
496
  this.put('states', state);
442
497
  this.put('state_commits', this.doc(scope, { id: randomUUID(), version: p.stateVersion, parentVersion: episode.baseStateVersion,
443
498
  episodeId: episode.id, callId, conceptRevisions: revisions, state: stateView(state), createdAt: this.now }), true);
444
- if (proposal.reply) {
499
+ if (publishReply) {
445
500
  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 });
446
501
  this.put('messages', message, true); this.journal(scope, 'messages', message);
447
502
  }
448
503
  const record = this.one('episodes', scope, ' AND id = ?', [episode.id]);
449
504
  if (!record || record.status !== 'running') fail('STALE');
505
+ delete record.executionControl;
450
506
  Object.assign(record, { status: 'completed', endedAt: this.now, stateVersion: p.stateVersion }); this.put('episodes', record);
451
507
  this.trace(episode.ownerId, episode.id, 'committed', { callId, parentVersion: episode.baseStateVersion, stateVersion: p.stateVersion, conceptRevisions: revisions, decision: proposal.decision });
452
508
  return { state: stateView(state) };
@@ -457,6 +513,7 @@ export class SqlitePersonStore {
457
513
  p.writeSerial++; p.epoch++; p.activeEpisodeId = null; p.leaseOwner = null; p.leaseUntil = new Date(0); this.put('persons', p);
458
514
  const record = this.one('episodes', this.episodeScope(episode), ' AND id = ?', [episode.id]);
459
515
  if (record?.status === 'running') {
516
+ delete record.executionControl;
460
517
  Object.assign(record, { status, terminalCode: code, endedAt: this.now }); this.put('episodes', record); this.abandonCall(episode.ownerId, record, code);
461
518
  }
462
519
  this.trace(episode.ownerId, episode.id, status, { code, baseStateVersion: episode.baseStateVersion });
@@ -469,6 +526,7 @@ export class SqlitePersonStore {
469
526
  p.epoch++; p.controlVersion++; p.writeSerial++; p.activeEpisodeId = null; p.leaseOwner = null; p.leaseUntil = new Date(0); this.put('persons', p);
470
527
  const record = this.one('episodes', scope, ' AND id = ?', [id]);
471
528
  if (record?.status === 'running') {
529
+ delete record.executionControl;
472
530
  Object.assign(record, { status: 'cancelled', terminalCode: 'CANCELLED', endedAt: this.now, callFinalizeUntil: until }); this.put('episodes', record);
473
531
  }
474
532
  this.trace(ownerId, id, 'cancelled', { code: 'CANCELLED' });
@@ -511,8 +569,10 @@ export class SqlitePersonStore {
511
569
  const { cursor, limit } = turnsPage(options);
512
570
  const scope = this.scope(ownerId);
513
571
  if (!this.sql(`SELECT 1 FROM persons WHERE ${SCOPE}`).get(...scopeValues(scope))) fail('NOT_OPEN');
514
- const project = fields => `json_object(${Object.entries(fields).map(([key, path]) => `'${key}', json_extract(record, '$.${path}')`).join(', ')})`;
515
- const episodes = this.sql(`SELECT ${project({ id: 'id', seq: 'inputWatermark', kind: 'kind', status: 'status', createdAt: 'createdAt', endedAt: 'endedAt', terminalCode: 'terminalCode', calls: 'budget.calls', timeoutMs: 'budget.timeoutMs' })} AS metadata
572
+ const project = fields => `json_object(${Object.entries(fields).map(([key, path]) => `'${key}', CASE json_type(record, '$.${path}')
573
+ WHEN 'text' THEN substr(json_extract(record, '$.${path}'), 1, 1000)
574
+ WHEN 'integer' THEN json_extract(record, '$.${path}') WHEN 'true' THEN 1 WHEN 'false' THEN 0 ELSE NULL END`).join(', ')})`;
575
+ const episodes = this.sql(`SELECT ${project({ id: 'id', seq: 'inputWatermark', kind: 'kind', status: 'status', createdAt: 'createdAt', endedAt: 'endedAt', terminalCode: 'terminalCode', controlStatus: 'executionControl.status', calls: 'budget.calls', timeoutMs: 'budget.timeoutMs' })} AS metadata
516
576
  FROM episodes WHERE ${SCOPE} AND (? IS NULL OR inputWatermark < ?) ORDER BY inputWatermark DESC LIMIT ?`)
517
577
  .all(...scopeValues(scope), cursor, cursor, limit + 1).map(row => JSON.parse(row.metadata));
518
578
  const fields = { kind: 'kind', callId: 'callId', callIndex: 'callIndex', createdAt: 'createdAt',
@@ -529,20 +589,27 @@ export class SqlitePersonStore {
529
589
  omittedConcepts: ['manifest.contextSources.omittedConcepts', 24], recallCount: ['manifest.contextSources.recall.count', 5] })
530
590
  .map(([key, args]) => `'${key}', ${number(...args)}`).join(', ');
531
591
  const recallKind = `CASE json_extract(record, '$.manifest.contextSources.recall.kind') WHEN 'messages' THEN 'messages' WHEN 'concepts' THEN 'concepts' ELSE NULL END`;
532
- const usage = `json_object(${TOKEN_FIELDS.map(key => `'${key}', json_extract(record, '$.output.usage.${key}')`).join(', ')},
533
- 'accountingVersion', json_extract(record, '$.output.usage.accountingVersion'),
592
+ const usage = `json_object(${TOKEN_FIELDS.map(key => `'${key}', ${number(`output.usage.${key}`)}`).join(', ')},
593
+ 'accountingVersion', ${number('output.usage.accountingVersion', 1)},
534
594
  'accountingIncomplete', json(CASE json_extract(record, '$.output.usage.accountingIncomplete') WHEN 1 THEN 'true' ELSE 'false' END),
535
595
  'cacheTokensAreIncludedInInput', json(CASE json_extract(record, '$.output.usage.cacheTokensAreIncludedInInput') WHEN 1 THEN 'true' WHEN 0 THEN 'false' ELSE 'null' END))`;
536
- const metadata = `${project(fields).slice(0, -1)}, 'capabilityId', COALESCE(json_extract(record, '$.capability.id'), json_extract(record, '$.capabilityId')),
537
- ${contextMetadata}, 'recallKind', ${recallKind}, 'usage', ${usage})`;
596
+ const metadata = `${project(fields).slice(0, -1)}, 'capabilityId', substr(COALESCE(CASE WHEN json_type(record, '$.capability.id') = 'text' THEN json_extract(record, '$.capability.id') END, CASE WHEN json_type(record, '$.capabilityId') = 'text' THEN json_extract(record, '$.capabilityId') END), 1, 128),
597
+ ${contextMetadata}, 'requestElapsedMs', ${number('requestElapsedMs')}, 'recallKind', ${recallKind}, 'usage', ${usage})`;
538
598
  const items = episodes.slice(0, limit).map(episode => {
539
- const events = this.sql(`SELECT ${metadata} AS metadata FROM traces WHERE ${SCOPE}
599
+ const starts = this.sql(`SELECT seq, ${number('callIndex')} AS callIndex FROM traces WHERE ${SCOPE} AND json_extract(record, '$.episodeId') = ?
600
+ AND json_extract(record, '$.kind') = 'call_started' ORDER BY seq DESC LIMIT 33`)
601
+ .all(...scopeValues(scope), episode.id);
602
+ const oldest = starts[Math.min(starts.length, 32) - 1]?.seq ?? 0;
603
+ const events = this.sql(`SELECT ${metadata} AS metadata FROM traces WHERE ${SCOPE} AND seq >= ?
540
604
  AND json_extract(record, '$.episodeId') = ? AND json_extract(record, '$.kind') IN
541
- ('call_started', 'call_output', 'call_failed', 'proposal_rejected', 'capability_started', 'capability_result', 'capability_failed', 'capability_finalized') ORDER BY seq ASC`)
542
- .all(...scopeValues(scope), episode.id).map(row => {
605
+ ('call_started', 'call_output', 'call_failed', 'proposal_rejected', 'capability_started', 'capability_result', 'capability_failed', 'capability_finalized') ORDER BY seq DESC LIMIT 257`)
606
+ .all(...scopeValues(scope), oldest, episode.id).map(row => {
543
607
  const event = JSON.parse(row.metadata); event.effortObserved = event.effortObserved === 1; return event;
544
608
  });
545
- return turnView(episode, events);
609
+ return { ...turnView(episode, events.slice(0, 256).reverse()),
610
+ loopCount: starts[0]?.callIndex != null ? starts[0].callIndex + 1 : starts.length > 32 ? null : starts.length,
611
+ callsTruncated: starts.length > 32 || events.length > 256, callWindowLimit: 32,
612
+ usageScope: starts.length > 32 || events.length > 256 ? 'latest-call-window' : 'episode' };
546
613
  });
547
614
  return { items, nextCursor: episodes.length > limit ? items.at(-1).seq : null };
548
615
  }
@@ -1,4 +1,4 @@
1
- import { mkdirSync, realpathSync, readFileSync, writeFileSync, renameSync, readdirSync, existsSync, lstatSync } from 'node:fs';
1
+ import { mkdirSync, realpathSync, readFileSync, writeFileSync, renameSync, readdirSync, opendirSync, existsSync, lstatSync } from 'node:fs';
2
2
  import { resolve, join, relative, isAbsolute, sep } from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { TaskManager } from '../tasks/manager.js';
@@ -96,8 +96,23 @@ function atomicJSON(path, value) {
96
96
  renameSync(temporary, path);
97
97
  }
98
98
 
99
- function jsonFiles(directory) {
99
+ function jsonFiles(directory, { limit } = {}) {
100
100
  if (!existsSync(directory)) return [];
101
+ if (limit != null) {
102
+ // Main cognition reads a bounded latest window, not every durable result at once.
103
+ // Iterate directory entries without assembling an unbounded filename array.
104
+ const handle = opendirSync(directory), records = [];
105
+ const time = record => new Date(record.updatedAt ?? record.task?.updatedAt ?? record.createdAt ?? 0).getTime() || 0;
106
+ try {
107
+ for (let entry; (entry = handle.readSync());) {
108
+ if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
109
+ try { records.push(JSON.parse(readFileSync(join(directory, entry.name), 'utf8'))); } catch { continue; }
110
+ records.sort((a, b) => time(b) - time(a));
111
+ if (records.length > limit) records.pop();
112
+ }
113
+ } finally { handle.closeSync(); }
114
+ return records;
115
+ }
101
116
  return readdirSync(directory).filter(name => name.endsWith('.json')).flatMap(name => {
102
117
  try { return [JSON.parse(readFileSync(join(directory, name), 'utf8'))]; } catch { return []; }
103
118
  });
@@ -562,9 +577,9 @@ class ScopedPersonTaskHost {
562
577
  this.#persistAgents();
563
578
  const recent = items => items.sort((a, b) => String(b.updatedAt ?? b.task?.updatedAt ?? '').localeCompare(String(a.updatedAt ?? a.task?.updatedAt ?? ''))).slice(0, limit);
564
579
  return { namespace: this.scope.key, sessionId: this.sessionId,
565
- tasks: recent(jsonFiles(this.#manager.store.sessionDir(this.sessionId))),
566
- agents: clone([...this.#agents.values()].slice(-limit)), completions: recent(jsonFiles(join(this.dataRoot, 'completions'))),
567
- toolResults: recent(jsonFiles(join(this.dataRoot, 'tool-results'))).map(result => ({ ...result,
580
+ tasks: recent(jsonFiles(this.#manager.store.sessionDir(this.sessionId), { limit })),
581
+ 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,
568
583
  output: result.output.slice(0, 8192), rawPath: join(this.dataRoot, 'tool-results', `${result.id}.json`) })) };
569
584
  }
570
585
 
@@ -51,7 +51,7 @@ export function callUsage(raw, completed) {
51
51
  return { ...usage, reportedCalls: Number(reported), missingCalls: Number(!reported), complete: completed && usage.totalTokens !== null };
52
52
  }
53
53
 
54
- function sumUsage(calls, terminal) {
54
+ export function sumUsage(calls, terminal) {
55
55
  const fields = [...TOKEN_FIELDS, 'inputTotalTokens', 'totalTokens'];
56
56
  const usage = Object.fromEntries(fields.map(key => {
57
57
  const known = calls.map(call => call.usage[key]).filter(value => value !== null);
@@ -84,7 +84,11 @@ export function turnView(episode, events) {
84
84
  if (!call) continue;
85
85
  if (event.kind === 'call_output' || event.kind === 'call_failed') {
86
86
  call.status = event.kind === 'call_output' ? 'completed' : ['CANCELLED', 'INTERRUPTED'].includes(event.code) ? event.code.toLowerCase() : 'failed';
87
- call.endedAt = event.createdAt; call.code = string(event.code);
87
+ call.endedAt = event.createdAt;
88
+ // Legacy call lifecycle timestamps include queue/pause time. Do not label
89
+ // them as provider duration when no actual dispatch measurement was stored.
90
+ call.elapsedMs = token(event.requestElapsedMs);
91
+ call.code = string(event.code);
88
92
  call.effective.effort = event.effortObserved === true ? string(event.effectiveEffort) : null;
89
93
  call.usage = callUsage(event.usage, event.kind === 'call_output');
90
94
  } else if (event.kind === 'proposal_rejected') {
@@ -97,7 +101,7 @@ export function turnView(episode, events) {
97
101
  }
98
102
  }
99
103
  const items = [...calls.values()].sort((a, b) => a.index - b.index);
100
- return { id: episode.id, seq: episode.seq, kind: episode.kind, status: episode.status, createdAt: episode.createdAt,
104
+ return { id: episode.id, seq: episode.seq, kind: episode.kind, status: episode.status, controlStatus: episode.status === 'running' ? string(episode.controlStatus) : null, createdAt: episode.createdAt,
101
105
  endedAt: episode.endedAt, terminalCode: episode.terminalCode, budget: { calls: token(episode.calls), timeoutMs: token(episode.timeoutMs) },
102
106
  models: [...new Set(items.map(call => call.dispatched.model).filter(Boolean))], usage: sumUsage(items, episode.status !== 'running'), calls: items };
103
107
  }
@@ -0,0 +1,81 @@
1
+ /** Shared, bounded user-visible response contract for native Sessions and Persons.
2
+ * Contains no executable markup, filesystem access or network requests. Runtime
3
+ * owns identity/phase; summary remains useful without the rich client.
4
+ */
5
+ const str = (maxLength = 2000) => ({ type: 'string', maxLength });
6
+ const arr = (items, maxItems, minItems = 1) => ({ type: 'array', items, minItems, maxItems });
7
+ const obj = (properties, required) => ({ type: 'object', properties, required, additionalProperties: false });
8
+ const block = (type, properties, required) => obj({ type: { type: 'string', enum: [type] }, title: str(160), ...properties }, ['type', ...required]);
9
+ export const PRESENTATION_SCHEMA = obj({
10
+ title: str(160),
11
+ summary: str(8000),
12
+ phase: { type: 'string', enum: ['progress', 'final'] },
13
+ blocks: arr({ anyOf: [
14
+ block('markdown', { text: str(12000) }, ['text']),
15
+ block('cards', { items: arr(obj({ title: str(160), text: str(2000), label: str(80) }, ['title', 'text']), 8) }, ['items']),
16
+ block('metrics', { items: arr(obj({ label: str(120), value: str(120), detail: str(240) }, ['label', 'value']), 8) }, ['items']),
17
+ block('table', { columns: arr(str(120), 8), rows: arr(arr(str(1000), 8, 1), 50) }, ['columns', 'rows']),
18
+ block('chart', { style: { type: 'string', enum: ['bar', 'line'] }, unit: str(60), series: arr(obj({ label: str(120), values: arr({ type: 'number' }, 40) }, ['label', 'values']), 4), labels: arr(str(120), 40) }, ['style', 'series', 'labels']),
19
+ block('timeline', { items: arr(obj({ label: str(120), title: str(160), text: str(2000) }, ['label', 'title', 'text']), 16) }, ['items']),
20
+ block('diagram', { nodes: arr(obj({ id: str(40), label: str(160) }, ['id', 'label']), 12), edges: arr(obj({ from: str(40), to: str(40), label: str(120) }, ['from', 'to']), 24, 0) }, ['nodes', 'edges']),
21
+ block('image', { url: str(2000), alt: str(240), caption: str(1000) }, ['url', 'alt']),
22
+ block('link', { url: str(2000), text: str(1000) }, ['url', 'text']),
23
+ block('map', { points: arr(obj({ label: str(160), latitude: { type: 'number', minimum: -90, maximum: 90 }, longitude: { type: 'number', minimum: -180, maximum: 180 }, detail: str(1000) }, ['label', 'latitude', 'longitude']), 20) }, ['points']),
24
+ ] }, 12),
25
+ }, ['summary', 'blocks']);
26
+
27
+ export const PRESENT_RESPONSE_DESCRIPTION = `Publish a rich user-visible response inside this turn: cards, metrics, comparison tables, bar/line charts, timelines, relationship diagrams, images, artifact links or coordinate maps. Optional: use normal text for simple answers. Supply a self-contained summary for copying, search, model history and older clients; do not repeat the whole rich answer afterwards. Use only verified data, mark uncertainty, and never fabricate chart values or coordinates. No HTML/JavaScript, arbitrary SVG, styling or executable content. Image/link URLs must be credential-free HTTP(S); images require the reader to load external content. Maps are coordinate sketches, not navigation maps, and do not fetch tiles. phase=progress continues work; phase=final is a delivery intent, not proof of task success. Runtime still owns persistence, cancellation and turn termination. Validation failure publishes nothing; correct the payload or answer in text.`;
28
+
29
+ function check(value, schema, path) {
30
+ if (schema.anyOf) {
31
+ const candidate = schema.anyOf.find(s => value?.type === s.properties.type.enum[0]);
32
+ if (!candidate) throw new Error(`${path}: unsupported block type`);
33
+ return check(value, candidate, path);
34
+ }
35
+ if (schema.type === 'object') {
36
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${path}: expected object`);
37
+ for (const key of Object.keys(value)) if (!Object.hasOwn(schema.properties, key)) throw new Error(`${path}.${key}: unknown field`);
38
+ for (const key of schema.required) if (!Object.hasOwn(value, key)) throw new Error(`${path}.${key}: required`);
39
+ for (const [key, item] of Object.entries(value)) check(item, schema.properties[key], `${path}.${key}`);
40
+ } else if (schema.type === 'array') {
41
+ if (!Array.isArray(value) || value.length < schema.minItems || value.length > schema.maxItems) throw new Error(`${path}: invalid item count`);
42
+ value.forEach((item, i) => check(item, schema.items, `${path}[${i}]`));
43
+ } else if (schema.type === 'string') {
44
+ if (typeof value !== 'string' || !value.trim() || value.length > schema.maxLength) throw new Error(`${path}: expected non-empty bounded text`);
45
+ if (schema.enum && !schema.enum.includes(value)) throw new Error(`${path}: unsupported value`);
46
+ } else if (schema.type === 'number') {
47
+ if (typeof value !== 'number' || !Number.isFinite(value) || Math.abs(value) > 1e12 || (schema.minimum !== undefined && value < schema.minimum) || (schema.maximum !== undefined && value > schema.maximum)) throw new Error(`${path}: invalid finite number`);
48
+ }
49
+ }
50
+
51
+ /** Return a canonical JSON-only snapshot; rejects rather than partially publishing. */
52
+ export function normalizePresentation(input) {
53
+ check(input, PRESENTATION_SCHEMA, 'response');
54
+ const encoded = JSON.stringify(input);
55
+ if (new TextEncoder().encode(encoded).length > 49152) throw new Error('response: exceeds 48 KiB');
56
+ const value = JSON.parse(encoded);
57
+ for (const b of value.blocks) {
58
+ if (b.type === 'table' && b.rows.some(row => row.length !== b.columns.length)) throw new Error('table: every row must match the columns');
59
+ if (b.type === 'chart' && b.series.some(series => series.values.length !== b.labels.length)) throw new Error('chart: values must match labels');
60
+ if (b.type === 'diagram') {
61
+ const ids = new Set(b.nodes.map(n => n.id));
62
+ if (ids.size !== b.nodes.length || b.edges.some(e => !ids.has(e.from) || !ids.has(e.to))) throw new Error('diagram: unique nodes and existing edge endpoints required');
63
+ }
64
+ if (b.type === 'image' || b.type === 'link') b.url = safePresentationUrl(b.url);
65
+ }
66
+ const canonical = { version: 1, title: value.title || '', summary: value.summary, phase: value.phase || 'final', blocks: value.blocks };
67
+ if (new TextEncoder().encode(JSON.stringify(canonical)).length > 49152) throw new Error('response: exceeds 48 KiB');
68
+ return canonical;
69
+ }
70
+
71
+ /** External URLs are metadata only. Never fetch server-side or accept credentials. */
72
+ export function safePresentationUrl(value) {
73
+ if (typeof value !== 'string' || /[\u0000-\u0020\u007f]/.test(value)) throw new Error('response: invalid URL');
74
+ let url;
75
+ try { url = new URL(value); } catch { throw new Error('response: invalid absolute URL'); }
76
+ if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password) throw new Error('response: unsafe URL');
77
+ // Canonical URLs are encoded, so input length alone is not a safe bound.
78
+ // Persisted snapshots must pass exactly the same limits on replay.
79
+ if (url.href.length > 2000) throw new Error('response: URL exceeds 2000 characters');
80
+ return url.href;
81
+ }
@@ -319,6 +319,12 @@ export function createStreamProjector({
319
319
  state.resultText += event.text || '';
320
320
  write({ ...base, type: 'assistant', subtype: 'text_delta', delta: { type: 'text_delta', text: event.text || '' } });
321
321
  break;
322
+ case 'response_presentation':
323
+ state.resultText += event.message?.content || '';
324
+ write({ ...base, type: 'assistant', subtype: 'response_presentation',
325
+ message: event.message, content: [{ type: 'text', text: event.message?.content || '' }],
326
+ presentation: event.message?.presentation });
327
+ break;
322
328
  case 'thinking_delta':
323
329
  write({ ...base, type: 'assistant', subtype: 'thinking_delta', delta: { type: 'thinking_delta', thinking: event.text || '' } });
324
330
  break;
@@ -10,6 +10,7 @@ export const ALWAYS_VISIBLE_TOOL_NAMES = Object.freeze([
10
10
  'EnterWorktree',
11
11
  'ExitWorktree',
12
12
  'AskUser',
13
+ 'PresentResponse',
13
14
  'DiscoverTools',
14
15
  'WebSearch',
15
16
  'WebFetch',
@@ -25,6 +25,7 @@ import exitWorktree from './exit-worktree.js';
25
25
 
26
26
  // --- P0 Core tools ---
27
27
  import askUser from './ask-user.js';
28
+ import presentResponse from './present-response.js';
28
29
  import webSearch from './web-search.js';
29
30
  import webFetch from './web-fetch.js';
30
31
  import historySearch from './history-search.js';
@@ -92,6 +93,7 @@ export const allTools = [
92
93
 
93
94
  // P0 Core
94
95
  askUser,
96
+ presentResponse,
95
97
  webSearch,
96
98
  webFetch,
97
99
  historySearch,
@@ -0,0 +1,27 @@
1
+ import { defineTool } from './types.js';
2
+ import { PRESENTATION_SCHEMA, normalizePresentation, PRESENT_RESPONSE_DESCRIPTION } from '../response-presentation.js';
3
+
4
+ export default defineTool({
5
+ name: 'PresentResponse',
6
+ description: PRESENT_RESPONSE_DESCRIPTION,
7
+ parameters: PRESENTATION_SCHEMA,
8
+ isReadOnly: () => false,
9
+ isConcurrencySafe: () => false,
10
+ cacheWithinQuery: false,
11
+ duplicateCallPolicy: () => 'allow',
12
+ errorOutput: 'json-error-envelope',
13
+ async execute(input, ctx) {
14
+ try {
15
+ // Validate the entire snapshot before invoking any host-side publication.
16
+ const presentation = normalizePresentation(input);
17
+ if (ctx?.signal?.aborted) throw new Error('Response publication was cancelled.');
18
+ if (typeof ctx?.publishResponse !== 'function') {
19
+ throw new Error('Response publication is unavailable in this runtime. Use normal text.');
20
+ }
21
+ const identity = await ctx.publishResponse(presentation);
22
+ return JSON.stringify({ accepted: true, ...identity, message: 'Response accepted for delivery. Continue work if needed; do not repeat the summary. The normal turn boundary still applies.' });
23
+ } catch (error) {
24
+ return JSON.stringify({ error: error.message });
25
+ }
26
+ },
27
+ });
@@ -1396,6 +1396,8 @@ function projectPersistedToHistoryEntry(m, { includeReflections = false } = {})
1396
1396
  entry.threadId = m.threadId || m.turnId || 'main';
1397
1397
  if (m.turnId) entry.turnId = m.turnId;
1398
1398
  if (m.imageAssetAnchor) entry.imageAssetAnchor = true;
1399
+ if (m.presentation) entry.presentation = m.presentation;
1400
+ if (m.presentationCallId) entry.presentationCallId = m.presentationCallId;
1399
1401
  if (m.responseKind === 'progress' || m.responseKind === 'result') entry.responseKind = m.responseKind;
1400
1402
  if (Number.isInteger(m.llmCallCount) && m.llmCallCount > 0) entry.llmCallCount = m.llmCallCount;
1401
1403
  for (const key of ['inputTokens', 'outputTokens', 'totalTokens', 'totalMs']) {
@@ -1541,6 +1543,8 @@ function projectVisibleHistoryChunkMessages(messages = []) {
1541
1543
  threadId: m.threadId || m.turnId || 'main',
1542
1544
  ...(m.turnId ? { turnId: m.turnId } : {}),
1543
1545
  ...(m.imageAssetAnchor === true ? { imageAssetAnchor: true } : {}),
1546
+ ...(m.presentation ? { presentation: m.presentation } : {}),
1547
+ ...(m.presentationCallId ? { presentationCallId: m.presentationCallId } : {}),
1544
1548
  ...(Array.isArray(m.attachments) && m.attachments.length > 0 ? { attachments: hydrateHistoryAttachmentPreviews(m.attachments, m.sessionId) } : {}),
1545
1549
  ...(m.quote ? { quote: m.quote } : {}),
1546
1550
  ...(Array.isArray(m.images) && m.images.length > 0 ? { images: m.images } : {}),
@@ -1615,6 +1619,7 @@ function emitLegacyHistoryOutputFrames(replayEntries) {
1615
1619
  type: 'assistant',
1616
1620
  message: {
1617
1621
  id: entry.id || null,
1622
+ ...(entry.presentation ? { presentation: entry.presentation, presentationCallId: entry.presentationCallId, responseKind: entry.responseKind } : {}),
1618
1623
  content: [
1619
1624
  ...(entry.content ? [{ type: 'text', text: entry.content }] : []),
1620
1625
  ...((entry.toolCalls || []).map(toolCall => ({ type: 'tool_use', ...toolCall, history: true }))),
@@ -4156,6 +4161,14 @@ function handleEngineEvent(event, hctx) {
4156
4161
  }
4157
4162
  if ((event.type === 'text_delta' || event.type === 'thinking_delta')
4158
4163
  && (typeof event.text !== 'string' || event.text.length === 0)) return;
4164
+ // Reject stale publications before touching timers, queued text or history.
4165
+ if (event.type === 'response_presentation') {
4166
+ const message = event.message;
4167
+ if (hctx.signal?.aborted || !message?.id || !message.presentation
4168
+ || message.sessionId !== hctx.sessionId || message.turnId !== hctx.turnId
4169
+ || message.speakerVpId !== hctx.vpId || message.threadId !== (hctx.threadId || 'main')
4170
+ || hctx.presentations?.some(row => row.id === message.id)) return;
4171
+ }
4159
4172
  const terminalTurnEnd = event.type === 'turn_end' && event.terminal === true;
4160
4173
  const managesQueryTimer = terminalTurnEnd
4161
4174
  || event.type === 'aborted'
@@ -4190,6 +4203,27 @@ function handleEngineEvent(event, hctx) {
4190
4203
  maybeTransitionVpStatus(hctx, 'streaming');
4191
4204
  break;
4192
4205
 
4206
+ case 'response_presentation': {
4207
+ const message = event.message;
4208
+ if (!hctx.presentations) hctx.presentations = [];
4209
+ hctx.presentations.push({ ...message, _textPartsCount: (hctx.assistantTextParts || []).length });
4210
+ hctx.presentationTextPartsCount = (hctx.assistantTextParts || []).length;
4211
+ if (!hctx.skipPartialHistory) {
4212
+ const prompts = hctx.includeInitialPrompt ? [hctx.prompt, ...(hctx.appendedUserPrompts || [])] : (hctx.appendedUserPrompts || []);
4213
+ appendTurnToSessionHistory(hctx.sessionId, hctx.threadId || 'main', hctx.vpId,
4214
+ prompts, hctx.assistantTextParts || [], hctx.toolCallsAccum || [],
4215
+ hctx.toolResultsAccum || [], hctx.thinkingBlocksAccum || [],
4216
+ { turnId: hctx.turnId, partial: true, presentations: hctx.presentations });
4217
+ }
4218
+ sendSessionOutputFrame({
4219
+ type: 'assistant',
4220
+ message: { id: message.id, content: message.content, presentation: message.presentation,
4221
+ presentationCallId: message.presentationCallId, responseKind: 'progress' },
4222
+ ts: message.time || null,
4223
+ }, envelope);
4224
+ break;
4225
+ }
4226
+
4193
4227
  case 'thinking_delta':
4194
4228
  sendSessionEvent({ type: 'thinking_delta', text: event.text }, envelope);
4195
4229
  break;
@@ -4308,7 +4342,7 @@ function handleEngineEvent(event, hctx) {
4308
4342
  hctx.toolCallsAccum || [],
4309
4343
  hctx.toolResultsAccum || [],
4310
4344
  hctx.thinkingBlocksAccum || [],
4311
- { turnId: hctx.turnId, partial: true },
4345
+ { turnId: hctx.turnId, partial: true, presentations: hctx.presentations },
4312
4346
  );
4313
4347
  }
4314
4348
  sendSessionOutputFrame({
@@ -4376,6 +4410,22 @@ function handleEngineEvent(event, hctx) {
4376
4410
  // stop the user-query silence watchdog before that best-effort work.
4377
4411
  if (event.terminal) {
4378
4412
  (hctx.stopQueryTimer || hctx.pauseQueryTimer)?.();
4413
+ const publications = hctx.presentations || [];
4414
+ for (const row of publications) {
4415
+ row.responseKind = 'progress';
4416
+ row.stopReason = event.stopReason;
4417
+ if (event.stopReason !== 'end_turn') row.incomplete = true;
4418
+ }
4419
+ const last = publications.at(-1);
4420
+ if (event.stopReason === 'end_turn' && last?.presentation.phase === 'final'
4421
+ && hctx.presentationTextPartsCount === (hctx.assistantTextParts || []).length) last.responseKind = 'result';
4422
+ if (publications.length && !hctx.skipPartialHistory) {
4423
+ const prompts = hctx.includeInitialPrompt ? [hctx.prompt, ...(hctx.appendedUserPrompts || [])] : (hctx.appendedUserPrompts || []);
4424
+ appendTurnToSessionHistory(hctx.sessionId, hctx.threadId || 'main', hctx.vpId,
4425
+ prompts, hctx.assistantTextParts || [], hctx.toolCallsAccum || [],
4426
+ hctx.toolResultsAccum || [], hctx.thinkingBlocksAccum || [],
4427
+ { turnId: hctx.turnId, partial: true, presentations: publications });
4428
+ }
4379
4429
  }
4380
4430
  // A normal tool_use stop means "run tools, then call the adapter again",
4381
4431
  // so it must NOT end the VP's visible turn. An aborted turn is terminal
@@ -5668,6 +5718,7 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
5668
5718
  pauseQueryTimer,
5669
5719
  touchQueryTimer: watchdog.touch,
5670
5720
  stopQueryTimer: watchdog.stop,
5721
+ signal: vpAbort.signal,
5671
5722
  sessionId,
5672
5723
  vpId,
5673
5724
  turnId,
@@ -5810,7 +5861,7 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
5810
5861
  // block after the target response.
5811
5862
  const visiblePrompts = inboundIsInternal ? appendedUserPrompts : [prompt, ...appendedUserPrompts];
5812
5863
  const appendHistoryStart = perfNowMs();
5813
- appendTurnToSessionHistory(sessionId, threadId, vpId, visiblePrompts, assistantTextParts, toolCallsAccum, toolResultsAccum, thinkingBlocksAccum, { turnId });
5864
+ appendTurnToSessionHistory(sessionId, threadId, vpId, visiblePrompts, assistantTextParts, toolCallsAccum, toolResultsAccum, thinkingBlocksAccum, { turnId, presentations: handlerCtx.presentations });
5814
5865
  if (perfTraceId) {
5815
5866
  recordAgentPerfTrace(ctx.CONFIG, {
5816
5867
  traceId: perfTraceId,
@@ -5993,7 +6044,11 @@ function buildTurnHistoryEntries(threadId, vpId, prompts, assistantTextParts, to
5993
6044
  }
5994
6045
  }
5995
6046
 
5996
- const fullText = assistantTextParts.join('');
6047
+ const presentations = opts.presentations || [];
6048
+ const firstPublicationOffset = presentations[0]?._textPartsCount;
6049
+ const fullText = Number.isInteger(firstPublicationOffset)
6050
+ ? assistantTextParts.slice(0, firstPublicationOffset).join('')
6051
+ : assistantTextParts.join('');
5997
6052
  if (fullText || toolCallsAccum.length > 0) {
5998
6053
  // Stamp speakerVpId on assistant + tool rows so the in-memory
5999
6054
  // baseSnapshot filter (filterSnapshotForVp) can mirror the disk
@@ -6037,6 +6092,19 @@ function buildTurnHistoryEntries(threadId, vpId, prompts, assistantTextParts, to
6037
6092
  entries.push(markEntry(toolMsg));
6038
6093
  }
6039
6094
  }
6095
+ for (let index = 0; index < presentations.length; index += 1) {
6096
+ const record = presentations[index];
6097
+ const entry = projectPersistedToHistoryEntry(record);
6098
+ if (entry) entries.push(markEntry(entry));
6099
+ // Keep later prose after its publication in the runtime cache as well as
6100
+ // on disk. The internal offset is never projected into public history.
6101
+ if (Number.isInteger(record._textPartsCount)) {
6102
+ const end = presentations[index + 1]?._textPartsCount ?? assistantTextParts.length;
6103
+ const text = assistantTextParts.slice(record._textPartsCount, end).join('');
6104
+ if (text) entries.push(markEntry({ role: 'assistant', content: text,
6105
+ threadId: threadId || 'main', ...(vpId ? { speakerVpId: vpId } : {}) }));
6106
+ }
6107
+ }
6040
6108
  return entries;
6041
6109
  }
6042
6110