agent-dag 1.33.36 → 1.33.37
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/web/index.html
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
6
|
<title>agents-deck</title>
|
|
7
7
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='84' font-size='84'%3E%E2%97%89%3C/text%3E%3C/svg%3E" />
|
|
8
|
-
<script type="module" crossorigin src="/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-BOQgzj5w.js"></script>
|
|
9
9
|
<link rel="stylesheet" crossorigin href="/assets/index-Cpi89XJ8.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-dag",
|
|
3
|
-
"version": "1.33.
|
|
3
|
+
"version": "1.33.37",
|
|
4
4
|
"description": "Live deck of Claude Code and Codex agents — watch parallel subagents fork, call tools, and return on one calm canvas. Also available as npx ccdeck and npx agent-dag.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/server/index.mjs
CHANGED
|
@@ -81,6 +81,197 @@ async function maybeRotatePersistFile() {
|
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
// ─── Incremental transcript scanning ─────────────────────────────────────
|
|
85
|
+
// Model, usage and context enrichment all derive from the same append-only
|
|
86
|
+
// transcript JSONL, and each one used to re-read and re-parse the whole file
|
|
87
|
+
// on every throttled pass. A session's transcript grows to tens of MB, so
|
|
88
|
+
// that cost O(n) per pass, O(n²) over the session, and — because the parse
|
|
89
|
+
// loop is one synchronous block — it stalled SSE broadcasts and /api/event
|
|
90
|
+
// ingest for as long as it ran.
|
|
91
|
+
//
|
|
92
|
+
// Instead we keep one cursor per file plus the running totals derived so
|
|
93
|
+
// far, read only the bytes appended since the last pass, and fold them into
|
|
94
|
+
// that state. The three scanners share it, so the first one to run in a
|
|
95
|
+
// cycle pays for the read and the other two reuse the result. This mirrors
|
|
96
|
+
// the offset tailing the Codex rollout watcher already does further down.
|
|
97
|
+
const transcriptScans = new Map(); // path -> scan state
|
|
98
|
+
const transcriptScanInFlight = new Map(); // path -> in-progress scan promise
|
|
99
|
+
const MAX_TRANSCRIPT_SCANS = 256; // bound the per-path state
|
|
100
|
+
|
|
101
|
+
const MODEL_ID_RE = /^claude[-_]/i;
|
|
102
|
+
const USAGE_BLOCK_RE = /"usage"\s*:\s*\{([^}]+)\}/g;
|
|
103
|
+
// CC `/clear` and `/compact` write a marker into the transcript and reset the
|
|
104
|
+
// context window to ~0 while the JSONL keeps growing — everything before the
|
|
105
|
+
// last marker is stale.
|
|
106
|
+
const CONTEXT_RESET_RE = /<command-name>\s*\/(?:clear|compact)\s*<\/command-name>/g;
|
|
107
|
+
const TYPE_USER_RE = /"type"\s*:\s*"user"/g;
|
|
108
|
+
const TYPE_ASSISTANT_RE = /"type"\s*:\s*"assistant"/g;
|
|
109
|
+
const TYPE_TOOL_USE_RE = /"type"\s*:\s*"tool_use"/g;
|
|
110
|
+
const TYPE_TOOL_RESULT_RE = /"type"\s*:\s*"tool_result"/g;
|
|
111
|
+
const SYSTEM_REMINDER_RE = /<system-reminder>/g;
|
|
112
|
+
const USAGE_FIELD_RE = {
|
|
113
|
+
input_tokens: /"input_tokens"\s*:\s*(\d+)/,
|
|
114
|
+
output_tokens: /"output_tokens"\s*:\s*(\d+)/,
|
|
115
|
+
cache_read_input_tokens: /"cache_read_input_tokens"\s*:\s*(\d+)/,
|
|
116
|
+
cache_creation_input_tokens: /"cache_creation_input_tokens"\s*:\s*(\d+)/,
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
function grabUsageField(blob, key) {
|
|
120
|
+
const m = blob.match(USAGE_FIELD_RE[key]);
|
|
121
|
+
return m ? Number(m[1]) : 0;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function newContextBreakdown() {
|
|
125
|
+
return {
|
|
126
|
+
msgsUser: 0,
|
|
127
|
+
msgsAssistant: 0,
|
|
128
|
+
toolUses: 0,
|
|
129
|
+
toolResults: 0,
|
|
130
|
+
systemReminders: 0,
|
|
131
|
+
currentContextTokens: 0,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function newTranscriptState() {
|
|
136
|
+
return {
|
|
137
|
+
offset: 0, // bytes already folded in
|
|
138
|
+
touchedAt: 0,
|
|
139
|
+
rootModel: null,
|
|
140
|
+
lastModel: null, // last claude-* model on any line, sidechain included
|
|
141
|
+
subagentModels: {},
|
|
142
|
+
usage: {
|
|
143
|
+
input_tokens: 0, output_tokens: 0,
|
|
144
|
+
cache_read_input_tokens: 0, cache_creation_input_tokens: 0,
|
|
145
|
+
},
|
|
146
|
+
ctx: newContextBreakdown(),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function readByteRange(path, from, to) {
|
|
151
|
+
const fh = await open(path, "r");
|
|
152
|
+
try {
|
|
153
|
+
const len = to - from;
|
|
154
|
+
if (len <= 0) return "";
|
|
155
|
+
const buf = Buffer.alloc(len);
|
|
156
|
+
await fh.read(buf, 0, len, from);
|
|
157
|
+
return buf.toString("utf8");
|
|
158
|
+
} finally {
|
|
159
|
+
await fh.close();
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Fold one transcript line into the running state. Every fact the three
|
|
164
|
+
* scanners need lives on a single line, so line-at-a-time folding sees
|
|
165
|
+
* exactly what a whole-file pass would. */
|
|
166
|
+
function foldTranscriptLine(state, line) {
|
|
167
|
+
if (!line) return;
|
|
168
|
+
|
|
169
|
+
// Model. Only a line that mentions a model can change it, and parsing the
|
|
170
|
+
// rest is what made the full rescan expensive.
|
|
171
|
+
if (line.includes('"model"')) {
|
|
172
|
+
let obj = null;
|
|
173
|
+
try { obj = JSON.parse(line); } catch {}
|
|
174
|
+
const msg = obj && obj.message;
|
|
175
|
+
const model = (msg && typeof msg.model === "string" && MODEL_ID_RE.test(msg.model)) ? msg.model
|
|
176
|
+
: (obj && typeof obj.model === "string" && MODEL_ID_RE.test(obj.model)) ? obj.model
|
|
177
|
+
: null;
|
|
178
|
+
if (model) {
|
|
179
|
+
state.lastModel = model;
|
|
180
|
+
const isSide = obj.isSidechain === true || obj.is_sidechain === true;
|
|
181
|
+
const ptid = obj.parentToolUseID || obj.parent_tool_use_id || obj.parentToolUseId || null;
|
|
182
|
+
if (isSide && ptid) state.subagentModels[ptid] = model;
|
|
183
|
+
else if (!isSide) state.rootModel = model;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Usage totals sum every block in the file, resets included.
|
|
188
|
+
for (const m of line.matchAll(USAGE_BLOCK_RE)) {
|
|
189
|
+
const blob = m[1];
|
|
190
|
+
state.usage.input_tokens += grabUsageField(blob, "input_tokens");
|
|
191
|
+
state.usage.output_tokens += grabUsageField(blob, "output_tokens");
|
|
192
|
+
state.usage.cache_read_input_tokens += grabUsageField(blob, "cache_read_input_tokens");
|
|
193
|
+
state.usage.cache_creation_input_tokens += grabUsageField(blob, "cache_creation_input_tokens");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Context counts only what follows the most recent /clear or /compact.
|
|
197
|
+
let ctxText = line;
|
|
198
|
+
let resetEnd = -1;
|
|
199
|
+
for (const m of line.matchAll(CONTEXT_RESET_RE)) resetEnd = (m.index ?? -1) + m[0].length;
|
|
200
|
+
if (resetEnd >= 0) {
|
|
201
|
+
state.ctx = newContextBreakdown();
|
|
202
|
+
ctxText = line.slice(resetEnd);
|
|
203
|
+
}
|
|
204
|
+
const ctx = state.ctx;
|
|
205
|
+
ctx.msgsUser += (ctxText.match(TYPE_USER_RE) ?? []).length;
|
|
206
|
+
ctx.msgsAssistant += (ctxText.match(TYPE_ASSISTANT_RE) ?? []).length;
|
|
207
|
+
ctx.toolUses += (ctxText.match(TYPE_TOOL_USE_RE) ?? []).length;
|
|
208
|
+
ctx.toolResults += (ctxText.match(TYPE_TOOL_RESULT_RE) ?? []).length;
|
|
209
|
+
ctx.systemReminders += (ctxText.match(SYSTEM_REMINDER_RE) ?? []).length;
|
|
210
|
+
// Current context size = the LAST usage block after the reset. Stays 0
|
|
211
|
+
// right after a /clear, which is what CC's own /context reports.
|
|
212
|
+
let lastBlob = null;
|
|
213
|
+
for (const m of ctxText.matchAll(USAGE_BLOCK_RE)) lastBlob = m[1];
|
|
214
|
+
if (lastBlob) {
|
|
215
|
+
ctx.currentContextTokens =
|
|
216
|
+
grabUsageField(lastBlob, "input_tokens") +
|
|
217
|
+
grabUsageField(lastBlob, "cache_read_input_tokens") +
|
|
218
|
+
grabUsageField(lastBlob, "cache_creation_input_tokens");
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function pruneTranscriptScans() {
|
|
223
|
+
if (transcriptScans.size <= MAX_TRANSCRIPT_SCANS) return;
|
|
224
|
+
let oldestPath = null;
|
|
225
|
+
let oldestAt = Infinity;
|
|
226
|
+
for (const [p, st] of transcriptScans) {
|
|
227
|
+
if (st.touchedAt < oldestAt) { oldestAt = st.touchedAt; oldestPath = p; }
|
|
228
|
+
}
|
|
229
|
+
if (oldestPath !== null) transcriptScans.delete(oldestPath);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Bring a transcript's scan state up to date and return it. Concurrent
|
|
233
|
+
* callers share one read — folding the same appended bytes twice would
|
|
234
|
+
* double the usage totals. Never throws; an unreadable file just leaves
|
|
235
|
+
* the state where it was. */
|
|
236
|
+
function scanTranscript(path) {
|
|
237
|
+
if (!path || typeof path !== "string") return Promise.resolve(null);
|
|
238
|
+
const inFlight = transcriptScanInFlight.get(path);
|
|
239
|
+
if (inFlight) return inFlight;
|
|
240
|
+
const run = (async () => {
|
|
241
|
+
let state = transcriptScans.get(path);
|
|
242
|
+
if (!state) {
|
|
243
|
+
state = newTranscriptState();
|
|
244
|
+
transcriptScans.set(path, state);
|
|
245
|
+
pruneTranscriptScans();
|
|
246
|
+
}
|
|
247
|
+
state.touchedAt = Date.now();
|
|
248
|
+
try {
|
|
249
|
+
const s = await stat(path);
|
|
250
|
+
// Shorter than the cursor means the file was truncated, rotated or
|
|
251
|
+
// replaced — the offset now points at unrelated bytes, so start over.
|
|
252
|
+
if (s.size < state.offset) {
|
|
253
|
+
state = newTranscriptState();
|
|
254
|
+
state.touchedAt = Date.now();
|
|
255
|
+
transcriptScans.set(path, state);
|
|
256
|
+
}
|
|
257
|
+
if (s.size <= state.offset) return state;
|
|
258
|
+
const text = await readByteRange(path, state.offset, s.size);
|
|
259
|
+
const lastNl = text.lastIndexOf("\n");
|
|
260
|
+
if (lastNl < 0) return state; // no complete line appended yet
|
|
261
|
+
const consumed = text.slice(0, lastNl);
|
|
262
|
+
// Advance before folding: a fold that throws half-way must not leave
|
|
263
|
+
// the cursor where the next pass would count those lines again.
|
|
264
|
+
state.offset += Buffer.byteLength(consumed, "utf8") + 1; // +1 for the \n
|
|
265
|
+
for (const line of consumed.split("\n")) foldTranscriptLine(state, line);
|
|
266
|
+
} catch { /* keep whatever we already folded */ }
|
|
267
|
+
return state;
|
|
268
|
+
})();
|
|
269
|
+
transcriptScanInFlight.set(path, run);
|
|
270
|
+
return run.finally(() => {
|
|
271
|
+
if (transcriptScanInFlight.get(path) === run) transcriptScanInFlight.delete(path);
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
84
275
|
// ─── Model enrichment ────────────────────────────────────────────────────
|
|
85
276
|
// CC's hook payloads never carry the `model` field — but every hook
|
|
86
277
|
// references a `transcript_path` JSONL that contains lines like
|
|
@@ -108,46 +299,13 @@ export function cachedModelId(cached) {
|
|
|
108
299
|
* inline with `isSidechain:true` + `parentToolUseID`). Current CC versions
|
|
109
300
|
* store subagents in `<sessionDir>/subagents/agent-<id>.jsonl` — those are
|
|
110
301
|
* handled by `readSubagentModelsFromDir` below. */
|
|
111
|
-
async function readModelFromTranscript(path) {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const buf = Buffer.alloc(s.size);
|
|
119
|
-
await fh.read(buf, 0, s.size, 0);
|
|
120
|
-
text = buf.toString("utf8");
|
|
121
|
-
} finally {
|
|
122
|
-
await fh.close();
|
|
123
|
-
}
|
|
124
|
-
let rootModel = null;
|
|
125
|
-
const subagentModels = {};
|
|
126
|
-
let anyModelSeen = null;
|
|
127
|
-
for (const line of text.split("\n")) {
|
|
128
|
-
if (!line) continue;
|
|
129
|
-
let obj;
|
|
130
|
-
try { obj = JSON.parse(line); } catch { continue; }
|
|
131
|
-
const msg = obj && obj.message;
|
|
132
|
-
const model = (msg && typeof msg.model === "string" && /^claude[-_]/i.test(msg.model)) ? msg.model
|
|
133
|
-
: (typeof obj.model === "string" && /^claude[-_]/i.test(obj.model)) ? obj.model
|
|
134
|
-
: null;
|
|
135
|
-
if (!model) continue;
|
|
136
|
-
anyModelSeen = model;
|
|
137
|
-
const isSide = obj.isSidechain === true || obj.is_sidechain === true;
|
|
138
|
-
const ptid = obj.parentToolUseID || obj.parent_tool_use_id || obj.parentToolUseId || null;
|
|
139
|
-
if (isSide && ptid) {
|
|
140
|
-
subagentModels[ptid] = model;
|
|
141
|
-
} else if (!isSide) {
|
|
142
|
-
rootModel = model;
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
if (!rootModel) rootModel = anyModelSeen;
|
|
146
|
-
if (!rootModel && Object.keys(subagentModels).length === 0) return null;
|
|
147
|
-
return { rootModel, subagentModels };
|
|
148
|
-
} catch {
|
|
149
|
-
return null;
|
|
150
|
-
}
|
|
302
|
+
export async function readModelFromTranscript(path) {
|
|
303
|
+
const state = await scanTranscript(path);
|
|
304
|
+
if (!state) return null;
|
|
305
|
+
const rootModel = state.rootModel ?? state.lastModel;
|
|
306
|
+
const subagentModels = { ...state.subagentModels };
|
|
307
|
+
if (!rootModel && Object.keys(subagentModels).length === 0) return null;
|
|
308
|
+
return { rootModel, subagentModels };
|
|
151
309
|
}
|
|
152
310
|
|
|
153
311
|
/** Newer CC schema (~2026-06): each subagent turn writes its OWN file at
|
|
@@ -173,30 +331,11 @@ async function readSubagentModelsFromDir(transcriptPath) {
|
|
|
173
331
|
const agentId = f.replace(/^agent-/, "").replace(/\.jsonl$/i, "");
|
|
174
332
|
const full = join(subDir, f);
|
|
175
333
|
try {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
const buf = Buffer.alloc(s.size);
|
|
182
|
-
await fh.read(buf, 0, s.size, 0);
|
|
183
|
-
text = buf.toString("utf8");
|
|
184
|
-
} finally {
|
|
185
|
-
await fh.close();
|
|
186
|
-
}
|
|
187
|
-
// Last-seen claude-* model wins — subagents may switch model mid-turn
|
|
188
|
-
// (Sonnet → Haiku for tool-call fallback etc.).
|
|
189
|
-
let last = null;
|
|
190
|
-
for (const line of text.split("\n")) {
|
|
191
|
-
if (!line) continue;
|
|
192
|
-
let obj;
|
|
193
|
-
try { obj = JSON.parse(line); } catch { continue; }
|
|
194
|
-
const msg = obj && obj.message;
|
|
195
|
-
const m = (msg && typeof msg.model === "string" && /^claude[-_]/i.test(msg.model)) ? msg.model
|
|
196
|
-
: (typeof obj.model === "string" && /^claude[-_]/i.test(obj.model)) ? obj.model
|
|
197
|
-
: null;
|
|
198
|
-
if (m) last = m;
|
|
199
|
-
}
|
|
334
|
+
// Same incremental cursor as the main transcript. Last-seen claude-*
|
|
335
|
+
// model wins — subagents may switch model mid-turn (Sonnet → Haiku for
|
|
336
|
+
// tool-call fallback etc.).
|
|
337
|
+
const state = await scanTranscript(full);
|
|
338
|
+
const last = state ? state.lastModel : null;
|
|
200
339
|
if (last) models[agentId] = last;
|
|
201
340
|
} catch { /* skip unreadable file */ }
|
|
202
341
|
}
|
|
@@ -250,47 +389,16 @@ const lastUsageReadAt = new Map(); // sid -> ms timestamp
|
|
|
250
389
|
const pendingUsageReads = new Set(); // sid currently being read
|
|
251
390
|
const USAGE_READ_THROTTLE_MS = 2500;
|
|
252
391
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
try {
|
|
264
|
-
buf = Buffer.alloc(s.size);
|
|
265
|
-
await fh.read(buf, 0, s.size, 0);
|
|
266
|
-
} finally {
|
|
267
|
-
await fh.close();
|
|
268
|
-
}
|
|
269
|
-
const text = buf.toString("utf8");
|
|
270
|
-
const totals = {
|
|
271
|
-
input_tokens: 0, output_tokens: 0,
|
|
272
|
-
cache_read_input_tokens: 0, cache_creation_input_tokens: 0,
|
|
273
|
-
};
|
|
274
|
-
// Match each `"usage":{...}` block and sum the four numeric fields.
|
|
275
|
-
// Regex is good enough — these blocks are flat single-level JSON.
|
|
276
|
-
const re = /"usage"\s*:\s*\{([^}]+)\}/g;
|
|
277
|
-
const grab = (blob, key) => {
|
|
278
|
-
const km = blob.match(new RegExp(`"${key}"\\s*:\\s*(\\d+)`));
|
|
279
|
-
return km ? Number(km[1]) : 0;
|
|
280
|
-
};
|
|
281
|
-
for (const m of text.matchAll(re)) {
|
|
282
|
-
const blob = m[1];
|
|
283
|
-
totals.input_tokens += grab(blob, "input_tokens");
|
|
284
|
-
totals.output_tokens += grab(blob, "output_tokens");
|
|
285
|
-
totals.cache_read_input_tokens += grab(blob, "cache_read_input_tokens");
|
|
286
|
-
totals.cache_creation_input_tokens += grab(blob, "cache_creation_input_tokens");
|
|
287
|
-
}
|
|
288
|
-
if (totals.input_tokens === 0 && totals.output_tokens === 0
|
|
289
|
-
&& totals.cache_read_input_tokens === 0 && totals.cache_creation_input_tokens === 0) return null;
|
|
290
|
-
return totals;
|
|
291
|
-
} catch {
|
|
292
|
-
return null;
|
|
293
|
-
}
|
|
392
|
+
// Every entry carries its own usage object and we sum every occurrence, so
|
|
393
|
+
// the totals are cumulative over the whole transcript — the running state
|
|
394
|
+
// keeps them across passes and each pass only adds the newly appended blocks.
|
|
395
|
+
export async function readUsageFromTranscript(path) {
|
|
396
|
+
const state = await scanTranscript(path);
|
|
397
|
+
if (!state) return null;
|
|
398
|
+
const totals = { ...state.usage };
|
|
399
|
+
if (totals.input_tokens === 0 && totals.output_tokens === 0
|
|
400
|
+
&& totals.cache_read_input_tokens === 0 && totals.cache_creation_input_tokens === 0) return null;
|
|
401
|
+
return totals;
|
|
294
402
|
}
|
|
295
403
|
|
|
296
404
|
function maybeResolveUsage(payload) {
|
|
@@ -323,61 +431,16 @@ const lastContextReadAt = new Map();
|
|
|
323
431
|
const pendingContextReads = new Set();
|
|
324
432
|
const CONTEXT_READ_THROTTLE_MS = 4000;
|
|
325
433
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
// into the same transcript file and resets its in-memory context window
|
|
337
|
-
// to ~0, but the JSONL keeps growing — every usage block before the
|
|
338
|
-
// clear marker is stale (pre-reset) and reading the LAST one made the
|
|
339
|
-
// donut report ~100% even though CC's actual context was empty. Same
|
|
340
|
-
// applies to `/compact`: it writes a summary and starts a fresh context.
|
|
341
|
-
// Slice the transcript to the segment AFTER the most recent reset so
|
|
342
|
-
// counts/usage reflect what CC is actually carrying forward.
|
|
343
|
-
const resetRe = /<command-name>\s*\/(?:clear|compact)\s*<\/command-name>/g;
|
|
344
|
-
let lastResetIdx = -1;
|
|
345
|
-
for (const m of fullText.matchAll(resetRe)) {
|
|
346
|
-
lastResetIdx = (m.index ?? -1) + m[0].length;
|
|
347
|
-
}
|
|
348
|
-
const text = lastResetIdx >= 0 ? fullText.slice(lastResetIdx) : fullText;
|
|
349
|
-
const breakdown = {
|
|
350
|
-
msgsUser: 0,
|
|
351
|
-
msgsAssistant: 0,
|
|
352
|
-
toolUses: 0,
|
|
353
|
-
toolResults: 0,
|
|
354
|
-
systemReminders: 0,
|
|
355
|
-
currentContextTokens: 0,
|
|
356
|
-
};
|
|
357
|
-
breakdown.msgsUser = (text.match(/"type"\s*:\s*"user"/g) ?? []).length;
|
|
358
|
-
breakdown.msgsAssistant = (text.match(/"type"\s*:\s*"assistant"/g) ?? []).length;
|
|
359
|
-
breakdown.toolUses = (text.match(/"type"\s*:\s*"tool_use"/g) ?? []).length;
|
|
360
|
-
breakdown.toolResults = (text.match(/"type"\s*:\s*"tool_result"/g) ?? []).length;
|
|
361
|
-
breakdown.systemReminders = (text.match(/<system-reminder>/g) ?? []).length;
|
|
362
|
-
// Current context size = input + cache_read + cache_create on the LAST
|
|
363
|
-
// usage block in the post-reset slice. If the user just ran /clear and
|
|
364
|
-
// hasn't sent a new prompt yet, this stays 0 (no usage blocks yet) —
|
|
365
|
-
// matches what CC's own `/context` would report.
|
|
366
|
-
const re = /"usage"\s*:\s*\{([^}]+)\}/g;
|
|
367
|
-
const grab = (blob, key) => {
|
|
368
|
-
const km = blob.match(new RegExp(`"${key}"\\s*:\\s*(\\d+)`));
|
|
369
|
-
return km ? Number(km[1]) : 0;
|
|
370
|
-
};
|
|
371
|
-
let lastBlob = null;
|
|
372
|
-
for (const m of text.matchAll(re)) lastBlob = m[1];
|
|
373
|
-
if (lastBlob) {
|
|
374
|
-
breakdown.currentContextTokens =
|
|
375
|
-
grab(lastBlob, "input_tokens") +
|
|
376
|
-
grab(lastBlob, "cache_read_input_tokens") +
|
|
377
|
-
grab(lastBlob, "cache_creation_input_tokens");
|
|
378
|
-
}
|
|
379
|
-
return breakdown;
|
|
380
|
-
} catch { return null; }
|
|
434
|
+
// The counts reset at every `/clear` or `/compact` marker (see
|
|
435
|
+
// foldTranscriptLine): CC resets its in-memory window there while the JSONL
|
|
436
|
+
// keeps growing, and reading the pre-reset blocks made the donut report ~100%
|
|
437
|
+
// on an empty context.
|
|
438
|
+
export async function readContextFromTranscript(path) {
|
|
439
|
+
const state = await scanTranscript(path);
|
|
440
|
+
// Nothing folded yet — the file is empty, unreadable, or has no complete
|
|
441
|
+
// line. Callers treat that as "no breakdown", same as before.
|
|
442
|
+
if (!state || state.offset === 0) return null;
|
|
443
|
+
return { ...state.ctx };
|
|
381
444
|
}
|
|
382
445
|
|
|
383
446
|
/** Encode an absolute path the way CC stores it under
|
|
@@ -671,19 +734,6 @@ async function listRecentCodexRollouts() {
|
|
|
671
734
|
return out;
|
|
672
735
|
}
|
|
673
736
|
|
|
674
|
-
async function readByteRange(path, from, to) {
|
|
675
|
-
const fh = await open(path, "r");
|
|
676
|
-
try {
|
|
677
|
-
const len = to - from;
|
|
678
|
-
if (len <= 0) return "";
|
|
679
|
-
const buf = Buffer.alloc(len);
|
|
680
|
-
await fh.read(buf, 0, len, from);
|
|
681
|
-
return buf.toString("utf8");
|
|
682
|
-
} finally {
|
|
683
|
-
await fh.close();
|
|
684
|
-
}
|
|
685
|
-
}
|
|
686
|
-
|
|
687
737
|
// Read the first complete JSON line of a rollout (the session_meta header)
|
|
688
738
|
// to learn sid + cwd before we start streaming. The header line can be large
|
|
689
739
|
// (base_instructions text runs tens of KB), so we read in growing chunks until
|