session-steward 0.6.0 → 0.8.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/CHANGELOG.md +20 -0
- package/README.md +16 -0
- package/bin/session-steward-cli.mjs +4 -0
- package/dist/assets/index-C94A1O5c.js +9 -0
- package/dist/assets/index-CXq8Tw8T.css +2 -0
- package/dist/index.html +2 -2
- package/lib/cli.mjs +119 -2
- package/lib/providers/claude-code/events.mjs +66 -1
- package/lib/providers/claude-code/index.mjs +2 -0
- package/lib/providers/claude-code/store.mjs +2 -2
- package/lib/providers/claude-code/tokens.mjs +174 -0
- package/lib/providers/codex/database-families.mjs +18 -2
- package/lib/providers/codex/events.mjs +54 -1
- package/lib/providers/codex/index.mjs +2 -0
- package/lib/providers/codex/store.mjs +101 -1
- package/lib/providers/codex/tokens.mjs +317 -0
- package/lib/server.mjs +30 -0
- package/lib/session-events.mjs +76 -0
- package/lib/session-token-cache.mjs +76 -0
- package/lib/session-tokens.mjs +73 -0
- package/lib/storage/jsonl.mjs +10 -1
- package/package.json +2 -1
- package/dist/assets/index-6OPqaZRp.js +0 -9
- package/dist/assets/index-Cn_JrqDr.css +0 -2
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { addTokenTotals, createTokenTotals, summarizeSessionTokens } from "../../session-tokens.mjs";
|
|
2
|
+
import { readCachedTokens, readFileStamp, signatureBytes, writeCachedTokens } from "../../session-token-cache.mjs";
|
|
3
|
+
import { visitJsonlSnapshotEntries } from "../../storage/jsonl.mjs";
|
|
4
|
+
import { getSessionRecord, loadSessionStore } from "./store.mjs";
|
|
5
|
+
|
|
6
|
+
const PAYLOAD_TYPE = "token_count";
|
|
7
|
+
|
|
8
|
+
// Codex reports a running total plus the cost of the turn that produced it. The
|
|
9
|
+
// running total is not a session total: subagents inherit the parent thread's
|
|
10
|
+
// counter, resume restarts it, and the same figures are re-emitted when nothing
|
|
11
|
+
// advanced. Only `last_token_usage` on an event whose running total *moved* is a
|
|
12
|
+
// turn that belongs to this session.
|
|
13
|
+
function readCount(value) {
|
|
14
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Codex counts cached tokens inside `input_tokens`; whether cache writes are
|
|
18
|
+
// also inside it is unconfirmed, because the field is zero everywhere we can
|
|
19
|
+
// observe. Subtracting assumes they are. The clamp keeps a wrong assumption from
|
|
20
|
+
// reaching the UI as a negative slice, and reports itself so it can be fixed
|
|
21
|
+
// from evidence rather than replaced with a second guess.
|
|
22
|
+
function normalizeUsage(value) {
|
|
23
|
+
if (!value || typeof value !== "object") return null;
|
|
24
|
+
|
|
25
|
+
const input = readCount(value.input_tokens);
|
|
26
|
+
const cachedInput = readCount(value.cached_input_tokens);
|
|
27
|
+
const cacheWrites = readCount(value.cache_write_input_tokens);
|
|
28
|
+
const output = readCount(value.output_tokens);
|
|
29
|
+
const underflows = input - cachedInput - cacheWrites < 0;
|
|
30
|
+
const freshInput = Math.max(0, underflows ? input - cachedInput : input - cachedInput - cacheWrites);
|
|
31
|
+
// The bar is drawn from these four segments, so the total has to be their sum
|
|
32
|
+
// or the slices add up to more than the whole. Codex's own `total_tokens`
|
|
33
|
+
// agrees on every billable event in the corpus (53,685 of 53,685), but it
|
|
34
|
+
// cannot agree once the clamp above has moved a token, and the clamp exists
|
|
35
|
+
// precisely because that case is unobserved rather than impossible.
|
|
36
|
+
const total = freshInput + cachedInput + cacheWrites + output;
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
// A turn with no input and no output has nothing to bill, whatever its
|
|
40
|
+
// reported total says.
|
|
41
|
+
billable: total > 0,
|
|
42
|
+
cachedInput,
|
|
43
|
+
cacheWrites,
|
|
44
|
+
freshInput,
|
|
45
|
+
output,
|
|
46
|
+
// Reasoning is part of output, never alongside it.
|
|
47
|
+
reasoning: Math.min(output, readCount(value.reasoning_output_tokens)),
|
|
48
|
+
// Codex advances its running counter by its own arithmetic, past events that
|
|
49
|
+
// report no components at all. Whether the counter moved is a question about
|
|
50
|
+
// its figure, so that figure is kept rather than recomputed.
|
|
51
|
+
reportedTotal: readCount(value.total_tokens),
|
|
52
|
+
total,
|
|
53
|
+
underflows,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function readModel(recordValue, payload) {
|
|
58
|
+
if (recordValue.type === "session_meta") return payload?.model ?? null;
|
|
59
|
+
if (recordValue.type === "turn_context") return payload?.model ?? recordValue.model ?? null;
|
|
60
|
+
if (payload?.type === "thread_settings_applied") return payload.thread_settings?.model ?? null;
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isSubagentSource(source) {
|
|
65
|
+
return Boolean(source) && typeof source === "object" && "subagent" in source;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// A turn is identified by two numbers: the counter it advanced to, and what it
|
|
69
|
+
// cost. `signatures` records those as plain number arrays — 16 bytes a turn,
|
|
70
|
+
// where the turn objects they replace cost 179 — and `prefix` plays a parent's
|
|
71
|
+
// recording back against a fork as the fork streams, so the fork retains nothing
|
|
72
|
+
// at all. Neither is on by default; a plain session needs neither.
|
|
73
|
+
export function createCodexTokenCollector({ prefix = null, signatures = false } = {}) {
|
|
74
|
+
const totals = createTokenTotals();
|
|
75
|
+
const modelTotals = new Map();
|
|
76
|
+
const inherited = prefix ? createTokenTotals() : null;
|
|
77
|
+
const own = prefix ? createTokenTotals() : null;
|
|
78
|
+
const ownModels = prefix ? new Map() : null;
|
|
79
|
+
const runningTotals = signatures ? [] : null;
|
|
80
|
+
const turnTotals = signatures ? [] : null;
|
|
81
|
+
let cacheWriteUnderflow = false;
|
|
82
|
+
let countedTurns = 0;
|
|
83
|
+
let forkedFromId = null;
|
|
84
|
+
let inheritedTurns = 0;
|
|
85
|
+
let matchingPrefix = Boolean(prefix);
|
|
86
|
+
let model = null;
|
|
87
|
+
let observedEvents = 0;
|
|
88
|
+
let previousRunningTotal = null;
|
|
89
|
+
let sessionId = null;
|
|
90
|
+
let subagent = false;
|
|
91
|
+
|
|
92
|
+
function countTurn(usage, runningTotal) {
|
|
93
|
+
countedTurns += 1;
|
|
94
|
+
if (usage.underflows) cacheWriteUnderflow = true;
|
|
95
|
+
addTokenTotals(totals, usage);
|
|
96
|
+
|
|
97
|
+
const key = model ?? "Unknown";
|
|
98
|
+
if (!modelTotals.has(key)) modelTotals.set(key, createTokenTotals());
|
|
99
|
+
addTokenTotals(modelTotals.get(key), usage);
|
|
100
|
+
|
|
101
|
+
if (signatures) {
|
|
102
|
+
runningTotals.push(runningTotal);
|
|
103
|
+
turnTotals.push(usage.total);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (!prefix) return;
|
|
107
|
+
|
|
108
|
+
// The replay is a leading run, so the first turn that fails to match ends
|
|
109
|
+
// it. Everything from there on is this session's own work.
|
|
110
|
+
if (matchingPrefix) {
|
|
111
|
+
if (
|
|
112
|
+
inheritedTurns < prefix.runningTotals.length
|
|
113
|
+
&& prefix.runningTotals[inheritedTurns] === runningTotal
|
|
114
|
+
&& prefix.turnTotals[inheritedTurns] === usage.total
|
|
115
|
+
) {
|
|
116
|
+
inheritedTurns += 1;
|
|
117
|
+
addTokenTotals(inherited, usage);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
matchingPrefix = false;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
addTokenTotals(own, usage);
|
|
125
|
+
if (!ownModels.has(key)) ownModels.set(key, createTokenTotals());
|
|
126
|
+
addTokenTotals(ownModels.get(key), usage);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
record(recordValue) {
|
|
131
|
+
if (!recordValue || typeof recordValue !== "object") return;
|
|
132
|
+
const payload = recordValue.payload && typeof recordValue.payload === "object"
|
|
133
|
+
? recordValue.payload
|
|
134
|
+
: null;
|
|
135
|
+
|
|
136
|
+
if (recordValue.type === "session_meta" && payload && sessionId === null) {
|
|
137
|
+
sessionId = payload.id ?? null;
|
|
138
|
+
forkedFromId = payload.forked_from_id ?? null;
|
|
139
|
+
subagent = isSubagentSource(payload.source);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const nextModel = readModel(recordValue, payload);
|
|
143
|
+
if (nextModel) model = nextModel;
|
|
144
|
+
|
|
145
|
+
if (payload?.type !== PAYLOAD_TYPE) return;
|
|
146
|
+
observedEvents += 1;
|
|
147
|
+
|
|
148
|
+
// `info` is absent on some builds; the event carries nothing to bill.
|
|
149
|
+
const info = payload.info;
|
|
150
|
+
if (!info || typeof info !== "object") return;
|
|
151
|
+
|
|
152
|
+
const running = normalizeUsage(info.total_token_usage);
|
|
153
|
+
const turn = normalizeUsage(info.last_token_usage);
|
|
154
|
+
if (!running || !turn) return;
|
|
155
|
+
|
|
156
|
+
// Unchanged running total means the event was re-emitted, or carries
|
|
157
|
+
// context-size telemetry rather than a billed turn. Either way it is not
|
|
158
|
+
// new spend. A *decrease* is a resume resetting the counter, which is.
|
|
159
|
+
if (previousRunningTotal !== null && running.reportedTotal === previousRunningTotal) return;
|
|
160
|
+
|
|
161
|
+
previousRunningTotal = running.reportedTotal;
|
|
162
|
+
|
|
163
|
+
// Exceeding the context window makes Codex advance the running total to a
|
|
164
|
+
// synthetic figure while reporting a turn with no components. The advance
|
|
165
|
+
// is real bookkeeping; the turn behind it is not spend.
|
|
166
|
+
if (!turn.billable) return;
|
|
167
|
+
|
|
168
|
+
countTurn(turn, running.reportedTotal);
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
result() {
|
|
172
|
+
return {
|
|
173
|
+
available: countedTurns > 0,
|
|
174
|
+
byModel: [...modelTotals]
|
|
175
|
+
.map(([name, modelSpend]) => ({ model: name, totals: modelSpend }))
|
|
176
|
+
.sort((left, right) => right.totals.total - left.totals.total),
|
|
177
|
+
cacheWriteUnderflow,
|
|
178
|
+
countedTurns,
|
|
179
|
+
fork: prefix
|
|
180
|
+
? {
|
|
181
|
+
inherited,
|
|
182
|
+
inheritedTurns,
|
|
183
|
+
own,
|
|
184
|
+
ownByModel: [...ownModels]
|
|
185
|
+
.map(([name, modelSpend]) => ({ model: name, totals: modelSpend }))
|
|
186
|
+
.sort((left, right) => right.totals.total - left.totals.total),
|
|
187
|
+
ownTurns: countedTurns - inheritedTurns,
|
|
188
|
+
parentAvailable: true,
|
|
189
|
+
}
|
|
190
|
+
: null,
|
|
191
|
+
forkedFromId,
|
|
192
|
+
observedEvents,
|
|
193
|
+
sessionId,
|
|
194
|
+
signatures: signatures ? { runningTotals, turnTotals } : null,
|
|
195
|
+
subagent,
|
|
196
|
+
totals,
|
|
197
|
+
};
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export async function collectCodexSessionTokens(filePath, { maxLineBytes, prefix, signatures, signal } = {}) {
|
|
203
|
+
const collector = createCodexTokenCollector({ prefix, signatures });
|
|
204
|
+
const { complete, snapshotBytes } = await visitJsonlSnapshotEntries(
|
|
205
|
+
filePath,
|
|
206
|
+
({ parsed }) => {
|
|
207
|
+
// A closed panel should not leave a large transcript being scanned.
|
|
208
|
+
if (signal?.aborted) return false;
|
|
209
|
+
if (parsed && typeof parsed === "object") collector.record(parsed);
|
|
210
|
+
return true;
|
|
211
|
+
},
|
|
212
|
+
maxLineBytes === undefined ? {} : { maxLineBytes },
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
return { ...collector.result(), complete, snapshotBytes };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// A fork's rollout replays the parent's turns before its own, so the two files
|
|
219
|
+
// share a leading run of identical turns. Everything after that run is this
|
|
220
|
+
// session's own work; the run itself was already billed to the parent and must
|
|
221
|
+
// not be counted twice, in a rollup or on the session's own panel.
|
|
222
|
+
//
|
|
223
|
+
// There is no marker for the boundary inside the fork file. Anchoring at the
|
|
224
|
+
// last `session_meta` locates it 3 times in 44; a timestamp-gap test manages 40
|
|
225
|
+
// in 44, failing on the subagents that matter most. Comparing against the parent
|
|
226
|
+
// is the only exact method — so the parent is read first, and the fork is split
|
|
227
|
+
// as it streams rather than buffered and diffed afterwards.
|
|
228
|
+
async function findSessionRecord({ codexHome, id }) {
|
|
229
|
+
const record = await getSessionRecord({ codexHome, id });
|
|
230
|
+
if (record) return record;
|
|
231
|
+
return (await loadSessionStore({ codexHome })).recordsById.get(id) ?? null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// A transcript can be deleted between the listing and the read. That is an
|
|
235
|
+
// answer about the session, not a failure of the server, and the timeline
|
|
236
|
+
// reader already treats it as one.
|
|
237
|
+
async function collectOrMissing(filePath, options) {
|
|
238
|
+
try {
|
|
239
|
+
return await collectCodexSessionTokens(filePath, options);
|
|
240
|
+
} catch (error) {
|
|
241
|
+
if (error?.code === "ENOENT") return null;
|
|
242
|
+
throw error;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// The parent is read only to locate the end of the replay, so its signatures
|
|
247
|
+
// are cached: a fork reopened, or several forks of one parent, read it once.
|
|
248
|
+
async function readParentSignatures({ codexHome, id, maxLineBytes, signal }) {
|
|
249
|
+
const record = await findSessionRecord({ codexHome, id });
|
|
250
|
+
if (!record?.rolloutPath) return null;
|
|
251
|
+
|
|
252
|
+
const stamp = await readFileStamp(record.rolloutPath);
|
|
253
|
+
const cached = readCachedTokens("signatures", record.rolloutPath, stamp);
|
|
254
|
+
if (cached !== undefined) return cached;
|
|
255
|
+
|
|
256
|
+
const parent = await collectOrMissing(record.rolloutPath, { maxLineBytes, signatures: true, signal });
|
|
257
|
+
const signatures = parent?.signatures ?? null;
|
|
258
|
+
if (signal?.aborted || parent?.complete === false) return signatures;
|
|
259
|
+
return writeCachedTokens("signatures", record.rolloutPath, stamp, signatures, signatureBytes(signatures));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function summarizeCollected(collected) {
|
|
263
|
+
return summarizeSessionTokens(collected, {
|
|
264
|
+
fork: collected.fork,
|
|
265
|
+
// The file itself is the authority on whether this is a fork. If it says so
|
|
266
|
+
// and no parent was read, the total still carries the replay.
|
|
267
|
+
forkParentMissing: Boolean(collected.forkedFromId) && !collected.fork,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Reading the timeline already streams every record of the same file, so the
|
|
272
|
+
// count rides along with that pass instead of paying for a second one. The
|
|
273
|
+
// parent, when there is one, still has to be read first — and is cached.
|
|
274
|
+
export async function createSessionTokenScan({ codexHome, maxLineBytes, record, signal }) {
|
|
275
|
+
if (!record?.rolloutPath) return null;
|
|
276
|
+
const stamp = await readFileStamp(record.rolloutPath);
|
|
277
|
+
const cached = readCachedTokens("summary", record.rolloutPath, stamp);
|
|
278
|
+
if (cached !== undefined) return { cached, record() {}, summarize: () => cached };
|
|
279
|
+
|
|
280
|
+
const prefix = record.forkedFromId
|
|
281
|
+
? await readParentSignatures({ codexHome, id: record.forkedFromId, maxLineBytes, signal })
|
|
282
|
+
: null;
|
|
283
|
+
const collector = createCodexTokenCollector({ prefix });
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
cached: null,
|
|
287
|
+
record(value) {
|
|
288
|
+
collector.record(value);
|
|
289
|
+
},
|
|
290
|
+
summarize({ complete }) {
|
|
291
|
+
const summary = summarizeCollected({ ...collector.result(), complete });
|
|
292
|
+
if (signal?.aborted || complete === false) return summary;
|
|
293
|
+
return writeCachedTokens("summary", record.rolloutPath, stamp, summary);
|
|
294
|
+
},
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export async function readSessionTokens({ codexHome, id, maxLineBytes, signal }) {
|
|
299
|
+
const record = await findSessionRecord({ codexHome, id });
|
|
300
|
+
if (!record) return null;
|
|
301
|
+
if (!record.rolloutPath) return { available: false, reason: "no-transcript-path" };
|
|
302
|
+
|
|
303
|
+
const stamp = await readFileStamp(record.rolloutPath);
|
|
304
|
+
const cached = readCachedTokens("summary", record.rolloutPath, stamp);
|
|
305
|
+
if (cached !== undefined) return cached;
|
|
306
|
+
|
|
307
|
+
const prefix = record.forkedFromId
|
|
308
|
+
? await readParentSignatures({ codexHome, id: record.forkedFromId, maxLineBytes, signal })
|
|
309
|
+
: null;
|
|
310
|
+
|
|
311
|
+
const collected = await collectOrMissing(record.rolloutPath, { maxLineBytes, prefix, signal });
|
|
312
|
+
if (!collected) return { available: false, reason: "transcript-missing" };
|
|
313
|
+
|
|
314
|
+
const summary = summarizeCollected(collected);
|
|
315
|
+
if (signal?.aborted || collected.complete === false) return summary;
|
|
316
|
+
return writeCachedTokens("summary", record.rolloutPath, stamp, summary);
|
|
317
|
+
}
|
package/lib/server.mjs
CHANGED
|
@@ -865,6 +865,9 @@ export async function startLocalServer({ claudeHome, codexHome, configDirectory,
|
|
|
865
865
|
id,
|
|
866
866
|
limit: getSessionEventLimit(requestUrl.searchParams.get("limit")),
|
|
867
867
|
signal: controller.signal,
|
|
868
|
+
// The scan is already streaming the whole transcript, so the count
|
|
869
|
+
// comes back with it rather than costing a second pass over it.
|
|
870
|
+
tokens: true,
|
|
868
871
|
});
|
|
869
872
|
|
|
870
873
|
if (controller.signal.aborted) return;
|
|
@@ -878,6 +881,33 @@ export async function startLocalServer({ claudeHome, codexHome, configDirectory,
|
|
|
878
881
|
return;
|
|
879
882
|
}
|
|
880
883
|
|
|
884
|
+
if (request.method === "GET" && requestUrl.pathname === "/api/session-tokens") {
|
|
885
|
+
const controller = new AbortController();
|
|
886
|
+
const abortRead = () => controller.abort();
|
|
887
|
+
request.once("aborted", abortRead);
|
|
888
|
+
response.once("close", () => {
|
|
889
|
+
if (!response.writableEnded) abortRead();
|
|
890
|
+
});
|
|
891
|
+
const providerId = resolveProviderId(requestUrl.searchParams.get("provider"));
|
|
892
|
+
const provider = getProvider(providerId);
|
|
893
|
+
const id = getSessionId(requestUrl.searchParams.get("id"));
|
|
894
|
+
const tokens = await provider.readSessionTokens({
|
|
895
|
+
...providerOptions(providerId, settings.getHome(providerId)),
|
|
896
|
+
id,
|
|
897
|
+
signal: controller.signal,
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
if (controller.signal.aborted) return;
|
|
901
|
+
|
|
902
|
+
if (!tokens) {
|
|
903
|
+
sendJson(response, 404, { error: "Session not found." });
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
sendJson(response, 200, { tokens });
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
|
|
881
911
|
if (request.method === "GET" && requestUrl.pathname.startsWith("/api/sessions/")) {
|
|
882
912
|
const providerId = resolveProviderId(requestUrl.searchParams.get("provider"));
|
|
883
913
|
const provider = getProvider(providerId);
|
package/lib/session-events.mjs
CHANGED
|
@@ -206,6 +206,72 @@ export function createSessionEventCoverage({
|
|
|
206
206
|
};
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
+
export function createSessionEventSummary({ asks = 0, commands = 0, edits = 0 } = {}) {
|
|
210
|
+
return {
|
|
211
|
+
asks: requiredCount(asks, "summary.asks"),
|
|
212
|
+
commands: requiredCount(commands, "summary.commands"),
|
|
213
|
+
edits: requiredCount(edits, "summary.edits"),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Ordered largest-concept-first so the bar and its legend agree
|
|
218
|
+
export const SESSION_EVENT_COMPOSITION_SEGMENTS = Object.freeze([
|
|
219
|
+
"toolOutput",
|
|
220
|
+
"largeRecords",
|
|
221
|
+
"compaction",
|
|
222
|
+
"attachments",
|
|
223
|
+
"messages",
|
|
224
|
+
"edits",
|
|
225
|
+
"reasoning",
|
|
226
|
+
"other",
|
|
227
|
+
]);
|
|
228
|
+
|
|
229
|
+
export function createSessionEventComposition({
|
|
230
|
+
attachments = 0,
|
|
231
|
+
compaction = 0,
|
|
232
|
+
edits = 0,
|
|
233
|
+
largeRecords = 0,
|
|
234
|
+
messages = 0,
|
|
235
|
+
other = 0,
|
|
236
|
+
reasoning = 0,
|
|
237
|
+
toolOutput = 0,
|
|
238
|
+
total = 0,
|
|
239
|
+
} = {}) {
|
|
240
|
+
const composition = {
|
|
241
|
+
attachments: requiredCount(attachments, "composition.attachments"),
|
|
242
|
+
compaction: requiredCount(compaction, "composition.compaction"),
|
|
243
|
+
edits: requiredCount(edits, "composition.edits"),
|
|
244
|
+
largeRecords: requiredCount(largeRecords, "composition.largeRecords"),
|
|
245
|
+
messages: requiredCount(messages, "composition.messages"),
|
|
246
|
+
other: requiredCount(other, "composition.other"),
|
|
247
|
+
reasoning: requiredCount(reasoning, "composition.reasoning"),
|
|
248
|
+
toolOutput: requiredCount(toolOutput, "composition.toolOutput"),
|
|
249
|
+
total: requiredCount(total, "composition.total"),
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const segments = SESSION_EVENT_COMPOSITION_SEGMENTS
|
|
253
|
+
.reduce((sum, segment) => sum + composition[segment], 0);
|
|
254
|
+
|
|
255
|
+
if (segments !== composition.total) {
|
|
256
|
+
throw new TypeError("Session event composition segments must add up to the transcript size.");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return composition;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function finalizeSessionEventComposition(composition, transcriptBytes) {
|
|
263
|
+
const attributed = SESSION_EVENT_COMPOSITION_SEGMENTS
|
|
264
|
+
.filter((segment) => segment !== "other")
|
|
265
|
+
.reduce((sum, segment) => sum + (composition[segment] ?? 0), 0);
|
|
266
|
+
const total = Math.max(transcriptBytes ?? 0, attributed);
|
|
267
|
+
|
|
268
|
+
return createSessionEventComposition({
|
|
269
|
+
...composition,
|
|
270
|
+
other: total - attributed,
|
|
271
|
+
total,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
209
275
|
export function sessionEventCoveragePercent(coverage) {
|
|
210
276
|
const normalizedCoverage = createSessionEventCoverage(coverage);
|
|
211
277
|
const considered = normalizedCoverage.total - normalizedCoverage.skipped;
|
|
@@ -246,13 +312,18 @@ export function createSessionEventHeader({
|
|
|
246
312
|
}
|
|
247
313
|
|
|
248
314
|
export function createSessionEventsResult({
|
|
315
|
+
composition,
|
|
249
316
|
coverage,
|
|
250
317
|
events = [],
|
|
251
318
|
header,
|
|
252
319
|
reason = null,
|
|
320
|
+
summary,
|
|
321
|
+
tokens = null,
|
|
253
322
|
window = {},
|
|
254
323
|
} = {}) {
|
|
255
324
|
const normalizedCoverage = createSessionEventCoverage(coverage);
|
|
325
|
+
const normalizedSummary = createSessionEventSummary(summary);
|
|
326
|
+
const normalizedComposition = createSessionEventComposition(composition);
|
|
256
327
|
|
|
257
328
|
if (
|
|
258
329
|
normalizedCoverage.recognized
|
|
@@ -298,6 +369,11 @@ export function createSessionEventsResult({
|
|
|
298
369
|
events: [...events],
|
|
299
370
|
header: createSessionEventHeader(header),
|
|
300
371
|
reason,
|
|
372
|
+
composition: normalizedComposition,
|
|
373
|
+
summary: normalizedSummary,
|
|
374
|
+
// Counted during this same pass, so the panel's header does not have to
|
|
375
|
+
// read the transcript a second time to fill in two fields.
|
|
376
|
+
tokens,
|
|
301
377
|
window: {
|
|
302
378
|
complete,
|
|
303
379
|
end,
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
// A token count is a pure function of a transcript's bytes, so an unchanged
|
|
4
|
+
// file never needs reading twice — which matters most for a fork, whose parent
|
|
5
|
+
// is a whole extra file scanned only to find where the replay ends.
|
|
6
|
+
//
|
|
7
|
+
// Identity is dev+inode alongside size and mtime, mirroring
|
|
8
|
+
// `transcriptActivityCache` (`lib/providers/claude-code/store.mjs:39`): a path
|
|
9
|
+
// that has been replaced is a different file, not a stale entry. An active
|
|
10
|
+
// session's mtime moves on every write, so it re-reads rather than serving a
|
|
11
|
+
// count that has stopped growing.
|
|
12
|
+
// Two limits, because entries are not the same size. A session summary is about
|
|
13
|
+
// a kilobyte whatever the transcript weighed; a fork parent's signature
|
|
14
|
+
// recording is 16 bytes a turn, so 512 of those is a number with no ceiling.
|
|
15
|
+
// Counting entries bounds the bookkeeping, counting bytes bounds the memory.
|
|
16
|
+
const MAX_ENTRIES = 512;
|
|
17
|
+
const MAX_BYTES = 32 * 1024 * 1024;
|
|
18
|
+
const cache = new Map();
|
|
19
|
+
let cachedBytes = 0;
|
|
20
|
+
|
|
21
|
+
export async function readFileStamp(filePath) {
|
|
22
|
+
if (!filePath) return null;
|
|
23
|
+
try {
|
|
24
|
+
const stats = await fs.stat(filePath);
|
|
25
|
+
return `${stats.dev ?? ""}:${stats.ino ?? ""}:${stats.size}:${stats.mtimeMs}`;
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function readCachedTokens(kind, filePath, stamp) {
|
|
32
|
+
if (!stamp) return undefined;
|
|
33
|
+
const entry = cache.get(`${kind} ${filePath}`);
|
|
34
|
+
return entry?.stamp === stamp ? entry.value : undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function evict(key) {
|
|
38
|
+
const entry = cache.get(key);
|
|
39
|
+
if (!entry) return;
|
|
40
|
+
cachedBytes -= entry.bytes;
|
|
41
|
+
cache.delete(key);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function writeCachedTokens(kind, filePath, stamp, value, bytes = 1024) {
|
|
45
|
+
if (!stamp) return value;
|
|
46
|
+
// An entry that cannot fit inside the budget would evict everything else and
|
|
47
|
+
// then sit there alone. Reading it again is cheaper than that.
|
|
48
|
+
if (bytes > MAX_BYTES) return value;
|
|
49
|
+
|
|
50
|
+
const key = `${kind} ${filePath}`;
|
|
51
|
+
evict(key);
|
|
52
|
+
cache.set(key, { bytes, stamp, value });
|
|
53
|
+
cachedBytes += bytes;
|
|
54
|
+
|
|
55
|
+
while (cache.size > MAX_ENTRIES || cachedBytes > MAX_BYTES) {
|
|
56
|
+
const oldest = cache.keys().next().value;
|
|
57
|
+
if (oldest === key) break;
|
|
58
|
+
evict(oldest);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Two numbers a turn, in arrays V8 stores unboxed.
|
|
65
|
+
export function signatureBytes(signatures) {
|
|
66
|
+
return signatures ? signatures.runningTotals.length * 16 : 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function sessionTokenCacheStats() {
|
|
70
|
+
return { bytes: cachedBytes, entries: cache.size, maxBytes: MAX_BYTES, maxEntries: MAX_ENTRIES };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function clearSessionTokenCache() {
|
|
74
|
+
cache.clear();
|
|
75
|
+
cachedBytes = 0;
|
|
76
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// The shape both providers report in, and the derived figures the UI renders.
|
|
2
|
+
//
|
|
3
|
+
// Codex counts cached tokens inside its input figure and Anthropic counts them
|
|
4
|
+
// beside it, so the providers cannot share a formula. They share this shape
|
|
5
|
+
// instead: each collector resolves its own cache math and hands back the same
|
|
6
|
+
// four buckets, which always sum to the total.
|
|
7
|
+
export const TOKEN_SEGMENT_KEYS = Object.freeze(["freshInput", "cachedInput", "cacheWrites", "output"]);
|
|
8
|
+
|
|
9
|
+
export function createTokenTotals() {
|
|
10
|
+
return { cachedInput: 0, cacheWrites: 0, freshInput: 0, output: 0, reasoning: 0, total: 0 };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function addTokenTotals(target, usage) {
|
|
14
|
+
target.cachedInput += usage.cachedInput;
|
|
15
|
+
target.cacheWrites += usage.cacheWrites;
|
|
16
|
+
target.freshInput += usage.freshInput;
|
|
17
|
+
target.output += usage.output;
|
|
18
|
+
target.reasoning += usage.reasoning;
|
|
19
|
+
target.total += usage.total;
|
|
20
|
+
return target;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function share(part, whole) {
|
|
24
|
+
return whole > 0 ? part / whole : 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function inputTokens(totals) {
|
|
28
|
+
return totals.freshInput + totals.cachedInput + totals.cacheWrites;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// A fork's rollout replays its parent's turns before its own. The bar describes
|
|
32
|
+
// what this session actually spent, so the inherited half is reported alongside
|
|
33
|
+
// it rather than folded into it.
|
|
34
|
+
export function summarizeSessionTokens(collected, { fork = null, forkParentMissing = false } = {}) {
|
|
35
|
+
if (!collected || collected.available !== true) {
|
|
36
|
+
return { available: false, reason: collected?.complete === false ? "incomplete" : "absent" };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const totals = fork ? fork.own : collected.totals;
|
|
40
|
+
const byModel = fork ? fork.ownByModel : collected.byModel;
|
|
41
|
+
const warnings = [];
|
|
42
|
+
if (collected.cacheWriteUnderflow) warnings.push("cache-write-underflow");
|
|
43
|
+
if (collected.complete === false) warnings.push("incomplete-scan");
|
|
44
|
+
// A fork whose parent is gone cannot be split, so the total still carries the
|
|
45
|
+
// inherited turns. Say so rather than presenting it as this session's spend.
|
|
46
|
+
if (forkParentMissing) warnings.push("fork-parent-missing");
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
available: true,
|
|
50
|
+
byModel: (byModel ?? []).map(({ model, totals: modelTotals }) => ({
|
|
51
|
+
model,
|
|
52
|
+
share: share(modelTotals.total, totals.total),
|
|
53
|
+
tokens: modelTotals.total,
|
|
54
|
+
})),
|
|
55
|
+
// "N% of input was served from cache" — output is not part of the question.
|
|
56
|
+
cacheHitRate: inputTokens(totals) > 0 ? share(totals.cachedInput, inputTokens(totals)) : null,
|
|
57
|
+
compactions: collected.compactions ?? 0,
|
|
58
|
+
inherited: fork ? { tokens: fork.inherited.total, turns: fork.inheritedTurns } : null,
|
|
59
|
+
// Reasoning is part of output, so it is reported against output rather than
|
|
60
|
+
// as a segment of its own.
|
|
61
|
+
reasoning: totals.reasoning > 0
|
|
62
|
+
? { share: share(totals.reasoning, totals.output), tokens: totals.reasoning }
|
|
63
|
+
: null,
|
|
64
|
+
segments: TOKEN_SEGMENT_KEYS.map((key) => ({
|
|
65
|
+
key,
|
|
66
|
+
share: share(totals[key], totals.total),
|
|
67
|
+
tokens: totals[key],
|
|
68
|
+
})),
|
|
69
|
+
total: totals.total,
|
|
70
|
+
totals,
|
|
71
|
+
warnings,
|
|
72
|
+
};
|
|
73
|
+
}
|
package/lib/storage/jsonl.mjs
CHANGED
|
@@ -41,7 +41,12 @@ export async function* readJsonlEntries(filePath) {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
function appendLineBytes(state, bytes, maxLineBytes) {
|
|
44
|
-
if (
|
|
44
|
+
if (bytes.length === 0) return;
|
|
45
|
+
|
|
46
|
+
// Counted even once a line is oversized, because the largest records in a
|
|
47
|
+
// transcript are exactly the ones that stop being buffered.
|
|
48
|
+
state.totalBytes += bytes.length;
|
|
49
|
+
if (state.oversized) return;
|
|
45
50
|
|
|
46
51
|
if (state.length + bytes.length > maxLineBytes) {
|
|
47
52
|
state.length = 0;
|
|
@@ -69,17 +74,20 @@ function createLineState(maxLineBytes) {
|
|
|
69
74
|
buffer: Buffer.allocUnsafe(Math.min(JSONL_STARTING_LINE_BYTES, maxLineBytes)),
|
|
70
75
|
length: 0,
|
|
71
76
|
oversized: false,
|
|
77
|
+
totalBytes: 0,
|
|
72
78
|
};
|
|
73
79
|
}
|
|
74
80
|
|
|
75
81
|
function resetLineState(state) {
|
|
76
82
|
state.length = 0;
|
|
77
83
|
state.oversized = false;
|
|
84
|
+
state.totalBytes = 0;
|
|
78
85
|
}
|
|
79
86
|
|
|
80
87
|
function snapshotEntry(state, index) {
|
|
81
88
|
if (state.oversized) {
|
|
82
89
|
return {
|
|
90
|
+
bytes: state.totalBytes,
|
|
83
91
|
index,
|
|
84
92
|
oversized: true,
|
|
85
93
|
parsed: null,
|
|
@@ -93,6 +101,7 @@ function snapshotEntry(state, index) {
|
|
|
93
101
|
|
|
94
102
|
const entry = parseLine(bytes.toString("utf8"), index);
|
|
95
103
|
return {
|
|
104
|
+
bytes: state.totalBytes,
|
|
96
105
|
index: entry.index,
|
|
97
106
|
oversized: false,
|
|
98
107
|
parsed: entry.parsed,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "session-steward",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Codex and Claude Code session manager - browse, back up, and delete old sessions. Local browser UI + CLI.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Mallik Cheripally",
|
|
@@ -59,6 +59,7 @@
|
|
|
59
59
|
"benchmark:overview": "node --expose-gc test/benchmarks/codex-overview.mjs",
|
|
60
60
|
"benchmark:scale": "node --expose-gc test/benchmarks/codex-list.mjs",
|
|
61
61
|
"benchmark:size": "node --expose-gc test/benchmarks/codex-size.mjs",
|
|
62
|
+
"benchmark:tokens": "node --expose-gc test/benchmarks/session-tokens.mjs",
|
|
62
63
|
"benchmark:transcripts": "node --expose-gc test/benchmarks/codex-transcripts.mjs",
|
|
63
64
|
"benchmark:versioned-stores": "node --expose-gc test/benchmarks/codex-versioned-stores.mjs",
|
|
64
65
|
"prepack": "npm run build",
|