cchubber 0.5.7 โ 0.6.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 +7 -0
- package/package.json +1 -1
- package/src/analyzers/cost-calculator.js +11 -0
- package/src/analyzers/drift-privacy.js +102 -0
- package/src/analyzers/drift-run.js +34 -0
- package/src/analyzers/drift.js +454 -0
- package/src/analyzers/reprice.js +154 -0
- package/src/cli/index.js +159 -11
- package/src/data/frontier-models.js +12 -0
- package/src/data/price-overrides.js +16 -0
- package/src/data/price-snapshot.json +49 -0
- package/src/readers/jsonl-reader.js +11 -14
- package/src/readers/plan.js +56 -0
- package/src/readers/stated-plan.js +187 -0
- package/src/readers/transcripts.js +259 -0
- package/src/renderers/drift-report.js +255 -0
- package/src/renderers/html-report.js +1301 -1272
- package/src/renderers/s1-report.js +166 -0
- package/src/renderers/spent-well.js +171 -0
- package/src/renderers/terminal-summary.js +10 -0
- package/src/renderers/vs-report.js +439 -0
- package/src/renderers/vs-text.js +149 -0
- package/src/telemetry.js +19 -3
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { readFileSync, existsSync, statSync } from 'fs';
|
|
2
|
+
import { join, dirname, resolve, relative, isAbsolute, sep } from 'path';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* "What you said you'd do", when you wrote it down. Two kinds of written plan are read, both local:
|
|
7
|
+
* - Mover OS: the day's Daily Note (Focus and Tasks), and the plan.md of the vault project a session ran in
|
|
8
|
+
* - anywhere else: a PLAN.md, plan.md or TODO.md in the session's project folder
|
|
9
|
+
* Only checklist items and focus lines are read. Nothing else in these files is used, and nothing leaves the machine.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const PLAN_NAMES = ['PLAN.md', 'plan.md', 'TODO.md', 'todo.md'];
|
|
13
|
+
// A plan with more open items than this is a long-running tracker, not a short to-do list. It is still used, as the
|
|
14
|
+
// project's vocabulary, but the report says "on the project" rather than "on this week's list".
|
|
15
|
+
export const BIG_PLAN_ITEMS = 60;
|
|
16
|
+
const CHECKBOX = /^\s*[-*+]\s+\[([ xX~\/>-])\]\s+(.+)$/;
|
|
17
|
+
|
|
18
|
+
// Mover's vault, from ~/.mover/config.json. Only vaultPath is read.
|
|
19
|
+
export function moverVault(configPath = join(homedir(), '.mover', 'config.json')) {
|
|
20
|
+
if (!existsSync(configPath)) return null;
|
|
21
|
+
try {
|
|
22
|
+
const vault = JSON.parse(readFileSync(configPath, 'utf-8')).vaultPath;
|
|
23
|
+
return typeof vault === 'string' && existsSync(vault) ? vault : null;
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Checklist items and focus lines, cleaned of Obsidian syntax and Mover's bookkeeping tags
|
|
30
|
+
export function cleanItem(text) {
|
|
31
|
+
return String(text)
|
|
32
|
+
.replace(/\[\[([^\]|]+)\|([^\]]+)\]\]/g, '$2')
|
|
33
|
+
.replace(/\[\[([^\]]+)\]\]/g, '$1')
|
|
34
|
+
.replace(/\s\^[\w-]+\s*$/, '')
|
|
35
|
+
.replace(/\[(Added|Due|Moved|Carried|From|UNVERIFIED|HYPOTHESIS ONLY)[^\]]*\]/gi, '')
|
|
36
|
+
.replace(/[*_`]/g, '')
|
|
37
|
+
.replace(/\s+/g, ' ')
|
|
38
|
+
.trim();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// A template placeholder like "[The ONE thing that makes today a win]" is not a plan
|
|
42
|
+
const isPlaceholder = (s) => !s || /^\[.*\]$/.test(s) || /^\(.*\)$/.test(s);
|
|
43
|
+
|
|
44
|
+
// Section headers that are structure or bookkeeping, not a statement of work
|
|
45
|
+
const BORING_HEADER = /^(#+\s*)?(\d+\.|phase\b|execution log|the roadmap|the north star|the architecture|changelog|notes|project control|deferred|resolved|table of|contents|appendix|status|done|completed|in progress|todo|backlog|log\b)/i;
|
|
46
|
+
|
|
47
|
+
export function parsePlanText(text) {
|
|
48
|
+
const open = []; // still-to-do items: the plain reading of "what you said you'd do"
|
|
49
|
+
const all = []; // every item, open or done, plus real section headers: the project's stated work, for matching
|
|
50
|
+
for (const line of String(text).split('\n')) {
|
|
51
|
+
const m = line.match(CHECKBOX);
|
|
52
|
+
if (m) {
|
|
53
|
+
const item = cleanItem(m[2]);
|
|
54
|
+
if (isPlaceholder(item) || m[1] === '-') continue; // placeholder or dropped
|
|
55
|
+
all.push(item);
|
|
56
|
+
// [x] done, [~] done but unchecked in Mover's convention; everything else is still open
|
|
57
|
+
if (!/[xX~]/.test(m[1])) open.push(item);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
// Section headers carry the intent of long trackers (Mover writes the verbatim brief into each "## R157 ..."),
|
|
61
|
+
// which the terse checkboxes below them do not. Skip the structural and bookkeeping ones.
|
|
62
|
+
const h = line.match(/^(#{2,4})\s+(.+?)\s*#*$/);
|
|
63
|
+
if (h && !BORING_HEADER.test(line)) {
|
|
64
|
+
const item = cleanItem(h[2].replace(/[๐๐๏ธโก๐งน๐ง ๐๐๐โ
โ๏ธ๐๐ง]/gu, ''));
|
|
65
|
+
if (item.length > 6 && !isPlaceholder(item)) all.push(item);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { open, all };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// A Daily Note's Focus lines (Single Test, The Hard Thing) and its task list. The Sacrifice is what you said you
|
|
72
|
+
// would NOT do, so it is left out.
|
|
73
|
+
export function parseDailyNote(text) {
|
|
74
|
+
const items = [];
|
|
75
|
+
let section = null;
|
|
76
|
+
for (const line of String(text).split('\n')) {
|
|
77
|
+
const h = line.match(/^##\s+(.+?)\s*$/);
|
|
78
|
+
if (h) { section = /^focus/i.test(h[1]) ? 'focus' : /^tasks/i.test(h[1]) ? 'tasks' : null; continue; }
|
|
79
|
+
if (/^#\s/.test(line)) { section = null; continue; }
|
|
80
|
+
if (section === 'focus') {
|
|
81
|
+
const f = line.match(/^\s*[-*]\s+\*\*(Single Test|The Hard Thing|Focus|Main Quest)[^*]*\*\*:?\s*(.+)$/i);
|
|
82
|
+
if (f) {
|
|
83
|
+
const item = cleanItem(f[2]);
|
|
84
|
+
if (!isPlaceholder(item)) items.push(item);
|
|
85
|
+
}
|
|
86
|
+
} else if (section === 'tasks') {
|
|
87
|
+
const m = line.match(CHECKBOX);
|
|
88
|
+
if (m && m[1] !== '-') {
|
|
89
|
+
const item = cleanItem(m[2]);
|
|
90
|
+
if (!isPlaceholder(item)) items.push(item);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return items;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function dailyNotePath(vault, dayKey) {
|
|
98
|
+
return join(vault, '02_Areas', 'Engine', 'Dailies', dayKey.slice(0, 7), `Daily - ${dayKey}.md`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const inside = (child, parent) => {
|
|
102
|
+
const rel = relative(parent, child);
|
|
103
|
+
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// The plan file for a session's folder: in a Mover vault, the project's plan.md (or dev/plan.md); anywhere else,
|
|
107
|
+
// the nearest PLAN.md / plan.md / TODO.md between the folder and its git root
|
|
108
|
+
export function findPlanFile(cwd, vault) {
|
|
109
|
+
if (!cwd) return null;
|
|
110
|
+
const dir = resolve(cwd);
|
|
111
|
+
if (vault && inside(dir, join(vault, '01_Projects'))) {
|
|
112
|
+
const project = relative(join(vault, '01_Projects'), dir).split(sep)[0];
|
|
113
|
+
if (!project) return null;
|
|
114
|
+
const root = join(vault, '01_Projects', project);
|
|
115
|
+
for (const f of ['plan.md', join('dev', 'plan.md')]) if (existsSync(join(root, f))) return join(root, f);
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
const home = homedir();
|
|
119
|
+
let d = dir;
|
|
120
|
+
for (let i = 0; i < 6; i++) {
|
|
121
|
+
for (const name of PLAN_NAMES) {
|
|
122
|
+
const f = join(d, name);
|
|
123
|
+
try { if (statSync(f).isFile()) return f; } catch { /* not here */ }
|
|
124
|
+
}
|
|
125
|
+
if (existsSync(join(d, '.git')) || d === home) break;
|
|
126
|
+
const up = dirname(d);
|
|
127
|
+
if (up === d) break;
|
|
128
|
+
d = up;
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Plan lookup for the analyzer: planFor(cwd, dayKey) returns { sources: [...], items } or null.
|
|
135
|
+
* `notes` records plans that were found and set aside, so the report can say why.
|
|
136
|
+
*/
|
|
137
|
+
export function readStatedPlans({ vault = moverVault() } = {}) {
|
|
138
|
+
const planCache = new Map();
|
|
139
|
+
const dailyCache = new Map();
|
|
140
|
+
const notes = new Map();
|
|
141
|
+
|
|
142
|
+
const plan = (cwd) => {
|
|
143
|
+
const file = findPlanFile(cwd, vault);
|
|
144
|
+
if (!file) return null;
|
|
145
|
+
if (!planCache.has(file)) {
|
|
146
|
+
let parsed = null;
|
|
147
|
+
try { parsed = parsePlanText(readFileSync(file, 'utf-8')); } catch { /* unreadable */ }
|
|
148
|
+
const name = file.split(sep).pop();
|
|
149
|
+
if (parsed && parsed.all.length) {
|
|
150
|
+
const big = parsed.open.length > BIG_PLAN_ITEMS;
|
|
151
|
+
// A short to-do list is matched item by item. A long tracker is matched as a whole: any of the project's
|
|
152
|
+
// work counts as on the plan, so "on-plan" reads as "on this project".
|
|
153
|
+
planCache.set(file, { name, items: parsed.all, scope: big ? 'project' : 'list', open: parsed.open.length });
|
|
154
|
+
if (big) notes.set(file, { name, open: parsed.open.length, scope: 'project' });
|
|
155
|
+
} else {
|
|
156
|
+
planCache.set(file, null);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return planCache.get(file);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const daily = (dayKey) => {
|
|
163
|
+
if (!vault) return null;
|
|
164
|
+
if (!dailyCache.has(dayKey)) {
|
|
165
|
+
const file = dailyNotePath(vault, dayKey);
|
|
166
|
+
let items = [];
|
|
167
|
+
try { items = parseDailyNote(readFileSync(file, 'utf-8')); } catch { /* no note that day */ }
|
|
168
|
+
dailyCache.set(dayKey, items.length ? { name: 'Daily Note', items, scope: 'list' } : null);
|
|
169
|
+
}
|
|
170
|
+
return dailyCache.get(dayKey);
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
mover: Boolean(vault),
|
|
175
|
+
// A day's Daily-Note focus is the sharpest statement of intent, so it leads; the project's plan backs it.
|
|
176
|
+
planFor(cwd, dayKey) {
|
|
177
|
+
const found = [daily(dayKey), plan(cwd)].filter(Boolean);
|
|
178
|
+
if (!found.length) return null;
|
|
179
|
+
return {
|
|
180
|
+
sources: found.map(f => f.name),
|
|
181
|
+
scope: found.some(f => f.scope === 'list') ? 'list' : 'project',
|
|
182
|
+
items: found.flatMap(f => f.items),
|
|
183
|
+
};
|
|
184
|
+
},
|
|
185
|
+
notes: () => [...notes.values()],
|
|
186
|
+
};
|
|
187
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { openSync, readSync, closeSync, readdirSync, statSync, existsSync } from 'fs';
|
|
2
|
+
import { join, basename } from 'path';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
import { projectRoots } from './jsonl-reader.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Transcripts for the drift report: what the user asked, and when the agent was working.
|
|
8
|
+
* Reads Claude Code sessions (~/.claude/projects) and Codex rollouts (~/.codex/sessions).
|
|
9
|
+
*
|
|
10
|
+
* Each session comes back as time-ordered events:
|
|
11
|
+
* { t, kind: 'prompt', text, cwd } something the user typed
|
|
12
|
+
* { t, kind: 'auto' } work started by a script or hook, not by the user
|
|
13
|
+
* { t, kind: 'step', words, files } the agent working: its visible reply text, and the files its tools named
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const DAY = 86400000;
|
|
17
|
+
|
|
18
|
+
// Files are read in chunks, never whole: one long-running session can pass 800 MB, and a JS string stops at ~512 MB.
|
|
19
|
+
export function forEachLine(filePath, fn) {
|
|
20
|
+
let fd;
|
|
21
|
+
try { fd = openSync(filePath, 'r'); } catch { return; }
|
|
22
|
+
const buf = Buffer.allocUnsafe(1 << 22);
|
|
23
|
+
let carry = null;
|
|
24
|
+
try {
|
|
25
|
+
let n;
|
|
26
|
+
while ((n = readSync(fd, buf, 0, buf.length, null)) > 0) {
|
|
27
|
+
let start = 0;
|
|
28
|
+
for (;;) {
|
|
29
|
+
const nl = buf.indexOf(10, start);
|
|
30
|
+
if (nl === -1 || nl >= n) break;
|
|
31
|
+
const piece = buf.subarray(start, nl);
|
|
32
|
+
const line = carry ? Buffer.concat([carry, piece]) : piece;
|
|
33
|
+
carry = null;
|
|
34
|
+
fn(line);
|
|
35
|
+
start = nl + 1;
|
|
36
|
+
}
|
|
37
|
+
if (start < n) {
|
|
38
|
+
const rest = Buffer.from(buf.subarray(start, n)); // copied, because buf is reused by the next read
|
|
39
|
+
carry = carry ? Buffer.concat([carry, rest]) : rest;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (carry && carry.length) fn(carry);
|
|
43
|
+
} catch { /* a file that fails mid-read keeps what was read */ }
|
|
44
|
+
finally { closeSync(fd); }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function defaultCodexDir() {
|
|
48
|
+
return process.env.CODEX_HOME || join(homedir(), '.codex');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Sessions with activity between `since` and `until` (ms). Events from up to `lookbackMs` earlier are kept, so a
|
|
53
|
+
* session resumed inside the window still knows what it was opened for.
|
|
54
|
+
*/
|
|
55
|
+
export function readTranscripts({ claudeDir, codexDir = defaultCodexDir(), since, until = Date.now(), lookbackMs = 2 * DAY } = {}) {
|
|
56
|
+
const from = since - lookbackMs;
|
|
57
|
+
const sessions = [];
|
|
58
|
+
const seen = new Set(); // resumed sessions copy earlier records into the new file; each record counts once
|
|
59
|
+
|
|
60
|
+
for (const root of claudeDir ? projectRoots(claudeDir) : []) {
|
|
61
|
+
for (const file of recentFiles(root, since, 1)) {
|
|
62
|
+
const s = readClaudeSession(file, from, until, seen);
|
|
63
|
+
if (s.events.length) sessions.push(s);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const codexSessions = join(codexDir || '', 'sessions');
|
|
68
|
+
if (codexDir && existsSync(codexSessions)) {
|
|
69
|
+
// Codex files sit under YYYY/MM/DD of the day the thread STARTED, and a thread can stay open for weeks,
|
|
70
|
+
// so every day folder is checked by modification time rather than by name
|
|
71
|
+
for (const file of recentFiles(codexSessions, since, 3)) {
|
|
72
|
+
const s = readCodexSession(file, from, until);
|
|
73
|
+
if (s.events.length) sessions.push(s);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for (const s of sessions) s.events.sort((a, b) => a.t - b.t);
|
|
78
|
+
return sessions;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// .jsonl files `depth` folders below root that were written to after `since`
|
|
82
|
+
function recentFiles(root, since, depth) {
|
|
83
|
+
const out = [];
|
|
84
|
+
const walk = (dir, d) => {
|
|
85
|
+
let names;
|
|
86
|
+
try { names = readdirSync(dir); } catch { return; }
|
|
87
|
+
for (const name of names) {
|
|
88
|
+
const full = join(dir, name);
|
|
89
|
+
let st;
|
|
90
|
+
try { st = statSync(full); } catch { continue; }
|
|
91
|
+
if (st.isDirectory()) { if (d > 0) walk(full, d - 1); }
|
|
92
|
+
else if (d === 0 && name.endsWith('.jsonl') && st.mtimeMs >= since) out.push(full);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
walk(root, depth);
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------- Claude Code
|
|
100
|
+
|
|
101
|
+
// Text Claude Code writes into the user's turn that the user did not type
|
|
102
|
+
const NOT_TYPED = /^\s*(<(local-command-|system-reminder|task-notification|bash-(input|stdout|stderr)|user-memory-input)|\[Request interrupted|Caveat: The messages below|This session is being continued|Base directory for this skill|Continue from where you left off\.?\s*$)/;
|
|
103
|
+
// Work the machine started on its own: scheduled hooks, goal check-ins, auto-resume heartbeats, and the
|
|
104
|
+
// "limit reset, continue" nudge the runner injects after a usage cap
|
|
105
|
+
const AUTO_WORK = /^\s*(Stop hook feedback|Goal check-in|<heartbeat|<automation|\[scheduled|(I hit|hit) (my )?usage limit)/i;
|
|
106
|
+
// Built-in commands whose arguments are settings, not a request
|
|
107
|
+
const BUILTIN = new Set(['/clear', '/compact', '/model', '/effort', '/resume', '/rewind', '/exit', '/config', '/login', '/logout',
|
|
108
|
+
'/permissions', '/add-dir', '/mcp', '/cost', '/usage', '/status', '/doctor', '/help', '/memory', '/init', '/theme', '/vim',
|
|
109
|
+
'/terminal-setup', '/ide', '/hooks', '/agents', '/bug', '/release-notes', '/export', '/context', '/statusline', '/fast',
|
|
110
|
+
'/output-style', '/plugin', '/upgrade', '/privacy-settings', '/remote-control', '/rename', '/tag', '/color']);
|
|
111
|
+
|
|
112
|
+
function readClaudeSession(file, from, until, seen) {
|
|
113
|
+
const s = { agent: 'claude', id: basename(file, '.jsonl'), cwd: null, events: [] };
|
|
114
|
+
forEachLine(file, (line) => {
|
|
115
|
+
// Cheap skips before parsing: harness records, file attachments and tool output are most of the bytes
|
|
116
|
+
const head = line.subarray(0, 400).toString('latin1');
|
|
117
|
+
if (head.startsWith('{"type":"') && !/^\{"type":"(user|assistant)"/.test(head)) return;
|
|
118
|
+
if (head.includes('"attachment":{')) return;
|
|
119
|
+
if (head.includes('"content":[{"tool_use_id"') || head.includes('"content":[{"type":"tool_result"')) return;
|
|
120
|
+
|
|
121
|
+
let r;
|
|
122
|
+
try { r = JSON.parse(line.toString('utf8')); } catch { return; }
|
|
123
|
+
if (r.type !== 'user' && r.type !== 'assistant') return;
|
|
124
|
+
if (r.isSidechain) return;
|
|
125
|
+
const t = Date.parse(r.timestamp);
|
|
126
|
+
if (!(t >= from && t <= until)) return;
|
|
127
|
+
if (r.uuid) {
|
|
128
|
+
if (seen.has(r.uuid)) return;
|
|
129
|
+
seen.add(r.uuid);
|
|
130
|
+
}
|
|
131
|
+
if (r.cwd) s.cwd = r.cwd;
|
|
132
|
+
|
|
133
|
+
if (r.type === 'assistant') {
|
|
134
|
+
const step = claudeStep(r);
|
|
135
|
+
s.events.push({ t, kind: 'step', words: step.words, files: step.files });
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const e = claudeUserEvent(r);
|
|
139
|
+
if (e) s.events.push({ t, ...e, cwd: r.cwd || s.cwd });
|
|
140
|
+
});
|
|
141
|
+
return s;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function claudeUserEvent(r) {
|
|
145
|
+
if (r.isCompactSummary) return null;
|
|
146
|
+
const kind = r.origin?.kind;
|
|
147
|
+
// A background agent reporting back continues the user's request
|
|
148
|
+
if (kind === 'task-notification' || kind === 'peer') return { kind: 'step', words: '', files: [] };
|
|
149
|
+
const text = userText(r.message?.content);
|
|
150
|
+
if (text === null) return null;
|
|
151
|
+
if (/^\s*<task-notification>/.test(text)) return { kind: 'step', words: '', files: [] };
|
|
152
|
+
if (AUTO_WORK.test(text)) return { kind: 'auto' };
|
|
153
|
+
if (r.isMeta) return null;
|
|
154
|
+
if (kind && kind !== 'human') return null;
|
|
155
|
+
// Runs started from a script (claude -p, the SDK): nobody typed these
|
|
156
|
+
if (r.promptSource === 'sdk' || /^sdk/.test(r.entrypoint || '')) return { kind: 'auto' };
|
|
157
|
+
if (text.startsWith('<command-name>') || text.startsWith('<command-message>')) return commandPrompt(text);
|
|
158
|
+
if (!text.trim() || NOT_TYPED.test(text)) return null;
|
|
159
|
+
return { kind: 'prompt', text: text.trim() };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// String content, or the text blocks of an array; null when the record is tool output
|
|
163
|
+
function userText(content) {
|
|
164
|
+
if (typeof content === 'string') return content;
|
|
165
|
+
if (!Array.isArray(content)) return null;
|
|
166
|
+
if (content.some(b => b?.type === 'tool_result')) return null;
|
|
167
|
+
return content.filter(b => b?.type === 'text').map(b => b.text || '').join('\n');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// "/goal ship the landing page" is stored as tags; the words after a command are the user's own, sometimes the plan itself
|
|
171
|
+
function commandPrompt(text) {
|
|
172
|
+
const name = text.match(/<command-name>\s*([^<\s]+)\s*<\/command-name>/)?.[1];
|
|
173
|
+
const args = text.match(/<command-args>([\s\S]*?)<\/command-args>/)?.[1]?.trim();
|
|
174
|
+
if (!name || !args || BUILTIN.has(name)) return null;
|
|
175
|
+
return { kind: 'prompt', text: args, command: name };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function claudeStep(r) {
|
|
179
|
+
const blocks = Array.isArray(r.message?.content) ? r.message.content : [];
|
|
180
|
+
let words = '';
|
|
181
|
+
const files = [];
|
|
182
|
+
for (const b of blocks) {
|
|
183
|
+
if (b?.type === 'text' && b.text) words += (words ? ' ' : '') + b.text.slice(0, 1500);
|
|
184
|
+
else if (b?.type === 'tool_use' && b.input && typeof b.input === 'object') {
|
|
185
|
+
const p = b.input.file_path || b.input.notebook_path || b.input.path;
|
|
186
|
+
if (typeof p === 'string' && p) files.push(p);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return { words: words.slice(0, 2000), files };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ---------------------------------------------------------------- Codex
|
|
193
|
+
|
|
194
|
+
// Context Codex puts in the user's slot that the user did not type
|
|
195
|
+
const CODEX_NOT_TYPED = /^\s*(<(environment_context|user_instructions|recommended_plugins|app-context|turn_aborted|subagent)|# AGENTS\.md)/;
|
|
196
|
+
const CODEX_STEP_ITEMS = new Set(['function_call', 'custom_tool_call', 'local_shell_call', 'function_call_output',
|
|
197
|
+
'custom_tool_call_output', 'reasoning', 'web_search_call', 'message']);
|
|
198
|
+
const CODEX_STEP_EVENTS = new Set(['agent_message', 'task_complete', 'token_count', 'exec_command_end', 'patch_apply_end', 'turn_aborted']);
|
|
199
|
+
|
|
200
|
+
function readCodexSession(file, from, until) {
|
|
201
|
+
const s = { agent: 'codex', id: basename(file, '.jsonl'), cwd: null, events: [] };
|
|
202
|
+
const lastPrompt = new Map(); // text -> time; some Codex versions log one prompt twice, in two shapes
|
|
203
|
+
forEachLine(file, (line) => {
|
|
204
|
+
const head = line.subarray(0, 120).toString('latin1');
|
|
205
|
+
const isContext = head.includes('"type":"session_meta"') || head.includes('"type":"turn_context"');
|
|
206
|
+
// Codex writes the timestamp first, so lines outside the window are dropped before parsing
|
|
207
|
+
const ts = Date.parse(head.match(/"timestamp":"([^"]+)"/)?.[1] || '');
|
|
208
|
+
if (!isContext && !(ts >= from && ts <= until)) return;
|
|
209
|
+
|
|
210
|
+
let r;
|
|
211
|
+
try { r = JSON.parse(line.toString('utf8')); } catch { return; }
|
|
212
|
+
const p = r.payload || {};
|
|
213
|
+
if (r.type === 'session_meta') { if (p.id) s.id = p.id; if (p.cwd) s.cwd = p.cwd; return; }
|
|
214
|
+
if (r.type === 'turn_context') { if (p.cwd) s.cwd = p.cwd; return; }
|
|
215
|
+
const t = Date.parse(r.timestamp);
|
|
216
|
+
if (!(t >= from && t <= until)) return;
|
|
217
|
+
|
|
218
|
+
const typed = codexPrompt(r);
|
|
219
|
+
if (typed !== null) {
|
|
220
|
+
const text = typed.trim();
|
|
221
|
+
if (!text || CODEX_NOT_TYPED.test(text)) return;
|
|
222
|
+
if (AUTO_WORK.test(text)) { s.events.push({ t, kind: 'auto' }); return; }
|
|
223
|
+
const prev = lastPrompt.get(text);
|
|
224
|
+
if (prev !== undefined && Math.abs(t - prev) < 5 * 60000) return;
|
|
225
|
+
lastPrompt.set(text, t);
|
|
226
|
+
s.events.push({ t, kind: 'prompt', text, cwd: s.cwd });
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (r.type === 'response_item' && CODEX_STEP_ITEMS.has(p.type)) {
|
|
230
|
+
if (p.type === 'message' && p.role !== 'assistant') return;
|
|
231
|
+
const words = p.type === 'message' ? (p.content || []).map(c => c?.text || '').join(' ').slice(0, 2000) : '';
|
|
232
|
+
s.events.push({ t, kind: 'step', words, files: patchFiles(p.input || p.arguments) });
|
|
233
|
+
} else if (r.type === 'event_msg' && CODEX_STEP_EVENTS.has(p.type)) {
|
|
234
|
+
s.events.push({ t, kind: 'step', words: p.type === 'agent_message' ? String(p.message || '').slice(0, 2000) : '', files: [] });
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
return s;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// The user's typed text: `user_message` events (older Codex) or completed UserMessage items (newer).
|
|
241
|
+
// Replayed history in forked threads arrives as response items, not events, so it is not counted twice.
|
|
242
|
+
function codexPrompt(r) {
|
|
243
|
+
if (r.type !== 'event_msg') return null;
|
|
244
|
+
const p = r.payload || {};
|
|
245
|
+
if (p.type === 'user_message') return typeof p.message === 'string' ? p.message : null;
|
|
246
|
+
if (p.type === 'item_completed' && p.item?.type === 'UserMessage') {
|
|
247
|
+
return (p.item.content || []).map(c => (typeof c?.text === 'string' ? c.text : '')).join('\n');
|
|
248
|
+
}
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Files named in an apply_patch body ("*** Update File: path")
|
|
253
|
+
function patchFiles(input) {
|
|
254
|
+
const text = typeof input === 'string' ? input : input ? JSON.stringify(input) : '';
|
|
255
|
+
if (!text.includes('*** ')) return [];
|
|
256
|
+
const out = [];
|
|
257
|
+
for (const m of text.matchAll(/\*\*\* (?:Add|Update|Delete) File: ([^\n\\"]+)/g)) out.push(m[1].trim());
|
|
258
|
+
return out;
|
|
259
|
+
}
|