@henryz2004/agency 1.0.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 +106 -0
- package/lib/codex.js +211 -0
- package/lib/control.js +168 -0
- package/lib/live.js +493 -0
- package/lib/opencode.js +447 -0
- package/lib/paths.js +12 -0
- package/lib/roster.js +204 -0
- package/lib/transcript.js +361 -0
- package/lib/usage.js +346 -0
- package/package.json +27 -0
- package/public/app.js +1021 -0
- package/public/audio-controls.js +165 -0
- package/public/avatar.js +467 -0
- package/public/characters/dev-auburn.json +32 -0
- package/public/characters/dev-auburn.png +0 -0
- package/public/characters/dev-beanie.json +32 -0
- package/public/characters/dev-beanie.png +0 -0
- package/public/characters/dev-glasses.json +32 -0
- package/public/characters/dev-glasses.png +0 -0
- package/public/chat-panel.css +514 -0
- package/public/chat-panel.js +815 -0
- package/public/index.html +190 -0
- package/public/lab.html +129 -0
- package/public/leaderboard.js +222 -0
- package/public/metric.js +34 -0
- package/public/mock-agents.js +70 -0
- package/public/mock.js +277 -0
- package/public/music/Console_Morning.mp3 +0 -0
- package/public/music/Midnight_Desk.mp3 +0 -0
- package/public/music/The_Plant_Beside_the_Door.mp3 +0 -0
- package/public/music/Three_AM_Window.mp3 +0 -0
- package/public/office.js +1484 -0
- package/public/sound.js +382 -0
- package/public/sprites.js +983 -0
- package/public/style.css +506 -0
- package/public/ui.js +50 -0
- package/scripts/_pixpng.mjs +104 -0
- package/scripts/animsheet.mjs +60 -0
- package/scripts/charsheet.mjs +61 -0
- package/scripts/install-hook.mjs +120 -0
- package/server.js +370 -0
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
// transcript.js — read-only "peek" into a Claude Code session's transcript so
|
|
2
|
+
// the UI can show the last few turns of a chat. Like the rest of Agency this
|
|
3
|
+
// NEVER writes to or executes against the session: getTranscript only reads the
|
|
4
|
+
// trailing slice of the on-disk JSONL transcript and returns a `resumeCmd`
|
|
5
|
+
// STRING for the UI to display/copy (it is never run here).
|
|
6
|
+
//
|
|
7
|
+
// Transcript path mapping mirrors lib/live.js: Claude encodes a session's cwd
|
|
8
|
+
// into the projects dir name by replacing every '/' and '.' with '-', and names
|
|
9
|
+
// the transcript <sessionId>.jsonl inside it. We duplicate that tiny mapping
|
|
10
|
+
// here rather than reach into live.js internals.
|
|
11
|
+
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import os from 'node:os';
|
|
15
|
+
|
|
16
|
+
const HOME = os.homedir();
|
|
17
|
+
const PROJECTS_DIR = path.join(HOME, '.claude', 'projects');
|
|
18
|
+
const TAIL_BYTES = 256 * 1024; // messages + lastAction read this much from the file end
|
|
19
|
+
// Metrics scan a LARGER window than the message/lastAction tail: on a hyper-active
|
|
20
|
+
// session a few giant entries (big tool_results) can fill 256KB and crowd recent
|
|
21
|
+
// small assistant entries out of it, intermittently yielding 0 tool calls / 0
|
|
22
|
+
// tokens. Reading ~1MB keeps the 30-min counts stable while staying BOUNDED (never
|
|
23
|
+
// the whole file). Past this bound we conservatively undercount, never overcount.
|
|
24
|
+
const METRICS_BYTES = 1024 * 1024;
|
|
25
|
+
|
|
26
|
+
// Resolve a session's transcript path the way live.js does: prefer the project
|
|
27
|
+
// dir derived from cwd; otherwise scan the projects dir for <sessionId>.jsonl.
|
|
28
|
+
function resolvePath(sessionId, cwd) {
|
|
29
|
+
if (cwd) {
|
|
30
|
+
const encoded = cwd.replace(/[/.]/g, '-');
|
|
31
|
+
const f = path.join(PROJECTS_DIR, encoded, `${sessionId}.jsonl`);
|
|
32
|
+
if (fs.existsSync(f)) return f;
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
for (const dir of fs.readdirSync(PROJECTS_DIR)) {
|
|
36
|
+
const f = path.join(PROJECTS_DIR, dir, `${sessionId}.jsonl`);
|
|
37
|
+
if (fs.existsSync(f)) return f;
|
|
38
|
+
}
|
|
39
|
+
} catch {
|
|
40
|
+
/* ignore — missing projects dir */
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Pull the displayable text out of one transcript line's message.content, which
|
|
46
|
+
// is either a plain string or an array of blocks; we join the text of the
|
|
47
|
+
// type:"text" blocks (skipping tool_use / tool_result / thinking blocks).
|
|
48
|
+
function extractText(content) {
|
|
49
|
+
if (typeof content === 'string') return content.trim();
|
|
50
|
+
if (!Array.isArray(content)) return '';
|
|
51
|
+
const parts = [];
|
|
52
|
+
for (const b of content) {
|
|
53
|
+
if (b && b.type === 'text' && typeof b.text === 'string') parts.push(b.text);
|
|
54
|
+
}
|
|
55
|
+
return parts.join('\n').trim();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Read the trailing `bytes` of `file` (cheap; avoids loading huge transcripts),
|
|
59
|
+
// mirroring live.js's readSessionMeta windowing. Defaults to TAIL_BYTES; metrics
|
|
60
|
+
// pass METRICS_BYTES for a wider, stable window. The first line of the slice is
|
|
61
|
+
// likely a partial JSON line and is skipped by the parse try/catch below.
|
|
62
|
+
function readTail(file, bytes = TAIL_BYTES) {
|
|
63
|
+
let fd;
|
|
64
|
+
try {
|
|
65
|
+
const st = fs.fstatSync((fd = fs.openSync(file, 'r')));
|
|
66
|
+
const len = Math.min(st.size, bytes);
|
|
67
|
+
const buf = Buffer.alloc(len);
|
|
68
|
+
fs.readSync(fd, buf, 0, len, st.size - len);
|
|
69
|
+
return buf.toString('utf8');
|
|
70
|
+
} finally {
|
|
71
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Human-readable summary of one tool_use block — "what the agent is doing right
|
|
76
|
+
// now". Picks the most informative argument (file path, command, query, url, …)
|
|
77
|
+
// as the target. Returns { tool, target } or null for an unrecognized block.
|
|
78
|
+
function describeTool(b) {
|
|
79
|
+
if (!b || b.type !== 'tool_use' || !b.name) return null;
|
|
80
|
+
const inp = b.input || {};
|
|
81
|
+
const base = (p) => (typeof p === 'string' ? p.replace(/\/+$/, '').split('/').pop() || p : null);
|
|
82
|
+
const clip = (s, n = 60) => {
|
|
83
|
+
if (typeof s !== 'string') return null;
|
|
84
|
+
const c = s.replace(/\s+/g, ' ').trim();
|
|
85
|
+
return c.length > n ? c.slice(0, n - 1) + '…' : c;
|
|
86
|
+
};
|
|
87
|
+
const name = b.name;
|
|
88
|
+
let verb = name;
|
|
89
|
+
let target = null;
|
|
90
|
+
switch (name) {
|
|
91
|
+
case 'Edit':
|
|
92
|
+
case 'Write':
|
|
93
|
+
case 'NotebookEdit':
|
|
94
|
+
verb = name === 'Write' ? 'Writing' : 'Editing';
|
|
95
|
+
target = base(inp.file_path || inp.notebook_path);
|
|
96
|
+
break;
|
|
97
|
+
case 'Read':
|
|
98
|
+
verb = 'Reading';
|
|
99
|
+
target = base(inp.file_path);
|
|
100
|
+
break;
|
|
101
|
+
case 'Bash':
|
|
102
|
+
verb = 'Running';
|
|
103
|
+
target = clip(inp.command, 70);
|
|
104
|
+
break;
|
|
105
|
+
case 'Grep':
|
|
106
|
+
verb = 'Searching';
|
|
107
|
+
target = clip(inp.pattern, 40);
|
|
108
|
+
break;
|
|
109
|
+
case 'Glob':
|
|
110
|
+
verb = 'Globbing';
|
|
111
|
+
target = clip(inp.pattern, 40);
|
|
112
|
+
break;
|
|
113
|
+
case 'Task':
|
|
114
|
+
case 'Agent':
|
|
115
|
+
verb = 'Dispatching';
|
|
116
|
+
target = clip(inp.description || inp.subagent_type || inp.agentType, 50);
|
|
117
|
+
break;
|
|
118
|
+
case 'WebFetch':
|
|
119
|
+
verb = 'Fetching';
|
|
120
|
+
target = clip(inp.url, 50);
|
|
121
|
+
break;
|
|
122
|
+
case 'WebSearch':
|
|
123
|
+
verb = 'Searching the web';
|
|
124
|
+
target = clip(inp.query, 50);
|
|
125
|
+
break;
|
|
126
|
+
case 'TodoWrite':
|
|
127
|
+
verb = 'Updating the plan';
|
|
128
|
+
break;
|
|
129
|
+
default:
|
|
130
|
+
// MCP tools read as "mcp__server__tool" — surface a tidy name.
|
|
131
|
+
verb = String(name).replace(/^mcp__/, '').replace(/__/g, ' · ');
|
|
132
|
+
target = clip(inp.path || inp.file_path || inp.query || inp.command, 50);
|
|
133
|
+
}
|
|
134
|
+
return { tool: name, verb, target };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Scan JSONL text for the agent's CURRENT action: the most recent assistant
|
|
138
|
+
// tool_use whose tool_result hasn't come back yet (i.e. the one genuinely in
|
|
139
|
+
// flight). If every tool has already returned, there is no current action and we
|
|
140
|
+
// return null — we never report a historical "last did X", because an idle agent
|
|
141
|
+
// must not look like it's still running its last-ever command.
|
|
142
|
+
// Caveat: this only sees the trailing ~256KB (readTail). A tool_use whose
|
|
143
|
+
// matching tool_result scrolled out of that window looks in-flight and may be
|
|
144
|
+
// reported even though it has returned; that's an accepted limitation.
|
|
145
|
+
// Returns { tool, verb, target } or null. Exported for unit-testing without the fs.
|
|
146
|
+
export function parseLastAction(text) {
|
|
147
|
+
const lines = text.split('\n');
|
|
148
|
+
const finished = new Set(); // tool_use ids that have a matching tool_result
|
|
149
|
+
const uses = []; // { id, desc } in file order (oldest → newest)
|
|
150
|
+
for (const line of lines) {
|
|
151
|
+
if (!line) continue;
|
|
152
|
+
let o;
|
|
153
|
+
try {
|
|
154
|
+
o = JSON.parse(line);
|
|
155
|
+
} catch {
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
const content = o.message && Array.isArray(o.message.content) ? o.message.content : null;
|
|
159
|
+
if (!content) continue;
|
|
160
|
+
if (o.type === 'assistant') {
|
|
161
|
+
for (const b of content) {
|
|
162
|
+
if (b && b.type === 'tool_use') {
|
|
163
|
+
const desc = describeTool(b);
|
|
164
|
+
if (desc) uses.push({ id: b.id || null, desc });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
} else if (o.type === 'user') {
|
|
168
|
+
for (const b of content) {
|
|
169
|
+
if (b && b.type === 'tool_result' && b.tool_use_id) finished.add(b.tool_use_id);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (!uses.length) return null;
|
|
174
|
+
// Return the newest still-in-flight tool_use (its result hasn't arrived).
|
|
175
|
+
for (let i = uses.length - 1; i >= 0; i--) {
|
|
176
|
+
if (!uses[i].id || !finished.has(uses[i].id)) return uses[i].desc;
|
|
177
|
+
}
|
|
178
|
+
// Everything has returned — the agent is idle, so there is no current action.
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Scan JSONL text once for recent-activity metrics over the last `windowMs`:
|
|
183
|
+
// `toolCalls30m` (tool_use blocks in assistant messages timestamped within the
|
|
184
|
+
// window) and `tokensOut30m` (their summed message.usage.output_tokens). Token
|
|
185
|
+
// shape mirrors usage.js: assistant lines carry o.message.usage.output_tokens
|
|
186
|
+
// and an o.message.content array whose type:"tool_use" blocks are the calls.
|
|
187
|
+
// Fail-soft: a line with a bad/absent timestamp or missing usage contributes 0,
|
|
188
|
+
// never throws. Window is named "30m" in the field names but honors windowMs.
|
|
189
|
+
// Exported so the metrics are unit-testable without the fs.
|
|
190
|
+
export function parseRecentMetrics(text, windowMs = 30 * 60 * 1000) {
|
|
191
|
+
const cutoff = Date.now() - windowMs;
|
|
192
|
+
let toolCalls30m = 0;
|
|
193
|
+
let tokensOut30m = 0;
|
|
194
|
+
for (const line of text.split('\n')) {
|
|
195
|
+
if (!line) continue;
|
|
196
|
+
let o;
|
|
197
|
+
try {
|
|
198
|
+
o = JSON.parse(line);
|
|
199
|
+
} catch {
|
|
200
|
+
continue; // partial line at the head of the tail window, or non-JSON
|
|
201
|
+
}
|
|
202
|
+
if (o.type !== 'assistant') continue;
|
|
203
|
+
const ts = o.timestamp ? new Date(o.timestamp).getTime() : NaN;
|
|
204
|
+
if (!Number.isFinite(ts) || ts < cutoff) continue; // bad/absent/old timestamp
|
|
205
|
+
const msg = o.message || {};
|
|
206
|
+
const out = msg.usage && msg.usage.output_tokens;
|
|
207
|
+
if (Number.isFinite(out)) tokensOut30m += out;
|
|
208
|
+
if (Array.isArray(msg.content)) {
|
|
209
|
+
for (const b of msg.content) {
|
|
210
|
+
if (b && b.type === 'tool_use') toolCalls30m++;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return { toolCalls30m, tokensOut30m, windowMin: Math.round(windowMs / 60000) };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Parse JSONL text → the last `limit` user/assistant turns as
|
|
218
|
+
// [{ role, text, ts }]. Exported so the parser is unit-testable without the fs.
|
|
219
|
+
export function parseTranscript(text, limit = 25) {
|
|
220
|
+
const out = [];
|
|
221
|
+
for (const line of text.split('\n')) {
|
|
222
|
+
if (!line) continue;
|
|
223
|
+
let o;
|
|
224
|
+
try {
|
|
225
|
+
o = JSON.parse(line);
|
|
226
|
+
} catch {
|
|
227
|
+
continue; // partial line at the head of the tail window, or non-JSON
|
|
228
|
+
}
|
|
229
|
+
if (o.type !== 'user' && o.type !== 'assistant') continue;
|
|
230
|
+
const content = o.message ? o.message.content : undefined;
|
|
231
|
+
const txt = extractText(content);
|
|
232
|
+
if (!txt) continue; // e.g. a turn that was only a tool_use / tool_result
|
|
233
|
+
out.push({ role: o.type, text: txt, ts: o.timestamp || null });
|
|
234
|
+
}
|
|
235
|
+
return out.slice(-limit);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Public API: a read-only peek at the tail of a session's transcript. Returns
|
|
239
|
+
// { messages, lastAction, metrics, resumeCmd, path }. `lastAction` is what the
|
|
240
|
+
// agent is doing right now (its in-flight tool_use) for the agent-detail
|
|
241
|
+
// "current task" view, or null if it's idle. `metrics` is the last-30-minutes
|
|
242
|
+
// activity summary ({ toolCalls30m, tokensOut30m, windowMin }). Fails soft —
|
|
243
|
+
// any error yields no messages / null lastAction / zeroed metrics and path:null,
|
|
244
|
+
// never throws.
|
|
245
|
+
export function getTranscript(sessionId, cwd, { limit = 25 } = {}) {
|
|
246
|
+
// resumeCmd is a STRING for the UI to show/copy; it is never executed here.
|
|
247
|
+
const resumeCmd = `cd ${cwd || '.'} && claude --resume ${sessionId}`;
|
|
248
|
+
const emptyMetrics = { toolCalls30m: 0, tokensOut30m: 0, windowMin: 30 };
|
|
249
|
+
try {
|
|
250
|
+
const file = resolvePath(sessionId, cwd);
|
|
251
|
+
if (!file) return { messages: [], lastAction: null, metrics: emptyMetrics, resumeCmd, path: null };
|
|
252
|
+
// Read the wider metrics window once, then derive the cheaper message/lastAction
|
|
253
|
+
// tail as its trailing slice — one file read, metrics get the full window.
|
|
254
|
+
const metricsText = readTail(file, METRICS_BYTES);
|
|
255
|
+
const text =
|
|
256
|
+
metricsText.length > TAIL_BYTES ? metricsText.slice(metricsText.length - TAIL_BYTES) : metricsText;
|
|
257
|
+
const messages = parseTranscript(text, limit);
|
|
258
|
+
const lastAction = parseLastAction(text);
|
|
259
|
+
const metrics = parseRecentMetrics(metricsText);
|
|
260
|
+
return { messages, lastAction, metrics, resumeCmd, path: file };
|
|
261
|
+
} catch {
|
|
262
|
+
return { messages: [], lastAction: null, metrics: emptyMetrics, resumeCmd, path: null };
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Tiny self-check (no test framework in this repo):
|
|
267
|
+
// node lib/transcript.js → run the synthetic parser assert
|
|
268
|
+
// node lib/transcript.js <sessionId> <cwd> → peek a real transcript
|
|
269
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
270
|
+
const [sessionId, cwd] = process.argv.slice(2);
|
|
271
|
+
if (sessionId) {
|
|
272
|
+
console.log(JSON.stringify(getTranscript(sessionId, cwd), null, 2));
|
|
273
|
+
} else {
|
|
274
|
+
const assert = (await import('node:assert')).default;
|
|
275
|
+
const jsonl = [
|
|
276
|
+
JSON.stringify({ type: 'summary', summary: 'ignored' }),
|
|
277
|
+
JSON.stringify({ type: 'user', timestamp: 't1', message: { content: 'hi there' } }),
|
|
278
|
+
JSON.stringify({
|
|
279
|
+
type: 'assistant',
|
|
280
|
+
timestamp: 't2',
|
|
281
|
+
message: {
|
|
282
|
+
content: [
|
|
283
|
+
{ type: 'text', text: 'hello' },
|
|
284
|
+
{ type: 'tool_use', name: 'Bash', input: {} },
|
|
285
|
+
],
|
|
286
|
+
},
|
|
287
|
+
}),
|
|
288
|
+
JSON.stringify({
|
|
289
|
+
type: 'user',
|
|
290
|
+
message: { content: [{ type: 'tool_result', tool_use_id: 'x' }] },
|
|
291
|
+
}), // tool-only turn → no text → dropped
|
|
292
|
+
'{ this is a partial/garbage line', // unparseable → skipped
|
|
293
|
+
].join('\n');
|
|
294
|
+
|
|
295
|
+
const msgs = parseTranscript(jsonl);
|
|
296
|
+
assert.strictEqual(msgs.length, 2, 'two text-bearing turns survive');
|
|
297
|
+
assert.deepStrictEqual(msgs[0], { role: 'user', text: 'hi there', ts: 't1' });
|
|
298
|
+
assert.deepStrictEqual(msgs[1], { role: 'assistant', text: 'hello', ts: 't2' });
|
|
299
|
+
assert.strictEqual(parseTranscript(jsonl, 1).length, 1, 'limit slices from the end');
|
|
300
|
+
assert.deepStrictEqual(parseTranscript(jsonl, 1)[0].role, 'assistant', 'keeps the LAST turn');
|
|
301
|
+
|
|
302
|
+
// parseLastAction: an in-flight Edit (no matching tool_result) wins over an
|
|
303
|
+
// earlier, already-returned Bash.
|
|
304
|
+
const actJsonl = [
|
|
305
|
+
JSON.stringify({
|
|
306
|
+
type: 'assistant',
|
|
307
|
+
message: { content: [{ type: 'tool_use', id: 'b1', name: 'Bash', input: { command: 'npm test' } }] },
|
|
308
|
+
}),
|
|
309
|
+
JSON.stringify({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 'b1' }] } }),
|
|
310
|
+
JSON.stringify({
|
|
311
|
+
type: 'assistant',
|
|
312
|
+
message: { content: [{ type: 'tool_use', id: 'e1', name: 'Edit', input: { file_path: '/a/b/render.js' } }] },
|
|
313
|
+
}),
|
|
314
|
+
].join('\n');
|
|
315
|
+
const act = parseLastAction(actJsonl);
|
|
316
|
+
assert.strictEqual(act.verb, 'Editing', 'in-flight Edit is the current action');
|
|
317
|
+
assert.strictEqual(act.target, 'render.js', 'Edit target is the basename');
|
|
318
|
+
assert.strictEqual(parseLastAction('') , null, 'no actions → null');
|
|
319
|
+
// When everything has returned, there is no current action → null (no stale
|
|
320
|
+
// "last did X" fallback): an idle agent must not look like it's still running.
|
|
321
|
+
const doneJsonl = [
|
|
322
|
+
JSON.stringify({ type: 'assistant', message: { content: [{ type: 'tool_use', id: 'r1', name: 'Read', input: { file_path: '/x/y/usage.js' } }] } }),
|
|
323
|
+
JSON.stringify({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 'r1' }] } }),
|
|
324
|
+
].join('\n');
|
|
325
|
+
assert.strictEqual(parseLastAction(doneJsonl), null, 'everything returned → no current action');
|
|
326
|
+
|
|
327
|
+
// parseRecentMetrics: an assistant entry timestamped "now" carrying usage and
|
|
328
|
+
// two tool_use blocks is counted; an entry from 2 hours ago is excluded.
|
|
329
|
+
const metricsJsonl = [
|
|
330
|
+
JSON.stringify({
|
|
331
|
+
type: 'assistant',
|
|
332
|
+
timestamp: new Date(Date.now()).toISOString(),
|
|
333
|
+
message: {
|
|
334
|
+
usage: { output_tokens: 123 },
|
|
335
|
+
content: [
|
|
336
|
+
{ type: 'text', text: 'working' },
|
|
337
|
+
{ type: 'tool_use', id: 'm1', name: 'Read', input: { file_path: '/a/b.js' } },
|
|
338
|
+
{ type: 'tool_use', id: 'm2', name: 'Bash', input: { command: 'ls' } },
|
|
339
|
+
],
|
|
340
|
+
},
|
|
341
|
+
}),
|
|
342
|
+
JSON.stringify({
|
|
343
|
+
type: 'assistant',
|
|
344
|
+
timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
|
|
345
|
+
message: {
|
|
346
|
+
usage: { output_tokens: 999 },
|
|
347
|
+
content: [{ type: 'tool_use', id: 'old1', name: 'Edit', input: { file_path: '/c/d.js' } }],
|
|
348
|
+
},
|
|
349
|
+
}),
|
|
350
|
+
].join('\n');
|
|
351
|
+
const m = parseRecentMetrics(metricsJsonl);
|
|
352
|
+
assert.strictEqual(m.toolCalls30m, 2, 'only the recent entry\'s tool_use blocks are counted');
|
|
353
|
+
assert.strictEqual(m.tokensOut30m, 123, 'only the recent entry\'s output_tokens are summed');
|
|
354
|
+
assert.strictEqual(m.windowMin, 30, 'window reported in minutes');
|
|
355
|
+
// Fail-soft: a bad timestamp and missing usage contribute nothing, no throw.
|
|
356
|
+
const badJsonl = JSON.stringify({ type: 'assistant', timestamp: 'not-a-date', message: { content: [{ type: 'tool_use', id: 'z' }] } });
|
|
357
|
+
assert.deepStrictEqual(parseRecentMetrics(badJsonl), { toolCalls30m: 0, tokensOut30m: 0, windowMin: 30 }, 'bad timestamp skipped');
|
|
358
|
+
|
|
359
|
+
console.log('transcript.js self-check OK');
|
|
360
|
+
}
|
|
361
|
+
}
|