@yeaft/webchat-agent 1.0.607 → 1.0.610
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/local-runtime/server/handlers/client-person.js +6 -1
- package/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +163 -110
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +2 -2
- package/local-runtime/web/style.bundle.css +1 -1
- package/local-runtime/web/style.bundle.css.gz +0 -0
- package/package.json +1 -2
- package/yeaft/llm/adapter.js +1 -1
- package/yeaft/llm/anthropic.js +8 -4
- package/yeaft/llm/openai-responses.js +4 -2
- package/yeaft/llm/provider-state.js +13 -0
- package/yeaft/person/bridge.js +3 -7
- package/yeaft/person/capabilities.js +1 -1
- package/yeaft/person/contracts.js +1 -1
- package/yeaft/person/provider.js +3 -3
- package/yeaft/person/runtime.js +3 -2
- package/yeaft/person/service.js +12 -15
- package/yeaft/person/sqlite-repository.js +1 -0
- package/yeaft/person/sqlite-schema.js +2 -0
- package/yeaft/person/sqlite-storage.js +24 -0
- package/yeaft/person/sqlite-store.js +38 -5
- package/yeaft/person/task-host.js +1 -1
- package/yeaft/person/turn-diagnostics.js +89 -0
- package/yeaft/person/repository.js +0 -516
- package/yeaft/person/storage.js +0 -36
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yeaft/webchat-agent",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.610",
|
|
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",
|
|
@@ -72,7 +72,6 @@
|
|
|
72
72
|
"dotenv": "^16.3.1",
|
|
73
73
|
"express": "^4.22.2",
|
|
74
74
|
"jsonwebtoken": "^9.0.2",
|
|
75
|
-
"mongodb": "^6.21.0",
|
|
76
75
|
"multer": "^2.2.0",
|
|
77
76
|
"nodemailer": "^9.0.3",
|
|
78
77
|
"puppeteer-core": "24.41.0",
|
package/yeaft/llm/adapter.js
CHANGED
|
@@ -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
|
*
|
package/yeaft/llm/anthropic.js
CHANGED
|
@@ -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
|
+
}
|
package/yeaft/person/bridge.js
CHANGED
|
@@ -2,8 +2,8 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import ctx from '../../context.js';
|
|
3
3
|
import { sendToServer } from '../../connection/buffer.js';
|
|
4
4
|
|
|
5
|
-
/** Lazy Person transport.
|
|
6
|
-
*
|
|
5
|
+
/** Lazy Person transport. SQLite storage belongs to this Agent instance;
|
|
6
|
+
* browser messages cannot choose a storage authority or instance directory.
|
|
7
7
|
*/
|
|
8
8
|
export function createPersonBridge({
|
|
9
9
|
context = ctx, send = sendToServer, env = process.env,
|
|
@@ -32,7 +32,6 @@ export function createPersonBridge({
|
|
|
32
32
|
await send({ ...response, ok: false, errorCode: 'invalid_request', error: 'Digital person request requires authenticated ownership' });
|
|
33
33
|
return true;
|
|
34
34
|
}
|
|
35
|
-
const uri = env.YEAFT_PERSON_MONGODB_URI;
|
|
36
35
|
const agentId = context.agentId || context.AGENT_ID;
|
|
37
36
|
if (!agentId || !context.CONFIG?.yeaftDir) throw new Error('identity');
|
|
38
37
|
// Server+Agent is a storage boundary; a re-registration must not reuse the old runtime.
|
|
@@ -45,10 +44,7 @@ export function createPersonBridge({
|
|
|
45
44
|
identity = namespace;
|
|
46
45
|
if (!servicePromise) {
|
|
47
46
|
servicePromise = Promise.resolve().then(() => createService({
|
|
48
|
-
uri,
|
|
49
|
-
storage: env.YEAFT_PERSON_STORAGE,
|
|
50
47
|
embedding: { enabled: env.YEAFT_PERSON_EMBEDDING !== 'off', allowDownload: env.YEAFT_PERSON_EMBEDDING_DOWNLOAD !== '0' },
|
|
51
|
-
dbName: env.YEAFT_PERSON_MONGODB_DB || 'yeaft_person',
|
|
52
48
|
namespace,
|
|
53
49
|
yeaftDir: context.CONFIG.yeaftDir,
|
|
54
50
|
workDir: context.CONFIG.workDir,
|
|
@@ -78,7 +74,7 @@ export function createPersonBridge({
|
|
|
78
74
|
TASK_CONTROL_UNAVAILABLE: 'Task control could not be confirmed; refresh before retrying',
|
|
79
75
|
STALE: 'Digital person state changed; refresh and try again',
|
|
80
76
|
IDEMPOTENCY_CONFLICT: 'This message identifier already belongs to a different request',
|
|
81
|
-
STORAGE_MISMATCH: '
|
|
77
|
+
STORAGE_MISMATCH: 'Existing digital person storage is not a valid SQLite binding; check instance storage before retrying',
|
|
82
78
|
UNSUPPORTED: 'This capability is not available in this digital person version',
|
|
83
79
|
};
|
|
84
80
|
const known = Object.hasOwn(safeErrors, error?.code);
|
|
@@ -83,7 +83,7 @@ export function inspectCapabilities(created, { cursor, limit }, nativeToolIds =
|
|
|
83
83
|
contract.evidence = item.evidence;
|
|
84
84
|
entries.push(item);
|
|
85
85
|
}
|
|
86
|
-
// IDs are ASCII identifiers. Binary ordering matches SQLite
|
|
86
|
+
// IDs are ASCII identifiers. Binary ordering matches SQLite.
|
|
87
87
|
const matches = entries.filter(entry => cursor === null || entry.id > cursor)
|
|
88
88
|
.sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
89
89
|
return inspectionPage(matches, limit, 'id');
|
|
@@ -5,7 +5,7 @@ export const LIMITS = Object.freeze({ inputBytes: 8192, outputBytes: 65536, cont
|
|
|
5
5
|
const ERRORS = {
|
|
6
6
|
INVALID_REQUEST: 'Invalid digital person request.', NOT_CONFIGURED: 'Digital person storage configuration is missing.',
|
|
7
7
|
STORAGE_UNAVAILABLE: 'Digital person storage is unavailable. Check instance storage and database configuration.',
|
|
8
|
-
STORAGE_MISMATCH: '
|
|
8
|
+
STORAGE_MISMATCH: 'Existing digital person storage is not a valid SQLite binding.',
|
|
9
9
|
NOT_OPEN: 'Open your digital person first.', BUSY: 'The digital person is already active.',
|
|
10
10
|
IDEMPOTENCY_CONFLICT: 'This clientMessageId was already used for a different request.',
|
|
11
11
|
STALE: 'The activity no longer owns the current person revision.', INVALID_PROPOSAL: 'The model returned an invalid cognitive proposal.',
|
package/yeaft/person/provider.js
CHANGED
|
@@ -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
|
}
|
package/yeaft/person/runtime.js
CHANGED
|
@@ -147,8 +147,8 @@ export class PersonRuntime {
|
|
|
147
147
|
this.running = new Map(); this.pendingCancellations = new Map(); this.workerId = randomUUID(); this.closed = false;
|
|
148
148
|
}
|
|
149
149
|
tasks() {
|
|
150
|
-
// Construction/status/open do not touch task storage.
|
|
151
|
-
//
|
|
150
|
+
// Construction/status/open do not touch task storage. An instance root is
|
|
151
|
+
// required before creating the durable task host.
|
|
152
152
|
if (!this.toolOptions.yeaftDir) return null;
|
|
153
153
|
return this.taskHost ??= new PersonTaskHost({ ...this.toolOptions, namespace: this.namespace });
|
|
154
154
|
}
|
|
@@ -259,6 +259,7 @@ export class PersonRuntime {
|
|
|
259
259
|
}) };
|
|
260
260
|
output = await collectOutput(observingAdapter, {
|
|
261
261
|
model: selection.model, effort: selection.effort ?? undefined, effortSource: 'auto',
|
|
262
|
+
usageCacheIncluded: provider.catalog.find(model => model.id === selection.model)?.usageCacheIncluded,
|
|
262
263
|
system: context.system, messages: context.messages, maxTokens: context.maxTokens, signal,
|
|
263
264
|
}, decision => {
|
|
264
265
|
effective.effort = decision.effective; effective.effortObserved = true;
|
package/yeaft/person/service.js
CHANGED
|
@@ -1,46 +1,42 @@
|
|
|
1
|
-
import { MongoPersonRepository } from './repository.js';
|
|
2
1
|
import { SqlitePersonRepository } from './sqlite-repository.js';
|
|
3
2
|
import { LocalPersonMemory } from './local-memory.js';
|
|
4
|
-
import {
|
|
3
|
+
import { ensurePersonStorage } from './sqlite-storage.js';
|
|
5
4
|
import { PersonRuntime } from './runtime.js';
|
|
6
5
|
import { allowedNativeToolIds } from './native-tools.js';
|
|
7
6
|
import { loadConfig } from '../config.js';
|
|
8
7
|
import { validateFiles } from './attachments.js';
|
|
9
8
|
import { createPersonProvider, resolveAgentDefaultModel, selectPersonModels, validateDefaultModel, validateModelCandidates } from './provider.js';
|
|
10
9
|
import { fail, identifier, LIMITS, object, page, personTaskRequest, safeError, text } from './contracts.js';
|
|
10
|
+
import { turnsPage } from './turn-diagnostics.js';
|
|
11
11
|
import { inspectRequest, personName, searchRequest } from './inspection.js';
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* One durable Person per (namespace, authenticated ownerId). The transport MUST
|
|
15
|
-
* supply authenticated ownerId, never accept it from a browser payload.
|
|
15
|
+
* supply authenticated ownerId, never accept it from a browser payload. Provider
|
|
16
16
|
* secrets stay inside this Agent. Constructor and status never initiate model calls.
|
|
17
|
-
*
|
|
18
|
-
*
|
|
17
|
+
* SQLite storage and native provider configuration belong to yeaftDir only
|
|
18
|
+
* (no Session initialization or alternate storage authority).
|
|
19
19
|
*/
|
|
20
20
|
export function createPersonService(options = {}) {
|
|
21
|
-
const {
|
|
21
|
+
const { namespace = 'default', yeaftDir, workDir, config, adapter, allowedModels } = options;
|
|
22
22
|
identifier(namespace);
|
|
23
|
-
|
|
24
|
-
if (uri != null && typeof uri !== 'string') fail('INVALID_REQUEST');
|
|
25
|
-
const { storage, configured } = selectPersonStorage({ ...options, yeaftDir });
|
|
23
|
+
const configured = typeof yeaftDir === 'string' && Boolean(yeaftDir.trim());
|
|
26
24
|
const calls = options.maxCalls ?? LIMITS.calls;
|
|
27
25
|
const timeoutMs = options.timeoutMs ?? LIMITS.timeoutMs;
|
|
28
26
|
const leaseMs = options.leaseMs ?? LIMITS.leaseMs;
|
|
29
27
|
if (!Number.isInteger(calls) || calls < 1 || calls > 32 || !Number.isInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > 300000 ||
|
|
30
28
|
!Number.isInteger(leaseMs) || leaseMs < 300 || leaseMs > 60000) fail('INVALID_REQUEST');
|
|
31
29
|
if (allowedModels != null && (!Array.isArray(allowedModels) || !allowedModels.length || allowedModels.some(m => typeof m !== 'string'))) fail('INVALID_REQUEST');
|
|
32
|
-
const repository =
|
|
33
|
-
? new MongoPersonRepository({ uri, dbName, namespace, MongoClient, leaseMs })
|
|
34
|
-
: new SqlitePersonRepository({ yeaftDir, namespace, leaseMs });
|
|
30
|
+
const repository = configured ? new SqlitePersonRepository({ yeaftDir, namespace, leaseMs }) : null;
|
|
35
31
|
const initialize = repository?.init.bind(repository);
|
|
36
32
|
let binding;
|
|
37
33
|
if (repository) repository.init = async () => {
|
|
38
|
-
if (!binding) binding =
|
|
34
|
+
if (!binding) binding = ensurePersonStorage(yeaftDir, namespace).catch(error => { binding = null; throw error; });
|
|
39
35
|
await binding;
|
|
40
36
|
return initialize();
|
|
41
37
|
};
|
|
42
38
|
const literalRecall = repository?.recall.bind(repository);
|
|
43
|
-
const memory = repository
|
|
39
|
+
const memory = repository ? new LocalPersonMemory({
|
|
44
40
|
repository, literalRecall, yeaftDir, namespace, embedding: options.embedding,
|
|
45
41
|
}) : null;
|
|
46
42
|
if (memory) repository.recall = (ownerId, args = {}, { signal } = {}) => args.query?.trim()
|
|
@@ -58,7 +54,7 @@ export function createPersonService(options = {}) {
|
|
|
58
54
|
object(payload, []);
|
|
59
55
|
let agentDefaultModel = null;
|
|
60
56
|
try { agentDefaultModel = resolveAgentDefaultModel(config ?? loadConfig({ dir: yeaftDir })); } catch { /* Invalid/missing native config. */ }
|
|
61
|
-
const status = { renameSupported: true, defaultModelSupported: true, configured, storage, modelReady: false,
|
|
57
|
+
const status = { renameSupported: true, defaultModelSupported: true, configured, storage: 'sqlite', modelReady: false,
|
|
62
58
|
defaultModel: null, agentDefaultModel, modelCandidates: [], effectiveModelCandidates: [], effectiveDefaultModel: null };
|
|
63
59
|
if (!configured) return { ...status, reason: 'Digital person storage configuration is missing.', storageReady: false };
|
|
64
60
|
try { await repository.init(); }
|
|
@@ -130,6 +126,7 @@ export function createPersonService(options = {}) {
|
|
|
130
126
|
}
|
|
131
127
|
case 'traces': await repository.recover(ownerId); return repository.list(ownerId, 'traces', page(payload));
|
|
132
128
|
case 'messages': return repository.list(ownerId, 'messages', page(payload));
|
|
129
|
+
case 'turns': return repository.turns(ownerId, turnsPage(payload));
|
|
133
130
|
// These reads deliberately bypass recover(), Recall and the runtime/provider.
|
|
134
131
|
case 'inspect': {
|
|
135
132
|
const request = inspectRequest(payload);
|
|
@@ -105,6 +105,7 @@ export class SqlitePersonRepository {
|
|
|
105
105
|
async cancel(ownerId, episodeId) { return this.request('cancel', [ownerId, episodeId]); }
|
|
106
106
|
async settings(ownerId, settings, expectedControlVersion) { return this.request('settings', [ownerId, settings, expectedControlVersion]); }
|
|
107
107
|
async list(ownerId, collection, options, filter) { return this.request('list', [ownerId, collection, options, filter]); }
|
|
108
|
+
async turns(ownerId, options) { return this.request('turns', [ownerId, options]); }
|
|
108
109
|
async snapshot(ownerId) { return this.request('snapshot', [ownerId]); }
|
|
109
110
|
async inspect(ownerId, options, nativeToolIds) { return this.request('inspect', [ownerId, options, nativeToolIds]); }
|
|
110
111
|
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';
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile, link, unlink } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { digest, fail } from './contracts.js';
|
|
5
|
+
|
|
6
|
+
/** Preserve existing instance bindings without silently creating a new authority.
|
|
7
|
+
* SQLite is the only supported storage; this marker is not a backend selector.
|
|
8
|
+
*/
|
|
9
|
+
export async function ensurePersonStorage(yeaftDir, namespace) {
|
|
10
|
+
const directory = join(yeaftDir, 'person');
|
|
11
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
12
|
+
const path = join(directory, `storage-${digest(namespace)}.json`);
|
|
13
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
14
|
+
await writeFile(temporary, JSON.stringify({ version: 1, storage: 'sqlite' }), { flag: 'wx', mode: 0o600 });
|
|
15
|
+
try {
|
|
16
|
+
// A fully written inode is linked atomically; competing openers never read partial JSON.
|
|
17
|
+
await link(temporary, path);
|
|
18
|
+
} catch (error) {
|
|
19
|
+
if (error.code !== 'EEXIST') throw error;
|
|
20
|
+
let binding;
|
|
21
|
+
try { binding = JSON.parse(await readFile(path, 'utf8')); } catch { fail('STORAGE_MISMATCH'); }
|
|
22
|
+
if (!binding || binding.version !== 1 || binding.storage !== 'sqlite') fail('STORAGE_MISMATCH');
|
|
23
|
+
} finally { await unlink(temporary).catch(() => {}); }
|
|
24
|
+
}
|
|
@@ -9,6 +9,7 @@ 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,11 +45,14 @@ 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']);
|
|
55
|
+
const READS = new Set(['receipt', 'turns', 'inspect', 'search', 'getPerson', 'context', 'recall', 'list', 'searchChanges', 'resolveMemories', 'createdCapabilities', 'episodeAttachments']);
|
|
52
56
|
const WRITES = new Set(['open', 'recover', 'admit', 'heartbeat', 'append', 'startCall', 'finalizeCall', 'startCapability', 'finalizeCapability', 'commit', '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; };
|
|
@@ -445,12 +449,41 @@ export class SqlitePersonStore {
|
|
|
445
449
|
' AND (? IS NULL OR seq < ?) AND instr(text, ?) > 0 ORDER BY seq DESC LIMIT ?', [cursor, cursor, query.toLowerCase(), limit + 1]);
|
|
446
450
|
return inspectionPage(records, limit, 'seq', messageView);
|
|
447
451
|
}
|
|
452
|
+
turns(ownerId, options = {}) {
|
|
453
|
+
const { cursor, limit } = turnsPage(options);
|
|
454
|
+
const scope = this.scope(ownerId);
|
|
455
|
+
if (!this.sql(`SELECT 1 FROM persons WHERE ${SCOPE}`).get(...scopeValues(scope))) fail('NOT_OPEN');
|
|
456
|
+
const project = fields => `json_object(${Object.entries(fields).map(([key, path]) => `'${key}', json_extract(record, '$.${path}')`).join(', ')})`;
|
|
457
|
+
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
|
|
458
|
+
FROM episodes WHERE ${SCOPE} AND (? IS NULL OR inputWatermark < ?) ORDER BY inputWatermark DESC LIMIT ?`)
|
|
459
|
+
.all(...scopeValues(scope), cursor, cursor, limit + 1).map(row => JSON.parse(row.metadata));
|
|
460
|
+
const fields = { kind: 'kind', callId: 'callId', callIndex: 'callIndex', createdAt: 'createdAt',
|
|
461
|
+
requestedModel: 'requested.model', requestedEffort: 'requested.effort', effectiveEffort: 'effective.effort', effortObserved: 'effective.effortObserved',
|
|
462
|
+
selectionOrigin: 'selectionOrigin', reason: 'reason', code: 'code', terminalCode: 'terminalCode', outcome: 'outcome',
|
|
463
|
+
contextBytes: 'manifest.contextBytes', contextBudgetBytes: 'manifest.contextBudgetBytes', outputTokensReserved: 'manifest.outputTokensReserved' };
|
|
464
|
+
const usage = `json_object(${TOKEN_FIELDS.map(key => `'${key}', json_extract(record, '$.output.usage.${key}')`).join(', ')},
|
|
465
|
+
'accountingVersion', json_extract(record, '$.output.usage.accountingVersion'),
|
|
466
|
+
'accountingIncomplete', json(CASE json_extract(record, '$.output.usage.accountingIncomplete') WHEN 1 THEN 'true' ELSE 'false' END),
|
|
467
|
+
'cacheTokensAreIncludedInInput', json(CASE json_extract(record, '$.output.usage.cacheTokensAreIncludedInInput') WHEN 1 THEN 'true' WHEN 0 THEN 'false' ELSE 'null' END))`;
|
|
468
|
+
const metadata = `${project(fields).slice(0, -1)}, 'capabilityId', COALESCE(json_extract(record, '$.capability.id'), json_extract(record, '$.capabilityId')),
|
|
469
|
+
'usage', ${usage})`;
|
|
470
|
+
const items = episodes.slice(0, limit).map(episode => {
|
|
471
|
+
const events = this.sql(`SELECT ${metadata} AS metadata FROM traces WHERE ${SCOPE}
|
|
472
|
+
AND json_extract(record, '$.episodeId') = ? AND json_extract(record, '$.kind') IN
|
|
473
|
+
('call_started', 'call_output', 'call_failed', 'proposal_rejected', 'capability_started', 'capability_result', 'capability_failed', 'capability_finalized') ORDER BY seq ASC`)
|
|
474
|
+
.all(...scopeValues(scope), episode.id).map(row => {
|
|
475
|
+
const event = JSON.parse(row.metadata); event.effortObserved = event.effortObserved === 1; return event;
|
|
476
|
+
});
|
|
477
|
+
return turnView(episode, events);
|
|
478
|
+
});
|
|
479
|
+
return { items, nextCursor: episodes.length > limit ? items.at(-1).seq : null };
|
|
480
|
+
}
|
|
448
481
|
list(ownerId, collection, { cursor = null, limit = 20 } = {}, filter = {}) {
|
|
449
482
|
if (!['messages', 'traces'].includes(collection)) fail('INVALID_REQUEST');
|
|
450
483
|
this.getPerson(ownerId); boundedLimit(limit);
|
|
451
|
-
if (Object.keys(filter).some(k => k !== '
|
|
452
|
-
|
|
453
|
-
|
|
484
|
+
if (Object.keys(filter).some(k => k !== 'query')) fail('INVALID_REQUEST');
|
|
485
|
+
const query = filter.query ?? '';
|
|
486
|
+
text(query, LIMITS.inputBytes, true);
|
|
454
487
|
if (query && collection !== 'messages') fail('INVALID_REQUEST');
|
|
455
488
|
let clause = '', params = [];
|
|
456
489
|
if (cursor != null) { clause += ' AND seq < ?'; params.push(sequence(cursor)); }
|
|
@@ -460,7 +493,7 @@ export class SqlitePersonStore {
|
|
|
460
493
|
}
|
|
461
494
|
recall(ownerId, { kind = 'messages', query = '', cursor = null, limit = 5 } = {}) {
|
|
462
495
|
memoryKind(kind); text(query, LIMITS.inputBytes, true); boundedLimit(limit);
|
|
463
|
-
if (kind === 'messages') return this.list(ownerId, kind, { cursor, limit },
|
|
496
|
+
if (kind === 'messages') return this.list(ownerId, kind, { cursor, limit }, { query });
|
|
464
497
|
this.getPerson(ownerId);
|
|
465
498
|
if (cursor != null) text(cursor, 128);
|
|
466
499
|
const docs = this.rows('concepts', this.scope(ownerId), ' AND (? IS NULL OR id > ?) AND instr(statement, ?) > 0 ORDER BY id ASC LIMIT ?', [cursor, cursor, query.toLowerCase(), limit + 1]);
|
|
@@ -61,7 +61,7 @@ function privatePath(root, path) {
|
|
|
61
61
|
// expose native command fallbacks; redact common sensitive forms before capping.
|
|
62
62
|
function summary(value, maxBytes = 512) {
|
|
63
63
|
const clean = String(value ?? '')
|
|
64
|
-
.replace(
|
|
64
|
+
.replace(/\b[a-z][a-z\d+.-]*:\/\/\S+/gi, '[url]')
|
|
65
65
|
.replace(/(?:bearer\s+|(?:api[_-]?key|password|secret|token)\s*[:=]\s*)\S+/gi, '[redacted]')
|
|
66
66
|
.replace(/\b(?:sk-[\w-]+|gh[pousr]_[\w]+|github_pat_[\w]+)\b/g, '[redacted]')
|
|
67
67
|
.replace(/(?:\/[\w.@~+-]+)+(?:\/[^\s]*)?|(?:~|\.\.?|[A-Za-z]:)[\\/][^\s]+/g, '[path]')
|
|
@@ -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
|
+
}
|