mixdog 1.3.3 → 1.3.4
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 +2 -2
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-client-version.mjs +1 -1
- package/src/runtime/shared/agent-room-host.mjs +1 -1
- package/src/runtime/shared/llm/usage-rollup.mjs +4 -383
- package/src/session-runtime/agent-tool-routing.mjs +49 -4
- package/src/session-runtime/boot/tools.mjs +2 -0
- package/src/session-runtime/services/agent-tool/close-flow.mjs +14 -2
- package/src/session-runtime/services/agent-tool/execute.mjs +6 -1
- package/src/session-runtime/services/agent-tool/job-views/agent-list.mjs +11 -2
- package/src/session-runtime/services/agent-tool/render.mjs +3 -1
- package/src/session-runtime/services/agent-tool/tool-def.mjs +1 -1
- package/src/session-runtime/usage-stats-api.mjs +1 -1
- package/src/tui/dist/index.mjs +311 -170
- package/src/vendor/statusline/src/gateway/claude-current.mjs +0 -58
- package/src/vendor/statusline/src/gateway/route-meta.mjs +1 -361
- package/src/vendor/statusline/src/gateway/session-routes.mjs +0 -55
- package/src/vendor/statusline/scripts/lib/gateway-settings.mjs +0 -285
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mixdog",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.4",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Open-source coding agent that matches Codex on Terminal-Bench 2.1 with 63% fewer tokens.",
|
|
@@ -153,7 +153,7 @@
|
|
|
153
153
|
"arm64"
|
|
154
154
|
],
|
|
155
155
|
"dependencies": {
|
|
156
|
-
"@anthropic-ai/sdk": "^0.
|
|
156
|
+
"@anthropic-ai/sdk": "^0.135.0",
|
|
157
157
|
"@formulajs/formulajs": "^4.6.1",
|
|
158
158
|
"@google/generative-ai": "^0.24.1",
|
|
159
159
|
"@huggingface/transformers": "^4.3.1",
|
|
@@ -10,7 +10,7 @@ import { createNpmVersionSource, maxSemver } from './npm-cli-version.mjs';
|
|
|
10
10
|
// source release just to advance the user-agent version.
|
|
11
11
|
// Claude Opus 5.5 requires 2.1.280 or newer. The release workflow raises this
|
|
12
12
|
// to the published CLI version (scripts/sync-client-version-floors.mjs).
|
|
13
|
-
export const DEFAULT_CLI_VERSION = '2.1.
|
|
13
|
+
export const DEFAULT_CLI_VERSION = '2.1.309';
|
|
14
14
|
export const CLAUDE_CLI_NPM_PACKAGE = '@anthropic-ai/claude-code';
|
|
15
15
|
|
|
16
16
|
const CACHE_SCHEMA_VERSION = 1;
|
|
@@ -30,7 +30,7 @@ function hosts() {
|
|
|
30
30
|
* resolves the turn's end status ('done' when it finished).
|
|
31
31
|
* - `stop(roomSessionId)` → boolean: the room's stop button: the speaking
|
|
32
32
|
* member's turn is cancelled, queued calls settle cancelled, and no one else
|
|
33
|
-
* gets a turn; true when an exchange was
|
|
33
|
+
* gets a turn; true when an exchange or incoming request was cancelled.
|
|
34
34
|
* `hop`: the send's or report's place in a bot-to-bot chain (bot-hops.mjs);
|
|
35
35
|
* the member's turn carries it, so the sends it makes count on from there.
|
|
36
36
|
* `follow(submissionId)` (both): the turn that answers is submitted under
|
|
@@ -1,78 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* right now". Totals for a month ago cannot be recovered from it once the cap
|
|
7
|
-
* evicts those rows, so every turn is ALSO folded into a per-day bucket here.
|
|
8
|
-
* A bucket is a few numbers, which keeps years of history in one small file.
|
|
9
|
-
*
|
|
10
|
-
* Most turns on this machine are NOT the user typing: background runners
|
|
11
|
-
* (memory cycles, schedules, webhooks) spend tokens on their own and outnumber
|
|
12
|
-
* conversation turns several times over. Every bucket therefore carries a
|
|
13
|
-
* `conversation` sub-total beside the full one, so "what did I spend" can be
|
|
14
|
-
* answered without the background drowning it.
|
|
15
|
-
*
|
|
16
|
-
* Session token totals are kept id-keyed only while a day is recent; older days
|
|
17
|
-
* keep the anonymous per-session totals alone. Only conversation sessions are
|
|
18
|
-
* tracked: a background runner mints a fresh id per cycle, which would turn
|
|
19
|
-
* "how many sessions" into a count of cycles.
|
|
20
|
-
*
|
|
21
|
-
* `firstTs`/`lastTs` bound the turns already folded into a day. A reader that
|
|
22
|
-
* also holds the raw event list adds ONLY the events outside that span, which
|
|
23
|
-
* is what lets both stores be read together without counting a turn twice.
|
|
24
|
-
*
|
|
25
|
-
* Shape:
|
|
26
|
-
* {
|
|
27
|
-
* version, updatedAt,
|
|
28
|
-
* days: {
|
|
29
|
-
* "2026-08-12": {
|
|
30
|
-
* turns, input, output, cacheRead, cacheWrite,
|
|
31
|
-
* costUsd, costBilled, costEstimated, costKnownTurns,
|
|
32
|
-
* durationMs, durationTurns, firstTs, lastTs,
|
|
33
|
-
* conversation: { …the same turn totals, user-driven turns only },
|
|
34
|
-
* conversationPartial,
|
|
35
|
-
* models: { "<provider>/<model>": {
|
|
36
|
-
* provider, model, kind, turns, input, output, cacheRead, cacheWrite,
|
|
37
|
-
* costUsd, conversation: { …route totals }
|
|
38
|
-
* } },
|
|
39
|
-
* sessions: { "<sessionId>": <tokens> },
|
|
40
|
-
* sessionTokens: [<tokens>, …],
|
|
41
|
-
* sessionsDropped
|
|
42
|
-
* }
|
|
43
|
-
* }
|
|
44
|
-
* }
|
|
2
|
+
* Day keys, conversation-source classification and numeric helpers shared by the
|
|
3
|
+
* usage ledger and the stats model. The daily JSON rollup these helpers were
|
|
4
|
+
* written for is no longer produced; its past day records are still imported
|
|
5
|
+
* by usage-ledger-import.mjs.
|
|
45
6
|
*/
|
|
46
|
-
import { billableInputTokensForProvider } from './cost.mjs';
|
|
47
|
-
|
|
48
|
-
const USAGE_ROLLUP_VERSION = 2;
|
|
49
|
-
|
|
50
|
-
// A day costs well under a kilobyte, so a long retention stays cheap while
|
|
51
|
-
// making "all time" mean something closer to the install date.
|
|
52
|
-
const RETENTION_DAYS = 400;
|
|
53
|
-
// Session ids are the only identifying values in the file; drop them once the
|
|
54
|
-
// day is old enough that per-session drill-down no longer matters.
|
|
55
|
-
const SESSION_DETAIL_DAYS = 45;
|
|
56
|
-
const MAX_MODELS_PER_DAY = 80;
|
|
57
|
-
const MAX_SESSION_IDS_PER_DAY = 400;
|
|
58
|
-
const MAX_SESSION_TOKENS_PER_DAY = 400;
|
|
59
|
-
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
60
|
-
const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
61
7
|
|
|
62
8
|
export function num(value) {
|
|
63
9
|
const n = Number(value);
|
|
64
10
|
return Number.isFinite(n) && n > 0 ? n : 0;
|
|
65
11
|
}
|
|
66
12
|
|
|
67
|
-
export function cleanId(value) {
|
|
68
|
-
const text = typeof value === 'string' ? value.trim() : '';
|
|
69
|
-
return text && !UNSAFE_KEYS.has(text) ? text.slice(0, 200) : '';
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export function round6(value) {
|
|
73
|
-
return Math.round(value * 1e6) / 1e6;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
13
|
/** Local calendar day, so "today" in the UI means the user's today. */
|
|
77
14
|
export function usageRollupDayKey(ts) {
|
|
78
15
|
const date = new Date(Number.isFinite(Number(ts)) ? Number(ts) : Date.now());
|
|
@@ -92,324 +29,8 @@ export function isConversationUsageSource(sourceType) {
|
|
|
92
29
|
return !BACKGROUND_SOURCES.has(id);
|
|
93
30
|
}
|
|
94
31
|
|
|
95
|
-
function emptyRouteTotals() {
|
|
96
|
-
return {
|
|
97
|
-
turns: 0,
|
|
98
|
-
input: 0,
|
|
99
|
-
output: 0,
|
|
100
|
-
cacheRead: 0,
|
|
101
|
-
cacheWrite: 0,
|
|
102
|
-
costUsd: 0,
|
|
103
|
-
costBilled: 0,
|
|
104
|
-
costEstimated: 0,
|
|
105
|
-
costKnownTurns: 0,
|
|
106
|
-
};
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function emptyTurnTotals() {
|
|
110
|
-
return {
|
|
111
|
-
...emptyRouteTotals(),
|
|
112
|
-
durationMs: 0,
|
|
113
|
-
durationTurns: 0,
|
|
114
|
-
};
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function addRouteTotals(target, turn, { priced, billed } = {}) {
|
|
118
|
-
target.turns += turn.turns;
|
|
119
|
-
target.input += turn.input;
|
|
120
|
-
target.output += turn.output;
|
|
121
|
-
target.cacheRead += turn.cacheRead;
|
|
122
|
-
target.cacheWrite += turn.cacheWrite;
|
|
123
|
-
target.costUsd = round6(target.costUsd + turn.costUsd);
|
|
124
|
-
if (priced) {
|
|
125
|
-
target.costKnownTurns += 1;
|
|
126
|
-
if (billed) target.costBilled = round6(target.costBilled + turn.costUsd);
|
|
127
|
-
else target.costEstimated = round6(target.costEstimated + turn.costUsd);
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
function addTurnTotals(target, turn, pricing) {
|
|
132
|
-
addRouteTotals(target, turn, pricing);
|
|
133
|
-
const { durationMs } = pricing;
|
|
134
|
-
if (durationMs > 0) {
|
|
135
|
-
target.durationMs += durationMs;
|
|
136
|
-
target.durationTurns += 1;
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function emptyModel(provider, model) {
|
|
141
|
-
return {
|
|
142
|
-
provider,
|
|
143
|
-
model,
|
|
144
|
-
kind: '',
|
|
145
|
-
...emptyRouteTotals(),
|
|
146
|
-
conversation: emptyRouteTotals(),
|
|
147
|
-
};
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
function emptyDay() {
|
|
151
|
-
return {
|
|
152
|
-
...emptyTurnTotals(),
|
|
153
|
-
firstTs: 0,
|
|
154
|
-
lastTs: 0,
|
|
155
|
-
conversation: emptyTurnTotals(),
|
|
156
|
-
conversationPartial: false,
|
|
157
|
-
// Rebuilt from session transcripts rather than recorded turn by turn. The
|
|
158
|
-
// flag travels with the bucket so a surface can keep saying so long after
|
|
159
|
-
// the transcripts it came from are gone.
|
|
160
|
-
restored: false,
|
|
161
|
-
// Set once a day's pre-classification session ids have been cleared, which
|
|
162
|
-
// is what tells a reader the remaining ids can be trusted.
|
|
163
|
-
sessionsPurged: true,
|
|
164
|
-
models: {},
|
|
165
|
-
sessions: {},
|
|
166
|
-
sessionTokens: [],
|
|
167
|
-
sessionsDropped: 0,
|
|
168
|
-
};
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
function readRouteTotals(raw, target) {
|
|
172
|
-
target.turns = num(raw?.turns);
|
|
173
|
-
target.input = num(raw?.input);
|
|
174
|
-
target.output = num(raw?.output);
|
|
175
|
-
target.cacheRead = num(raw?.cacheRead);
|
|
176
|
-
target.cacheWrite = num(raw?.cacheWrite);
|
|
177
|
-
target.costUsd = round6(num(raw?.costUsd));
|
|
178
|
-
target.costBilled = round6(num(raw?.costBilled));
|
|
179
|
-
target.costEstimated = round6(
|
|
180
|
-
raw?.costEstimated == null ? Math.max(0, target.costUsd - target.costBilled) : num(raw.costEstimated)
|
|
181
|
-
);
|
|
182
|
-
if (raw?.costKnownTurns != null) target.costKnownTurns = num(raw.costKnownTurns);
|
|
183
|
-
else target.costKnownTurns = target.costUsd > 0 ? target.turns : 0;
|
|
184
|
-
return target;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
32
|
/** Turns with a known cost: the recorded count, else every turn when the usage carries any cost at all. */
|
|
188
33
|
export function knownCostTurns(usage, amount = num) {
|
|
189
34
|
if (usage.costKnownTurns != null) return amount(usage.costKnownTurns);
|
|
190
35
|
return amount(usage.costUsd) > 0 ? amount(usage.turns) : 0;
|
|
191
36
|
}
|
|
192
|
-
|
|
193
|
-
function readTurnTotals(raw, target) {
|
|
194
|
-
readRouteTotals(raw, target);
|
|
195
|
-
target.costEstimated = round6(num(raw?.costEstimated));
|
|
196
|
-
target.costKnownTurns = num(raw?.costKnownTurns);
|
|
197
|
-
target.durationMs = num(raw?.durationMs);
|
|
198
|
-
target.durationTurns = num(raw?.durationTurns);
|
|
199
|
-
return target;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
function normalizeModel(raw, key) {
|
|
203
|
-
const slash = typeof key === 'string' ? key.indexOf('/') : -1;
|
|
204
|
-
const bucket = emptyModel(
|
|
205
|
-
cleanId(raw?.provider) || (slash > 0 ? key.slice(0, slash) : ''),
|
|
206
|
-
cleanId(raw?.model) || (slash > 0 ? key.slice(slash + 1) : '')
|
|
207
|
-
);
|
|
208
|
-
bucket.kind = cleanId(raw?.kind);
|
|
209
|
-
readRouteTotals(raw, bucket);
|
|
210
|
-
// A bucket written before turns carried a source has no split to read.
|
|
211
|
-
// `null` means unknown, which a reader must not quietly treat as zero.
|
|
212
|
-
bucket.conversation =
|
|
213
|
-
raw?.conversation && typeof raw.conversation === 'object'
|
|
214
|
-
? readRouteTotals(raw.conversation, emptyRouteTotals())
|
|
215
|
-
: null;
|
|
216
|
-
return bucket;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
function normalizeDay(raw) {
|
|
220
|
-
const day = emptyDay();
|
|
221
|
-
readTurnTotals(raw, day);
|
|
222
|
-
day.firstTs = num(raw?.firstTs);
|
|
223
|
-
day.lastTs = num(raw?.lastTs);
|
|
224
|
-
day.conversation =
|
|
225
|
-
raw?.conversation && typeof raw.conversation === 'object'
|
|
226
|
-
? readTurnTotals(raw.conversation, emptyTurnTotals())
|
|
227
|
-
: null;
|
|
228
|
-
day.conversationPartial = raw?.conversationPartial === true;
|
|
229
|
-
day.restored = raw?.restored === true;
|
|
230
|
-
// A day that never held unclassified turns needs no purge; one that did is
|
|
231
|
-
// trusted only once it says the old ids were cleared.
|
|
232
|
-
day.sessionsPurged = !day.conversationPartial || raw?.sessionsPurged === true;
|
|
233
|
-
day.sessionsDropped = num(raw?.sessionsDropped);
|
|
234
|
-
const models = raw?.models && typeof raw.models === 'object' ? raw.models : {};
|
|
235
|
-
for (const [key, value] of Object.entries(models)) {
|
|
236
|
-
const id = cleanId(key);
|
|
237
|
-
if (!id) continue;
|
|
238
|
-
day.models[id] = normalizeModel(value, id);
|
|
239
|
-
}
|
|
240
|
-
const sessions = raw?.sessions && typeof raw.sessions === 'object' ? raw.sessions : {};
|
|
241
|
-
for (const [key, value] of Object.entries(sessions)) {
|
|
242
|
-
const id = cleanId(key);
|
|
243
|
-
if (!id) continue;
|
|
244
|
-
day.sessions[id] = num(value);
|
|
245
|
-
}
|
|
246
|
-
if (Array.isArray(raw?.sessionTokens)) {
|
|
247
|
-
day.sessionTokens = raw.sessionTokens.map(num).slice(-MAX_SESSION_TOKENS_PER_DAY);
|
|
248
|
-
}
|
|
249
|
-
return day;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
/** Defensive read: a hand-edited or partially written file must not throw. */
|
|
253
|
-
export function normalizeUsageRollup(raw) {
|
|
254
|
-
const days = {};
|
|
255
|
-
const source = raw?.days && typeof raw.days === 'object' ? raw.days : {};
|
|
256
|
-
for (const [key, value] of Object.entries(source)) {
|
|
257
|
-
if (!/^\d{4}-\d{2}-\d{2}$/.test(key)) continue;
|
|
258
|
-
days[key] = normalizeDay(value);
|
|
259
|
-
}
|
|
260
|
-
return {
|
|
261
|
-
version: USAGE_ROLLUP_VERSION,
|
|
262
|
-
updatedAt: num(raw?.updatedAt),
|
|
263
|
-
days,
|
|
264
|
-
};
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
/** Retention + identifier aging. Both bounds are evaluated against `now`. */
|
|
268
|
-
function pruneUsageRollup(rollup, now = Date.now()) {
|
|
269
|
-
const dropBefore = usageRollupDayKey(now - RETENTION_DAYS * DAY_MS);
|
|
270
|
-
const anonymizeBefore = usageRollupDayKey(now - SESSION_DETAIL_DAYS * DAY_MS);
|
|
271
|
-
for (const [key, day] of Object.entries(rollup.days)) {
|
|
272
|
-
if (key < dropBefore) {
|
|
273
|
-
delete rollup.days[key];
|
|
274
|
-
continue;
|
|
275
|
-
}
|
|
276
|
-
if (key >= anonymizeBefore) continue;
|
|
277
|
-
const ids = Object.keys(day.sessions);
|
|
278
|
-
if (!ids.length) continue;
|
|
279
|
-
const archived = day.sessionTokens.concat(ids.map((id) => day.sessions[id]));
|
|
280
|
-
day.sessionTokens = archived.slice(-MAX_SESSION_TOKENS_PER_DAY);
|
|
281
|
-
day.sessions = {};
|
|
282
|
-
}
|
|
283
|
-
return rollup;
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
/**
|
|
287
|
-
* Fold one gateway usage summary into the rollup. Pure: the caller owns the
|
|
288
|
-
* document, which is what makes this testable without touching disk.
|
|
289
|
-
*/
|
|
290
|
-
export function foldUsageRollup(current, summary, now = Date.now()) {
|
|
291
|
-
const rollup = normalizeUsageRollup(current);
|
|
292
|
-
const provider = cleanId(summary?.provider);
|
|
293
|
-
const model = cleanId(summary?.model);
|
|
294
|
-
// A turn that cannot be attributed to a route would silently inflate every
|
|
295
|
-
// total it lands in, so it is dropped rather than bucketed as "unknown".
|
|
296
|
-
if (!provider || !model) return pruneUsageRollup(rollup, now);
|
|
297
|
-
|
|
298
|
-
const at = Number(summary?.ts ?? summary?.at);
|
|
299
|
-
const ts = Number.isFinite(at) && at > 0 ? at : now;
|
|
300
|
-
const key = usageRollupDayKey(ts);
|
|
301
|
-
const day = rollup.days[key] || emptyDay();
|
|
302
|
-
|
|
303
|
-
const cacheRead = num(summary?.cacheReadTokens);
|
|
304
|
-
const cacheWrite = num(summary?.cacheWriteTokens);
|
|
305
|
-
const turn = {
|
|
306
|
-
turns: 1,
|
|
307
|
-
// Providers disagree about what `inputTokens` counts: several report the
|
|
308
|
-
// whole prompt with the cached part inside it. Stripping it where it is
|
|
309
|
-
// already included is what keeps one cache hit from being counted twice.
|
|
310
|
-
input: billableInputTokensForProvider(provider, num(summary?.inputTokens), cacheRead, cacheWrite),
|
|
311
|
-
output: num(summary?.outputTokens),
|
|
312
|
-
cacheRead,
|
|
313
|
-
cacheWrite,
|
|
314
|
-
costUsd: num(summary?.costUsd),
|
|
315
|
-
};
|
|
316
|
-
const tokens = turn.input + turn.output + turn.cacheRead + turn.cacheWrite;
|
|
317
|
-
const costSource = cleanId(summary?.costSource);
|
|
318
|
-
// costSource 'none' means neither the provider nor the catalog priced this
|
|
319
|
-
// turn; counting it keeps the surface honest about partial cost coverage.
|
|
320
|
-
const pricing = {
|
|
321
|
-
priced:
|
|
322
|
-
summary?.costUsd != null &&
|
|
323
|
-
Number.isFinite(Number(summary.costUsd)) &&
|
|
324
|
-
!['', 'none', 'unpriced'].includes(costSource),
|
|
325
|
-
billed: costSource === 'provider',
|
|
326
|
-
durationMs: num(summary?.durationMs),
|
|
327
|
-
};
|
|
328
|
-
const conversation = isConversationUsageSource(summary?.sourceType);
|
|
329
|
-
|
|
330
|
-
addTurnTotals(day, turn, pricing);
|
|
331
|
-
day.firstTs = day.firstTs > 0 ? Math.min(day.firstTs, ts) : ts;
|
|
332
|
-
day.lastTs = Math.max(day.lastTs, ts);
|
|
333
|
-
if (conversation) {
|
|
334
|
-
if (!day.conversation) {
|
|
335
|
-
// A day carried over from before turns were classified: what is already
|
|
336
|
-
// in it stays unattributed, and the flag lets a reader say so.
|
|
337
|
-
day.conversation = emptyTurnTotals();
|
|
338
|
-
day.conversationPartial = true;
|
|
339
|
-
// Those ids were collected while background cycles still counted, and
|
|
340
|
-
// most of them are cycles. Keeping them would report cycles as sessions.
|
|
341
|
-
day.sessions = {};
|
|
342
|
-
day.sessionTokens = [];
|
|
343
|
-
day.sessionsPurged = true;
|
|
344
|
-
}
|
|
345
|
-
addTurnTotals(day.conversation, turn, pricing);
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
const modelKey = `${provider}/${model}`;
|
|
349
|
-
const known = Object.hasOwn(day.models, modelKey);
|
|
350
|
-
if (known || Object.keys(day.models).length < MAX_MODELS_PER_DAY) {
|
|
351
|
-
const bucket = known ? day.models[modelKey] : emptyModel(provider, model);
|
|
352
|
-
bucket.kind = cleanId(summary?.providerKind) || bucket.kind;
|
|
353
|
-
addRouteTotals(bucket, turn, pricing);
|
|
354
|
-
if (conversation) {
|
|
355
|
-
if (!bucket.conversation) bucket.conversation = emptyRouteTotals();
|
|
356
|
-
addRouteTotals(bucket.conversation, turn, pricing);
|
|
357
|
-
}
|
|
358
|
-
day.models[modelKey] = bucket;
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
// Only conversation sessions are kept: a background runner mints a new id per
|
|
362
|
-
// cycle, so counting those would report cycles as sessions.
|
|
363
|
-
const sessionId = conversation ? cleanId(summary?.sessionId) : '';
|
|
364
|
-
if (sessionId) {
|
|
365
|
-
if (Object.hasOwn(day.sessions, sessionId)) {
|
|
366
|
-
day.sessions[sessionId] += tokens;
|
|
367
|
-
} else if (Object.keys(day.sessions).length < MAX_SESSION_IDS_PER_DAY) {
|
|
368
|
-
day.sessions[sessionId] = tokens;
|
|
369
|
-
} else {
|
|
370
|
-
day.sessionsDropped += 1;
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
rollup.days[key] = day;
|
|
375
|
-
rollup.updatedAt = now;
|
|
376
|
-
return pruneUsageRollup(rollup, now);
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
/**
|
|
380
|
-
* Write transcript-rebuilt days into the rollup, so they outlive the sessions
|
|
381
|
-
* they were derived from.
|
|
382
|
-
*
|
|
383
|
-
* The session store prunes: ephemeral sessions past 14 days or beyond the
|
|
384
|
-
* newest 300 are deleted, which silently takes their tokens out of any figure
|
|
385
|
-
* derived from them. The rollup is a few numbers per day kept for 400 days, so
|
|
386
|
-
* once a rebuilt day lands here it stops depending on the transcripts at all.
|
|
387
|
-
*
|
|
388
|
-
* Recording always wins. A day the rollup already holds is left exactly as it
|
|
389
|
-
* is — including a day already frozen this way, because re-deriving it from a
|
|
390
|
-
* half-pruned store would quietly shrink it.
|
|
391
|
-
*
|
|
392
|
-
* Pure: the caller owns the document.
|
|
393
|
-
*/
|
|
394
|
-
export function freezeRestoredDays(current, historyDays, now = Date.now()) {
|
|
395
|
-
const rollup = normalizeUsageRollup(current);
|
|
396
|
-
const source = historyDays && typeof historyDays === 'object' ? historyDays : {};
|
|
397
|
-
const dropBefore = usageRollupDayKey(now - RETENTION_DAYS * DAY_MS);
|
|
398
|
-
const today = usageRollupDayKey(now);
|
|
399
|
-
let frozen = 0;
|
|
400
|
-
for (const [key, day] of Object.entries(source)) {
|
|
401
|
-
if (!/^\d{4}-\d{2}-\d{2}$/.test(key)) continue;
|
|
402
|
-
if (Object.hasOwn(rollup.days, key)) continue;
|
|
403
|
-
if (key < dropBefore) continue;
|
|
404
|
-
// Today is still being recorded turn by turn; freezing it now would pin a
|
|
405
|
-
// partial rebuild and lock out the rest of the day's real events.
|
|
406
|
-
if (key >= today) continue;
|
|
407
|
-
const bucket = normalizeDay(day);
|
|
408
|
-
if (bucket.turns <= 0) continue;
|
|
409
|
-
bucket.restored = true;
|
|
410
|
-
rollup.days[key] = bucket;
|
|
411
|
-
frozen += 1;
|
|
412
|
-
}
|
|
413
|
-
if (frozen > 0) rollup.updatedAt = now;
|
|
414
|
-
return { rollup, frozen };
|
|
415
|
-
}
|
|
@@ -4,12 +4,57 @@
|
|
|
4
4
|
* status for the facade.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
import { clean } from '../runtime/shared/clean.mjs';
|
|
8
|
+
|
|
9
|
+
const lower = (value) => clean(value).toLowerCase();
|
|
10
|
+
|
|
11
|
+
// A pinned model named by its catalog display name ("GPT-6.1-Sol") is not a
|
|
12
|
+
// wire id. Canonicalize it only on an unambiguous EXACT match: the id itself,
|
|
13
|
+
// or a display name / label, case-insensitively, within the requested provider
|
|
14
|
+
// (or across providers when none is given and exactly one row matches).
|
|
15
|
+
// Anything else — no match, several matches, an empty or partial catalog —
|
|
16
|
+
// returns the args unchanged: the catalog never refuses a model and never
|
|
17
|
+
// substitutes a near one.
|
|
18
|
+
export function canonicalizePinnedModel(args, rows) {
|
|
19
|
+
const model = clean(args?.model);
|
|
20
|
+
if (!model || !Array.isArray(rows)) return args;
|
|
21
|
+
const provider = lower(args.provider);
|
|
22
|
+
const pool = rows.filter((row) => row && (!provider || lower(row.provider) === provider));
|
|
23
|
+
const wanted = model.toLowerCase();
|
|
24
|
+
const exactId = pool.filter((row) => row.id === model);
|
|
25
|
+
// A partial catalog may lack the real id a display name collides with, so
|
|
26
|
+
// only an exact id can pass there; aliases wait for a complete catalog.
|
|
27
|
+
const partial = pool.some((row) => row.catalogPartial === true);
|
|
28
|
+
const hits = exactId.length
|
|
29
|
+
? exactId
|
|
30
|
+
: partial
|
|
31
|
+
? []
|
|
32
|
+
: pool.filter((row) => lower(row.id) === wanted || lower(row.display) === wanted || lower(row.label) === wanted);
|
|
33
|
+
const distinct = [...new Map(hits.map((row) => [`${row.provider}\n${row.id}`, row])).values()];
|
|
34
|
+
if (distinct.length !== 1) return args;
|
|
35
|
+
const [hit] = distinct;
|
|
36
|
+
if (hit.id === model && hit.provider === args.provider) return args;
|
|
37
|
+
return { ...args, provider: hit.provider, model: hit.id };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// `listProviderModels` is this runtime's provider catalog. The agent control
|
|
41
|
+
// behind `executeAgentControl` (the daemon's canonical agent) has none, so a
|
|
42
|
+
// display-name model is canonicalized here before it is forwarded. Only the
|
|
43
|
+
// cached/quick catalog is read, so a spawn never waits on provider listings.
|
|
44
|
+
export function createRoutedAgentTool({ rt, agentTool, executeAgentControl = null, listProviderModels = null }) {
|
|
8
45
|
const routedAgentTool = {
|
|
9
46
|
...agentTool,
|
|
10
|
-
execute(args, context = {}) {
|
|
11
|
-
if (typeof executeAgentControl
|
|
12
|
-
|
|
47
|
+
async execute(args, context = {}) {
|
|
48
|
+
if (typeof executeAgentControl !== 'function') return agentTool.execute(args, context);
|
|
49
|
+
let routedArgs = args;
|
|
50
|
+
if (typeof listProviderModels === 'function' && clean(args?.model) && (clean(args.type) || 'spawn') === 'spawn') {
|
|
51
|
+
try {
|
|
52
|
+
routedArgs = canonicalizePinnedModel(args, await listProviderModels({ quick: true }));
|
|
53
|
+
} catch {
|
|
54
|
+
routedArgs = args;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return executeAgentControl(routedArgs, context);
|
|
13
58
|
},
|
|
14
59
|
closeAll(reason, scope = {}) {
|
|
15
60
|
if (typeof executeAgentControl !== 'function') {
|
|
@@ -98,6 +98,8 @@ function wireAgent(boot) {
|
|
|
98
98
|
rt,
|
|
99
99
|
agentTool,
|
|
100
100
|
executeAgentControl: params.executeAgentControl,
|
|
101
|
+
// Lazy, as above.
|
|
102
|
+
listProviderModels: (options) => boot.collectProviderModels(options),
|
|
101
103
|
});
|
|
102
104
|
bootProfile('agent:ready', { ms: (performance.now() - agentToolStartedAt).toFixed(1) });
|
|
103
105
|
Object.assign(boot, { agentTool, routedAgentTool, agentStatusState });
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
listBackgroundTasks,
|
|
6
6
|
taskIdFromArgs,
|
|
7
7
|
} from '../../../runtime/shared/background-tasks.mjs';
|
|
8
|
-
import { agentScope, clean, clearAgentStatuslineRoute } from './helpers.mjs';
|
|
8
|
+
import { agentScope, clean, clearAgentStatuslineRoute, sessionMatchesContext } from './helpers.mjs';
|
|
9
9
|
import { isLeadPoolAgent } from './worker-rows.mjs';
|
|
10
10
|
import { createCloseAll } from './close-flow/close-all.mjs';
|
|
11
11
|
import { closeManagedSession } from '../../lifecycle/shared.mjs';
|
|
@@ -20,6 +20,16 @@ export function createCloseFlow({ mgr, registry, views }) {
|
|
|
20
20
|
// Stale local metadata only: resolveTag found no session in this
|
|
21
21
|
// terminal/scope, so there is no sessionId-safe worker row to delete. Never
|
|
22
22
|
// turn it into a tag-wide persisted-row removal.
|
|
23
|
+
// `tags`/`tagAgents` are shared by every caller on the canonical agent, so a
|
|
24
|
+
// binding alone does not prove the caller owns it: only a live session or a
|
|
25
|
+
// persisted row inside the caller's scope does. Another session's tag must
|
|
26
|
+
// stay untouched.
|
|
27
|
+
function tagBoundByCaller(target, scopedContext) {
|
|
28
|
+
const live = registry.getLiveSession(tags.get(target));
|
|
29
|
+
if (live) return sessionMatchesContext(live, scopedContext);
|
|
30
|
+
return registry.readWorkerRows(scopedContext).some((row) => clean(row.tag) === target);
|
|
31
|
+
}
|
|
32
|
+
|
|
23
33
|
function forgetStaleLocalTag(target, task) {
|
|
24
34
|
registry.unbindTag(target);
|
|
25
35
|
if (task?.taskId) cancelBackgroundTask(task.taskId, CLOSE_REASON);
|
|
@@ -55,7 +65,9 @@ export function createCloseFlow({ mgr, registry, views }) {
|
|
|
55
65
|
}
|
|
56
66
|
const sessionId = registry.resolveTag(target, scopedContext, { scanSessions: registry.wantsSessionScan(args) });
|
|
57
67
|
if (!sessionId) {
|
|
58
|
-
if (!target.startsWith('sess_') && tagAgents.has(target)
|
|
68
|
+
if (!target.startsWith('sess_') && tagAgents.has(target) && tagBoundByCaller(target, scopedContext)) {
|
|
69
|
+
return forgetStaleLocalTag(target, task);
|
|
70
|
+
}
|
|
59
71
|
throw new Error(`agent close: target "${target}" not found`);
|
|
60
72
|
}
|
|
61
73
|
if (isLeadPoolAgent(registry.getLiveSession(sessionId)?.agent)) {
|
|
@@ -67,7 +67,12 @@ export function createAgentExecute({
|
|
|
67
67
|
}
|
|
68
68
|
// Targetless status is the same compact overview as list.
|
|
69
69
|
return renderResult({
|
|
70
|
-
workers
|
|
70
|
+
// Idle workers stay open for follow-ups; the model must see them to close them.
|
|
71
|
+
workers: views.list({
|
|
72
|
+
scanSessions: registry.wantsSessionScan(args),
|
|
73
|
+
context: scopedContext,
|
|
74
|
+
includeIdle: true,
|
|
75
|
+
}),
|
|
71
76
|
jobs: views.listJobs(scopedContext),
|
|
72
77
|
});
|
|
73
78
|
case 'read':
|
|
@@ -25,6 +25,14 @@ export function isActiveWorkerRow(row = {}) {
|
|
|
25
25
|
return !isTerminalWorkerStatus(effective);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
// A finished worker that is still open: it takes follow-ups and can be closed.
|
|
29
|
+
function isOpenIdleRow(row = {}) {
|
|
30
|
+
const status = String(row.status ?? '').trim().toLowerCase();
|
|
31
|
+
const stage = String(row.stage ?? '').trim().toLowerCase();
|
|
32
|
+
if (CANCELLED_WORKER_STATUS.test(stage) || CANCELLED_WORKER_STATUS.test(status)) return false;
|
|
33
|
+
return (status || stage) === 'idle';
|
|
34
|
+
}
|
|
35
|
+
|
|
28
36
|
function rowState(session, runtime) {
|
|
29
37
|
const status = session.closed === true ? 'closed' : session.status || 'idle';
|
|
30
38
|
const stage =
|
|
@@ -78,7 +86,7 @@ function agentRow({ tag, session, runtime, progress, now, indexRow = null }) {
|
|
|
78
86
|
}
|
|
79
87
|
|
|
80
88
|
export function createAgentList({ mgr, refreshTagsFromSessions, agentSessionEntries, progress }) {
|
|
81
|
-
return function list({ scanSessions = false, context = {}, includeTerminal = false } = {}) {
|
|
89
|
+
return function list({ scanSessions = false, context = {}, includeTerminal = false, includeIdle = false } = {}) {
|
|
82
90
|
refreshTagsFromSessions({ scanSessions, context });
|
|
83
91
|
const now = Date.now();
|
|
84
92
|
const rows = [];
|
|
@@ -89,7 +97,8 @@ export function createAgentList({ mgr, refreshTagsFromSessions, agentSessionEntr
|
|
|
89
97
|
// loads the worker's stored session, and a Lead's status pulse (every
|
|
90
98
|
// 2 s) would otherwise read every worker it ever ran, whole transcripts
|
|
91
99
|
// on first sight, synchronously on the daemon loop.
|
|
92
|
-
|
|
100
|
+
const state = rowState(session, runtime);
|
|
101
|
+
if (!includeTerminal && !isActiveWorkerRow(state) && !(includeIdle && isOpenIdleRow(state))) continue;
|
|
93
102
|
rows.push(
|
|
94
103
|
agentRow({
|
|
95
104
|
tag,
|
|
@@ -156,9 +156,11 @@ export function renderResult(value, { includeDiagnostics = false } = {}) {
|
|
|
156
156
|
if (typeof value === 'string') return value;
|
|
157
157
|
if (value && typeof value === 'object') {
|
|
158
158
|
if (Array.isArray(value.workers) || Array.isArray(value.jobs)) return renderRoster(value);
|
|
159
|
+
// close-flow results carry `closed` plus task_id: render the close outcome.
|
|
160
|
+
// close-flow results carry `closed` plus task_id: render the close outcome.
|
|
161
|
+
if (value.closed !== undefined) return renderClosed(value);
|
|
159
162
|
if (value.task_id) return renderTask(value, includeDiagnostics);
|
|
160
163
|
if (value.queued) return renderQueued(value);
|
|
161
|
-
if (value.closed !== undefined) return renderClosed(value);
|
|
162
164
|
if (value.content !== undefined) return renderContent(value);
|
|
163
165
|
}
|
|
164
166
|
return JSON.stringify(value, null, 2);
|
|
@@ -28,7 +28,7 @@ export const AGENT_TOOL = {
|
|
|
28
28
|
agentHidden: false,
|
|
29
29
|
},
|
|
30
30
|
description:
|
|
31
|
-
'Run scoped agent work as background tasks. spawn/send return task_id immediately. Reuse one tag for one scope for its whole lifetime; give distinct tags only to independent scopes. Never mint a new tag because a session expired or was lost — same-tag spawn respawns it with the full brief. Wait for completion notifications; use status/read only for manual recovery.',
|
|
31
|
+
'Run scoped agent work as background tasks. spawn/send return task_id immediately. Reuse one tag for one scope for its whole lifetime; give distinct tags only to independent scopes. Never mint a new tag because a session expired or was lost — same-tag spawn respawns it with the full brief. Wait for completion notifications; use status/read only for manual recovery. Close finished agents yourself.',
|
|
32
32
|
inputSchema: {
|
|
33
33
|
type: 'object',
|
|
34
34
|
properties: {
|
|
@@ -203,7 +203,7 @@ export function createUsageStatsApi({
|
|
|
203
203
|
// beyond a clock captured before the import started.
|
|
204
204
|
const now = Date.now();
|
|
205
205
|
const period = usagePeriodFor(options, now);
|
|
206
|
-
// The rollup query runs off the event loop (usage-
|
|
206
|
+
// The rollup query runs off the event loop (usage-ledger-worker.mjs).
|
|
207
207
|
const rollup = await store.rollupAsync(usageRollupQuery(period));
|
|
208
208
|
// The ledger returns the same rollup object while nothing was committed
|
|
209
209
|
// (and while a rolling window lost no record), so identical back-to-back
|