praxis-agent 0.16.0 → 0.17.0
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/README.md +11 -5
- package/dist/application/background-agent-manager.d.ts +7 -1
- package/dist/application/background-agent-manager.js +148 -5
- package/dist/application/metered-model-completion.d.ts +15 -0
- package/dist/application/metered-model-completion.js +76 -0
- package/dist/application/model-compactor.js +25 -1
- package/dist/application/session-cost-tracker.d.ts +50 -0
- package/dist/application/session-cost-tracker.js +254 -0
- package/dist/application/session-service.d.ts +13 -0
- package/dist/application/session-service.js +594 -69
- package/dist/application/subagent-service.d.ts +11 -1
- package/dist/application/subagent-service.js +41 -1
- package/dist/application/tool-use-summary.d.ts +7 -2
- package/dist/application/tool-use-summary.js +42 -10
- package/dist/application/workflow-manager.d.ts +9 -4
- package/dist/application/workflow-manager.js +151 -7
- package/dist/cli/interactive.d.ts +5 -1
- package/dist/cli/interactive.js +227 -15
- package/dist/cli/protocol.d.ts +11 -0
- package/dist/cli/protocol.js +16 -1
- package/dist/cli/tui/claude-command-inventory.d.ts +27 -27
- package/dist/cli/tui/claude-command-inventory.js +26 -26
- package/dist/cli/tui/config-dashboard.d.ts +2 -9
- package/dist/cli/tui/config-dashboard.js +6 -6
- package/dist/cli/tui/cost-summary.d.ts +34 -0
- package/dist/cli/tui/cost-summary.js +192 -0
- package/dist/cli/tui/doctor-dashboard.d.ts +15 -0
- package/dist/cli/tui/doctor-dashboard.js +81 -0
- package/dist/cli/tui/slash-commands.js +10 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +192 -15
- package/dist/core/compaction.d.ts +2 -0
- package/dist/core/runtime.d.ts +38 -1
- package/dist/core/runtime.js +265 -11
- package/dist/persistence/claude-cost-state-store.d.ts +48 -0
- package/dist/persistence/claude-cost-state-store.js +335 -0
- package/dist/providers/anthropic-compatible.js +34 -9
- package/dist/providers/fallback-provider.d.ts +2 -1
- package/dist/providers/fallback-provider.js +28 -3
- package/dist/tools/line-changes.d.ts +8 -0
- package/dist/tools/line-changes.js +35 -0
- package/dist/tools/local-tools.js +30 -1
- package/package.json +2 -1
|
@@ -30,7 +30,9 @@ import { ClaudeTaskToolRegistry } from '../tools/claude-task-tools.js';
|
|
|
30
30
|
import { ClaudeWorkflowToolRegistry } from '../tools/claude-workflow-tools.js';
|
|
31
31
|
import { WorkflowManager, } from './workflow-manager.js';
|
|
32
32
|
import { SessionWorktreeManager } from './session-worktree.js';
|
|
33
|
+
import { ClaudeSessionCostTracker, } from './session-cost-tracker.js';
|
|
33
34
|
import { ClaudeWorktreeToolRegistry } from '../tools/claude-worktree-tools.js';
|
|
35
|
+
import { completeMeteredModelRequest } from './metered-model-completion.js';
|
|
34
36
|
import { FilteredToolRegistry } from '../tools/filtered-tool-registry.js';
|
|
35
37
|
import { generateToolUseSummary } from './tool-use-summary.js';
|
|
36
38
|
import { ClaudeUserMessageToolRegistry, CLAUDE_USER_MESSAGE_PROMPT, } from '../tools/claude-user-message.js';
|
|
@@ -62,11 +64,207 @@ const emptyToolRegistry = {
|
|
|
62
64
|
function mergeUsage(left, right) {
|
|
63
65
|
const cacheReadInputTokens = (left.cacheReadInputTokens ?? 0) + (right.cacheReadInputTokens ?? 0);
|
|
64
66
|
const cacheCreationInputTokens = (left.cacheCreationInputTokens ?? 0) + (right.cacheCreationInputTokens ?? 0);
|
|
67
|
+
const webSearchRequests = (left.webSearchRequests ?? 0) + (right.webSearchRequests ?? 0);
|
|
65
68
|
return {
|
|
66
69
|
inputTokens: left.inputTokens + right.inputTokens,
|
|
67
70
|
outputTokens: left.outputTokens + right.outputTokens,
|
|
68
71
|
...(cacheReadInputTokens === 0 ? {} : { cacheReadInputTokens }),
|
|
69
72
|
...(cacheCreationInputTokens === 0 ? {} : { cacheCreationInputTokens }),
|
|
73
|
+
...(webSearchRequests === 0 ? {} : { webSearchRequests }),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
const sessionUsageCounterFields = [
|
|
77
|
+
'inputTokens',
|
|
78
|
+
'outputTokens',
|
|
79
|
+
'cacheReadInputTokens',
|
|
80
|
+
'cacheCreationInputTokens',
|
|
81
|
+
'webSearchRequests',
|
|
82
|
+
];
|
|
83
|
+
const sessionUsageMetadataFields = ['contextWindow', 'maxOutputTokens'];
|
|
84
|
+
function assertValidSessionUsageEntry(model, usage) {
|
|
85
|
+
if (model.trim() === '') {
|
|
86
|
+
throw new Error('Model usage breakdown contains a blank model name');
|
|
87
|
+
}
|
|
88
|
+
for (const field of sessionUsageCounterFields) {
|
|
89
|
+
const value = usage[field];
|
|
90
|
+
if (value !== undefined && (!Number.isSafeInteger(value) || value < 0)) {
|
|
91
|
+
throw new Error(`Model usage for "${model}" has an invalid ${field} counter`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
for (const field of sessionUsageMetadataFields) {
|
|
95
|
+
const value = usage[field];
|
|
96
|
+
if (value !== undefined && (!Number.isSafeInteger(value) || value < 1)) {
|
|
97
|
+
throw new Error(`Model usage for "${model}" has an invalid ${field} metadata value`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function mergeSessionUsageMetadata(model, left, right) {
|
|
102
|
+
const contextWindow = mergeSessionUsageMetadataField(model, 'contextWindow', left.contextWindow, right.contextWindow);
|
|
103
|
+
const maxOutputTokens = mergeSessionUsageMetadataField(model, 'maxOutputTokens', left.maxOutputTokens, right.maxOutputTokens);
|
|
104
|
+
return {
|
|
105
|
+
...(contextWindow === undefined ? {} : { contextWindow }),
|
|
106
|
+
...(maxOutputTokens === undefined ? {} : { maxOutputTokens }),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
function mergeSessionUsageMetadataField(model, field, left, right) {
|
|
110
|
+
if (left === undefined)
|
|
111
|
+
return right;
|
|
112
|
+
if (right === undefined)
|
|
113
|
+
return left;
|
|
114
|
+
if (left !== right) {
|
|
115
|
+
throw new Error(`Model usage for "${model}" has conflicting ${field} values: ${left} vs ${right}`);
|
|
116
|
+
}
|
|
117
|
+
return left;
|
|
118
|
+
}
|
|
119
|
+
function addSessionUsageChecked(model, left, right) {
|
|
120
|
+
const inputTokens = left.inputTokens + right.inputTokens;
|
|
121
|
+
const outputTokens = left.outputTokens + right.outputTokens;
|
|
122
|
+
const cacheReadInputTokens = (left.cacheReadInputTokens ?? 0) + (right.cacheReadInputTokens ?? 0);
|
|
123
|
+
const cacheCreationInputTokens = (left.cacheCreationInputTokens ?? 0) + (right.cacheCreationInputTokens ?? 0);
|
|
124
|
+
const webSearchRequests = (left.webSearchRequests ?? 0) + (right.webSearchRequests ?? 0);
|
|
125
|
+
if (!Number.isSafeInteger(inputTokens) ||
|
|
126
|
+
!Number.isSafeInteger(outputTokens) ||
|
|
127
|
+
!Number.isSafeInteger(cacheReadInputTokens) ||
|
|
128
|
+
!Number.isSafeInteger(cacheCreationInputTokens) ||
|
|
129
|
+
!Number.isSafeInteger(webSearchRequests)) {
|
|
130
|
+
throw new Error('Model usage total overflow');
|
|
131
|
+
}
|
|
132
|
+
const metadata = mergeSessionUsageMetadata(model, left, right);
|
|
133
|
+
return {
|
|
134
|
+
inputTokens,
|
|
135
|
+
outputTokens,
|
|
136
|
+
...(cacheReadInputTokens === 0 ? {} : { cacheReadInputTokens }),
|
|
137
|
+
...(cacheCreationInputTokens === 0 ? {} : { cacheCreationInputTokens }),
|
|
138
|
+
...(webSearchRequests === 0 ? {} : { webSearchRequests }),
|
|
139
|
+
...metadata,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
function mergeSessionRawModelUsage(...parts) {
|
|
143
|
+
const rows = [];
|
|
144
|
+
for (const part of parts) {
|
|
145
|
+
if (part === undefined)
|
|
146
|
+
continue;
|
|
147
|
+
for (const [model, usage] of Object.entries(part)) {
|
|
148
|
+
assertValidSessionUsageEntry(model, usage);
|
|
149
|
+
rows.push([model, usage]);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (rows.length === 0)
|
|
153
|
+
return undefined;
|
|
154
|
+
// Validate every pairwise counter/metadata/overflow merge across all rows
|
|
155
|
+
// before any row becomes observable so a malformed or conflicting batch never
|
|
156
|
+
// merges partially.
|
|
157
|
+
const preview = new Map();
|
|
158
|
+
for (const [model, usage] of rows) {
|
|
159
|
+
const existing = preview.get(model);
|
|
160
|
+
preview.set(model, existing === undefined
|
|
161
|
+
? { ...usage }
|
|
162
|
+
: addSessionUsageChecked(model, existing, usage));
|
|
163
|
+
}
|
|
164
|
+
const merged = new Map();
|
|
165
|
+
for (const [model, usage] of rows) {
|
|
166
|
+
const existing = merged.get(model);
|
|
167
|
+
merged.set(model, existing === undefined
|
|
168
|
+
? { ...usage }
|
|
169
|
+
: addSessionUsageChecked(model, existing, usage));
|
|
170
|
+
}
|
|
171
|
+
return Object.fromEntries(merged);
|
|
172
|
+
}
|
|
173
|
+
function hasNonZeroUsage(usage) {
|
|
174
|
+
return (usage.inputTokens > 0 ||
|
|
175
|
+
usage.outputTokens > 0 ||
|
|
176
|
+
(usage.cacheReadInputTokens ?? 0) > 0 ||
|
|
177
|
+
(usage.cacheCreationInputTokens ?? 0) > 0 ||
|
|
178
|
+
(usage.webSearchRequests ?? 0) > 0);
|
|
179
|
+
}
|
|
180
|
+
function requireUsageCounter(value, field) {
|
|
181
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
182
|
+
throw new TypeError(`${field} must be a nonnegative safe integer`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
function requireManualCompactUsage(usage) {
|
|
186
|
+
requireUsageCounter(usage.inputTokens, 'usage.inputTokens');
|
|
187
|
+
requireUsageCounter(usage.outputTokens, 'usage.outputTokens');
|
|
188
|
+
if (usage.cacheReadInputTokens !== undefined) {
|
|
189
|
+
requireUsageCounter(usage.cacheReadInputTokens, 'usage.cacheReadInputTokens');
|
|
190
|
+
}
|
|
191
|
+
if (usage.cacheCreationInputTokens !== undefined) {
|
|
192
|
+
requireUsageCounter(usage.cacheCreationInputTokens, 'usage.cacheCreationInputTokens');
|
|
193
|
+
}
|
|
194
|
+
if (usage.webSearchRequests !== undefined) {
|
|
195
|
+
requireUsageCounter(usage.webSearchRequests, 'usage.webSearchRequests');
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
function requireCompactionDurations(result) {
|
|
199
|
+
const durationMs = result.durationMs;
|
|
200
|
+
if (typeof durationMs !== 'number' ||
|
|
201
|
+
!Number.isFinite(durationMs) ||
|
|
202
|
+
durationMs < 0) {
|
|
203
|
+
throw new TypeError('compaction durationMs must be a finite nonnegative number');
|
|
204
|
+
}
|
|
205
|
+
if (result.durationWithoutRetriesMs === undefined) {
|
|
206
|
+
return { durationMs, durationWithoutRetriesMs: durationMs };
|
|
207
|
+
}
|
|
208
|
+
const durationWithoutRetriesMs = result.durationWithoutRetriesMs;
|
|
209
|
+
if (typeof durationWithoutRetriesMs !== 'number' ||
|
|
210
|
+
!Number.isFinite(durationWithoutRetriesMs) ||
|
|
211
|
+
durationWithoutRetriesMs < 0) {
|
|
212
|
+
throw new TypeError('compaction durationWithoutRetriesMs must be a finite nonnegative number');
|
|
213
|
+
}
|
|
214
|
+
return { durationMs, durationWithoutRetriesMs };
|
|
215
|
+
}
|
|
216
|
+
function addToolDuration(value, total) {
|
|
217
|
+
if (value === undefined)
|
|
218
|
+
return total;
|
|
219
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
|
|
220
|
+
throw new TypeError('durationToolMs must be a finite nonnegative number');
|
|
221
|
+
}
|
|
222
|
+
const next = total + value;
|
|
223
|
+
if (!Number.isFinite(next) || next < 0) {
|
|
224
|
+
throw new TypeError('durationToolMs total overflow');
|
|
225
|
+
}
|
|
226
|
+
return next;
|
|
227
|
+
}
|
|
228
|
+
function addApiDuration(value, total, field) {
|
|
229
|
+
if (value === undefined)
|
|
230
|
+
return total;
|
|
231
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
232
|
+
throw new TypeError(`${field} must be a finite nonnegative number`);
|
|
233
|
+
}
|
|
234
|
+
const next = total + value;
|
|
235
|
+
if (!Number.isFinite(next) || next < 0) {
|
|
236
|
+
throw new TypeError(`${field} total overflow`);
|
|
237
|
+
}
|
|
238
|
+
return next;
|
|
239
|
+
}
|
|
240
|
+
function createLineCountAccumulator() {
|
|
241
|
+
let linesAdded = 0;
|
|
242
|
+
let linesRemoved = 0;
|
|
243
|
+
const addCounter = (current, value, field) => {
|
|
244
|
+
if (value === undefined)
|
|
245
|
+
return current;
|
|
246
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
247
|
+
throw new TypeError(`${field} must be a nonnegative safe integer`);
|
|
248
|
+
}
|
|
249
|
+
const next = current + value;
|
|
250
|
+
if (!Number.isSafeInteger(next) || next < 0) {
|
|
251
|
+
throw new TypeError(`${field} total must be a nonnegative safe integer`);
|
|
252
|
+
}
|
|
253
|
+
return next;
|
|
254
|
+
};
|
|
255
|
+
return {
|
|
256
|
+
get linesAdded() {
|
|
257
|
+
return linesAdded;
|
|
258
|
+
},
|
|
259
|
+
get linesRemoved() {
|
|
260
|
+
return linesRemoved;
|
|
261
|
+
},
|
|
262
|
+
add(input) {
|
|
263
|
+
if (input.isError)
|
|
264
|
+
return;
|
|
265
|
+
linesAdded = addCounter(linesAdded, input.linesAdded, 'linesAdded');
|
|
266
|
+
linesRemoved = addCounter(linesRemoved, input.linesRemoved, 'linesRemoved');
|
|
267
|
+
},
|
|
70
268
|
};
|
|
71
269
|
}
|
|
72
270
|
export function workflowTokenTarget(prompt) {
|
|
@@ -121,6 +319,9 @@ export class ClaudeSessionService {
|
|
|
121
319
|
downloadedFileResourceSessions = new Set();
|
|
122
320
|
activeProvider;
|
|
123
321
|
mcpClosePromise;
|
|
322
|
+
sessionCostTrackers = new Map();
|
|
323
|
+
activeCostSessionId;
|
|
324
|
+
closeCostSavePromise;
|
|
124
325
|
runtimeCwd;
|
|
125
326
|
constructor(options) {
|
|
126
327
|
this.options = options;
|
|
@@ -209,6 +410,8 @@ export class ClaudeSessionService {
|
|
|
209
410
|
this.hostedSubagents.clear();
|
|
210
411
|
this.backgroundTasks.clear();
|
|
211
412
|
await this.workflowManager?.close();
|
|
413
|
+
this.closeCostSavePromise ??= this.persistActiveSessionCost();
|
|
414
|
+
await this.closeCostSavePromise;
|
|
212
415
|
this.mcpClosePromise ??= this.options.mcp?.close?.() ?? Promise.resolve();
|
|
213
416
|
await this.mcpClosePromise;
|
|
214
417
|
}
|
|
@@ -456,30 +659,37 @@ export class ClaudeSessionService {
|
|
|
456
659
|
const budget = this.contextBudget(provider);
|
|
457
660
|
if (budget)
|
|
458
661
|
budget.assertFits(budget.evaluate(messages));
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
662
|
+
await this.activateSessionCostTracker(activeSessionId);
|
|
663
|
+
const model = provider.model !== undefined && provider.model.trim() !== ''
|
|
664
|
+
? provider.model
|
|
665
|
+
: 'praxis/provider';
|
|
666
|
+
const metrics = await completeMeteredModelRequest(provider, {
|
|
462
667
|
messages,
|
|
463
668
|
...(this.options.effort ? { effort: this.options.effort } : {}),
|
|
464
669
|
...(signal ? { signal } : {}),
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
usage = event.usage;
|
|
472
|
-
}
|
|
473
|
-
else if (event.type === 'tool-call') {
|
|
474
|
-
throw new Error('Side questions cannot call tools; press f to fork');
|
|
475
|
-
}
|
|
670
|
+
}, {
|
|
671
|
+
...(onDelta ? { onTextDelta: onDelta } : {}),
|
|
672
|
+
onMetrics: (recorded) => this.recordAuxiliaryMetrics(activeSessionId, recorded),
|
|
673
|
+
});
|
|
674
|
+
if (metrics.toolCalls.length > 0) {
|
|
675
|
+
throw new Error('Side questions cannot call tools; press f to fork');
|
|
476
676
|
}
|
|
477
|
-
const pricing = this.options.pricing?.resolve(
|
|
677
|
+
const pricing = this.options.pricing?.resolve(model);
|
|
678
|
+
const costUsd = pricing ? usageCostUsd(metrics.usage, pricing) : undefined;
|
|
478
679
|
return {
|
|
479
680
|
sessionId: activeSessionId,
|
|
480
|
-
text,
|
|
481
|
-
usage,
|
|
482
|
-
...(
|
|
681
|
+
text: metrics.text,
|
|
682
|
+
usage: metrics.usage,
|
|
683
|
+
...(costUsd === undefined ? {} : { costUsd }),
|
|
684
|
+
...(hasNonZeroUsage(metrics.usage)
|
|
685
|
+
? { modelUsage: { [model]: metrics.usage } }
|
|
686
|
+
: {}),
|
|
687
|
+
...(metrics.durationApiMs === 0
|
|
688
|
+
? {}
|
|
689
|
+
: {
|
|
690
|
+
durationApiMs: metrics.durationApiMs,
|
|
691
|
+
durationApiWithoutRetriesMs: metrics.durationApiWithoutRetriesMs,
|
|
692
|
+
}),
|
|
483
693
|
};
|
|
484
694
|
}
|
|
485
695
|
async forkSideQuestion(sessionId, question, signal) {
|
|
@@ -570,21 +780,20 @@ export class ClaudeSessionService {
|
|
|
570
780
|
tools: mainAgentToolNames(this.options.tools, agent),
|
|
571
781
|
})
|
|
572
782
|
: this.options.tools;
|
|
573
|
-
|
|
574
|
-
|
|
783
|
+
await this.activateSessionCostTracker(sessionId);
|
|
784
|
+
const metrics = await completeMeteredModelRequest(provider, {
|
|
575
785
|
messages,
|
|
576
786
|
...(provider.capabilities.tools
|
|
577
787
|
? { tools: suggestionTools?.definitions() ?? [] }
|
|
578
788
|
: {}),
|
|
579
789
|
...(this.options.effort ? { effort: this.options.effort } : {}),
|
|
580
790
|
...(signal ? { signal } : {}),
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
return validPromptSuggestion(suggestion);
|
|
791
|
+
}, {
|
|
792
|
+
onMetrics: (recorded) => this.recordAuxiliaryMetrics(sessionId, recorded),
|
|
793
|
+
});
|
|
794
|
+
if (metrics.toolCalls.length > 0)
|
|
795
|
+
return null;
|
|
796
|
+
return validPromptSuggestion(metrics.text);
|
|
588
797
|
}
|
|
589
798
|
async sessionNameSuggestion(sessionId, signal) {
|
|
590
799
|
const provider = this.provider();
|
|
@@ -592,21 +801,20 @@ export class ClaudeSessionService {
|
|
|
592
801
|
if (loaded.status === 'conflict' || loaded.value.entries.length === 0) {
|
|
593
802
|
return null;
|
|
594
803
|
}
|
|
595
|
-
|
|
596
|
-
|
|
804
|
+
await this.activateSessionCostTracker(sessionId);
|
|
805
|
+
const metrics = await completeMeteredModelRequest(provider, {
|
|
597
806
|
messages: [
|
|
598
807
|
...projectClaudeModelMessages(loaded.value.entries),
|
|
599
808
|
{ role: 'user', content: SESSION_NAME_INSTRUCTION },
|
|
600
809
|
],
|
|
601
810
|
...(this.options.effort ? { effort: this.options.effort } : {}),
|
|
602
811
|
...(signal ? { signal } : {}),
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
return validSessionName(suggestion);
|
|
812
|
+
}, {
|
|
813
|
+
onMetrics: (recorded) => this.recordAuxiliaryMetrics(sessionId, recorded),
|
|
814
|
+
});
|
|
815
|
+
if (metrics.toolCalls.length > 0)
|
|
816
|
+
return null;
|
|
817
|
+
return validSessionName(metrics.text);
|
|
610
818
|
}
|
|
611
819
|
async sessions() {
|
|
612
820
|
const paths = this.paths(randomUUID());
|
|
@@ -1043,6 +1251,7 @@ export class ClaudeSessionService {
|
|
|
1043
1251
|
if (snapshot.entries.length === 0) {
|
|
1044
1252
|
throw new Error(`Claude session not found: ${sessionId}`);
|
|
1045
1253
|
}
|
|
1254
|
+
await this.activateSessionCostTracker(sessionId);
|
|
1046
1255
|
const activeEntries = selectClaudeActiveTranscript(snapshot.entries);
|
|
1047
1256
|
const allMessages = projectClaudeModelMessages(activeEntries);
|
|
1048
1257
|
let selectedEntries = activeEntries;
|
|
@@ -1100,12 +1309,55 @@ export class ClaudeSessionService {
|
|
|
1100
1309
|
});
|
|
1101
1310
|
if (signal?.aborted)
|
|
1102
1311
|
throw new AgentRunCancelledError();
|
|
1103
|
-
const
|
|
1312
|
+
const { durationMs: compactedDurationMs, durationWithoutRetriesMs: compactedDurationWithoutRetriesMs, } = requireCompactionDurations(compacted);
|
|
1313
|
+
requireManualCompactUsage(compacted.usage);
|
|
1314
|
+
const compactorModel = compacted.model;
|
|
1315
|
+
const compactModel = compactorModel !== undefined && compactorModel.trim() !== ''
|
|
1316
|
+
? compactorModel
|
|
1317
|
+
: provider.model;
|
|
1104
1318
|
const summary = compacted.summary;
|
|
1319
|
+
const meaningfulMetering = summary.trim().length > 0 &&
|
|
1320
|
+
(hasNonZeroUsage(compacted.usage) || compactedDurationMs > 0);
|
|
1321
|
+
if (meaningfulMetering &&
|
|
1322
|
+
(compactModel === undefined || compactModel.trim() === '')) {
|
|
1323
|
+
throw new Error('Manual compact usage requires a nonblank model identity');
|
|
1324
|
+
}
|
|
1325
|
+
const preTokens = estimateModelRequestTokens(allMessages);
|
|
1105
1326
|
const preservedMessages = projectClaudeModelMessages(preservedEntries);
|
|
1106
1327
|
const boundaryUuid = randomUUID();
|
|
1107
1328
|
const summaryUuid = randomUUID();
|
|
1108
1329
|
const uuids = [boundaryUuid, summaryUuid];
|
|
1330
|
+
let meteringTurnInput;
|
|
1331
|
+
if (meaningfulMetering &&
|
|
1332
|
+
compactModel !== undefined &&
|
|
1333
|
+
compactModel.trim() !== '') {
|
|
1334
|
+
const tracker = this.sessionCostTrackers.get(sessionId);
|
|
1335
|
+
if (!tracker) {
|
|
1336
|
+
throw new Error(`Session cost tracker is not active for session ${sessionId}`);
|
|
1337
|
+
}
|
|
1338
|
+
const pricing = this.options.pricing?.resolve(compactModel);
|
|
1339
|
+
const costUsd = pricing
|
|
1340
|
+
? usageCostUsd(compacted.usage, pricing)
|
|
1341
|
+
: undefined;
|
|
1342
|
+
meteringTurnInput = {
|
|
1343
|
+
model: compactModel,
|
|
1344
|
+
usage: compacted.usage,
|
|
1345
|
+
...(costUsd === undefined ? {} : { costUsd }),
|
|
1346
|
+
...(compacted.usage.webSearchRequests === undefined
|
|
1347
|
+
? {}
|
|
1348
|
+
: { webSearchRequests: compacted.usage.webSearchRequests }),
|
|
1349
|
+
apiDurationMs: compactedDurationMs,
|
|
1350
|
+
apiDurationWithoutRetriesMs: compactedDurationWithoutRetriesMs,
|
|
1351
|
+
};
|
|
1352
|
+
// Preflight the exact record input against a clone of the live
|
|
1353
|
+
// tracker so a cumulative total overflow rejects before the compact
|
|
1354
|
+
// boundary is appended rather than after a half-commit.
|
|
1355
|
+
const preflight = new ClaudeSessionCostTracker({
|
|
1356
|
+
sessionId,
|
|
1357
|
+
restored: tracker.snapshot(),
|
|
1358
|
+
});
|
|
1359
|
+
preflight.recordTurn(meteringTurnInput);
|
|
1360
|
+
}
|
|
1109
1361
|
const entries = createClaudeCompactEntries({
|
|
1110
1362
|
sessionId,
|
|
1111
1363
|
logicalParentUuid,
|
|
@@ -1116,7 +1368,7 @@ export class ClaudeSessionService {
|
|
|
1116
1368
|
...preservedMessages,
|
|
1117
1369
|
]),
|
|
1118
1370
|
previousCumulativeDroppedTokens: getCumulativeDroppedTokens(snapshot.entries),
|
|
1119
|
-
durationMs:
|
|
1371
|
+
durationMs: compactedDurationMs,
|
|
1120
1372
|
cwd: this.activeCwd(),
|
|
1121
1373
|
claudeVersion: this.options.claudeVersion,
|
|
1122
1374
|
gitBranch: null,
|
|
@@ -1142,6 +1394,13 @@ export class ClaudeSessionService {
|
|
|
1142
1394
|
preTokens,
|
|
1143
1395
|
uuid: boundaryUuid,
|
|
1144
1396
|
});
|
|
1397
|
+
if (meteringTurnInput !== undefined) {
|
|
1398
|
+
const tracker = this.sessionCostTrackers.get(sessionId);
|
|
1399
|
+
if (!tracker) {
|
|
1400
|
+
throw new Error(`Session cost tracker is not active for session ${sessionId}`);
|
|
1401
|
+
}
|
|
1402
|
+
tracker.recordTurn(meteringTurnInput);
|
|
1403
|
+
}
|
|
1145
1404
|
return {
|
|
1146
1405
|
summary,
|
|
1147
1406
|
usage: compacted.usage,
|
|
@@ -1306,6 +1565,83 @@ export class ClaudeSessionService {
|
|
|
1306
1565
|
throw new Error(`Claude permission mode update conflict: ${result.reason}`);
|
|
1307
1566
|
}
|
|
1308
1567
|
}
|
|
1568
|
+
async activateSessionCostTracker(sessionId) {
|
|
1569
|
+
if (this.activeCostSessionId === sessionId)
|
|
1570
|
+
return;
|
|
1571
|
+
const store = this.options.costStateStore;
|
|
1572
|
+
if (store) {
|
|
1573
|
+
// Load the target native state before any save so the single
|
|
1574
|
+
// `.claude.json` slot is not overwritten before it is read.
|
|
1575
|
+
const loaded = await store.load(sessionId);
|
|
1576
|
+
const currentId = this.activeCostSessionId;
|
|
1577
|
+
if (currentId !== undefined) {
|
|
1578
|
+
const current = this.sessionCostTrackers.get(currentId);
|
|
1579
|
+
if (current)
|
|
1580
|
+
await store.save(current.snapshot());
|
|
1581
|
+
}
|
|
1582
|
+
if (!this.sessionCostTrackers.has(sessionId)) {
|
|
1583
|
+
this.sessionCostTrackers.set(sessionId, new ClaudeSessionCostTracker({
|
|
1584
|
+
sessionId,
|
|
1585
|
+
...(loaded ? { restored: loaded } : {}),
|
|
1586
|
+
}));
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
else if (!this.sessionCostTrackers.has(sessionId)) {
|
|
1590
|
+
this.sessionCostTrackers.set(sessionId, new ClaudeSessionCostTracker({ sessionId }));
|
|
1591
|
+
}
|
|
1592
|
+
this.activeCostSessionId = sessionId;
|
|
1593
|
+
}
|
|
1594
|
+
persistActiveSessionCost() {
|
|
1595
|
+
const store = this.options.costStateStore;
|
|
1596
|
+
const activeId = this.activeCostSessionId;
|
|
1597
|
+
if (!store || activeId === undefined)
|
|
1598
|
+
return Promise.resolve();
|
|
1599
|
+
const tracker = this.sessionCostTrackers.get(activeId);
|
|
1600
|
+
if (!tracker)
|
|
1601
|
+
return Promise.resolve();
|
|
1602
|
+
return store.save(tracker.snapshot());
|
|
1603
|
+
}
|
|
1604
|
+
recordAuxiliaryMetrics(sessionId, metrics) {
|
|
1605
|
+
const tracker = this.sessionCostTrackers.get(sessionId);
|
|
1606
|
+
if (!tracker) {
|
|
1607
|
+
throw new Error(`Session cost tracker is not active for session ${sessionId}`);
|
|
1608
|
+
}
|
|
1609
|
+
const model = metrics.model !== undefined && metrics.model.trim() !== ''
|
|
1610
|
+
? metrics.model
|
|
1611
|
+
: 'praxis/provider';
|
|
1612
|
+
if (hasNonZeroUsage(metrics.usage)) {
|
|
1613
|
+
const pricing = this.options.pricing?.resolve(model);
|
|
1614
|
+
const costUsd = pricing ? usageCostUsd(metrics.usage, pricing) : undefined;
|
|
1615
|
+
tracker.recordTurn({
|
|
1616
|
+
model,
|
|
1617
|
+
usage: metrics.usage,
|
|
1618
|
+
...(costUsd === undefined ? {} : { costUsd }),
|
|
1619
|
+
...(metrics.usage.webSearchRequests === undefined
|
|
1620
|
+
? {}
|
|
1621
|
+
: { webSearchRequests: metrics.usage.webSearchRequests }),
|
|
1622
|
+
apiDurationMs: metrics.durationApiMs,
|
|
1623
|
+
apiDurationWithoutRetriesMs: metrics.durationApiWithoutRetriesMs,
|
|
1624
|
+
});
|
|
1625
|
+
return;
|
|
1626
|
+
}
|
|
1627
|
+
tracker.recordDurations({
|
|
1628
|
+
apiDurationMs: metrics.durationApiMs,
|
|
1629
|
+
apiDurationWithoutRetriesMs: metrics.durationApiWithoutRetriesMs,
|
|
1630
|
+
});
|
|
1631
|
+
}
|
|
1632
|
+
async costSnapshot(sessionId) {
|
|
1633
|
+
const existing = this.sessionCostTrackers.get(sessionId);
|
|
1634
|
+
if (existing)
|
|
1635
|
+
return existing.snapshot();
|
|
1636
|
+
const store = this.options.costStateStore;
|
|
1637
|
+
const loaded = store ? await store.load(sessionId) : null;
|
|
1638
|
+
const tracker = new ClaudeSessionCostTracker({
|
|
1639
|
+
sessionId,
|
|
1640
|
+
...(loaded ? { restored: loaded } : {}),
|
|
1641
|
+
});
|
|
1642
|
+
this.sessionCostTrackers.set(sessionId, tracker);
|
|
1643
|
+
return tracker.snapshot();
|
|
1644
|
+
}
|
|
1309
1645
|
async executeTurn(sessionId, prompt, requireExisting, signal, name, images = [], documents = [], resumeSessionAt, shellCommand, skipUserPrompt = false) {
|
|
1310
1646
|
this.assertWritable();
|
|
1311
1647
|
if (prompt.length === 0 && images.length === 0 && documents.length === 0)
|
|
@@ -1316,6 +1652,7 @@ export class ClaudeSessionService {
|
|
|
1316
1652
|
if (shellCommand !== undefined && shellCommand.trim().length === 0) {
|
|
1317
1653
|
throw new Error('Shell command must not be empty');
|
|
1318
1654
|
}
|
|
1655
|
+
await this.activateSessionCostTracker(sessionId);
|
|
1319
1656
|
await this.ensureFileResources(sessionId, signal);
|
|
1320
1657
|
this.worktreeManager?.bindSession(sessionId);
|
|
1321
1658
|
if (this.options.initialWorktree) {
|
|
@@ -1540,6 +1877,13 @@ export class ClaudeSessionService {
|
|
|
1540
1877
|
: {}),
|
|
1541
1878
|
permissionUpdates: () => this.sessionPermissionUpdates.get(sessionId) ?? [],
|
|
1542
1879
|
onPermissionUpdates: (updates) => this.applyPermissionUpdates(sessionId, updates),
|
|
1880
|
+
onLineChanges: (changes) => {
|
|
1881
|
+
const tracker = this.sessionCostTrackers.get(sessionId);
|
|
1882
|
+
if (!tracker) {
|
|
1883
|
+
throw new Error(`Session cost tracker is not active for session ${sessionId}`);
|
|
1884
|
+
}
|
|
1885
|
+
tracker.recordLineChanges(changes);
|
|
1886
|
+
},
|
|
1543
1887
|
...(this.options.eventSink
|
|
1544
1888
|
? { eventSink: this.options.eventSink }
|
|
1545
1889
|
: {}),
|
|
@@ -1665,7 +2009,7 @@ export class ClaudeSessionService {
|
|
|
1665
2009
|
emitInitialContextState: false,
|
|
1666
2010
|
...(this.options.emitToolUseSummaries
|
|
1667
2011
|
? {
|
|
1668
|
-
generateToolUseSummary: ({ tools, lastAssistantText, signal }) => generateToolUseSummary(provider, tools, signal, lastAssistantText),
|
|
2012
|
+
generateToolUseSummary: ({ tools, lastAssistantText, signal }) => generateToolUseSummary(provider, tools, signal, lastAssistantText, (metrics) => this.recordAuxiliaryMetrics(sessionId, metrics)),
|
|
1669
2013
|
}
|
|
1670
2014
|
: {}),
|
|
1671
2015
|
...(this.options.pricing
|
|
@@ -1837,10 +2181,12 @@ export class ClaudeSessionService {
|
|
|
1837
2181
|
throw new Error(`Claude session tool call ${unresolvedToolCall.id} requires explicit recovery approval`);
|
|
1838
2182
|
}
|
|
1839
2183
|
const recoveryResults = await runtime.recoverToolCalls(unresolvedToolCalls, recoveryRequest);
|
|
1840
|
-
const recoveryUsage = recoveryResults.reduce((usage, result) => ({
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
2184
|
+
const recoveryUsage = recoveryResults.reduce((usage, result) => result.usage ? mergeUsage(usage, result.usage) : usage, { inputTokens: 0, outputTokens: 0 });
|
|
2185
|
+
const recoveryModelUsage = mergeSessionRawModelUsage(...recoveryResults.map((result) => result.isError ? undefined : result.modelUsage));
|
|
2186
|
+
const foregroundLineChanges = createLineCountAccumulator();
|
|
2187
|
+
for (const result of recoveryResults) {
|
|
2188
|
+
foregroundLineChanges.add(result);
|
|
2189
|
+
}
|
|
1844
2190
|
if (this.options.agent &&
|
|
1845
2191
|
agent &&
|
|
1846
2192
|
getClaudeAgentSetting(snapshot.entries) !== this.options.agent) {
|
|
@@ -1904,6 +2250,9 @@ export class ClaudeSessionService {
|
|
|
1904
2250
|
inputTokens: 0,
|
|
1905
2251
|
outputTokens: 0,
|
|
1906
2252
|
};
|
|
2253
|
+
let compactionDurationMs;
|
|
2254
|
+
let compactionDurationWithoutRetriesMs;
|
|
2255
|
+
let compactionModelUsage;
|
|
1907
2256
|
const definitions = provider.capabilities.tools
|
|
1908
2257
|
? (structuredTools?.definitions() ?? [])
|
|
1909
2258
|
: [];
|
|
@@ -2015,6 +2364,16 @@ export class ClaudeSessionService {
|
|
|
2015
2364
|
contextWindowTokens: budget.contextWindowTokens,
|
|
2016
2365
|
...(signal ? { signal } : {}),
|
|
2017
2366
|
});
|
|
2367
|
+
const { durationMs: compactedDurationMs, durationWithoutRetriesMs: compactedDurationWithoutRetriesMs, } = requireCompactionDurations(compacted);
|
|
2368
|
+
const proposedCompactionDurationMs = (compactionDurationMs ?? 0) + compactedDurationMs;
|
|
2369
|
+
if (!Number.isFinite(proposedCompactionDurationMs)) {
|
|
2370
|
+
throw new TypeError('compaction durationMs total overflow');
|
|
2371
|
+
}
|
|
2372
|
+
const proposedCompactionDurationWithoutRetriesMs = (compactionDurationWithoutRetriesMs ?? 0) +
|
|
2373
|
+
compactedDurationWithoutRetriesMs;
|
|
2374
|
+
if (!Number.isFinite(proposedCompactionDurationWithoutRetriesMs)) {
|
|
2375
|
+
throw new TypeError('compaction durationWithoutRetriesMs total overflow');
|
|
2376
|
+
}
|
|
2018
2377
|
const boundaryUuid = randomUUID();
|
|
2019
2378
|
const summaryUuid = randomUUID();
|
|
2020
2379
|
const timestamp = new Date().toISOString();
|
|
@@ -2028,7 +2387,7 @@ export class ClaudeSessionService {
|
|
|
2028
2387
|
preTokens,
|
|
2029
2388
|
postTokens,
|
|
2030
2389
|
previousCumulativeDroppedTokens: getCumulativeDroppedTokens(snapshot.entries),
|
|
2031
|
-
durationMs:
|
|
2390
|
+
durationMs: compactedDurationMs,
|
|
2032
2391
|
cwd: this.activeCwd(),
|
|
2033
2392
|
claudeVersion: this.options.claudeVersion,
|
|
2034
2393
|
gitBranch: null,
|
|
@@ -2071,6 +2430,59 @@ export class ClaudeSessionService {
|
|
|
2071
2430
|
];
|
|
2072
2431
|
if (signal?.aborted)
|
|
2073
2432
|
throw new AgentRunCancelledError();
|
|
2433
|
+
// Construct the exact tracker mutations for this single committed
|
|
2434
|
+
// boundary and preflight them against a clone of the live tracker so
|
|
2435
|
+
// invalid input or cumulative overflow rejects before the compact
|
|
2436
|
+
// boundary is appended rather than after a half-commit.
|
|
2437
|
+
const tracker = this.sessionCostTrackers.get(sessionId);
|
|
2438
|
+
if (!tracker) {
|
|
2439
|
+
throw new Error(`Session cost tracker is not active for session ${sessionId}`);
|
|
2440
|
+
}
|
|
2441
|
+
const compactModel = compacted.model !== undefined && compacted.model.trim() !== ''
|
|
2442
|
+
? compacted.model
|
|
2443
|
+
: provider.model;
|
|
2444
|
+
const compactModelNonBlank = compactModel !== undefined && compactModel.trim() !== '';
|
|
2445
|
+
if (hasNonZeroUsage(compacted.usage) && !compactModelNonBlank) {
|
|
2446
|
+
throw new Error('Auto compact usage requires a nonblank model identity');
|
|
2447
|
+
}
|
|
2448
|
+
let meteringTurnInput;
|
|
2449
|
+
if (compactModelNonBlank && hasNonZeroUsage(compacted.usage)) {
|
|
2450
|
+
const pricing = this.options.pricing?.resolve(compactModel);
|
|
2451
|
+
const costUsd = pricing
|
|
2452
|
+
? usageCostUsd(compacted.usage, pricing)
|
|
2453
|
+
: undefined;
|
|
2454
|
+
meteringTurnInput = {
|
|
2455
|
+
model: compactModel,
|
|
2456
|
+
usage: compacted.usage,
|
|
2457
|
+
...(costUsd === undefined ? {} : { costUsd }),
|
|
2458
|
+
...(compacted.usage.webSearchRequests === undefined
|
|
2459
|
+
? {}
|
|
2460
|
+
: { webSearchRequests: compacted.usage.webSearchRequests }),
|
|
2461
|
+
};
|
|
2462
|
+
}
|
|
2463
|
+
let meteringDurationsInput;
|
|
2464
|
+
if (compactedDurationMs > 0 ||
|
|
2465
|
+
compactedDurationWithoutRetriesMs > 0) {
|
|
2466
|
+
meteringDurationsInput = {
|
|
2467
|
+
...(compactedDurationMs === 0
|
|
2468
|
+
? {}
|
|
2469
|
+
: { apiDurationMs: compactedDurationMs }),
|
|
2470
|
+
apiDurationWithoutRetriesMs: compactedDurationWithoutRetriesMs,
|
|
2471
|
+
};
|
|
2472
|
+
}
|
|
2473
|
+
if (meteringTurnInput !== undefined ||
|
|
2474
|
+
meteringDurationsInput !== undefined) {
|
|
2475
|
+
const preflight = new ClaudeSessionCostTracker({
|
|
2476
|
+
sessionId,
|
|
2477
|
+
restored: tracker.snapshot(),
|
|
2478
|
+
});
|
|
2479
|
+
if (meteringTurnInput !== undefined) {
|
|
2480
|
+
preflight.recordTurn(meteringTurnInput);
|
|
2481
|
+
}
|
|
2482
|
+
if (meteringDurationsInput !== undefined) {
|
|
2483
|
+
preflight.recordDurations(meteringDurationsInput);
|
|
2484
|
+
}
|
|
2485
|
+
}
|
|
2074
2486
|
const appendResult = await lease.appendMany(snapshot.tail, entries);
|
|
2075
2487
|
if (appendResult.status === 'conflict') {
|
|
2076
2488
|
throw new Error(`Claude transcript append conflict: ${appendResult.reason}`);
|
|
@@ -2086,10 +2498,22 @@ export class ClaudeSessionService {
|
|
|
2086
2498
|
uuid: boundaryUuid,
|
|
2087
2499
|
});
|
|
2088
2500
|
compactionAnchorUuid = compactSummaryUuid;
|
|
2089
|
-
compactionUsage =
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2501
|
+
compactionUsage = mergeUsage(compactionUsage, compacted.usage);
|
|
2502
|
+
compactionDurationMs = proposedCompactionDurationMs;
|
|
2503
|
+
compactionDurationWithoutRetriesMs =
|
|
2504
|
+
proposedCompactionDurationWithoutRetriesMs;
|
|
2505
|
+
if (meteringTurnInput !== undefined) {
|
|
2506
|
+
compactionModelUsage = mergeSessionRawModelUsage(compactionModelUsage, { [meteringTurnInput.model]: compacted.usage });
|
|
2507
|
+
}
|
|
2508
|
+
// Commit the preflighted mutations once the boundary is durable so a
|
|
2509
|
+
// later main-provider failure or cancellation cannot lose the
|
|
2510
|
+
// compactor's usage/cost/API durations.
|
|
2511
|
+
if (meteringTurnInput !== undefined) {
|
|
2512
|
+
tracker.recordTurn(meteringTurnInput);
|
|
2513
|
+
}
|
|
2514
|
+
if (meteringDurationsInput !== undefined) {
|
|
2515
|
+
tracker.recordDurations(meteringDurationsInput);
|
|
2516
|
+
}
|
|
2093
2517
|
};
|
|
2094
2518
|
await compactIfNeeded(pendingUserMessages);
|
|
2095
2519
|
for (const [index, message] of expandedMessages.entries()) {
|
|
@@ -2146,6 +2570,7 @@ export class ClaudeSessionService {
|
|
|
2146
2570
|
}
|
|
2147
2571
|
}
|
|
2148
2572
|
let shellUsage = { inputTokens: 0, outputTokens: 0 };
|
|
2573
|
+
let shellModelUsage;
|
|
2149
2574
|
if (shellCommand !== undefined) {
|
|
2150
2575
|
const call = {
|
|
2151
2576
|
id: `shell_${randomUUID().replaceAll('-', '')}`,
|
|
@@ -2184,6 +2609,10 @@ export class ClaudeSessionService {
|
|
|
2184
2609
|
throw error;
|
|
2185
2610
|
}
|
|
2186
2611
|
shellUsage = shellResult.usage ?? shellUsage;
|
|
2612
|
+
if (!shellResult.isError) {
|
|
2613
|
+
shellModelUsage = shellResult.modelUsage;
|
|
2614
|
+
foregroundLineChanges.add(shellResult);
|
|
2615
|
+
}
|
|
2187
2616
|
const stdout = shellResult.processOutput?.stdout ??
|
|
2188
2617
|
(shellResult.isError ? '' : shellResult.content);
|
|
2189
2618
|
const stderr = shellResult.processOutput?.stderr ??
|
|
@@ -2240,7 +2669,9 @@ export class ClaudeSessionService {
|
|
|
2240
2669
|
? { maxModelTurns: this.options.maxModelTurns }
|
|
2241
2670
|
: {}),
|
|
2242
2671
|
...(this.options.betas?.length ? { betas: this.options.betas } : {}),
|
|
2243
|
-
...(this.options.collectMetrics
|
|
2672
|
+
...(this.options.collectMetrics || this.options.costStateStore
|
|
2673
|
+
? { collectMetrics: true }
|
|
2674
|
+
: {}),
|
|
2244
2675
|
reloadMessages: async () => {
|
|
2245
2676
|
await compactIfNeeded([], currentTurnUserMessages ?? []);
|
|
2246
2677
|
return [
|
|
@@ -2282,16 +2713,40 @@ export class ClaudeSessionService {
|
|
|
2282
2713
|
if (scheduled) {
|
|
2283
2714
|
messages.push(...scheduled.map(({ prompt }) => prompt));
|
|
2284
2715
|
}
|
|
2716
|
+
const batchUsage = background || workflow
|
|
2717
|
+
? mergeUsage(background?.usage ?? {
|
|
2718
|
+
inputTokens: 0,
|
|
2719
|
+
outputTokens: 0,
|
|
2720
|
+
}, workflow?.usage ?? {
|
|
2721
|
+
inputTokens: 0,
|
|
2722
|
+
outputTokens: 0,
|
|
2723
|
+
})
|
|
2724
|
+
: undefined;
|
|
2725
|
+
const batchModelUsage = mergeSessionRawModelUsage(background?.modelUsage, workflow?.modelUsage);
|
|
2726
|
+
const batchDurationApiMs = addApiDuration(workflow?.durationApiMs, addApiDuration(background?.durationApiMs, 0, 'durationApiMs'), 'durationApiMs');
|
|
2727
|
+
const backgroundDurationWithoutRetries = background?.durationApiWithoutRetriesMs ??
|
|
2728
|
+
background?.durationApiMs;
|
|
2729
|
+
const workflowDurationWithoutRetries = workflow?.durationApiWithoutRetriesMs ??
|
|
2730
|
+
workflow?.durationApiMs;
|
|
2731
|
+
const batchDurationApiWithoutRetriesMs = addApiDuration(workflowDurationWithoutRetries, addApiDuration(backgroundDurationWithoutRetries, 0, 'durationApiWithoutRetriesMs'), 'durationApiWithoutRetriesMs');
|
|
2732
|
+
const hasBatchDuration = background?.durationApiMs !== undefined ||
|
|
2733
|
+
background?.durationApiWithoutRetriesMs !== undefined ||
|
|
2734
|
+
workflow?.durationApiMs !== undefined ||
|
|
2735
|
+
workflow?.durationApiWithoutRetriesMs !== undefined;
|
|
2285
2736
|
return {
|
|
2286
2737
|
messages,
|
|
2287
|
-
...(
|
|
2738
|
+
...(batchUsage
|
|
2288
2739
|
? {
|
|
2289
|
-
usage:
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2740
|
+
usage: batchUsage,
|
|
2741
|
+
...(batchModelUsage
|
|
2742
|
+
? { modelUsage: batchModelUsage }
|
|
2743
|
+
: {}),
|
|
2744
|
+
}
|
|
2745
|
+
: {}),
|
|
2746
|
+
...(hasBatchDuration
|
|
2747
|
+
? {
|
|
2748
|
+
durationApiMs: batchDurationApiMs,
|
|
2749
|
+
durationApiWithoutRetriesMs: batchDurationApiWithoutRetriesMs,
|
|
2295
2750
|
}
|
|
2296
2751
|
: {}),
|
|
2297
2752
|
};
|
|
@@ -2322,26 +2777,96 @@ export class ClaudeSessionService {
|
|
|
2322
2777
|
}));
|
|
2323
2778
|
}
|
|
2324
2779
|
const totalUsage = mergeUsage(mergeUsage(mergeUsage(recoveryUsage, compactionUsage), shellUsage), result.usage);
|
|
2780
|
+
const tracker = this.sessionCostTrackers.get(sessionId);
|
|
2781
|
+
if (!tracker) {
|
|
2782
|
+
throw new Error(`Session cost tracker is not active for session ${sessionId}`);
|
|
2783
|
+
}
|
|
2784
|
+
const turnModelUsage = mergeSessionRawModelUsage(recoveryModelUsage, compactionModelUsage, shellModelUsage, result.modelUsage);
|
|
2785
|
+
const combinedDurationMs = compactionDurationMs === undefined &&
|
|
2786
|
+
result.durationApiMs === undefined
|
|
2787
|
+
? undefined
|
|
2788
|
+
: (compactionDurationMs ?? 0) + (result.durationApiMs ?? 0);
|
|
2789
|
+
let rawCostUsd;
|
|
2790
|
+
if (turnModelUsage) {
|
|
2791
|
+
for (const [model, usage] of Object.entries(turnModelUsage)) {
|
|
2792
|
+
const pricing = this.options.pricing?.resolve(model);
|
|
2793
|
+
const costUsd = pricing ? usageCostUsd(usage, pricing) : undefined;
|
|
2794
|
+
if (costUsd !== undefined)
|
|
2795
|
+
rawCostUsd = (rawCostUsd ?? 0) + costUsd;
|
|
2796
|
+
}
|
|
2797
|
+
}
|
|
2798
|
+
// Auto-compaction metering was already recorded atomically with each
|
|
2799
|
+
// committed boundary, and externally metered tool-summary metrics were
|
|
2800
|
+
// committed through the summary callback, so the live tracker receives
|
|
2801
|
+
// only the unrecorded subset here. The inclusive public aggregates
|
|
2802
|
+
// above still contain every row and duration.
|
|
2803
|
+
const trackedModelUsage = mergeSessionRawModelUsage(recoveryModelUsage, shellModelUsage, result.unrecordedModelUsage ?? result.modelUsage);
|
|
2804
|
+
if (trackedModelUsage) {
|
|
2805
|
+
for (const [model, usage] of Object.entries(trackedModelUsage)) {
|
|
2806
|
+
const pricing = this.options.pricing?.resolve(model);
|
|
2807
|
+
const costUsd = pricing ? usageCostUsd(usage, pricing) : undefined;
|
|
2808
|
+
tracker.recordTurn({
|
|
2809
|
+
model,
|
|
2810
|
+
usage,
|
|
2811
|
+
...(costUsd === undefined ? {} : { costUsd }),
|
|
2812
|
+
...(usage.webSearchRequests === undefined
|
|
2813
|
+
? {}
|
|
2814
|
+
: { webSearchRequests: usage.webSearchRequests }),
|
|
2815
|
+
});
|
|
2816
|
+
}
|
|
2817
|
+
}
|
|
2818
|
+
let combinedToolDurationMs = 0;
|
|
2819
|
+
for (const recoveryResult of recoveryResults) {
|
|
2820
|
+
combinedToolDurationMs = addToolDuration(recoveryResult.durationToolMs, combinedToolDurationMs);
|
|
2821
|
+
}
|
|
2822
|
+
combinedToolDurationMs = addToolDuration(result.durationToolMs, combinedToolDurationMs);
|
|
2823
|
+
const trackedDurationApiMs = result.unrecordedDurationApiMs ?? result.durationApiMs;
|
|
2824
|
+
const trackedDurationApiWithoutRetriesMs = result.unrecordedDurationApiWithoutRetriesMs ??
|
|
2825
|
+
result.durationApiWithoutRetriesMs;
|
|
2826
|
+
tracker.recordDurations({
|
|
2827
|
+
...(trackedDurationApiMs === undefined
|
|
2828
|
+
? {}
|
|
2829
|
+
: { apiDurationMs: trackedDurationApiMs }),
|
|
2830
|
+
...(trackedDurationApiWithoutRetriesMs === undefined
|
|
2831
|
+
? {}
|
|
2832
|
+
: {
|
|
2833
|
+
apiDurationWithoutRetriesMs: trackedDurationApiWithoutRetriesMs,
|
|
2834
|
+
}),
|
|
2835
|
+
...(combinedToolDurationMs === 0
|
|
2836
|
+
? {}
|
|
2837
|
+
: { toolDurationMs: combinedToolDurationMs }),
|
|
2838
|
+
});
|
|
2839
|
+
foregroundLineChanges.add({
|
|
2840
|
+
isError: false,
|
|
2841
|
+
...(result.linesAdded === undefined
|
|
2842
|
+
? {}
|
|
2843
|
+
: { linesAdded: result.linesAdded }),
|
|
2844
|
+
...(result.linesRemoved === undefined
|
|
2845
|
+
? {}
|
|
2846
|
+
: { linesRemoved: result.linesRemoved }),
|
|
2847
|
+
});
|
|
2848
|
+
if (foregroundLineChanges.linesAdded !== 0 ||
|
|
2849
|
+
foregroundLineChanges.linesRemoved !== 0) {
|
|
2850
|
+
tracker.recordLineChanges({
|
|
2851
|
+
...(foregroundLineChanges.linesAdded === 0
|
|
2852
|
+
? {}
|
|
2853
|
+
: { linesAdded: foregroundLineChanges.linesAdded }),
|
|
2854
|
+
...(foregroundLineChanges.linesRemoved === 0
|
|
2855
|
+
? {}
|
|
2856
|
+
: { linesRemoved: foregroundLineChanges.linesRemoved }),
|
|
2857
|
+
});
|
|
2858
|
+
}
|
|
2325
2859
|
return {
|
|
2326
2860
|
sessionId,
|
|
2327
2861
|
text: structuredCapture && structuredCapture.calls === 1
|
|
2328
2862
|
? JSON.stringify(structuredCapture.value)
|
|
2329
2863
|
: result.text,
|
|
2330
2864
|
usage: totalUsage,
|
|
2331
|
-
...(
|
|
2865
|
+
...(combinedDurationMs === undefined
|
|
2332
2866
|
? {}
|
|
2333
|
-
: { durationApiMs:
|
|
2334
|
-
...(
|
|
2335
|
-
|
|
2336
|
-
const pricing = this.options.pricing?.resolve(provider.model ?? 'praxis/provider');
|
|
2337
|
-
return pricing
|
|
2338
|
-
? { costUsd: usageCostUsd(result.usage, pricing) }
|
|
2339
|
-
: {};
|
|
2340
|
-
})()
|
|
2341
|
-
: {}),
|
|
2342
|
-
...(provider.model
|
|
2343
|
-
? { modelUsage: { [provider.model]: result.usage } }
|
|
2344
|
-
: {}),
|
|
2867
|
+
: { durationApiMs: combinedDurationMs }),
|
|
2868
|
+
...(rawCostUsd === undefined ? {} : { costUsd: rawCostUsd }),
|
|
2869
|
+
...(turnModelUsage ? { modelUsage: { ...turnModelUsage } } : {}),
|
|
2345
2870
|
...(structuredCapture && structuredCapture.calls === 1
|
|
2346
2871
|
? { structuredOutput: structuredCapture.value }
|
|
2347
2872
|
: {}),
|