@yeaft/webchat-agent 1.0.636 → 1.0.637

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.
@@ -147,19 +147,27 @@ export function createPersonService(options = {}) {
147
147
  object(payload, op === 'dream' ? ['clientMessageId'] : ['text', 'clientMessageId', 'files'], ['clientMessageId']);
148
148
  identifier(payload.clientMessageId);
149
149
  validateFiles(payload.files, payload.text ?? '', op);
150
+ const stopGeneration = autonomyStops.get(ownerId) ?? 0;
150
151
  // Durable cancellation may already fence the old episode; do not start a
151
152
  // new local activity while its host effects are still being joined.
152
- if (runtime.isOwnerRunning(ownerId)) {
153
+ if (runtime.pendingCancellations.has(ownerId) || (op !== 'send' && runtime.isOwnerRunning(ownerId))) {
153
154
  const receipt = await repository.receipt(ownerId, payload.clientMessageId);
154
155
  if (!receipt.found) fail('BUSY');
155
156
  }
156
157
  const admitted = await repository.admit(ownerId, { kind: op, text: payload.text ?? '', clientMessageId: payload.clientMessageId,
157
158
  workerId: runtime.workerId, budget: runtime.budget, files: payload.files });
158
- if (!admitted.duplicate) {
159
- if (closed) await repository.finish(admitted.episode, 'interrupted', 'INTERRUPTED');
160
- else runtime.start(admitted.episode);
159
+ if (!admitted.duplicate && !admitted.appended) {
160
+ const cancelled = (autonomyStops.get(ownerId) ?? 0) !== stopGeneration || runtime.pendingCancellations.has(ownerId);
161
+ if (closed || cancelled) {
162
+ // Admission can outlive the entire cancellation join. Fence only this
163
+ // new lease, never an appended input or a replacement job.
164
+ await repository.finish(admitted.episode, cancelled ? 'cancelled' : 'interrupted', cancelled ? 'CANCELLED' : 'INTERRUPTED');
165
+ const receipt = await repository.receipt(ownerId, payload.clientMessageId);
166
+ admitted.status = receipt.status; admitted.deliveryState = receipt.deliveryState;
167
+ } else runtime.start(admitted.episode);
161
168
  }
162
- return { episodeId: admitted.episodeId, duplicate: admitted.duplicate, status: admitted.status };
169
+ return { episodeId: admitted.episodeId, duplicate: admitted.duplicate, status: admitted.status,
170
+ messageId: admitted.messageId, deliveryState: admitted.deliveryState };
163
171
  }
164
172
  case 'traces': await repository.recover(ownerId); return repository.list(ownerId, 'traces', page(payload));
165
173
  case 'messages': return repository.list(ownerId, 'messages', page(payload));
@@ -93,6 +93,17 @@ export class SqlitePersonRepository {
93
93
  async autonomyAdmit(ownerId, input) { return this.request('autonomyAdmit', [ownerId, input]); }
94
94
  async admit(ownerId, input) { return this.request('admit', [ownerId, input]); }
95
95
  async receipt(ownerId, clientMessageId, requestHash) { return this.request('receipt', [ownerId, clientMessageId, requestHash]); }
96
+ /** Read-only, live-owner check of stored received version against arrivals. */
97
+ async pendingInputs(episode) { return this.request('pendingInputs', [episode]); }
98
+ /** Atomically acknowledge at most four inputs with bounded textual context.
99
+ * Returns public messages, full attachment envelopes and the received version.
100
+ * The caller must set episode.inboxVersion to that version before new work. */
101
+ async receiveInputs(episode) { return this.request('receiveInputs', [episode]); }
102
+ /** Throws INPUT_PENDING without invalidating the lease or in-flight finalizers. */
103
+ async guardExecutionInput(episode) { return this.request('guardExecutionInput', [episode]); }
104
+ /** Exact consumed user targets; first publication requires its started
105
+ * Conversation.reply capability. Identical callId retries return the message. */
106
+ async publishReply(episode, data) { return this.request('publishReply', [episode, data]); }
96
107
  async executionControl(episode, control) { return this.request('executionControl', [episode, control]); }
97
108
  async heartbeat(episode) { return this.request('heartbeat', [episode]); }
98
109
  async append(episode, kind, data) { return this.request('append', [episode, kind, data]); }
@@ -23,7 +23,7 @@ const scopeValues = s => [s.namespace, s.ownerId, s.personId];
23
23
  const SEARCH_COLUMNS = Object.freeze({ messages: 'text', concepts: 'statement' });
24
24
  const metadataDates = Object.freeze({
25
25
  persons: ['createdAt', 'leaseUntil'], episodes: ['createdAt', 'endedAt', 'callFinalizeUntil'],
26
- messages: ['createdAt'], states: ['updatedAt'], concepts: ['updatedAt'], concept_revisions: ['updatedAt'],
26
+ messages: ['createdAt', 'receivedAt'], states: ['updatedAt'], concepts: ['updatedAt'], concept_revisions: ['updatedAt'],
27
27
  state_commits: ['createdAt'], traces: ['createdAt'],
28
28
  });
29
29
  const decode = (row, table) => {
@@ -93,8 +93,8 @@ const objectOutput = (record, data) => {
93
93
  if (!mimes.includes(record.mimeType) || !(data instanceof Uint8Array) || data.length !== record.size) fail('INVALID_REQUEST');
94
94
  }
95
95
  };
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']);
96
+ const READS = new Set(['pendingInputs', 'guardExecutionInput', 'autonomyOwners', 'checkExecutionRead', 'executionRead', 'outputs', 'outputRead', 'checkOutputPublication', 'receipt', 'turns', 'inspect', 'search', 'getPerson', 'context', 'recall', 'list', 'searchChanges', 'resolveMemories', 'createdCapabilities', 'episodeAttachments']);
97
+ const WRITES = new Set(['receiveInputs', 'publishReply', 'autonomyObserve', 'autonomyAdmit', 'executionControl', 'savePresentation', 'saveOutput', 'open', 'recover', 'admit', 'heartbeat', 'append', 'startCall', 'finalizeCall', 'startCapability', 'finalizeCapability', 'commit', 'publishProgress', 'finish', 'cancel', 'settings', 'snapshot', 'saveCreatedCapability']);
98
98
  const memoryKind = kind => { if (!['messages', 'concepts'].includes(kind)) fail('INVALID_REQUEST'); return kind; };
99
99
  const boundedLimit = (limit, max = 100) => { if (!Number.isSafeInteger(limit) || limit < 1 || limit > max) fail('INVALID_REQUEST'); return limit; };
100
100
  const sequence = value => {
@@ -197,7 +197,7 @@ export class SqlitePersonStore {
197
197
  p = this.doc(scope, {
198
198
  name, soul: 'A continuous, curious and honest digital person. Preserve uncertainty, reconsider your judgments, distinguish imagination from experience, and respect permissions. You may disagree without acting without authority.',
199
199
  soulRevision: 1, createdAt: this.now, settings: { autonomyEnabled: false, defaultModel: null },
200
- epoch: 0, writeSerial: 0, inputWatermark: 0, controlVersion: 0, stateVersion: 0,
200
+ epoch: 0, writeSerial: 0, inputWatermark: 0, inboxVersion: 0, controlVersion: 0, stateVersion: 0,
201
201
  traceSeq: 0, messageSeq: 0, activeEpisodeId: null, leaseOwner: null, leaseUntil: new Date(0),
202
202
  });
203
203
  this.put('persons', p, true);
@@ -236,6 +236,7 @@ export class SqlitePersonStore {
236
236
  Object.assign(episode, { status: 'interrupted', terminalCode: 'INTERRUPTED', endedAt: this.now });
237
237
  this.put('episodes', episode); this.abandonCall(ownerId, episode, 'INTERRUPTED');
238
238
  }
239
+ this.stopInputs(scope, id);
239
240
  this.trace(ownerId, id, 'interrupted', { code: 'INTERRUPTED', reason: 'lease-expired', baseStateVersion: p.stateVersion });
240
241
  }
241
242
  for (const episode of this.rows('episodes', scope, " AND status = ? AND callFinalizeUntil <= ? AND json_type(record, '$.openCall') IS NOT NULL", ['cancelled', this.now.getTime()])) {
@@ -245,8 +246,14 @@ export class SqlitePersonStore {
245
246
  }
246
247
  receipt(ownerId, clientMessageId, requestHash) {
247
248
  identifier(clientMessageId);
248
- const episode = this.one('episodes', this.scope(ownerId), ' AND clientMessageId = ?', [clientMessageId]);
249
- return admissionReceipt(episode, clientMessageId, requestHash);
249
+ const scope = this.scope(ownerId);
250
+ const message = this.one('messages', scope, " AND json_extract(record, '$.clientMessageId') = ? AND json_extract(record, '$.role') = 'user'", [clientMessageId]);
251
+ const episode = message ? this.one('episodes', scope, ' AND id = ?', [message.episodeId])
252
+ : this.one('episodes', scope, ' AND clientMessageId = ?', [clientMessageId]);
253
+ const receipt = admissionReceipt(episode && message ? { ...episode, kind: message.id === episode.messageId ? episode.kind : 'send',
254
+ messageId: message.id, text: message.text, attachments: message.attachments, requestHash: message.requestHash ?? episode.requestHash } : episode,
255
+ clientMessageId, requestHash);
256
+ return receipt.found ? { ...receipt, deliveryState: message?.deliveryState ?? (receipt.messageId ? 'received' : null) } : receipt;
250
257
  }
251
258
  /** Only explicit v1 opt-in is schedulable. Legacy settings never acquire consent. */
252
259
  autonomyOwners({ after = '', limit = 50 } = {}) {
@@ -328,13 +335,27 @@ export class SqlitePersonStore {
328
335
  identifier(clientMessageId); text(workerId, 256);
329
336
  this.recover(ownerId);
330
337
  const scope = this.scope(ownerId), requestHash = attachmentRequestHash(kind, input, validated);
331
- const existing = this.one('episodes', scope, ' AND clientMessageId = ?', [clientMessageId]);
332
- if (existing) {
333
- if (existing.requestHash !== requestHash) fail('IDEMPOTENCY_CONFLICT');
334
- return { episodeId: existing.id, duplicate: true, status: existing.status };
335
- }
338
+ const existing = this.receipt(ownerId, clientMessageId, requestHash);
339
+ if (existing.found) return { episodeId: existing.episodeId, duplicate: true, status: existing.status,
340
+ messageId: existing.messageId, deliveryState: existing.deliveryState };
336
341
  const p = this.getPerson(ownerId);
337
- if (p.activeEpisodeId) fail('BUSY');
342
+ if (p.activeEpisodeId) {
343
+ if (kind !== 'send') fail('BUSY');
344
+ const active = this.one('episodes', scope, ' AND id = ?', [p.activeEpisodeId]);
345
+ if (!active || active.status !== 'running') fail('STALE');
346
+ // Arrival changes no execution lease/fence or cognitive revision. Only an
347
+ // explicit receive acknowledges this new instruction at a safe boundary.
348
+ p.inboxVersion = (p.inboxVersion ?? 0) + 1; p.messageSeq++; p.writeSerial++;
349
+ this.put('persons', p);
350
+ for (const file of validated) this.put('attachments', this.doc(scope, file));
351
+ const message = this.doc(scope, { id: randomUUID(), revision: 1, seq: p.messageSeq, episodeId: active.id,
352
+ role: 'user', text: input, attachments, createdAt: this.now, clientMessageId, requestHash,
353
+ inboxVersion: p.inboxVersion, deliveryState: 'queued' });
354
+ this.put('messages', message, true); this.journal(scope, 'messages', message);
355
+ this.trace(ownerId, active.id, 'input_queued', { messageId: message.id, inboxVersion: p.inboxVersion });
356
+ return { episodeId: active.id, duplicate: false, appended: true, status: active.status,
357
+ messageId: message.id, deliveryState: message.deliveryState };
358
+ }
338
359
  if (kind !== 'autonomous' && p.settings.autonomyEnabled === true && p.autonomy?.version === 1) {
339
360
  p.autonomy = { ...p.autonomy, backoffMs: 60000, nextAt: this.now.getTime() + 60000, lastFingerprint: null };
340
361
  }
@@ -344,20 +365,93 @@ export class SqlitePersonStore {
344
365
  // must never replace the latest substantive user instruction.
345
366
  const hasMessage = kind === 'send' || (kind === 'think' && (input.trim().length > 0 || attachments.length > 0));
346
367
  p.activeEpisodeId = id; p.leaseOwner = workerId; p.leaseUntil = new Date(this.now.getTime() + this.leaseMs);
347
- p.epoch++; p.writeSerial++; p.inputWatermark++; if (hasMessage) p.messageSeq++;
368
+ p.epoch++; p.writeSerial++; p.inputWatermark++;
369
+ p.inboxVersion = (p.inboxVersion ?? 0) + Number(hasMessage); if (hasMessage) p.messageSeq++;
348
370
  this.put('persons', p);
349
371
  let messageId = null;
350
372
  for (const file of validated) this.put('attachments', this.doc(scope, file));
351
373
  if (hasMessage) {
352
374
  messageId = randomUUID();
353
- const message = this.doc(scope, { id: messageId, revision: 1, seq: p.messageSeq, episodeId: id, role: 'user', text: input, attachments, createdAt: this.now, clientMessageId });
375
+ const message = this.doc(scope, { id: messageId, revision: 1, seq: p.messageSeq, episodeId: id, role: 'user', text: input, attachments, createdAt: this.now, clientMessageId, requestHash,
376
+ inboxVersion: p.inboxVersion, deliveryState: 'received', receivedAt: this.now });
354
377
  this.put('messages', message, true); this.journal(scope, 'messages', message);
355
378
  }
356
379
  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,
357
- baseStateVersion: p.stateVersion, inputWatermark: p.inputWatermark, controlVersion: p.controlVersion, autonomyGeneration: p.settings.autonomyEnabled === true ? p.autonomy?.generation ?? null : null, budget, createdAt: this.now });
380
+ baseStateVersion: p.stateVersion, inboxVersion: p.inboxVersion, lastInputSeq: hasMessage ? p.messageSeq : 0, inputMessageIds: messageId ? [messageId] : [], inputWatermark: p.inputWatermark, controlVersion: p.controlVersion, autonomyGeneration: p.settings.autonomyEnabled === true ? p.autonomy?.generation ?? null : null, budget, createdAt: this.now });
358
381
  this.put('episodes', episode, true);
359
382
  this.trace(ownerId, id, 'accepted', { trigger: { kind, text: input, messageId, attachments }, baseStateVersion: p.stateVersion, budget });
360
- return { episodeId: id, duplicate: false, status: 'running', episode };
383
+ return { episodeId: id, duplicate: false, status: 'running', messageId, deliveryState: hasMessage ? 'received' : null, episode };
384
+ }
385
+ inputEpisode(episode) {
386
+ const p = this.own(episode), scope = this.episodeScope(episode);
387
+ const record = this.one('episodes', scope, ' AND id = ?', [episode.id]);
388
+ if (!record || record.status !== 'running') fail('STALE');
389
+ return { p, scope, record };
390
+ }
391
+ pendingInputs(episode) {
392
+ const { p, record } = this.inputEpisode(episode);
393
+ return (record.inboxVersion ?? 0) < (p.inboxVersion ?? 0);
394
+ }
395
+ inputCurrent(episode, p, record) {
396
+ if ((record.inboxVersion ?? 0) < (p.inboxVersion ?? 0)) return false;
397
+ if (episode.inboxVersion !== undefined) return episode.inboxVersion === (record.inboxVersion ?? 0);
398
+ // Legacy callers may omit the version for an initial episode, but cannot
399
+ // silently claim instructions subsequently consumed by this same worker.
400
+ const initial = record.messageId ? this.one('messages', this.episodeScope(episode), ' AND id = ?', [record.messageId]) : null;
401
+ return (record.lastInputSeq ?? 0) <= (initial?.seq ?? 0);
402
+ }
403
+ guardExecutionInput(episode) {
404
+ const { p, record } = this.inputEpisode(episode);
405
+ if (!this.inputCurrent(episode, p, record)) fail('INPUT_PENDING');
406
+ return true;
407
+ }
408
+ inputFiles(scope, messages) {
409
+ const files = new Map();
410
+ for (const message of messages) for (const ref of message.attachments ?? []) {
411
+ const file = this.one('attachments', scope, ' AND id = ?', [ref.id]);
412
+ if (!file || file.sha256 !== ref.sha256) fail('STORAGE_UNAVAILABLE');
413
+ files.set(file.id, publicDoc(file));
414
+ }
415
+ return [...files.values()];
416
+ }
417
+ receiveInputs(episode) {
418
+ const { p, scope, record } = this.inputEpisode(episode);
419
+ const queued = this.rows('messages', scope,
420
+ " AND json_extract(record, '$.episodeId') = ? AND json_extract(record, '$.deliveryState') = 'queued' ORDER BY seq ASC LIMIT 4", [episode.id]);
421
+ const messages = []; let size = 0;
422
+ for (const message of queued) {
423
+ // The textual context is <=64KiB; image bytes are separately bounded by
424
+ // attachment admission (and returned whole, never partially acknowledged).
425
+ const files = this.inputFiles(scope, [message]);
426
+ const cost = bytes(messageView(message)) + files.reduce((n, file) => n + bytes(file.content ?? ''), 0);
427
+ if (messages.length && size + cost > 64 * 1024) break;
428
+ size += cost;
429
+ message.deliveryState = 'received'; message.receivedAt = this.now; message.revision++;
430
+ this.put('messages', message); this.journal(scope, 'messages', message);
431
+ record.inboxVersion = message.inboxVersion; record.lastInputSeq = message.seq;
432
+ record.inputMessageIds = [...new Set([...(record.inputMessageIds ?? (record.messageId ? [record.messageId] : [])), message.id])];
433
+ messages.push(message);
434
+ }
435
+ if (messages.length) {
436
+ this.put('episodes', record); p.writeSerial++; this.put('persons', p);
437
+ this.trace(episode.ownerId, episode.id, 'inputs_received', { messageIds: messages.map(message => message.id), inboxVersion: record.inboxVersion });
438
+ }
439
+ return { messages: messages.map(messageView), attachments: this.inputFiles(scope, messages), inboxVersion: record.inboxVersion ?? 0 };
440
+ }
441
+ stopInputs(scope, episodeId) {
442
+ for (const message of this.rows('messages', scope,
443
+ " AND json_extract(record, '$.episodeId') = ? AND json_extract(record, '$.deliveryState') = 'queued' ORDER BY seq ASC", [episodeId])) {
444
+ message.deliveryState = 'stopped'; message.revision++;
445
+ this.put('messages', message); this.journal(scope, 'messages', message);
446
+ }
447
+ }
448
+ consumedMessageIds(scope, record, targets) {
449
+ const consumed = new Set(record.inputMessageIds ?? (record.messageId ? [record.messageId] : []));
450
+ return (targets ?? [...consumed].slice(-24)).filter(id => {
451
+ if (!consumed.has(id)) return false;
452
+ const message = this.one('messages', scope, ' AND id = ?', [id]);
453
+ return message?.role === 'user' && message.episodeId === record.id && (message.deliveryState ?? 'received') === 'received';
454
+ });
361
455
  }
362
456
  episodeAttachments(episode) {
363
457
  this.own(episode);
@@ -405,7 +499,7 @@ export class SqlitePersonStore {
405
499
  }
406
500
  append(episode, kind, data) {
407
501
  // Call proof/publication events are emitted only by their transactional methods.
408
- if (['call_started', 'call_output', 'call_failed', 'capability_created', 'capability_finalized', 'output_published', 'presentation_published'].includes(kind)) fail('INVALID_REQUEST');
502
+ if (['call_started', 'call_output', 'call_failed', 'capability_created', 'capability_finalized', 'output_published', 'presentation_published', 'message_reply', 'input_queued', 'inputs_received'].includes(kind)) fail('INVALID_REQUEST');
409
503
  const p = this.own(episode);
410
504
  const record = this.one('episodes', this.episodeScope(episode), ' AND id = ?', [episode.id]);
411
505
  if (!record || record.status !== 'running') fail('STALE');
@@ -416,6 +510,7 @@ export class SqlitePersonStore {
416
510
  return this.trace(episode.ownerId, episode.id, kind, data);
417
511
  }
418
512
  startCall(episode, data) {
513
+ this.guardExecutionInput(episode);
419
514
  identifier(data.callId);
420
515
  const p = this.own(episode), scope = this.episodeScope(episode);
421
516
  const record = this.one('episodes', scope, ' AND id = ?', [episode.id]);
@@ -467,6 +562,7 @@ export class SqlitePersonStore {
467
562
  return started.modelCallId === undefined && started.toolUseId === undefined && started.toolIndex === undefined && !proof.output.protocol ? proof : null;
468
563
  }
469
564
  startCapability(episode, data) {
565
+ this.guardExecutionInput(episode);
470
566
  identifier(data.callId);
471
567
  if (data.modelCallId !== undefined) identifier(data.modelCallId);
472
568
  if (!data.capability || typeof data.capability.id !== 'string' || !data.capability.args || typeof data.capability.args !== 'object') fail('INVALID_REQUEST');
@@ -540,6 +636,7 @@ export class SqlitePersonStore {
540
636
  return this.rows('created_capabilities', this.episodeScope(episode), ' ORDER BY id ASC LIMIT 32').map(publicDoc);
541
637
  }
542
638
  saveCreatedCapability(episode, input) {
639
+ this.guardExecutionInput(episode);
543
640
  const { definition, evidence, callId } = validateCreatedCapability(input);
544
641
  this.own(episode);
545
642
  const scope = this.episodeScope(episode), active = this.one('episodes', scope, ' AND id = ?', [episode.id]);
@@ -617,6 +714,7 @@ export class SqlitePersonStore {
617
714
  * publishProgress's no-open-capability path. JSON carries the canonical payload;
618
715
  * text remains the searchable/model/legacy fallback. phase is delivery intent. */
619
716
  savePresentation(episode, { callId, args }) {
717
+ this.guardExecutionInput(episode);
620
718
  identifier(callId);
621
719
  const presentation = personPresentation(args);
622
720
  const p = this.own(episode), scope = this.episodeScope(episode);
@@ -662,10 +760,41 @@ export class SqlitePersonStore {
662
760
  " AND json_extract(record, '$.episodeId') = ? AND json_type(record, '$.presentation') = 'object'", [episodeId])
663
761
  .some(message => message.text.trim() === reply.trim());
664
762
  }
763
+ /** A targeted user-facing delivery, not a state commit or an answered flag.
764
+ * Idempotency is scoped to this episode/call and the exact target/text payload. */
765
+ publishReply(episode, { callId, messageIds, text: reply }) {
766
+ this.guardExecutionInput(episode);
767
+ identifier(callId); text(reply, LIMITS.outputBytes);
768
+ if (!Array.isArray(messageIds) || !messageIds.length || messageIds.length > 24 ||
769
+ new Set(messageIds).size !== messageIds.length) fail('INVALID_REQUEST');
770
+ for (const id of messageIds) identifier(id);
771
+ const { p, scope, record } = this.inputEpisode(episode);
772
+ const consumed = new Set(this.consumedMessageIds(scope, record, messageIds));
773
+ if (messageIds.some(id => !consumed.has(id))) fail('INVALID_REQUEST');
774
+ const invocationHash = digest({ messageIds, text: reply });
775
+ const prior = this.one('messages', scope,
776
+ " AND json_extract(record, '$.episodeId') = ? AND json_extract(record, '$.callId') = ? AND json_extract(record, '$.replyKind') = 'message-reply'", [episode.id, callId]);
777
+ if (prior) {
778
+ if (prior.publicationHash !== invocationHash) fail('INVALID_REQUEST');
779
+ return messageView(prior);
780
+ }
781
+ if (record.openCall || !record.openCapability || record.openCapability.callId !== callId || record.openCapability.capability.id !== 'Conversation.reply' ||
782
+ !isDeepStrictEqual(record.openCapability.capability.args, { messageIds, text: reply }) ||
783
+ !this.capabilityProof(scope, episode.id, record.openCapability)) fail('STALE');
784
+ p.messageSeq++; p.writeSerial++; this.put('persons', p);
785
+ const message = this.doc(scope, { id: randomUUID(), revision: 1, seq: p.messageSeq, episodeId: episode.id,
786
+ callId, publicationHash: invocationHash, replyKind: 'message-reply', replyToMessageIds: [...messageIds], role: 'assistant', text: reply, createdAt: this.now });
787
+ this.put('messages', message, true); this.journal(scope, 'messages', message);
788
+ record.lastReplyAt = this.now.toISOString(); delete record.feedback; this.put('episodes', record);
789
+ this.trace(episode.ownerId, episode.id, 'message_reply', { callId, messageId: message.id, replyToMessageIds: message.replyToMessageIds });
790
+ return messageView(message);
791
+ }
665
792
  /** Publish only a validated, complete call's reply. This is communication, not
666
793
  * a state/Concept commit. Fencing, dedupe and the memory journal share one transaction. */
667
794
  publishProgress(episode, { callId, reply, final = false }) {
668
795
  const p = this.own(episode), scope = this.episodeScope(episode);
796
+ const input = this.inputEpisode(episode);
797
+ if (!this.inputCurrent(episode, input.p, input.record)) return null;
669
798
  identifier(callId); text(reply, LIMITS.outputBytes);
670
799
  if (typeof final !== 'boolean') fail('INVALID_REQUEST');
671
800
  const record = this.one('episodes', scope, ' AND id = ?', [episode.id]);
@@ -695,7 +824,7 @@ export class SqlitePersonStore {
695
824
  sameReply || this.repeatsAutonomousReply(scope, episode, reply) || this.repeatsPresentation(scope, episode.id, reply)) return null;
696
825
  p.messageSeq++; p.writeSerial++; this.put('persons', p);
697
826
  const message = this.doc(scope, { id: randomUUID(), revision: 1, seq: p.messageSeq, episodeId: episode.id,
698
- callId, replyKind: final ? 'execution-final' : 'progress', role: 'assistant', text: reply, createdAt: this.now });
827
+ callId, replyKind: final ? 'execution-final' : 'progress', ...(final ? { replyToMessageIds: this.consumedMessageIds(scope, record).slice(-24) } : {}), role: 'assistant', text: reply, createdAt: this.now });
699
828
  this.put('messages', message, true); this.journal(scope, 'messages', message);
700
829
  record.lastReplyAt = this.now.toISOString(); delete record.feedback; this.put('episodes', record);
701
830
  this.trace(episode.ownerId, episode.id, 'progress_reply', { callId, messageId: message.id });
@@ -704,7 +833,9 @@ export class SqlitePersonStore {
704
833
  commit(episode, proposal, selection, callId, reportedSources = new Map()) {
705
834
  const p = this.own(episode), scope = this.episodeScope(episode);
706
835
  const record = this.one('episodes', scope, ' AND id = ?', [episode.id]);
707
- if (!record || record.status !== 'running' || proposal.baseStateVersion !== episode.baseStateVersion) fail('STALE');
836
+ if (!record || record.status !== 'running') fail('STALE');
837
+ if (!this.inputCurrent(episode, p, record)) return { inputPending: true };
838
+ if (proposal.baseStateVersion !== episode.baseStateVersion) fail('STALE');
708
839
  const repeatsExecutionReply = proposal.reply && this.rows('messages', scope,
709
840
  " AND json_extract(record, '$.episodeId') = ? AND json_extract(record, '$.replyKind') IN ('progress', 'execution-final')", [episode.id])
710
841
  .some(message => message.text.trim() === proposal.reply.trim());
@@ -744,7 +875,7 @@ export class SqlitePersonStore {
744
875
  this.put('state_commits', this.doc(scope, { id: randomUUID(), version: p.stateVersion, parentVersion: episode.baseStateVersion,
745
876
  episodeId: episode.id, callId, conceptRevisions: revisions, state: stateView(state), createdAt: this.now }), true);
746
877
  if (publishReply) {
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 });
878
+ const message = this.doc(scope, { id: randomUUID(), revision: 1, seq: p.messageSeq, episodeId: episode.id, callId, replyKind: 'final', replyToMessageIds: this.consumedMessageIds(scope, record).slice(-24), role: 'assistant', text: proposal.reply, createdAt: this.now });
748
879
  this.put('messages', message, true); this.journal(scope, 'messages', message);
749
880
  }
750
881
  if (p.settings.autonomyEnabled === true && p.autonomy?.generation === record.autonomyGeneration) {
@@ -772,6 +903,7 @@ export class SqlitePersonStore {
772
903
  delete record.executionControl;
773
904
  Object.assign(record, { status, terminalCode: code, endedAt: this.now }); this.put('episodes', record); this.abandonCall(episode.ownerId, record, code);
774
905
  }
906
+ this.stopInputs(this.episodeScope(episode), episode.id);
775
907
  this.trace(episode.ownerId, episode.id, status, { code, baseStateVersion: episode.baseStateVersion });
776
908
  return true;
777
909
  }
@@ -795,6 +927,7 @@ export class SqlitePersonStore {
795
927
  delete record.executionControl;
796
928
  Object.assign(record, { status: 'cancelled', terminalCode: 'CANCELLED', endedAt: this.now, callFinalizeUntil: until }); this.put('episodes', record);
797
929
  }
930
+ this.stopInputs(scope, id);
798
931
  this.trace(ownerId, id, 'cancelled', { code: 'CANCELLED' });
799
932
  return { cancelled: true, episodeId: id };
800
933
  }
@@ -914,6 +1047,7 @@ export class SqlitePersonStore {
914
1047
  return { items: docs.slice(0, limit).map(conceptView), nextCursor: docs.length > limit ? docs[limit - 1].id : null };
915
1048
  }
916
1049
  checkOutputPublication(episode, { callId, args }) {
1050
+ this.guardExecutionInput(episode);
917
1051
  outputArgs(args); this.own(episode);
918
1052
  const scope = this.episodeScope(episode), active = this.one('episodes', scope, ' AND id = ?', [episode.id]);
919
1053
  if (!active || active.status !== 'running' || active.openCall || active.openCapability?.callId !== callId ||
@@ -1,4 +1,4 @@
1
- import { fail, identifier, object, page, PersonError, text } from './contracts.js';
1
+ import { fail, identifier, LIMITS, object, page, PersonError, text } from './contracts.js';
2
2
  import { validateCreatedDefinition } from './created-capability-contract.js';
3
3
  import { executionReadRequest } from './execution-evidence.js';
4
4
  import { outputArgs } from './outputs.js';
@@ -51,7 +51,12 @@ export function validateToolArguments(value, schema) {
51
51
  * callers must not wrap capability execution in the same catch. */
52
52
  export function validateCapabilityArguments({ id, args }) {
53
53
  try {
54
- if (id === 'Execution.goal') validateExecutionGoal(args);
54
+ if (id === 'Conversation.reply') {
55
+ object(args, ['messageIds', 'text']);
56
+ if (!Array.isArray(args.messageIds) || args.messageIds.length < 1 || args.messageIds.length > 24 || new Set(args.messageIds).size !== args.messageIds.length) fail('INVALID_REQUEST');
57
+ args.messageIds.forEach(id => identifier(id));
58
+ text(args.text, LIMITS.outputBytes);
59
+ } else if (id === 'Execution.goal') validateExecutionGoal(args);
55
60
  else if (id === 'Capability.create') validateCreatedDefinition(args);
56
61
  else if (id === 'Execution.read') executionReadRequest(args);
57
62
  else if (id === 'Output.publish') outputArgs(args);