@yeaft/webchat-agent 1.0.608 → 1.0.611

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.608",
3
+ "version": "1.0.611",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -46,7 +46,7 @@ import { utf8PrefixWithinBytes } from '../utf8.js';
46
46
  * @typedef {{ type: 'tool_call', id: string, name: string, input: object }} ToolCallEvent
47
47
  * @typedef {{ type: 'provider_activity' }} ProviderActivityEvent Content-free progress on a non-empty hidden delta; watchdog only, never UI/history.
48
48
  * @typedef {{ type: 'provider_state', providerState: object, providerStateBytes: number }} ProviderStateEvent Internal only; never forward to UI/search.
49
- * @typedef {{ type: 'usage', inputTokens: number, outputTokens: number, reasoningTokens?: number, cacheReadTokens?: number, cacheWriteTokens?: number, cacheTokensAreIncludedInInput?: boolean }} UsageEvent
49
+ * @typedef {{ type: 'usage', inputTokens: number, outputTokens: number, reasoningTokens?: number, cacheReadTokens?: number, cacheWriteTokens?: number, cacheTokensAreIncludedInInput?: boolean, reportedTokenFields?: string[], usageIncomplete?: boolean }} UsageEvent
50
50
  * @typedef {{ type: 'stop', stopReason: 'end_turn' | 'tool_use' | 'max_tokens' }} StopEvent
51
51
  * @typedef {{ type: 'error', error: Error, retryable: boolean }} ErrorEvent
52
52
  *
@@ -64,7 +64,7 @@ export function applyAnthropicThinking(body, model, effort, effortContext = {})
64
64
  }
65
65
  }
66
66
 
67
- import { ProviderStateError, createProviderContext, createProviderState, replayProviderState, providerStateBytes, applyAnthropicCaching, reasoningUsage } from './provider-state.js';
67
+ import { ProviderStateError, createProviderContext, createProviderState, replayProviderState, providerStateBytes, applyAnthropicCaching, reasoningUsage, reportedUsageFields, hasUsageCounts } from './provider-state.js';
68
68
 
69
69
  const DEFAULT_BASE_URL = 'https://api.anthropic.com';
70
70
  const API_VERSION = '2023-06-01';
@@ -330,7 +330,7 @@ export class AnthropicAdapter extends LLMAdapter {
330
330
  const result = await response.json();
331
331
  // Account for consumed tokens even when the completed response cannot
332
332
  // safely publish tools. Budget consumers may abort on this usage event.
333
- yield { type: 'usage', inputTokens: result.usage?.input_tokens || 0, outputTokens: result.usage?.output_tokens || 0,
333
+ yield { type: 'usage', reportedTokenFields: reportedUsageFields(result.usage, 'anthropic'), usageIncomplete: !hasUsageCounts(result.usage), inputTokens: result.usage?.input_tokens || 0, outputTokens: result.usage?.output_tokens || 0,
334
334
  cacheReadTokens: result.usage?.cache_read_input_tokens || 0, cacheWriteTokens: result.usage?.cache_creation_input_tokens || 0,
335
335
  ...reasoningUsage(result.usage, 'anthropic') };
336
336
  if (signal?.aborted) throw new LLMAbortError();
@@ -557,11 +557,13 @@ export class AnthropicAdapter extends LLMAdapter {
557
557
  }
558
558
  yield {
559
559
  type: 'usage',
560
+ reportedTokenFields: reportedUsageFields(event.usage, 'anthropic'),
561
+ usageIncomplete: !hasUsageCounts(event.usage, ['output_tokens']),
560
562
  ...reasoning,
561
563
  inputTokens: 0, // Only in message_start
562
564
  outputTokens,
563
565
  };
564
- }
566
+ } else yield { type: 'usage', reportedTokenFields: [], usageIncomplete: true };
565
567
  if (signal?.aborted) throw new LLMAbortError();
566
568
  const stopReason = event.delta?.stop_reason;
567
569
  if (stopReason === 'max_tokens' && hasToolBlocks()) {
@@ -598,13 +600,15 @@ export class AnthropicAdapter extends LLMAdapter {
598
600
  cumulativeReasoningTokens = reasoning.reasoningTokens || 0;
599
601
  yield {
600
602
  type: 'usage',
603
+ reportedTokenFields: reportedUsageFields(event.message.usage, 'anthropic'),
604
+ usageIncomplete: !hasUsageCounts(event.message.usage),
601
605
  ...reasoning,
602
606
  inputTokens: event.message.usage.input_tokens || 0,
603
607
  outputTokens: cumulativeOutputTokens,
604
608
  cacheReadTokens: event.message.usage.cache_read_input_tokens || 0,
605
609
  cacheWriteTokens: event.message.usage.cache_creation_input_tokens || 0,
606
610
  };
607
- }
611
+ } else yield { type: 'usage', reportedTokenFields: [], usageIncomplete: true };
608
612
  } else if (type === 'error') {
609
613
  stateFailed = true;
610
614
  yield {
@@ -52,7 +52,7 @@ import {
52
52
  mapEffortToOpenAIReasoning,
53
53
  } from '../models.js';
54
54
 
55
- import { createProviderContext, createProviderState, replayProviderState, providerStateBytes, applyResponsesContinuity, reasoningUsage } from './provider-state.js';
55
+ import { createProviderContext, createProviderState, replayProviderState, providerStateBytes, applyResponsesContinuity, reasoningUsage, reportedUsageFields, hasUsageCounts } from './provider-state.js';
56
56
 
57
57
  const DEFAULT_BASE_URL = 'https://api.openai.com/v1';
58
58
 
@@ -369,7 +369,7 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
369
369
  if (state) yield { type: 'provider_state', providerState: state, providerStateBytes: providerStateBytes(state) };
370
370
  }
371
371
  const usage = result.usage || {};
372
- yield { type: 'usage', inputTokens: usage.input_tokens || 0, outputTokens: usage.output_tokens || 0,
372
+ yield { type: 'usage', reportedTokenFields: reportedUsageFields(result.usage, 'openai-responses'), usageIncomplete: !hasUsageCounts(result.usage), inputTokens: usage.input_tokens || 0, outputTokens: usage.output_tokens || 0,
373
373
  cacheReadTokens: usage.input_tokens_details?.cached_tokens || 0, cacheWriteTokens: 0,
374
374
  cacheTokensAreIncludedInInput: true,
375
375
  ...reasoningUsage(usage, 'openai-responses') };
@@ -528,6 +528,8 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
528
528
  const usage = respObj.usage || {};
529
529
  yield {
530
530
  type: 'usage',
531
+ reportedTokenFields: reportedUsageFields(respObj.usage, 'openai-responses'),
532
+ usageIncomplete: !hasUsageCounts(respObj.usage),
531
533
  inputTokens: usage.input_tokens || 0,
532
534
  outputTokens: usage.output_tokens || 0,
533
535
  cacheReadTokens: usage.input_tokens_details?.cached_tokens || 0,
@@ -241,3 +241,16 @@ export function reasoningUsage(usage, protocol) {
241
241
  const value = protocol === 'anthropic' ? usage?.output_tokens_details?.thinking_tokens : usage?.output_tokens_details?.reasoning_tokens;
242
242
  return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? { reasoningTokens: value } : {};
243
243
  }
244
+
245
+ /** Which counters the provider actually reported, before legacy zero defaults. */
246
+ export function hasUsageCounts(usage, fields = ['input_tokens', 'output_tokens']) {
247
+ return fields.every(key => Number.isSafeInteger(usage?.[key]) && usage[key] >= 0);
248
+ }
249
+
250
+ export function reportedUsageFields(usage, protocol) {
251
+ const counts = { inputTokens: usage?.input_tokens, outputTokens: usage?.output_tokens,
252
+ cacheReadTokens: protocol === 'anthropic' ? usage?.cache_read_input_tokens : usage?.input_tokens_details?.cached_tokens,
253
+ cacheWriteTokens: protocol === 'anthropic' ? usage?.cache_creation_input_tokens : undefined,
254
+ reasoningTokens: protocol === 'anthropic' ? usage?.output_tokens_details?.thinking_tokens : usage?.output_tokens_details?.reasoning_tokens };
255
+ return Object.keys(counts).filter(key => Number.isSafeInteger(counts[key]) && counts[key] >= 0);
256
+ }
@@ -1,7 +1,10 @@
1
1
  import { createHash } from 'node:crypto';
2
2
 
3
3
  // 只允许应用层显式产物;不要求或保存 provider 的隐藏推理。
4
- export const LIMITS = Object.freeze({ inputBytes: 8192, outputBytes: 65536, contextBytes: 65536, calls: 16, timeoutMs: 120000, leaseMs: 15000 });
4
+ export const LIMITS = Object.freeze({ inputBytes: 8192, outputBytes: 65536, contextBytes: 65536, calls: 16, leaseMs: 15000 });
5
+ // Feedback cadence is not an execution deadline. Slow providers/tools remain cancellable.
6
+ export const FEEDBACK = Object.freeze({ minMs: 30000, maxMs: 60000 });
7
+ export const FEEDBACK_INSTRUCTIONS = "feedback describes user-visible communication, not a deadline. After about 30 seconds without a substantive reply, use reply for a concise, useful progress update at a natural execution boundary; aim for 30\u201360 seconds according to actual progress. Explain verified findings, limitations and what you are checking next, in the user's language. Do not repeat feedback.lastReply, invent progress, expose internal deliberation/decision/selfCheck, or claim an unexecuted tool succeeded. If no meaningful update exists, reply:null is appropriate; the host shows an honest waiting status around 60 seconds without interrupting the current request. With next non-null, a validated reply may be published as progress independently of cognitive state, and retained on cancellation/failure. Only next:null commits cognition and supplies the final reply. Do not call a tool or an extra model just to report progress. Each final reply should stand on its own and note unfinished work.";
5
8
  const ERRORS = {
6
9
  INVALID_REQUEST: 'Invalid digital person request.', NOT_CONFIGURED: 'Digital person storage configuration is missing.',
7
10
  STORAGE_UNAVAILABLE: 'Digital person storage is unavailable. Check instance storage and database configuration.',
@@ -20,7 +20,7 @@ export function personName(value) {
20
20
  const pick = (value, keys) => value == null ? null : Object.fromEntries(keys.filter(key => Object.hasOwn(value, key)).map(key => [key, value[key]]));
21
21
  export const settingsView = value => pick(value, ['autonomyEnabled', 'modelCandidates', 'defaultModel']);
22
22
  export function messageView(value) {
23
- const result = pick(value, ['id', 'schemaVersion', 'revision', 'seq', 'episodeId', 'clientMessageId', 'role', 'text', 'createdAt']);
23
+ const result = pick(value, ['id', 'schemaVersion', 'revision', 'seq', 'episodeId', 'clientMessageId', 'callId', 'replyKind', 'role', 'text', 'createdAt']);
24
24
  if (result && Array.isArray(value.attachments)) result.attachments = value.attachments.slice(0, 4)
25
25
  .map(file => pick(file, ['id', 'name', 'mimeType', 'size', 'sha256', 'kind']));
26
26
  return result;
@@ -6,6 +6,7 @@ import { normalizeKnownProviderForRuntime } from '../llm/known-providers.js';
6
6
  import { normalizeEffort, resolveContextWindow, resolveMaxOutputTokens } from '../models.js';
7
7
  import { utf8PrefixWithinBytes } from '../utf8.js';
8
8
  import { bytes, digest, fail, LIMITS, PersonError } from './contracts.js';
9
+ import { addUsage } from './turn-diagnostics.js';
9
10
 
10
11
  export const MODEL_LIMITS = Object.freeze({ candidates: 8, available: 100 });
11
12
  export function validateModelCandidates(refs) {
@@ -92,7 +93,7 @@ export async function createPersonProvider({ yeaftDir, config: suppliedConfig, a
92
93
  const explicitImages = m.supportsImages ?? rawModel?.supportsImages ?? rawProvider?.supportsImages;
93
94
  const protocol = routedModel?.protocol || routedProvider?.protocol || inferProtocolFromModelId(m.id) || 'openai-responses';
94
95
  const imageBudget = !routedProvider || explicitImages === false ? null : imageInputBudget(m.id, protocol);
95
- return { id: m.ref || m.id, efforts, maxOutput, supportsImages: imageBudget !== null, imageBudget,
96
+ return { id: m.ref || m.id, efforts, maxOutput, usageCacheIncluded: protocol === 'anthropic' ? false : undefined, supportsImages: imageBudget !== null, imageBudget,
96
97
  contextWindow: Math.floor(resolveContextWindow(m.id, { ...config, modelInfo: m })) };
97
98
  }).filter(m => typeof m.id === 'string' && m.id.length <= 256 && m.contextWindow > m.maxOutput + 1024 && m.maxOutput >= 256);
98
99
  safeModels.sort((a, b) => Number(b.id === agentDefaultModel) - Number(a.id === agentDefaultModel));
@@ -147,8 +148,7 @@ export async function collectOutput(adapter, params, onEffort) {
147
148
  else if (event.type === 'error') fail('PROVIDER_FAILED');
148
149
  else if (event.type === 'tool_call') fail('INVALID_PROPOSAL');
149
150
  else if (event.type === 'usage') {
150
- usage = {};
151
- for (const key of ['inputTokens', 'outputTokens', 'reasoningTokens', 'cacheReadTokens', 'cacheWriteTokens']) if (Number.isFinite(event[key]) && event[key] >= 0) usage[key] = event[key];
151
+ usage = addUsage(usage, event, params.usageCacheIncluded);
152
152
  }
153
153
  // Hidden thinking, signatures, opaque provider state and raw HTTP exchanges are deliberately not archived.
154
154
  }
@@ -1,16 +1,17 @@
1
1
  import { createPersonToolHost, createPersonNativeRegistry, isNativeTool, projectNativeResult } from './native-tools.js';
2
2
  import { PersonTaskHost } from './task-host.js';
3
3
  import { randomUUID } from 'node:crypto';
4
+ import { utf8PrefixWithinBytes } from '../utf8.js';
4
5
  import { CAPABILITY_MAP, CAPABILITY_LIMITS, foundationCapabilities, PersonCapabilities, catalogRevision as capabilityCatalogRevision } from './capabilities.js';
5
6
  import { attachmentMetadata } from './attachments.js';
6
7
  import { abortable, collectOutput } from './provider.js';
7
- import { bytes, digest, fail, LIMITS, PersonError, PROPOSAL_INSTRUCTIONS, reportedLineage, safeError, validateProposal, validateSelection } from './contracts.js';
8
+ import { bytes, digest, fail, FEEDBACK, FEEDBACK_INSTRUCTIONS, LIMITS, PersonError, PROPOSAL_INSTRUCTIONS, reportedLineage, safeError, validateProposal, validateSelection } from './contracts.js';
8
9
 
9
10
  const messageRef = m => `message:${m.id}:${m.revision}`;
10
11
  const conceptRef = c => `concept:${c.id}:${c.revision}`;
11
12
 
12
13
  /** Assemble bounded request copies. Omitting a record never deletes or truncates its durable original. */
13
- export function assembleContext({ snapshot, episode, provider, selection, previous, capabilityResult, remainingCalls, taskEvidence, dependencyRefs = [], activeCapabilities = foundationCapabilities(), capabilityMap = CAPABILITY_MAP, attachments = [], environment }) {
14
+ export function assembleContext({ snapshot, episode, provider, selection, previous, capabilityResult, remainingCalls, taskEvidence, dependencyRefs = [], activeCapabilities = foundationCapabilities(), capabilityMap = CAPABILITY_MAP, attachments = [], environment, feedback }) {
14
15
  const model = validateSelection(selection, provider.catalog);
15
16
  // UTF-8 bytes is a conservative text-token bound; reserve explicit envelope/output overhead.
16
17
  const images = attachments.filter(file => file.kind === 'image');
@@ -74,6 +75,16 @@ export function assembleContext({ snapshot, episode, provider, selection, previo
74
75
  omittedCapabilities.push({ id: contract.id, reason: 'context-budget', inspect: 'catalog.view' });
75
76
  }
76
77
  }
78
+ // Feedback is a request-only hint, never grounds to fail the next call.
79
+ // Keep durable replies intact; avoid duplicating up to 8 KiB from previousProposal.
80
+ if (feedback) {
81
+ const preview = utf8PrefixWithinBytes(feedback.lastReply ?? '', 512).text;
82
+ context.feedback = { ...feedback, instructions: FEEDBACK_INSTRUCTIONS, lastReply: preview || null,
83
+ lastReplyTruncated: bytes(feedback.lastReply ?? '') > bytes(preview) };
84
+ if (!fits()) { context.feedback.lastReply = null; context.feedback.lastReplyTruncated = Boolean(feedback.lastReply); }
85
+ if (!fits()) delete context.feedback.instructions;
86
+ if (!fits()) delete context.feedback;
87
+ }
77
88
  const add = (field, item, ref) => {
78
89
  if (renderedRefs.has(ref)) return;
79
90
  const inherited = sourceRefs.has(ref);
@@ -189,8 +200,8 @@ export class PersonRuntime {
189
200
  isOwnerRunning(ownerId) { return this.pendingCancellations.has(ownerId) || [...this.running.values()].some(job => job.episode.ownerId === ownerId); }
190
201
  async run(episode, controller) {
191
202
  const { signal } = controller;
192
- const timeout = setTimeout(() => controller.abort(new PersonError('TIMEOUT')), episode.budget.timeoutMs);
193
- timeout.unref?.();
203
+ let lastReplyAt = new Date(episode.createdAt).getTime() || Date.now();
204
+ let lastReply = null;
194
205
  let heartbeatRunning = false;
195
206
  const heartbeat = setInterval(async () => {
196
207
  if (heartbeatRunning || signal.aborted) return;
@@ -198,7 +209,7 @@ export class PersonRuntime {
198
209
  try { await this.repository.heartbeat(episode); }
199
210
  catch (error) { controller.abort(safeError(error)); }
200
211
  finally { heartbeatRunning = false; }
201
- }, Math.max(100, Math.floor(this.repository.leaseMs / 3)));
212
+ }, Math.max(100, Math.min(5000, Math.floor(this.repository.leaseMs / 3))));
202
213
  heartbeat.unref?.();
203
214
  try {
204
215
  const provider = await abortable(this.getProvider(episode.modelCandidates ?? [], episode.defaultModel ?? null), signal);
@@ -232,7 +243,11 @@ export class PersonRuntime {
232
243
  for (let index = 0; index < episode.budget.calls; index++) {
233
244
  signal.throwIfAborted();
234
245
  const callId = randomUUID();
235
- const context = assembleContext({ snapshot, episode, provider, selection, previous, capabilityResult, dependencyRefs, remainingCalls: episode.budget.calls - index, taskEvidence: projectTaskEvidence(this.tasks()?.snapshot(episode.ownerId)), attachments, activeCapabilities: capabilities.context(), capabilityMap: capabilities.catalog(), environment: toolHost.environment });
246
+ const context = assembleContext({ snapshot, episode, provider, selection, previous, capabilityResult, dependencyRefs, remainingCalls: episode.budget.calls - index, taskEvidence: projectTaskEvidence(this.tasks()?.snapshot(episode.ownerId)), attachments, activeCapabilities: capabilities.context(), capabilityMap: capabilities.catalog(), environment: toolHost.environment, feedback: {
247
+ minIntervalMs: FEEDBACK.minMs, maxIntervalMs: FEEDBACK.maxMs,
248
+ elapsedSinceReplyMs: Math.max(0, Date.now() - lastReplyAt),
249
+ due: Date.now() - lastReplyAt >= FEEDBACK.minMs, lastReply,
250
+ } });
236
251
  capabilities.activate(context.activeCapabilities);
237
252
  dependencyRefs = context.manifest.inputDependencyRefs;
238
253
  for (const [id, concept] of context.concepts) readConcepts.set(id, concept);
@@ -259,6 +274,7 @@ export class PersonRuntime {
259
274
  }) };
260
275
  output = await collectOutput(observingAdapter, {
261
276
  model: selection.model, effort: selection.effort ?? undefined, effortSource: 'auto',
277
+ usageCacheIncluded: provider.catalog.find(model => model.id === selection.model)?.usageCacheIncluded,
262
278
  system: context.system, messages: context.messages, maxTokens: context.maxTokens, signal,
263
279
  }, decision => {
264
280
  effective.effort = decision.effective; effective.effortObserved = true;
@@ -288,6 +304,11 @@ export class PersonRuntime {
288
304
  await this.repository.commit(episode, proposal, selection, callId, new Map(proposal.concepts.map(c => [c.id, c.epistemicState === 'reported' ? reportedLineage(c, readSources) : []])));
289
305
  return;
290
306
  }
307
+ if (proposal.reply) {
308
+ signal.throwIfAborted();
309
+ const published = await this.repository.publishProgress(episode, { callId, reply: proposal.reply });
310
+ if (published) { lastReply = published.text; lastReplyAt = new Date(published.createdAt).getTime(); }
311
+ }
291
312
  if (index + 1 >= episode.budget.calls) {
292
313
  await this.tasks()?.cancel({ ownerId: episode.ownerId, episodeId: episode.id, reason: 'CALL_BUDGET' });
293
314
  await this.repository.finish(episode, 'budget_exhausted', 'CALL_BUDGET');
@@ -344,7 +365,7 @@ export class PersonRuntime {
344
365
  // On authority failure no alternate memory store is used. Expired leases become durable interrupted records on next access.
345
366
  await this.repository.finish(episode, status, safe.code).catch(() => {});
346
367
  } finally {
347
- clearTimeout(timeout); clearInterval(heartbeat);
368
+ clearInterval(heartbeat);
348
369
  if (!signal.aborted) controller.abort(new PersonError('INTERRUPTED'));
349
370
  }
350
371
  }
@@ -7,6 +7,7 @@ import { loadConfig } from '../config.js';
7
7
  import { validateFiles } from './attachments.js';
8
8
  import { createPersonProvider, resolveAgentDefaultModel, selectPersonModels, validateDefaultModel, validateModelCandidates } from './provider.js';
9
9
  import { fail, identifier, LIMITS, object, page, personTaskRequest, safeError, text } from './contracts.js';
10
+ import { turnsPage } from './turn-diagnostics.js';
10
11
  import { inspectRequest, personName, searchRequest } from './inspection.js';
11
12
 
12
13
  /**
@@ -21,9 +22,9 @@ export function createPersonService(options = {}) {
21
22
  identifier(namespace);
22
23
  const configured = typeof yeaftDir === 'string' && Boolean(yeaftDir.trim());
23
24
  const calls = options.maxCalls ?? LIMITS.calls;
24
- const timeoutMs = options.timeoutMs ?? LIMITS.timeoutMs;
25
+ // Legacy timeoutMs is intentionally ignored: an episode has no wall-clock deadline.
25
26
  const leaseMs = options.leaseMs ?? LIMITS.leaseMs;
26
- if (!Number.isInteger(calls) || calls < 1 || calls > 32 || !Number.isInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > 300000 ||
27
+ if (!Number.isInteger(calls) || calls < 1 || calls > 32 ||
27
28
  !Number.isInteger(leaseMs) || leaseMs < 300 || leaseMs > 60000) fail('INVALID_REQUEST');
28
29
  if (allowedModels != null && (!Array.isArray(allowedModels) || !allowedModels.length || allowedModels.some(m => typeof m !== 'string'))) fail('INVALID_REQUEST');
29
30
  const repository = configured ? new SqlitePersonRepository({ yeaftDir, namespace, leaseMs }) : null;
@@ -42,7 +43,7 @@ export function createPersonService(options = {}) {
42
43
  ? memory.recall(ownerId, args, { signal }) : literalRecall(ownerId, args);
43
44
  // Config/adapter are loaded per explicit episode, not a permanent stale cache.
44
45
  const getProvider = (modelCandidates = [], defaultModel = null) => createPersonProvider({ yeaftDir, config, adapter, allowedModels, modelCandidates, defaultModel, effortEnabled: options.effortEnabled });
45
- const runtime = new PersonRuntime({ repository, getProvider, budget: { calls, timeoutMs }, workDir, yeaftDir, config, namespace });
46
+ const runtime = new PersonRuntime({ repository, getProvider, budget: { calls }, workDir, yeaftDir, config, namespace });
46
47
  let closed = false;
47
48
  const requests = new Set();
48
49
  async function handle({ ownerId, op, payload = {} } = {}) {
@@ -125,6 +126,7 @@ export function createPersonService(options = {}) {
125
126
  }
126
127
  case 'traces': await repository.recover(ownerId); return repository.list(ownerId, 'traces', page(payload));
127
128
  case 'messages': return repository.list(ownerId, 'messages', page(payload));
129
+ case 'turns': return repository.turns(ownerId, turnsPage(payload));
128
130
  // These reads deliberately bypass recover(), Recall and the runtime/provider.
129
131
  case 'inspect': {
130
132
  const request = inspectRequest(payload);
@@ -100,11 +100,13 @@ export class SqlitePersonRepository {
100
100
  async episodeAttachments(episode) { return this.request('episodeAttachments', [episode]); }
101
101
  async context(episode) { return this.request('context', [episode]); }
102
102
  async recall(ownerId, options) { return this.request('recall', [ownerId, options]); }
103
+ async publishProgress(episode, data) { return this.request('publishProgress', [episode, data]); }
103
104
  async commit(episode, proposal, selection, callId, reportedSources = new Map()) { return this.request('commit', [episode, proposal, selection, callId, reportedSources]); }
104
105
  async finish(episode, status, code) { return this.request('finish', [episode, status, code]); }
105
106
  async cancel(ownerId, episodeId) { return this.request('cancel', [ownerId, episodeId]); }
106
107
  async settings(ownerId, settings, expectedControlVersion) { return this.request('settings', [ownerId, settings, expectedControlVersion]); }
107
108
  async list(ownerId, collection, options, filter) { return this.request('list', [ownerId, collection, options, filter]); }
109
+ async turns(ownerId, options) { return this.request('turns', [ownerId, options]); }
108
110
  async snapshot(ownerId) { return this.request('snapshot', [ownerId]); }
109
111
  async inspect(ownerId, options, nativeToolIds) { return this.request('inspect', [ownerId, options, nativeToolIds]); }
110
112
  async search(ownerId, options) { return this.request('search', [ownerId, options]); }
@@ -75,6 +75,8 @@ CREATE TABLE IF NOT EXISTS traces (
75
75
  PRIMARY KEY(namespace, ownerId, personId, id), UNIQUE(namespace, ownerId, personId, seq),
76
76
  FOREIGN KEY(namespace, ownerId, personId) REFERENCES persons(namespace, ownerId, personId)
77
77
  ) STRICT;
78
+ CREATE INDEX IF NOT EXISTS traces_turn_metadata ON traces(namespace, ownerId, personId,
79
+ json_extract(record, '$.episodeId'), seq);
78
80
  CREATE INDEX IF NOT EXISTS traces_call_output ON traces(namespace, ownerId, personId,
79
81
  json_extract(record, '$.episodeId'), json_extract(record, '$.callId'))
80
82
  WHERE json_extract(record, '$.kind') = 'call_output';
@@ -3,12 +3,13 @@ import { mkdirSync } from 'node:fs';
3
3
  import { dirname } from 'node:path';
4
4
  import { randomUUID } from 'node:crypto';
5
5
  import { isMainThread } from 'node:worker_threads';
6
- import { admissionReceipt, bytes, digest, fail, identifier, LIMITS, PersonError, text } from './contracts.js';
6
+ import { admissionReceipt, bytes, digest, fail, FEEDBACK, identifier, LIMITS, PersonError, text } from './contracts.js';
7
7
  import { CREATED_CAPABILITY_LIMITS, createdCapabilityRecord, validateCreatedCapability } from './created-capability-contract.js';
8
8
  import { SCHEMA, TABLES } from './sqlite-schema.js';
9
9
  import { attachmentMetadata, attachmentRequestHash, validateFiles } from './attachments.js';
10
10
  import { capabilityExperienceView, recordCapabilityExperience } from './capability-experience.js';
11
11
  import { conceptView, inspectionPage, inspectRequest, messageView, personName, searchRequest, settingsView, stateView } from './inspection.js';
12
+ import { TOKEN_FIELDS, turnsPage, turnView } from './turn-diagnostics.js';
12
13
  import { inspectCapabilities } from './capabilities.js';
13
14
 
14
15
  const SCOPE = 'namespace = ? AND ownerId = ? AND personId = ?';
@@ -44,12 +45,15 @@ const publicOutput = (output, failed) => {
44
45
  if (usage) for (const key of ['inputTokens', 'outputTokens', 'reasoningTokens', 'cacheReadTokens', 'cacheWriteTokens']) {
45
46
  if (Number.isFinite(output.usage[key]) && output.usage[key] >= 0) usage[key] = output.usage[key];
46
47
  }
48
+ if (usage && output.usage.accountingVersion === 1) usage.accountingVersion = 1;
49
+ if (usage && output.usage.accountingIncomplete === true) usage.accountingIncomplete = true;
50
+ if (usage && typeof output.usage.cacheTokensAreIncludedInInput === 'boolean') usage.cacheTokensAreIncludedInInput = output.usage.cacheTokensAreIncludedInInput;
47
51
  const stopReason = typeof output.stopReason === 'string' && bytes(output.stopReason) <= 128 ? output.stopReason : null;
48
52
  return failed ? { text: output.text, retainedBytes, observedBytes, complete: false, accepted: false, availability: 'captured', usage, stopReason }
49
53
  : { text: output.text, bytes: retainedBytes, complete: true, usage, stopReason };
50
54
  };
51
- const READS = new Set(['receipt', 'inspect', 'search', 'getPerson', 'context', 'recall', 'list', 'searchChanges', 'resolveMemories', 'createdCapabilities', 'episodeAttachments']);
52
- const WRITES = new Set(['open', 'recover', 'admit', 'heartbeat', 'append', 'startCall', 'finalizeCall', 'startCapability', 'finalizeCapability', 'commit', 'finish', 'cancel', 'settings', 'snapshot', 'saveCreatedCapability']);
55
+ const READS = new Set(['receipt', 'turns', 'inspect', 'search', 'getPerson', 'context', 'recall', 'list', 'searchChanges', 'resolveMemories', 'createdCapabilities', 'episodeAttachments']);
56
+ const WRITES = new Set(['open', 'recover', 'admit', 'heartbeat', 'append', 'startCall', 'finalizeCall', 'startCapability', 'finalizeCapability', 'commit', 'publishProgress', 'finish', 'cancel', 'settings', 'snapshot', 'saveCreatedCapability']);
53
57
  const memoryKind = kind => { if (!['messages', 'concepts'].includes(kind)) fail('INVALID_REQUEST'); return kind; };
54
58
  const boundedLimit = (limit, max = 100) => { if (!Number.isSafeInteger(limit) || limit < 1 || limit > max) fail('INVALID_REQUEST'); return limit; };
55
59
  const sequence = value => {
@@ -253,6 +257,17 @@ export class SqlitePersonStore {
253
257
  own(episode) { const p = this.fenced(episode); if (!p) fail('STALE'); return p; }
254
258
  heartbeat(episode) {
255
259
  const p = this.own(episode); p.leaseUntil = new Date(this.now.getTime() + this.leaseMs); p.writeSerial++; this.put('persons', p);
260
+ const record = this.one('episodes', this.episodeScope(episode), ' AND id = ?', [episode.id]);
261
+ if (!record || record.status !== 'running') fail('STALE');
262
+ const lastAt = new Date(record.feedback?.at ?? record.lastReplyAt ?? record.createdAt).getTime();
263
+ // Heartbeats are at most 5s apart: publish the fallback between 55–60s.
264
+ // This updates one snapshot field, never adds messages or schedules cognition.
265
+ if (this.now.getTime() - lastAt >= FEEDBACK.maxMs - 5000 || record.feedback) {
266
+ const phase = record.openCall ? 'model' : record.openCapability ? 'capability' : 'preparing';
267
+ record.feedback = { at: this.now.getTime() - lastAt >= FEEDBACK.maxMs - 5000 ? this.now.toISOString() : record.feedback.at, phase };
268
+ if (phase === 'capability') record.feedback.capabilityId = record.openCapability.capability?.id;
269
+ this.put('episodes', record);
270
+ }
256
271
  }
257
272
  append(episode, kind, data) {
258
273
  // Call proof/publication events are emitted only by their transactional methods.
@@ -362,6 +377,32 @@ export class SqlitePersonStore {
362
377
  state: stateView(state), messages: this.rows('messages', scope, ' ORDER BY seq DESC LIMIT 12').reverse().map(messageView),
363
378
  concepts: [...new Map([...focused, ...recent].map(c => [c.id, conceptView(c)])).values()] };
364
379
  }
380
+ /** Publish only a validated, complete call's reply. This is communication, not
381
+ * a state/Concept commit. Fencing, dedupe and the memory journal share one transaction. */
382
+ publishProgress(episode, { callId, reply }) {
383
+ const p = this.own(episode), scope = this.episodeScope(episode);
384
+ identifier(callId); text(reply, 8192);
385
+ const record = this.one('episodes', scope, ' AND id = ?', [episode.id]);
386
+ if (!record || record.status !== 'running' || record.openCall || record.openCapability) fail('STALE');
387
+ const prior = this.rows('messages', scope, " AND json_extract(record, '$.episodeId') = ? AND json_extract(record, '$.replyKind') = 'progress'", [episode.id]);
388
+ const duplicate = prior.find(m => m.callId === callId);
389
+ if (duplicate) { if (duplicate.text !== reply) fail('INVALID_REQUEST'); return messageView(duplicate); }
390
+ const proof = this.one('traces', scope, " 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]);
391
+ const accepted = this.one('traces', scope, " AND json_extract(record, '$.episodeId') = ? AND json_extract(record, '$.kind') = 'activity' AND json_extract(record, '$.callId') = ? AND json_extract(record, '$.disposition') = 'candidate'", [episode.id, callId]);
392
+ let proposal;
393
+ try { proposal = JSON.parse(proof?.output?.text); } catch { fail('INVALID_REQUEST'); }
394
+ if (!accepted || !proposal.next || proposal.reply !== reply) fail('INVALID_REQUEST');
395
+ // Provider latency counts too: a call started before 30s can finish after it.
396
+ if (this.now.getTime() - new Date(record.lastReplyAt ?? record.createdAt).getTime() < FEEDBACK.minMs ||
397
+ prior.some(m => m.text.trim() === reply.trim())) return null;
398
+ p.messageSeq++; p.writeSerial++; this.put('persons', p);
399
+ const message = this.doc(scope, { id: randomUUID(), revision: 1, seq: p.messageSeq, episodeId: episode.id,
400
+ callId, replyKind: 'progress', role: 'assistant', text: reply, createdAt: this.now });
401
+ this.put('messages', message, true); this.journal(scope, 'messages', message);
402
+ record.lastReplyAt = this.now.toISOString(); delete record.feedback; this.put('episodes', record);
403
+ this.trace(episode.ownerId, episode.id, 'progress_reply', { callId, messageId: message.id });
404
+ return messageView(message);
405
+ }
365
406
  commit(episode, proposal, selection, callId, reportedSources = new Map()) {
366
407
  const p = this.own(episode), scope = this.episodeScope(episode);
367
408
  if (proposal.baseStateVersion !== episode.baseStateVersion) fail('STALE');
@@ -387,7 +428,7 @@ export class SqlitePersonStore {
387
428
  this.put('state_commits', this.doc(scope, { id: randomUUID(), version: p.stateVersion, parentVersion: episode.baseStateVersion,
388
429
  episodeId: episode.id, callId, conceptRevisions: revisions, state: stateView(state), createdAt: this.now }), true);
389
430
  if (proposal.reply) {
390
- const message = this.doc(scope, { id: randomUUID(), revision: 1, seq: p.messageSeq, episodeId: episode.id, role: 'assistant', text: proposal.reply, createdAt: this.now });
431
+ 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 });
391
432
  this.put('messages', message, true); this.journal(scope, 'messages', message);
392
433
  }
393
434
  const record = this.one('episodes', scope, ' AND id = ?', [episode.id]);
@@ -445,6 +486,35 @@ export class SqlitePersonStore {
445
486
  ' AND (? IS NULL OR seq < ?) AND instr(text, ?) > 0 ORDER BY seq DESC LIMIT ?', [cursor, cursor, query.toLowerCase(), limit + 1]);
446
487
  return inspectionPage(records, limit, 'seq', messageView);
447
488
  }
489
+ turns(ownerId, options = {}) {
490
+ const { cursor, limit } = turnsPage(options);
491
+ const scope = this.scope(ownerId);
492
+ if (!this.sql(`SELECT 1 FROM persons WHERE ${SCOPE}`).get(...scopeValues(scope))) fail('NOT_OPEN');
493
+ const project = fields => `json_object(${Object.entries(fields).map(([key, path]) => `'${key}', json_extract(record, '$.${path}')`).join(', ')})`;
494
+ 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
495
+ FROM episodes WHERE ${SCOPE} AND (? IS NULL OR inputWatermark < ?) ORDER BY inputWatermark DESC LIMIT ?`)
496
+ .all(...scopeValues(scope), cursor, cursor, limit + 1).map(row => JSON.parse(row.metadata));
497
+ const fields = { kind: 'kind', callId: 'callId', callIndex: 'callIndex', createdAt: 'createdAt',
498
+ requestedModel: 'requested.model', requestedEffort: 'requested.effort', effectiveEffort: 'effective.effort', effortObserved: 'effective.effortObserved',
499
+ selectionOrigin: 'selectionOrigin', reason: 'reason', code: 'code', terminalCode: 'terminalCode', outcome: 'outcome',
500
+ contextBytes: 'manifest.contextBytes', contextBudgetBytes: 'manifest.contextBudgetBytes', outputTokensReserved: 'manifest.outputTokensReserved' };
501
+ const usage = `json_object(${TOKEN_FIELDS.map(key => `'${key}', json_extract(record, '$.output.usage.${key}')`).join(', ')},
502
+ 'accountingVersion', json_extract(record, '$.output.usage.accountingVersion'),
503
+ 'accountingIncomplete', json(CASE json_extract(record, '$.output.usage.accountingIncomplete') WHEN 1 THEN 'true' ELSE 'false' END),
504
+ 'cacheTokensAreIncludedInInput', json(CASE json_extract(record, '$.output.usage.cacheTokensAreIncludedInInput') WHEN 1 THEN 'true' WHEN 0 THEN 'false' ELSE 'null' END))`;
505
+ const metadata = `${project(fields).slice(0, -1)}, 'capabilityId', COALESCE(json_extract(record, '$.capability.id'), json_extract(record, '$.capabilityId')),
506
+ 'usage', ${usage})`;
507
+ const items = episodes.slice(0, limit).map(episode => {
508
+ const events = this.sql(`SELECT ${metadata} AS metadata FROM traces WHERE ${SCOPE}
509
+ AND json_extract(record, '$.episodeId') = ? AND json_extract(record, '$.kind') IN
510
+ ('call_started', 'call_output', 'call_failed', 'proposal_rejected', 'capability_started', 'capability_result', 'capability_failed', 'capability_finalized') ORDER BY seq ASC`)
511
+ .all(...scopeValues(scope), episode.id).map(row => {
512
+ const event = JSON.parse(row.metadata); event.effortObserved = event.effortObserved === 1; return event;
513
+ });
514
+ return turnView(episode, events);
515
+ });
516
+ return { items, nextCursor: episodes.length > limit ? items.at(-1).seq : null };
517
+ }
448
518
  list(ownerId, collection, { cursor = null, limit = 20 } = {}, filter = {}) {
449
519
  if (!['messages', 'traces'].includes(collection)) fail('INVALID_REQUEST');
450
520
  this.getPerson(ownerId); boundedLimit(limit);
@@ -471,7 +541,8 @@ export class SqlitePersonStore {
471
541
  const scope = this.scope(ownerId), p = this.getPerson(ownerId), state = this.one('states', scope);
472
542
  const messages = this.rows('messages', scope, ' ORDER BY seq DESC LIMIT 21');
473
543
  const episode = this.one('episodes', scope, ' ORDER BY inputWatermark DESC');
474
- const latestEpisode = episode ? { id: episode.id, status: episode.status,
544
+ const latestEpisode = episode ? { id: episode.id, status: episode.status, createdAt: episode.createdAt,
545
+ ...(episode.status === 'running' && episode.feedback ? { feedback: episode.feedback } : {}),
475
546
  ...(episode.terminalCode ? { terminalCode: episode.terminalCode } : {}), ...(episode.endedAt ? { endedAt: episode.endedAt } : {}) } : null;
476
547
  return { latestEpisode, person: this.personView(p), state: stateView(state), concepts: this.focused(scope, state.focusConceptIds).slice(0, 12).map(conceptView),
477
548
  messages: messages.slice(0, 20).reverse().map(messageView), nextMessagesCursor: messages.length > 20 ? String(messages[19].seq) : null,
@@ -0,0 +1,89 @@
1
+ import { fail, object } from './contracts.js';
2
+
3
+ export const TOKEN_FIELDS = Object.freeze(['inputTokens', 'outputTokens', 'reasoningTokens', 'cacheReadTokens', 'cacheWriteTokens']);
4
+ const numeric = value => Number.isSafeInteger(value) && value >= 0;
5
+ const token = value => numeric(value) ? value : null;
6
+ const string = value => typeof value === 'string' ? value : null;
7
+
8
+ /** Turn cursor is an exclusive numeric episode watermark, never a trace offset. */
9
+ export function turnsPage(payload = {}) {
10
+ object(payload, ['cursor', 'limit'], []);
11
+ const { cursor = null, limit = 20 } = payload;
12
+ if (!Number.isInteger(limit) || limit < 1 || limit > 20 ||
13
+ (cursor !== null && (!Number.isSafeInteger(cursor) || cursor < 1))) fail('INVALID_REQUEST');
14
+ return { cursor, limit };
15
+ }
16
+
17
+ /** Adapter usage events are additive. Keep unknown fields unknown, including cache semantics. */
18
+ export function addUsage(usage, event, inclusion) {
19
+ const result = { ...usage, accountingVersion: 1 };
20
+ if (event.usageIncomplete === true) result.accountingIncomplete = true;
21
+ for (const key of TOKEN_FIELDS) if ((!Array.isArray(event.reportedTokenFields) || event.reportedTokenFields.includes(key)) && numeric(event[key])) result[key] = (result[key] ?? 0) + event[key];
22
+ const flag = typeof event.cacheTokensAreIncludedInInput === 'boolean' ? event.cacheTokensAreIncludedInInput : inclusion;
23
+ if (typeof flag === 'boolean') {
24
+ if (typeof result.cacheTokensAreIncludedInInput === 'boolean' && result.cacheTokensAreIncludedInInput !== flag) result.cacheTokensAreIncludedInInput = null;
25
+ else if (result.cacheTokensAreIncludedInInput !== null) result.cacheTokensAreIncludedInInput = flag;
26
+ }
27
+ return result;
28
+ }
29
+
30
+ export function callUsage(raw, completed) {
31
+ const usage = Object.fromEntries(TOKEN_FIELDS.map(key => [key, token(raw?.[key])]));
32
+ const reported = TOKEN_FIELDS.some(key => usage[key] !== null);
33
+ const caches = (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0);
34
+ // Historical traces dropped the flag. Nonzero cache counts without it are ambiguous.
35
+ usage.inputTotalTokens = raw?.accountingVersion !== 1 || raw.accountingIncomplete === true || usage.inputTokens === null || (caches > 0 && typeof raw?.cacheTokensAreIncludedInInput !== 'boolean') ? null
36
+ : usage.inputTokens + (raw?.cacheTokensAreIncludedInInput === false ? caches : 0);
37
+ usage.totalTokens = usage.inputTotalTokens === null || usage.outputTokens === null ? null : usage.inputTotalTokens + usage.outputTokens;
38
+ // Reasoning is a subset of output, not an additional billable output quantity.
39
+ return { ...usage, reportedCalls: Number(reported), missingCalls: Number(!reported), complete: completed && usage.totalTokens !== null };
40
+ }
41
+
42
+ function sumUsage(calls, terminal) {
43
+ const fields = [...TOKEN_FIELDS, 'inputTotalTokens', 'totalTokens'];
44
+ const usage = Object.fromEntries(fields.map(key => {
45
+ const known = calls.map(call => call.usage[key]).filter(value => value !== null);
46
+ // Raw metrics sum reported values even for partial calls. Exact totals require every call.
47
+ return [key, !calls.length ? 0 : (key.endsWith('TotalTokens') || key === 'totalTokens') && known.length !== calls.length ? null
48
+ : known.length ? known.reduce((sum, value) => sum + value, 0) : null];
49
+ }));
50
+ return { ...usage, reportedCalls: calls.reduce((sum, call) => sum + call.usage.reportedCalls, 0),
51
+ missingCalls: calls.reduce((sum, call) => sum + call.usage.missingCalls, 0), complete: terminal && calls.every(call => call.usage.complete) };
52
+ }
53
+
54
+ /** Only metadata supplied by SQLite's explicit projection enters this view. */
55
+ export function turnView(episode, events) {
56
+ const calls = new Map();
57
+ for (const event of events) {
58
+ if (event.kind === 'call_started') {
59
+ calls.set(event.callId, { callId: event.callId, index: numeric(event.callIndex) ? event.callIndex + 1 : calls.size + 1,
60
+ status: 'running', requested: { model: string(event.requestedModel), effort: string(event.requestedEffort) },
61
+ // Native router dispatches the configured ref (only credential refresh can retry).
62
+ // It does not expose the response model: do not label configured intent as observed reality.
63
+ dispatched: { model: string(event.requestedModel) }, effective: { model: null, effort: null },
64
+ selectionOrigin: string(event.selectionOrigin), reason: string(event.reason), createdAt: event.createdAt, endedAt: null, code: null,
65
+ contextBytes: token(event.contextBytes), contextBudgetBytes: token(event.contextBudgetBytes), outputTokensReserved: token(event.outputTokensReserved),
66
+ usage: callUsage(null, false) });
67
+ continue;
68
+ }
69
+ const call = calls.get(event.callId);
70
+ if (!call) continue;
71
+ if (event.kind === 'call_output' || event.kind === 'call_failed') {
72
+ call.status = event.kind === 'call_output' ? 'completed' : ['CANCELLED', 'INTERRUPTED'].includes(event.code) ? event.code.toLowerCase() : 'failed';
73
+ call.endedAt = event.createdAt; call.code = string(event.code);
74
+ call.effective.effort = event.effortObserved === true ? string(event.effectiveEffort) : null;
75
+ call.usage = callUsage(event.usage, event.kind === 'call_output');
76
+ } else if (event.kind === 'proposal_rejected') {
77
+ call.status = 'rejected'; call.code = string(event.code);
78
+ } else if (event.kind === 'capability_started') {
79
+ call.capability = { id: string(event.capabilityId), status: 'running', code: null };
80
+ } else if (['capability_result', 'capability_failed', 'capability_finalized'].includes(event.kind)) {
81
+ call.capability = { id: string(event.capabilityId) ?? call.capability?.id ?? null,
82
+ status: (event.outcome ?? event.kind) === 'capability_failed' ? 'failed' : 'completed', code: string(event.code) ?? string(event.terminalCode) };
83
+ }
84
+ }
85
+ const items = [...calls.values()].sort((a, b) => a.index - b.index);
86
+ return { id: episode.id, seq: episode.seq, kind: episode.kind, status: episode.status, createdAt: episode.createdAt,
87
+ endedAt: episode.endedAt, terminalCode: episode.terminalCode, budget: { calls: token(episode.calls), timeoutMs: token(episode.timeoutMs) },
88
+ models: [...new Set(items.map(call => call.dispatched.model).filter(Boolean))], usage: sumUsage(items, episode.status !== 'running'), calls: items };
89
+ }