@adecore/agents 0.31.5 → 0.31.7
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/dist/chat/acp-backend.js +10 -3
- package/dist/chat/acp-protocol.d.ts +3 -0
- package/dist/chat/acp-protocol.js +26 -2
- package/dist/chat/chat-session.js +5 -2
- package/dist/chat/fixtures/copilot-1.0.101-turn.json +677 -0
- package/dist/testing/fake-acp-capture.d.ts +11 -0
- package/dist/testing/fake-acp-capture.js +50 -0
- package/dist/usage/aggregate.js +1 -1
- package/dist/usage/index-file.d.ts +2 -0
- package/dist/usage/index-file.js +19 -10
- package/dist/usage/pricing.d.ts +3 -2
- package/dist/usage/pricing.js +42 -8
- package/dist/usage/readers/codex.d.ts +9 -0
- package/dist/usage/readers/codex.js +75 -14
- package/dist/usage/record.d.ts +1 -0
- package/dist/usage/scanner.js +32 -8
- package/package.json +3 -3
- package/src/chat/acp-backend.ts +9 -3
- package/src/chat/acp-protocol.ts +30 -2
- package/src/chat/chat-session.ts +5 -2
- package/src/chat/fixtures/copilot-1.0.101-turn.json +677 -0
- package/src/testing/fake-acp-capture.ts +70 -0
- package/src/usage/aggregate.ts +1 -1
- package/src/usage/index-file.ts +25 -18
- package/src/usage/pricing.ts +46 -8
- package/src/usage/readers/codex.ts +86 -14
- package/src/usage/record.ts +1 -0
- package/src/usage/scanner.ts +35 -8
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { FakeCli } from './fake-cli.ts';
|
|
2
|
+
export interface AcpCapture {
|
|
3
|
+
sessionId: string;
|
|
4
|
+
initialize: unknown;
|
|
5
|
+
sessionNew: unknown;
|
|
6
|
+
commands: unknown[];
|
|
7
|
+
setConfigOption: unknown;
|
|
8
|
+
promptUpdates: unknown[];
|
|
9
|
+
promptResponse: unknown;
|
|
10
|
+
}
|
|
11
|
+
export declare function fakeAcpCapture(capture: AcpCapture): FakeCli;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export function fakeAcpCapture(capture) {
|
|
2
|
+
return (io) => {
|
|
3
|
+
const send = (frame) => {
|
|
4
|
+
io.out({ jsonrpc: '2.0', ...frame });
|
|
5
|
+
};
|
|
6
|
+
const update = (fields) => {
|
|
7
|
+
send({ method: 'session/update', params: { sessionId: capture.sessionId, update: fields } });
|
|
8
|
+
};
|
|
9
|
+
return {
|
|
10
|
+
onLine: (line) => {
|
|
11
|
+
const frame = JSON.parse(line);
|
|
12
|
+
const id = frame.id;
|
|
13
|
+
switch (frame.method) {
|
|
14
|
+
case undefined:
|
|
15
|
+
return;
|
|
16
|
+
case 'initialize':
|
|
17
|
+
send({ id, result: capture.initialize });
|
|
18
|
+
return;
|
|
19
|
+
case 'session/new':
|
|
20
|
+
update({ sessionUpdate: 'available_commands_update', availableCommands: capture.commands });
|
|
21
|
+
send({ id, result: capture.sessionNew });
|
|
22
|
+
return;
|
|
23
|
+
case 'session/set_config_option':
|
|
24
|
+
send({ id, result: capture.setConfigOption });
|
|
25
|
+
return;
|
|
26
|
+
case 'session/set_mode':
|
|
27
|
+
case 'session/close':
|
|
28
|
+
send({ id, result: {} });
|
|
29
|
+
return;
|
|
30
|
+
case 'session/prompt': {
|
|
31
|
+
const blocks = Array.isArray(frame.params?.prompt) ? frame.params.prompt : [];
|
|
32
|
+
const text = blocks.find((block) => block.type === 'text')?.text;
|
|
33
|
+
for (const fields of capture.promptUpdates) {
|
|
34
|
+
const named = fields.sessionUpdate === 'session_info_update';
|
|
35
|
+
update(named ? { ...fields, title: text } : fields);
|
|
36
|
+
}
|
|
37
|
+
send({ id, result: capture.promptResponse });
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
case 'session/cancel':
|
|
41
|
+
return;
|
|
42
|
+
default:
|
|
43
|
+
if (id !== undefined) {
|
|
44
|
+
send({ id, error: { code: -32601, message: `Method not found: ${String(frame.method)}` } });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
}
|
package/dist/usage/aggregate.js
CHANGED
|
@@ -111,7 +111,7 @@ export async function aggregate(records, payload, prices, known) {
|
|
|
111
111
|
if (day < payload.from || day > payload.to) {
|
|
112
112
|
continue;
|
|
113
113
|
}
|
|
114
|
-
const lookup = prices.look(record.model);
|
|
114
|
+
const lookup = prices.look(record.model, record.serviceTier, record.timestampMs);
|
|
115
115
|
const cost = record.costUsd ?? (lookup.price === null ? 0 : costOf(record.totals, lookup.price));
|
|
116
116
|
foldBucket(buckets, slot, record, account, lookup.price, cost);
|
|
117
117
|
foldModel(models, record, account, lookup, cost);
|
|
@@ -10,6 +10,8 @@ export interface IndexedFile {
|
|
|
10
10
|
tail: UsageRecord[];
|
|
11
11
|
codex: CodexParserState | null;
|
|
12
12
|
head: string | null;
|
|
13
|
+
parserVersion?: number;
|
|
14
|
+
compressed?: boolean;
|
|
13
15
|
}
|
|
14
16
|
export type UsageIndex = Map<string, IndexedFile>;
|
|
15
17
|
export declare function encodeIndex(index: UsageIndex): string;
|
package/dist/usage/index-file.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { UsageProviderSchema } from '@adecore/agent-contracts';
|
|
2
|
-
/*
|
|
3
|
-
const INDEX_VERSION =
|
|
2
|
+
/* Old records survive deleted transcripts; the scanner reparses existing Codex files with its current reader. */
|
|
3
|
+
const INDEX_VERSION = 3;
|
|
4
4
|
/* Before accounts: every record was the default account's, so it reads as that without a scan. */
|
|
5
5
|
const ACCOUNTLESS_VERSION = 1;
|
|
6
6
|
class Interner {
|
|
@@ -39,8 +39,11 @@ export function encodeIndex(index) {
|
|
|
39
39
|
record.dedupeKey ?? 0,
|
|
40
40
|
record.account === undefined ? 0 : accounts.index(record.account) + 1
|
|
41
41
|
];
|
|
42
|
-
if (record.costUsd !== undefined) {
|
|
43
|
-
counted
|
|
42
|
+
if (record.costUsd !== undefined || record.serviceTier !== undefined) {
|
|
43
|
+
counted[13] = record.costUsd;
|
|
44
|
+
}
|
|
45
|
+
if (record.serviceTier !== undefined) {
|
|
46
|
+
counted[14] = record.serviceTier;
|
|
44
47
|
}
|
|
45
48
|
return counted;
|
|
46
49
|
};
|
|
@@ -53,7 +56,9 @@ export function encodeIndex(index) {
|
|
|
53
56
|
c: file.codex,
|
|
54
57
|
r: file.records.map(row),
|
|
55
58
|
t: file.tail.map(row),
|
|
56
|
-
...(file.head === null ? {} : { h: file.head })
|
|
59
|
+
...(file.head === null ? {} : { h: file.head }),
|
|
60
|
+
...(file.parserVersion === undefined ? {} : { v: file.parserVersion }),
|
|
61
|
+
...(file.compressed === undefined ? {} : { z: file.compressed })
|
|
57
62
|
};
|
|
58
63
|
}
|
|
59
64
|
const document = {
|
|
@@ -68,11 +73,12 @@ export function encodeIndex(index) {
|
|
|
68
73
|
}
|
|
69
74
|
function isRow(row, version) {
|
|
70
75
|
return (Array.isArray(row) &&
|
|
71
|
-
(version === ACCOUNTLESS_VERSION ? row.length === 12 : row.length
|
|
76
|
+
(version === ACCOUNTLESS_VERSION ? row.length === 12 : row.length >= 13 && row.length <= (version === INDEX_VERSION ? 15 : 14)) &&
|
|
72
77
|
row.slice(0, 11).every((value) => typeof value === 'number') &&
|
|
73
78
|
(typeof row[11] === 'string' || row[11] === 0) &&
|
|
74
79
|
(version === ACCOUNTLESS_VERSION || typeof row[12] === 'number') &&
|
|
75
|
-
(row.length < 14 || typeof row[13] === 'number')
|
|
80
|
+
(row.length < 14 || row[13] === null || row[13] === undefined || typeof row[13] === 'number') &&
|
|
81
|
+
(row.length < 15 || typeof row[14] === 'string'));
|
|
76
82
|
}
|
|
77
83
|
/*
|
|
78
84
|
* A row that does not read back costs a cold parse of the file it belonged to, never a wrong total,
|
|
@@ -88,7 +94,7 @@ export function decodeIndex(text) {
|
|
|
88
94
|
return index;
|
|
89
95
|
}
|
|
90
96
|
const version = document?.version;
|
|
91
|
-
if ((version !== INDEX_VERSION && version !== ACCOUNTLESS_VERSION) ||
|
|
97
|
+
if ((version !== INDEX_VERSION && version !== 2 && version !== ACCOUNTLESS_VERSION) ||
|
|
92
98
|
!Array.isArray(document.models) ||
|
|
93
99
|
!Array.isArray(document.sessions) ||
|
|
94
100
|
!Array.isArray(document.folders)) {
|
|
@@ -126,7 +132,8 @@ export function decodeIndex(text) {
|
|
|
126
132
|
cwd,
|
|
127
133
|
...(account === undefined ? {} : { account }),
|
|
128
134
|
totals: { calls: row[4], input: row[5], cacheRead: row[6], cacheWrite: row[7], cacheWrite1h: row[8], output: row[9], reasoning: row[10] },
|
|
129
|
-
...(row[13] ===
|
|
135
|
+
...(typeof row[13] === 'number' ? { costUsd: row[13] } : {}),
|
|
136
|
+
...(typeof row[14] === 'string' ? { serviceTier: row[14] } : {}),
|
|
130
137
|
dedupeKey: row[11] === 0 ? null : row[11]
|
|
131
138
|
});
|
|
132
139
|
}
|
|
@@ -143,7 +150,9 @@ export function decodeIndex(text) {
|
|
|
143
150
|
records,
|
|
144
151
|
tail,
|
|
145
152
|
codex: file.c ?? null,
|
|
146
|
-
head: typeof file.h === 'string' ? file.h : null
|
|
153
|
+
head: typeof file.h === 'string' ? file.h : null,
|
|
154
|
+
...(typeof file.v === 'number' ? { parserVersion: file.v } : {}),
|
|
155
|
+
...(typeof file.z === 'boolean' ? { compressed: file.z } : {})
|
|
147
156
|
});
|
|
148
157
|
}
|
|
149
158
|
}
|
package/dist/usage/pricing.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export interface ModelPrice {
|
|
|
6
6
|
cacheRead: number;
|
|
7
7
|
cacheWrite: number;
|
|
8
8
|
cacheWrite1h: number;
|
|
9
|
+
tiers?: Partial<Record<string, ModelPrice>>;
|
|
9
10
|
}
|
|
10
11
|
export type PriceTable = Map<string, ModelPrice>;
|
|
11
12
|
export interface PriceLookup {
|
|
@@ -14,7 +15,7 @@ export interface PriceLookup {
|
|
|
14
15
|
pricedAs: string | null;
|
|
15
16
|
}
|
|
16
17
|
export declare function parsePriceTable(document: unknown): PriceTable;
|
|
17
|
-
export declare function lookupPrice(table: PriceTable, model: string): PriceLookup;
|
|
18
|
+
export declare function lookupPrice(table: PriceTable, model: string, serviceTier?: string, timestampMs?: number): PriceLookup;
|
|
18
19
|
export declare function costOf(totals: UsageTotals, price: ModelPrice): number;
|
|
19
20
|
export declare function cacheSavingsOf(totals: UsageTotals, price: ModelPrice): number;
|
|
20
21
|
export declare class PriceBook {
|
|
@@ -27,7 +28,7 @@ export declare class PriceBook {
|
|
|
27
28
|
private inFlight;
|
|
28
29
|
constructor(home: string, allowFetch?: boolean);
|
|
29
30
|
get pricing(): UsagePricing;
|
|
30
|
-
look(model: string): PriceLookup;
|
|
31
|
+
look(model: string, serviceTier?: string, timestampMs?: number): PriceLookup;
|
|
31
32
|
load(): Promise<void>;
|
|
32
33
|
ensure(): Promise<boolean>;
|
|
33
34
|
private get file();
|
package/dist/usage/pricing.js
CHANGED
|
@@ -50,6 +50,23 @@ export function parsePriceTable(document) {
|
|
|
50
50
|
cacheWrite: rate(value.cache_creation_input_token_cost) ?? input * CACHE_WRITE_SHARE,
|
|
51
51
|
cacheWrite1h: input * CACHE_WRITE_1H_SHARE
|
|
52
52
|
};
|
|
53
|
+
const tiers = {};
|
|
54
|
+
for (const [tier, multiplier] of [
|
|
55
|
+
['priority', 2],
|
|
56
|
+
['flex', 0.5],
|
|
57
|
+
['ultrafast', 6]
|
|
58
|
+
]) {
|
|
59
|
+
tiers[tier] = {
|
|
60
|
+
input: rate(value[`input_cost_per_token_${tier}`]) ?? input * multiplier,
|
|
61
|
+
output: rate(value[`output_cost_per_token_${tier}`]) ?? output * multiplier,
|
|
62
|
+
cacheRead: rate(value[`cache_read_input_token_cost_${tier}`]) ?? price.cacheRead * multiplier,
|
|
63
|
+
cacheWrite: rate(value[`cache_creation_input_token_cost_${tier}`]) ?? price.cacheWrite * multiplier,
|
|
64
|
+
cacheWrite1h: price.cacheWrite1h * multiplier
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
if (value.litellm_provider === 'openai') {
|
|
68
|
+
price.tiers = tiers;
|
|
69
|
+
}
|
|
53
70
|
const key = name.trim().toLowerCase();
|
|
54
71
|
table.set(key, price);
|
|
55
72
|
const bare = key.slice(key.lastIndexOf('/') + 1);
|
|
@@ -64,12 +81,23 @@ function withoutDate(key) {
|
|
|
64
81
|
const match = /^(.*)-\d{6,8}$/.exec(key);
|
|
65
82
|
return match === null ? null : match[1];
|
|
66
83
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
84
|
+
// The internal review alias has no published price; expose ccusage's dated estimate as a family match.
|
|
85
|
+
// https://github.com/ccusage/ccusage/blob/main/rust/adapters/codex/src/codex-auto-review-fallbacks.json
|
|
86
|
+
const REVIEW_MODELS = [
|
|
87
|
+
['2026-07-30', 'gpt-5.6-luna'],
|
|
88
|
+
['2026-03-05', 'gpt-5.4'],
|
|
89
|
+
['2026-02-05', 'gpt-5.3-codex'],
|
|
90
|
+
['2025-12-11', 'gpt-5.2-codex'],
|
|
91
|
+
['2025-11-13', 'gpt-5.1-codex'],
|
|
92
|
+
['2025-09-15', 'gpt-5-codex'],
|
|
93
|
+
['2025-08-07', 'gpt-5']
|
|
94
|
+
];
|
|
95
|
+
export function lookupPrice(table, model, serviceTier, timestampMs) {
|
|
96
|
+
const found = lookupBasePrice(table, model, timestampMs);
|
|
97
|
+
const tier = serviceTier === 'fast' ? 'priority' : serviceTier;
|
|
98
|
+
return { ...found, price: (tier === undefined ? undefined : found.price?.tiers?.[tier]) ?? found.price };
|
|
99
|
+
}
|
|
100
|
+
function lookupBasePrice(table, model, timestampMs) {
|
|
73
101
|
const key = model.trim().toLowerCase().split('[')[0];
|
|
74
102
|
const bare = key.slice(key.lastIndexOf('/') + 1);
|
|
75
103
|
if (bare === '' || AMBIGUOUS.has(bare)) {
|
|
@@ -79,6 +107,12 @@ export function lookupPrice(table, model) {
|
|
|
79
107
|
if (exact !== undefined) {
|
|
80
108
|
return { price: exact, basis: 'exact', pricedAs: null };
|
|
81
109
|
}
|
|
110
|
+
if (bare === 'codex-auto-review' && timestampMs !== undefined && Number.isFinite(timestampMs)) {
|
|
111
|
+
const date = new Date(timestampMs).toISOString().slice(0, 10);
|
|
112
|
+
const fallback = REVIEW_MODELS.find(([released]) => date >= released)?.[1] ?? 'gpt-5';
|
|
113
|
+
const found = lookupBasePrice(table, fallback);
|
|
114
|
+
return { price: found.price, basis: found.price === null ? 'unknown' : 'family', pricedAs: found.price === null ? null : fallback };
|
|
115
|
+
}
|
|
82
116
|
const dated = withoutDate(bare);
|
|
83
117
|
const undated = dated === null ? undefined : table.get(dated);
|
|
84
118
|
if (undated !== undefined && dated !== null) {
|
|
@@ -128,8 +162,8 @@ export class PriceBook {
|
|
|
128
162
|
get pricing() {
|
|
129
163
|
return { source: this.table.size === 0 ? 'none' : this.source, fetchedAt: this.fetchedAt, models: this.table.size };
|
|
130
164
|
}
|
|
131
|
-
look(model) {
|
|
132
|
-
return lookupPrice(this.table, model);
|
|
165
|
+
look(model, serviceTier, timestampMs) {
|
|
166
|
+
return lookupPrice(this.table, model, serviceTier, timestampMs);
|
|
133
167
|
}
|
|
134
168
|
/* Reads the table an earlier run stored, once; asking LiteLLM is left to `ensure`. */
|
|
135
169
|
load() {
|
|
@@ -16,6 +16,15 @@ export interface CodexParserState {
|
|
|
16
16
|
suppressing: boolean;
|
|
17
17
|
anchorMs: number;
|
|
18
18
|
lastSignature: string | null;
|
|
19
|
+
serviceTier: string | null;
|
|
20
|
+
pendingCompactions: Record<string, {
|
|
21
|
+
record: UsageRecord;
|
|
22
|
+
counts: CodexCounts;
|
|
23
|
+
threadTotal: CodexCounts | null;
|
|
24
|
+
}>;
|
|
25
|
+
compacted: string[];
|
|
26
|
+
handledResponses: string[];
|
|
27
|
+
latestResponse: string | null;
|
|
19
28
|
}
|
|
20
29
|
export declare function createCodexState(): CodexParserState;
|
|
21
30
|
export declare function cloneCodexState(state: CodexParserState): CodexParserState;
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { asObject, asString, int } from '../record.js';
|
|
2
|
-
/* A `token_count` line carries the counts; the other two carry the model and the directory they belong to. */
|
|
3
2
|
export function codexMightCarryUsage(line) {
|
|
4
|
-
return
|
|
3
|
+
return ['token_count', 'turn_context', 'session_meta', 'thread_settings_applied', 'token_usage_record', 'compacted'].some((type) => line.includes(type));
|
|
5
4
|
}
|
|
6
5
|
/* A fork replays its ancestors' events into the new file the moment it is created, so anything this
|
|
7
6
|
close to the file's own start is a copy of work that was already counted somewhere else. */
|
|
@@ -15,11 +14,16 @@ export function createCodexState() {
|
|
|
15
14
|
sawSessionMeta: false,
|
|
16
15
|
suppressing: false,
|
|
17
16
|
anchorMs: 0,
|
|
18
|
-
lastSignature: null
|
|
17
|
+
lastSignature: null,
|
|
18
|
+
serviceTier: null,
|
|
19
|
+
pendingCompactions: {},
|
|
20
|
+
compacted: [],
|
|
21
|
+
handledResponses: [],
|
|
22
|
+
latestResponse: null
|
|
19
23
|
};
|
|
20
24
|
}
|
|
21
25
|
export function cloneCodexState(state) {
|
|
22
|
-
return
|
|
26
|
+
return structuredClone(state);
|
|
23
27
|
}
|
|
24
28
|
function countsOf(usage) {
|
|
25
29
|
return {
|
|
@@ -52,13 +56,8 @@ export function isForkOrSubagent(payload) {
|
|
|
52
56
|
const spawn = asObject(asObject(payload.source)?.subagent)?.thread_spawn;
|
|
53
57
|
return typeof asObject(spawn)?.parent_thread_id === 'string';
|
|
54
58
|
}
|
|
55
|
-
/*
|
|
56
|
-
|
|
57
|
-
* reports a cumulative total per turn, so the call is the difference with the line before it; when
|
|
58
|
-
* that difference falls the run started over and `last_token_usage` is the turn on its own. Codex
|
|
59
|
-
* counts cache reads and writes inside `input_tokens`, unlike Anthropic, so what is left after
|
|
60
|
-
* taking both out is the input that was actually paid for at the full rate.
|
|
61
|
-
*/
|
|
59
|
+
/* Codex includes cache reads and writes in input_tokens. Compaction can appear separately from
|
|
60
|
+
cumulative token_count events, so response identities prevent charging the same request twice. */
|
|
62
61
|
export function parseCodexLine(line, state) {
|
|
63
62
|
let parsed;
|
|
64
63
|
try {
|
|
@@ -97,6 +96,16 @@ export function parseCodexLine(line, state) {
|
|
|
97
96
|
}
|
|
98
97
|
return null;
|
|
99
98
|
}
|
|
99
|
+
if (payload.type === 'thread_settings_applied') {
|
|
100
|
+
const settings = asObject(payload.thread_settings);
|
|
101
|
+
if (settings !== null && typeof settings.service_tier === 'string') {
|
|
102
|
+
state.serviceTier = settings.service_tier;
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
if (record.type === 'token_usage_record' || record.type === 'compacted') {
|
|
107
|
+
return parseCompaction(record, payload, state);
|
|
108
|
+
}
|
|
100
109
|
if (payload.type !== 'token_count') {
|
|
101
110
|
return null;
|
|
102
111
|
}
|
|
@@ -129,6 +138,58 @@ export function parseCodexLine(line, state) {
|
|
|
129
138
|
// The first event that is not part of the replayed burst is this thread's own work.
|
|
130
139
|
state.suppressing = false;
|
|
131
140
|
}
|
|
141
|
+
let dedupeKey = null;
|
|
142
|
+
const pending = state.latestResponse === null ? undefined : state.pendingCompactions[state.latestResponse];
|
|
143
|
+
if (pending !== undefined && (sameCounts(pending.counts, counts) || sameCounts(pending.threadTotal, cumulative))) {
|
|
144
|
+
dedupeKey = `codex:response:${state.latestResponse}`;
|
|
145
|
+
delete state.pendingCompactions[state.latestResponse];
|
|
146
|
+
state.handledResponses.push(state.latestResponse);
|
|
147
|
+
}
|
|
148
|
+
return usageRecord(counts, timestampMs, state.model, state, dedupeKey);
|
|
149
|
+
}
|
|
150
|
+
function sameCounts(first, second) {
|
|
151
|
+
return (first !== null &&
|
|
152
|
+
first.input === second.input &&
|
|
153
|
+
first.cached === second.cached &&
|
|
154
|
+
first.cacheWrite === second.cacheWrite &&
|
|
155
|
+
first.output === second.output &&
|
|
156
|
+
first.reasoning === second.reasoning);
|
|
157
|
+
}
|
|
158
|
+
function parseCompaction(record, payload, state) {
|
|
159
|
+
const id = asString(record.type === 'compacted' ? payload.compaction_response_id : payload.response_id);
|
|
160
|
+
if (id === '' || state.handledResponses.includes(id)) {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
if (record.type === 'compacted') {
|
|
164
|
+
if (!state.compacted.includes(id)) {
|
|
165
|
+
state.compacted.push(id);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
const usage = asObject(payload.usage);
|
|
170
|
+
const model = asString(payload.model) || state.model;
|
|
171
|
+
const timestampMs = Date.parse(asString(record.timestamp));
|
|
172
|
+
if (usage === null || model === null || !Number.isFinite(timestampMs)) {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
const counts = countsOf(usage);
|
|
176
|
+
const parsed = usageRecord(counts, timestampMs, model, state, `codex:response:${id}`);
|
|
177
|
+
if (parsed === null) {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
state.latestResponse = id;
|
|
181
|
+
const threadTotal = asObject(payload.thread_token_usage);
|
|
182
|
+
state.pendingCompactions[id] ??= { record: parsed, counts, threadTotal: threadTotal === null ? null : countsOf(threadTotal) };
|
|
183
|
+
}
|
|
184
|
+
const pending = state.pendingCompactions[id];
|
|
185
|
+
if (!state.compacted.includes(id) || pending === undefined) {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
state.handledResponses.push(id);
|
|
189
|
+
delete state.pendingCompactions[id];
|
|
190
|
+
return pending.record;
|
|
191
|
+
}
|
|
192
|
+
function usageRecord(counts, timestampMs, model, state, dedupeKey) {
|
|
132
193
|
const uncached = Math.max(0, counts.input - counts.cached - counts.cacheWrite);
|
|
133
194
|
const totals = {
|
|
134
195
|
calls: 1,
|
|
@@ -145,11 +206,11 @@ export function parseCodexLine(line, state) {
|
|
|
145
206
|
return {
|
|
146
207
|
provider: 'codex',
|
|
147
208
|
timestampMs,
|
|
148
|
-
model
|
|
209
|
+
model,
|
|
149
210
|
sessionId: state.sessionId,
|
|
150
211
|
cwd: state.cwd,
|
|
151
212
|
totals,
|
|
152
|
-
|
|
153
|
-
dedupeKey
|
|
213
|
+
...(state.serviceTier === null ? {} : { serviceTier: state.serviceTier }),
|
|
214
|
+
dedupeKey
|
|
154
215
|
};
|
|
155
216
|
}
|
package/dist/usage/record.d.ts
CHANGED
package/dist/usage/scanner.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { mkdir, open, readFile, readdir, stat } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
+
import { zstdDecompressSync } from 'node:zlib';
|
|
4
5
|
import { errorText } from '../error-text.js';
|
|
5
6
|
import { isNotFound, writeAtomic } from '../fs.js';
|
|
6
7
|
import { decodeIndex, encodeIndex } from './index-file.js';
|
|
@@ -11,6 +12,7 @@ import { hostLogMightCarryUsage, parseHostUsageLine } from './readers/host-log.j
|
|
|
11
12
|
import { cloneCodexState, codexMightCarryUsage, createCodexState, parseCodexLine } from './readers/codex.js';
|
|
12
13
|
import { usageRoots } from './roots.js';
|
|
13
14
|
const HASH_CHUNK_BYTES = 65536;
|
|
15
|
+
const CODEX_PARSER_VERSION = 2;
|
|
14
16
|
/*
|
|
15
17
|
* Hashes the bytes before `from` and returns the rest, in one pass over one handle, so verifying
|
|
16
18
|
* what a transcript committed and parsing what it added never reads a byte twice.
|
|
@@ -55,7 +57,7 @@ function readsWhole(provider) {
|
|
|
55
57
|
return provider === 'grok';
|
|
56
58
|
}
|
|
57
59
|
function isUsageFile(provider, name) {
|
|
58
|
-
return readsWhole(provider) ? name === GROK_USAGE_FILE : name.endsWith('.jsonl');
|
|
60
|
+
return readsWhole(provider) ? name === GROK_USAGE_FILE : name.endsWith('.jsonl') || (provider === 'codex' && name.endsWith('.jsonl.zst'));
|
|
59
61
|
}
|
|
60
62
|
async function listUsageFiles(dir, provider, out = []) {
|
|
61
63
|
let entries;
|
|
@@ -215,7 +217,12 @@ export class UsageScanner {
|
|
|
215
217
|
}
|
|
216
218
|
let failure = null;
|
|
217
219
|
const accountOf = accountResolver(root, sessionAccounts);
|
|
218
|
-
|
|
220
|
+
const paths = await listUsageFiles(root.path, root.provider);
|
|
221
|
+
const plain = new Set(paths);
|
|
222
|
+
for (const path of paths) {
|
|
223
|
+
if (root.provider === 'codex' && path.endsWith('.jsonl.zst') && plain.has(path.slice(0, -4))) {
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
219
226
|
files += 1;
|
|
220
227
|
try {
|
|
221
228
|
if (await this.readFileInto(path, root.provider, accountOf)) {
|
|
@@ -246,15 +253,31 @@ export class UsageScanner {
|
|
|
246
253
|
}
|
|
247
254
|
async readFileInto(path, provider, accountOf) {
|
|
248
255
|
const info = await stat(path);
|
|
249
|
-
const
|
|
250
|
-
|
|
256
|
+
const compressed = provider === 'codex' && path.endsWith('.jsonl.zst');
|
|
257
|
+
const key = compressed ? path.slice(0, -4) : path;
|
|
258
|
+
const known = this.index.get(key);
|
|
259
|
+
const currentParser = provider !== 'codex' || known?.parserVersion === CODEX_PARSER_VERSION;
|
|
260
|
+
if (currentParser &&
|
|
261
|
+
known !== undefined &&
|
|
262
|
+
known.provider === provider &&
|
|
263
|
+
(known.compressed ?? false) === compressed &&
|
|
264
|
+
known.size === info.size &&
|
|
265
|
+
known.mtimeMs === info.mtimeMs) {
|
|
251
266
|
return false;
|
|
252
267
|
}
|
|
253
268
|
if (readsWhole(provider)) {
|
|
254
269
|
return this.readWholeInto(path, provider, accountOf, info);
|
|
255
270
|
}
|
|
256
|
-
const appended =
|
|
257
|
-
|
|
271
|
+
const appended = !compressed &&
|
|
272
|
+
currentParser &&
|
|
273
|
+
known?.compressed !== true &&
|
|
274
|
+
known !== undefined &&
|
|
275
|
+
known.provider === provider &&
|
|
276
|
+
info.size > known.size &&
|
|
277
|
+
known.head?.startsWith('sha256:') === true;
|
|
278
|
+
let read = compressed
|
|
279
|
+
? { bytes: zstdDecompressSync(await readFile(path)), hash: createHash('sha256') }
|
|
280
|
+
: await readHashed(path, appended ? known.offset : 0);
|
|
258
281
|
// A file only counts as grown while it starts the way it did; one rewritten larger is read again from the start.
|
|
259
282
|
const grown = appended && headOf(read.hash) === known.head;
|
|
260
283
|
if (appended && !grown) {
|
|
@@ -273,9 +296,10 @@ export class UsageScanner {
|
|
|
273
296
|
records: foldByKey([...before, ...withAccount(parsed.records, accountOf)]),
|
|
274
297
|
tail: withAccount(parsed.tail, accountOf),
|
|
275
298
|
codex: parsed.codex,
|
|
276
|
-
head: headOf(read.hash)
|
|
299
|
+
head: headOf(read.hash),
|
|
300
|
+
...(provider === 'codex' ? { parserVersion: CODEX_PARSER_VERSION, compressed } : {})
|
|
277
301
|
};
|
|
278
|
-
this.index.set(
|
|
302
|
+
this.index.set(key, entry);
|
|
279
303
|
this.dirty = true;
|
|
280
304
|
return true;
|
|
281
305
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adecore/agents",
|
|
3
|
-
"version": "0.31.
|
|
3
|
+
"version": "0.31.7",
|
|
4
4
|
"description": "Node-compatible agent chat hosts, providers, accounts, usage and durable task coordination.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -81,8 +81,8 @@
|
|
|
81
81
|
"test": "bun --conditions=source test"
|
|
82
82
|
},
|
|
83
83
|
"dependencies": {
|
|
84
|
-
"@adecore/agent-contracts": "0.31.
|
|
85
|
-
"@adecore/intelligent-ui": "0.31.
|
|
84
|
+
"@adecore/agent-contracts": "0.31.7",
|
|
85
|
+
"@adecore/intelligent-ui": "0.31.7",
|
|
86
86
|
"zod": "^4.6.5"
|
|
87
87
|
},
|
|
88
88
|
"devDependencies": {
|
package/src/chat/acp-backend.ts
CHANGED
|
@@ -357,7 +357,9 @@ export class AcpBackend implements ChatBackend {
|
|
|
357
357
|
const named: ChatAttachment[] = input.attachments.filter((attachment) => !images.includes(attachment));
|
|
358
358
|
const parts = [this.contextPending, input.preamble, input.text, attachmentNote(named)].filter((part): part is string => part !== null && part !== '');
|
|
359
359
|
this.contextPending = null;
|
|
360
|
-
|
|
360
|
+
const text = parts.join('\n\n');
|
|
361
|
+
this.protocol.prompted(text);
|
|
362
|
+
return [{ type: 'text', text }, ...blocks, ...this.mentionBlocks(input.mentions)];
|
|
361
363
|
}
|
|
362
364
|
|
|
363
365
|
/*
|
|
@@ -577,13 +579,17 @@ export class AcpBackend implements ChatBackend {
|
|
|
577
579
|
this.emit({ type: 'exit', exitCode, ...(stderr === null ? {} : { stderr }) });
|
|
578
580
|
}
|
|
579
581
|
|
|
580
|
-
/* A cancelled turn still spent what it reports. */
|
|
582
|
+
/* A cancelled turn still spent what it reports. A host that fails to keep it costs the record, never the end of the turn. */
|
|
581
583
|
private report(result: unknown): void {
|
|
582
584
|
const usage = this.spec.onUsage === undefined ? null : promptUsageOf(result);
|
|
583
585
|
if (usage === null) {
|
|
584
586
|
return;
|
|
585
587
|
}
|
|
586
|
-
|
|
588
|
+
try {
|
|
589
|
+
this.spec.onUsage?.({ ...usage, sessionId: this.sessionId, cwd: this.launch.cwd, model: this.model ?? this.launch.selection.model });
|
|
590
|
+
} catch (error) {
|
|
591
|
+
console.warn(`Could not keep the usage of a ${this.spec.name} turn:`, errorText(error));
|
|
592
|
+
}
|
|
587
593
|
}
|
|
588
594
|
|
|
589
595
|
private emitAll(events: BackendEvent[]): void {
|
package/src/chat/acp-protocol.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { isRecord, type ChatFileChange, type RuntimeMode } from '@adecore/agent-contracts';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { MODE_ORDER } from '../modes.ts';
|
|
4
|
+
import { cleanTitle } from '../title-file.ts';
|
|
4
5
|
import type { ApprovalDecision, BackendEvent } from './backend.ts';
|
|
5
6
|
|
|
6
7
|
/*
|
|
@@ -463,6 +464,13 @@ const KIND_NAMES: Record<string, string> = {
|
|
|
463
464
|
other: 'Tool'
|
|
464
465
|
};
|
|
465
466
|
|
|
467
|
+
// A session's title comes from its opening prompts; later ones are not kept.
|
|
468
|
+
const KEPT_PROMPTS = 3;
|
|
469
|
+
|
|
470
|
+
function foldSpace(text: string): string {
|
|
471
|
+
return text.replace(/\s+/g, ' ').trim();
|
|
472
|
+
}
|
|
473
|
+
|
|
466
474
|
function str(value: unknown): string | null {
|
|
467
475
|
return typeof value === 'string' && value !== '' ? value : null;
|
|
468
476
|
}
|
|
@@ -598,6 +606,8 @@ export class AcpProtocol {
|
|
|
598
606
|
private thought: { ref: string; text: string } | null = null;
|
|
599
607
|
private counter = 0;
|
|
600
608
|
private costUsd = 0;
|
|
609
|
+
// The opening prompts of the process, whitespace folded, which an agent may hand back as the session's title.
|
|
610
|
+
private readonly prompts: string[] = [];
|
|
601
611
|
|
|
602
612
|
constructor(generation: number, modes: AcpModeMap, words: AcpStopWords) {
|
|
603
613
|
this.generation = generation;
|
|
@@ -647,13 +657,31 @@ export class AcpProtocol {
|
|
|
647
657
|
}
|
|
648
658
|
return [{ type: 'usage', contextTokens: usage.data.used, contextWindow: usage.data.size, ...(cost === undefined ? {} : { costUsd: cost }) }];
|
|
649
659
|
}
|
|
650
|
-
case 'session_info_update':
|
|
651
|
-
|
|
660
|
+
case 'session_info_update': {
|
|
661
|
+
const title = cleanTitle(update.title);
|
|
662
|
+
return title === null || this.restatesPrompt(title) ? [] : [{ type: 'title', title }];
|
|
663
|
+
}
|
|
652
664
|
default:
|
|
653
665
|
return [];
|
|
654
666
|
}
|
|
655
667
|
}
|
|
656
668
|
|
|
669
|
+
/* Notes a prompt sent to the agent, so a title that only repeats its start is not taken for a name. */
|
|
670
|
+
prompted(text: string): void {
|
|
671
|
+
if (this.prompts.length < KEPT_PROMPTS) {
|
|
672
|
+
this.prompts.push(foldSpace(text));
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/*
|
|
677
|
+
* Whether a title is only the start of a prompt, as Copilot CLI 1.0.101 names a session: with a host's instructions in
|
|
678
|
+
* front of the message that names every chat after them, and it keeps the host from naming the chat itself.
|
|
679
|
+
*/
|
|
680
|
+
private restatesPrompt(title: string): boolean {
|
|
681
|
+
const start = foldSpace(title.endsWith('…') ? title.slice(0, -1) : title);
|
|
682
|
+
return start !== '' && this.prompts.some((prompt) => prompt.startsWith(start));
|
|
683
|
+
}
|
|
684
|
+
|
|
657
685
|
modeEvent(modeId: string): BackendEvent {
|
|
658
686
|
const runtimeMode = runtimeModeOf(modeId, this.modes);
|
|
659
687
|
return { type: 'permissions', permissionMode: modeId, ...(runtimeMode === null ? {} : { effectiveRuntimeMode: runtimeMode }) };
|
package/src/chat/chat-session.ts
CHANGED
|
@@ -36,6 +36,7 @@ import { ChatThread } from './thread.ts';
|
|
|
36
36
|
import { UiReplies, type UiReplyHost } from './ui-replies.ts';
|
|
37
37
|
import { errorText } from '../error-text.ts';
|
|
38
38
|
import { systemClock, type OutboxClock } from '../outbox/outbox-worker.ts';
|
|
39
|
+
import { cleanTitle } from '../title-file.ts';
|
|
39
40
|
|
|
40
41
|
/* What a host keeps of a chat's turns in git (or anything like it), so a turn can show what it changed. */
|
|
41
42
|
export interface TurnCheckpoints {
|
|
@@ -2171,8 +2172,10 @@ export class ChatSession {
|
|
|
2171
2172
|
.catch(() => undefined);
|
|
2172
2173
|
}
|
|
2173
2174
|
|
|
2174
|
-
private applyTitle(
|
|
2175
|
-
|
|
2175
|
+
private applyTitle(raw: string): void {
|
|
2176
|
+
// Every info a client reads carries the title, so one it would refuse would leave the chat stuck there.
|
|
2177
|
+
const title = cleanTitle(raw);
|
|
2178
|
+
if (title === null || title === this.thread.info.suggestedTitle) {
|
|
2176
2179
|
return;
|
|
2177
2180
|
}
|
|
2178
2181
|
this.emit([this.thread.patchInfo({ suggestedTitle: title })]);
|