tokenmaxxing-cli 0.1.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/dist/format.js ADDED
@@ -0,0 +1,49 @@
1
+ /** 950 → "950", 2900 → "2.9k", 317_500_000 → "317.5M", 23e9 → "23.0B". */
2
+ export function fmtTokens(n) {
3
+ const a = Math.abs(n);
4
+ if (a < 1000)
5
+ return String(Math.round(n));
6
+ if (a < 1e6)
7
+ return (n / 1e3).toFixed(1) + 'k';
8
+ if (a < 1e9)
9
+ return (n / 1e6).toFixed(1) + 'M';
10
+ if (a < 1e12)
11
+ return (n / 1e9).toFixed(1) + 'B';
12
+ return (n / 1e12).toFixed(1) + 'T';
13
+ }
14
+ /** 4476.25 → "$4,476.25" */
15
+ export function fmtUsd(n, decimals = 2) {
16
+ const sign = n < 0 ? '-' : '';
17
+ const [int, frac] = Math.abs(n).toFixed(decimals).split('.');
18
+ return `${sign}$${int.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}${frac ? '.' + frac : ''}`;
19
+ }
20
+ export function fmtInt(n) {
21
+ return Math.round(n).toLocaleString('en-US');
22
+ }
23
+ /** 20 → "$20", 1020 → "$1,020", 19.99 → "$19.99" */
24
+ export function fmtPrice(n) {
25
+ return fmtUsd(n, Number.isInteger(n) ? 0 : 2);
26
+ }
27
+ export function padEnd(s, w) {
28
+ return s.length >= w ? s : s + ' '.repeat(w - s.length);
29
+ }
30
+ export function padStart(s, w) {
31
+ return s.length >= w ? s : ' '.repeat(w - s.length) + s;
32
+ }
33
+ /** Render rows as a fixed-width table. `align[i]` is 'l' or 'r'. */
34
+ export function table(header, rows, align, gap = 3) {
35
+ const widths = header.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? '').length)));
36
+ const line = (cells) => cells
37
+ .map((c, i) => (align[i] === 'r' ? padStart(c ?? '', widths[i]) : padEnd(c ?? '', widths[i])))
38
+ .join(' '.repeat(gap))
39
+ .replace(/\s+$/, '');
40
+ return [line(header), ...rows.map(line)];
41
+ }
42
+ /** Local calendar date YYYY-MM-DD for an instant. */
43
+ export function localDate(d) {
44
+ const x = new Date(d);
45
+ const y = x.getFullYear();
46
+ const m = String(x.getMonth() + 1).padStart(2, '0');
47
+ const day = String(x.getDate()).padStart(2, '0');
48
+ return `${y}-${m}-${day}`;
49
+ }
package/dist/fsutil.js ADDED
@@ -0,0 +1,61 @@
1
+ import { mkdirSync, readFileSync, renameSync, writeFileSync, existsSync, statSync } from 'node:fs';
2
+ import { readdir } from 'node:fs/promises';
3
+ import { dirname, join } from 'node:path';
4
+ export function readJson(path, fallback) {
5
+ try {
6
+ return JSON.parse(readFileSync(path, 'utf8'));
7
+ }
8
+ catch {
9
+ return fallback;
10
+ }
11
+ }
12
+ /** Write atomically (tmp file + rename). */
13
+ export function writeFileAtomic(path, data) {
14
+ mkdirSync(dirname(path), { recursive: true });
15
+ const tmp = `${path}.${process.pid}.tmp`;
16
+ writeFileSync(tmp, data);
17
+ renameSync(tmp, path);
18
+ }
19
+ export function writeJsonAtomic(path, value, pretty = true) {
20
+ writeFileAtomic(path, JSON.stringify(value, null, pretty ? 2 : undefined) + '\n');
21
+ }
22
+ export function isDir(p) {
23
+ try {
24
+ return statSync(p).isDirectory();
25
+ }
26
+ catch {
27
+ return false;
28
+ }
29
+ }
30
+ export function isFile(p) {
31
+ try {
32
+ return statSync(p).isFile();
33
+ }
34
+ catch {
35
+ return false;
36
+ }
37
+ }
38
+ export { existsSync };
39
+ /** Recursively list files under `dir` whose basename passes `match`. Missing dirs yield []. Symlinked dirs are not followed. */
40
+ export async function walkFiles(dir, match) {
41
+ const out = [];
42
+ const stack = [dir];
43
+ while (stack.length) {
44
+ const d = stack.pop();
45
+ let entries;
46
+ try {
47
+ entries = await readdir(d, { withFileTypes: true });
48
+ }
49
+ catch {
50
+ continue;
51
+ }
52
+ for (const e of entries) {
53
+ const full = join(d, e.name);
54
+ if (e.isDirectory())
55
+ stack.push(full);
56
+ else if (e.isFile() && match(e.name, full))
57
+ out.push(full);
58
+ }
59
+ }
60
+ return out.sort();
61
+ }
package/dist/hook.js ADDED
@@ -0,0 +1,194 @@
1
+ import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { isDir, isFile } from './fsutil.js';
4
+ import { claudeCandidateRoots, codexHome, home } from './paths.js';
5
+ import { confirm, lineDiff } from './prompt.js';
6
+ export const HOOK_COMMAND = 'npx -y tokenmaxxing-cli sync --quiet';
7
+ export const CODEX_NOTIFY_LINE = 'notify = ["npx","-y","tokenmaxxing-cli","sync","--quiet"]';
8
+ const BACKUP_SUFFIX = '.tokenmaxxing.bak';
9
+ export function claudeSettingsPath() {
10
+ const env = process.env.CLAUDE_CONFIG_DIR;
11
+ const root = env && env.trim() ? env.split(',')[0].trim() : claudeCandidateRoots().at(-1) ?? join(home(), '.claude');
12
+ return join(root, 'settings.json');
13
+ }
14
+ export function codexConfigPath() {
15
+ return join(codexHome(), 'config.toml');
16
+ }
17
+ function isOurEntry(e) {
18
+ return (!!e &&
19
+ Array.isArray(e.hooks) &&
20
+ e.hooks.some((h) => h && h.type === 'command' && h.command === HOOK_COMMAND));
21
+ }
22
+ export function claudeHasHook(settings) {
23
+ const stop = settings?.hooks?.Stop;
24
+ return Array.isArray(stop) && stop.some(isOurEntry);
25
+ }
26
+ function detectIndent(text) {
27
+ const m = /\n([ \t]+)"/.exec(text);
28
+ if (!m)
29
+ return 2;
30
+ return m[1].includes('\t') ? '\t' : m[1].length;
31
+ }
32
+ export function addClaudeHook(settings) {
33
+ const next = structuredClone(settings ?? {});
34
+ if (!next.hooks || typeof next.hooks !== 'object')
35
+ next.hooks = {};
36
+ if (!Array.isArray(next.hooks.Stop))
37
+ next.hooks.Stop = [];
38
+ next.hooks.Stop.push({ matcher: '', hooks: [{ type: 'command', command: HOOK_COMMAND }] });
39
+ return next;
40
+ }
41
+ /** Remove exactly the entry `install` added (and containers left empty by that removal). */
42
+ export function removeClaudeHook(settings) {
43
+ const next = structuredClone(settings ?? {});
44
+ const stop = next?.hooks?.Stop;
45
+ if (!Array.isArray(stop))
46
+ return next;
47
+ next.hooks.Stop = stop.filter((e) => !(isOurEntry(e) && e.hooks.length === 1 && (e.matcher === '' || e.matcher === undefined)));
48
+ // If our command was merged into a larger entry by hand, drop only our command from it.
49
+ next.hooks.Stop = next.hooks.Stop.filter((e) => {
50
+ if (!isOurEntry(e))
51
+ return true;
52
+ e.hooks = e.hooks.filter((h) => !(h?.type === 'command' && h.command === HOOK_COMMAND));
53
+ return e.hooks.length > 0;
54
+ });
55
+ if (!next.hooks.Stop.length)
56
+ delete next.hooks.Stop;
57
+ if (!Object.keys(next.hooks).length)
58
+ delete next.hooks;
59
+ return next;
60
+ }
61
+ const NOTIFY_KEY = /^\s*notify\s*=/m;
62
+ export function codexHasNotify(toml) {
63
+ return NOTIFY_KEY.test(toml);
64
+ }
65
+ export function codexHasOurNotify(toml) {
66
+ return toml.split('\n').some((l) => l.trim() === CODEX_NOTIFY_LINE);
67
+ }
68
+ /** Insert the top-level `notify` key before the first [table] so TOML keeps it at the root. */
69
+ export function addCodexNotify(toml) {
70
+ const lines = toml === '' ? [] : toml.split('\n');
71
+ const idx = lines.findIndex((l) => /^\s*\[/.test(l));
72
+ const insert = ['# added by `tokenmaxxing hook install` (remove with `tokenmaxxing hook uninstall`)', CODEX_NOTIFY_LINE];
73
+ if (idx === -1) {
74
+ const body = toml === '' || toml.endsWith('\n') ? toml : toml + '\n';
75
+ return body + insert.join('\n') + '\n';
76
+ }
77
+ lines.splice(idx, 0, ...insert, '');
78
+ return lines.join('\n');
79
+ }
80
+ export function removeCodexNotify(toml) {
81
+ const lines = toml.split('\n');
82
+ const out = [];
83
+ for (let i = 0; i < lines.length; i++) {
84
+ if (lines[i].trim() === CODEX_NOTIFY_LINE) {
85
+ if (out.length && out[out.length - 1].startsWith('# added by `tokenmaxxing hook install`'))
86
+ out.pop();
87
+ if (lines[i + 1] === '' && /^\s*\[/.test(lines[i + 2] ?? ''))
88
+ i++; // blank line install added
89
+ continue;
90
+ }
91
+ out.push(lines[i]);
92
+ }
93
+ return out.join('\n');
94
+ }
95
+ function readText(path) {
96
+ return isFile(path) ? readFileSync(path, 'utf8') : '';
97
+ }
98
+ function planClaude(action, log) {
99
+ const path = claudeSettingsPath();
100
+ if (!isDir(dirname(path))) {
101
+ log(`Claude Code: ${dirname(path)} not found — skipped.`);
102
+ return null;
103
+ }
104
+ const before = readText(path);
105
+ let obj = {};
106
+ try {
107
+ obj = before.trim() ? JSON.parse(before) : {};
108
+ }
109
+ catch {
110
+ log(`Claude Code: ${path} is not valid JSON — not touching it.`);
111
+ return null;
112
+ }
113
+ const has = claudeHasHook(obj);
114
+ if (action === 'install' && has) {
115
+ log(`Claude Code: Stop hook already installed in ${path}.`);
116
+ return null;
117
+ }
118
+ if (action === 'uninstall' && !has) {
119
+ log(`Claude Code: no tokenmaxxing Stop hook in ${path}.`);
120
+ return null;
121
+ }
122
+ const next = action === 'install' ? addClaudeHook(obj) : removeClaudeHook(obj);
123
+ const after = JSON.stringify(next, null, detectIndent(before)) + '\n';
124
+ return { label: 'Claude Code', path, before, after };
125
+ }
126
+ function planCodex(action, log) {
127
+ const path = codexConfigPath();
128
+ if (!isDir(dirname(path))) {
129
+ log(`Codex: ${dirname(path)} not found — skipped.`);
130
+ return null;
131
+ }
132
+ const before = readText(path);
133
+ if (action === 'install') {
134
+ if (codexHasOurNotify(before)) {
135
+ log(`Codex: notify hook already installed in ${path}.`);
136
+ return null;
137
+ }
138
+ if (codexHasNotify(before)) {
139
+ log(`Codex: ${path} already has a \`notify\` command, so tokenmaxxing will not edit it.\n` +
140
+ ` To sync after Codex turns too, make your notify program also run: ${HOOK_COMMAND}`);
141
+ return null;
142
+ }
143
+ return { label: 'Codex', path, before, after: addCodexNotify(before) };
144
+ }
145
+ if (!codexHasOurNotify(before)) {
146
+ log(`Codex: no tokenmaxxing notify hook in ${path}.`);
147
+ return null;
148
+ }
149
+ return { label: 'Codex', path, before, after: removeCodexNotify(before) };
150
+ }
151
+ export async function hookCommand(sub, opts, log = console.log) {
152
+ if (sub === 'status' || sub === undefined) {
153
+ const cp = claudeSettingsPath();
154
+ let claudeOn = false;
155
+ try {
156
+ claudeOn = claudeHasHook(JSON.parse(readText(cp) || '{}'));
157
+ }
158
+ catch {
159
+ /* invalid json */
160
+ }
161
+ const xp = codexConfigPath();
162
+ const t = readText(xp);
163
+ log(`Claude Code Stop hook: ${claudeOn ? 'installed' : 'not installed'} (${cp})`);
164
+ log(`Codex notify hook: ${codexHasOurNotify(t) ? 'installed' : codexHasNotify(t) ? 'not installed (another notify command is configured)' : 'not installed'} (${xp})`);
165
+ if (sub === undefined)
166
+ log('\nUsage: tokenmaxxing hook install|uninstall|status [--yes]');
167
+ return 0;
168
+ }
169
+ if (sub !== 'install' && sub !== 'uninstall') {
170
+ log('Usage: tokenmaxxing hook install|uninstall|status [--yes]');
171
+ return 1;
172
+ }
173
+ const edits = [planClaude(sub, log), planCodex(sub, log)].filter((e) => !!e);
174
+ if (!edits.length)
175
+ return 0;
176
+ for (const e of edits) {
177
+ log(`\n${e.label}: ${e.before ? 'edit' : 'create'} ${e.path}`);
178
+ log(lineDiff(e.before, e.after));
179
+ }
180
+ log('');
181
+ const ok = opts.yes || (await confirm(`Apply ${edits.length === 1 ? 'this change' : 'these changes'}?`));
182
+ if (!ok) {
183
+ log(process.stdin.isTTY ? 'Aborted — nothing was changed.' : 'Nothing was changed. Re-run with --yes to apply.');
184
+ return opts.yes ? 0 : 1;
185
+ }
186
+ for (const e of edits) {
187
+ if (sub === 'install' && e.before)
188
+ copyFileSync(e.path, e.path + BACKUP_SUFFIX);
189
+ mkdirSync(dirname(e.path), { recursive: true });
190
+ writeFileSync(e.path, e.after);
191
+ log(`${sub === 'install' ? 'Installed' : 'Removed'}: ${e.path}${sub === 'install' && e.before ? ` (backup: ${e.path + BACKUP_SUFFIX})` : ''}`);
192
+ }
193
+ return 0;
194
+ }
package/dist/lines.js ADDED
@@ -0,0 +1,61 @@
1
+ import { createReadStream } from 'node:fs';
2
+ const NL = 10;
3
+ const CR = 13;
4
+ /**
5
+ * Stream `path` from byte `start` up to `endExclusive`, calling `onLine` for every newline-terminated line
6
+ * (without the trailing \n / \r\n). Never loads the whole file: only the current chunk and any line that
7
+ * spans chunks are held in memory.
8
+ */
9
+ export async function scanLines(path, start, endExclusive, onLine) {
10
+ if (endExclusive <= start)
11
+ return { end: start, tail: null };
12
+ const stream = createReadStream(path, { start, end: endExclusive - 1, highWaterMark: 1 << 20 });
13
+ let pending = [];
14
+ let pendingLen = 0;
15
+ let consumed = start; // offset of the first byte not yet emitted as part of a complete line
16
+ let pos = start; // absolute offset of current chunk start
17
+ for await (const chunk of stream) {
18
+ let from = 0;
19
+ let idx = chunk.indexOf(NL, from);
20
+ while (idx !== -1) {
21
+ let line;
22
+ if (pendingLen) {
23
+ pending.push(chunk.subarray(from, idx));
24
+ line = Buffer.concat(pending, pendingLen + (idx - from));
25
+ pending = [];
26
+ pendingLen = 0;
27
+ }
28
+ else {
29
+ line = chunk.subarray(from, idx);
30
+ }
31
+ if (line.length && line[line.length - 1] === CR)
32
+ line = line.subarray(0, line.length - 1);
33
+ if (line.length)
34
+ onLine(line);
35
+ consumed = pos + idx + 1;
36
+ from = idx + 1;
37
+ idx = chunk.indexOf(NL, from);
38
+ }
39
+ if (from < chunk.length) {
40
+ // Copy so we don't pin the 1MB chunk for a small tail.
41
+ const rest = Buffer.from(chunk.subarray(from));
42
+ pending.push(rest);
43
+ pendingLen += rest.length;
44
+ }
45
+ pos += chunk.length;
46
+ }
47
+ const tail = pendingLen ? Buffer.concat(pending, pendingLen) : null;
48
+ return { end: consumed, tail };
49
+ }
50
+ /** Cheap substring test on a raw line before paying for JSON.parse. */
51
+ export function has(line, needle) {
52
+ return line.indexOf(needle) !== -1;
53
+ }
54
+ export function parseLine(line) {
55
+ try {
56
+ return JSON.parse(line.toString('utf8'));
57
+ }
58
+ catch {
59
+ return undefined;
60
+ }
61
+ }
@@ -0,0 +1,203 @@
1
+ import { stat } from 'node:fs/promises';
2
+ import { join, sep } from 'node:path';
3
+ import { BoundedSet } from '../dedup.js';
4
+ import { hourOf } from '../bucket.js';
5
+ import { isFile, walkFiles } from '../fsutil.js';
6
+ import { has, parseLine, scanLines } from '../lines.js';
7
+ import { DEDUP_LIMIT, RECENT_LIMIT, num } from './context.js';
8
+ const USAGE = Buffer.from('"usage"');
9
+ const USER = Buffer.from('"type":"user"');
10
+ const USER_SPACED = Buffer.from('"type": "user"');
11
+ const SUBAGENTS = `${sep}subagents${sep}`;
12
+ const MAX_PENDING = 50;
13
+ /** All Claude Code transcript files (including subagents/) under each root's projects/ dir. */
14
+ export async function claudeFiles(roots) {
15
+ const out = [];
16
+ for (const root of roots) {
17
+ out.push(...(await walkFiles(join(root, 'projects'), (n) => n.endsWith('.jsonl'))));
18
+ }
19
+ return [...new Set(out)];
20
+ }
21
+ /** Map a Claude `message.usage` object to our token fields; null when every counter is zero. */
22
+ export function claudeUsage(u) {
23
+ const input = num(u.input_tokens);
24
+ const output = num(u.output_tokens);
25
+ const cacheRead = num(u.cache_read_input_tokens);
26
+ const cacheCreation = num(u.cache_creation_input_tokens);
27
+ let w5;
28
+ let w1;
29
+ if (u.cache_creation && typeof u.cache_creation === 'object') {
30
+ w5 = num(u.cache_creation.ephemeral_5m_input_tokens);
31
+ w1 = num(u.cache_creation.ephemeral_1h_input_tokens);
32
+ // Model-fallback responses report `cache_creation` for the first iteration but the top-level
33
+ // total for the final one. The total wins (as in ccusage); keep the split's 5m/1h ratio.
34
+ if (u.cache_creation_input_tokens !== undefined && w5 + w1 !== cacheCreation) {
35
+ const split = w5 + w1;
36
+ w1 = split > 0 ? Math.round((cacheCreation * w1) / split) : 0;
37
+ w5 = cacheCreation - w1;
38
+ }
39
+ }
40
+ else {
41
+ w5 = cacheCreation;
42
+ w1 = 0;
43
+ }
44
+ if (input + output + cacheRead + w5 + w1 === 0)
45
+ return null;
46
+ return { input, cache_read: cacheRead, cache_write_5m: w5, cache_write_1h: w1, output, reasoning: 0 };
47
+ }
48
+ const totalOf = (u) => u.input + u.cache_read + u.cache_write_5m + u.cache_write_1h + u.output;
49
+ const negate = (u) => ({
50
+ input: -u.input, cache_read: -u.cache_read, cache_write_5m: -u.cache_write_5m,
51
+ cache_write_1h: -u.cache_write_1h, output: -u.output, reasoning: -u.reasoning,
52
+ });
53
+ function loadRecent(rows) {
54
+ const m = new Map();
55
+ for (const r of rows ?? []) {
56
+ if (!Array.isArray(r) || r.length < 8)
57
+ continue;
58
+ const usage = { input: r[3], cache_read: r[4], cache_write_5m: r[5], cache_write_1h: r[6], output: r[7], reasoning: 0 };
59
+ m.set(r[0], { hour: r[1], model: r[2], usage, total: totalOf(usage) });
60
+ }
61
+ return m;
62
+ }
63
+ function saveRecent(m) {
64
+ const all = [...m.entries()];
65
+ return all.slice(Math.max(0, all.length - RECENT_LIMIT)).map(([k, c]) => [
66
+ k, c.hour, c.model, c.usage.input, c.usage.cache_read, c.usage.cache_write_5m, c.usage.cache_write_1h, c.usage.output,
67
+ ]);
68
+ }
69
+ function isPrompt(content) {
70
+ if (typeof content === 'string')
71
+ return true;
72
+ return Array.isArray(content) && content.some((b) => b && typeof b === 'object' && b.type === 'text');
73
+ }
74
+ export async function parse(ctx) {
75
+ const { cursor, dedup, acc, stats } = ctx;
76
+ const conv = new BoundedSet(DEDUP_LIMIT, cursor.convDedup ?? []);
77
+ // Per-key contributions: every key seen this run, plus the most recent keys from earlier syncs.
78
+ const contribs = loadRecent(cursor.recent);
79
+ const list = await claudeFiles(ctx.paths);
80
+ const live = new Set(list);
81
+ for (const file of list) {
82
+ stats.filesSeen++;
83
+ let st;
84
+ try {
85
+ st = await stat(file);
86
+ }
87
+ catch {
88
+ continue;
89
+ }
90
+ const prev = cursor.files[file];
91
+ if (prev && prev.size === st.size && prev.mtimeMs === st.mtimeMs)
92
+ continue;
93
+ stats.filesChanged++;
94
+ const shrank = !!prev && st.size < prev.offset;
95
+ const start = prev && !shrank ? prev.offset : 0;
96
+ let lastModel = shrank ? undefined : prev?.model;
97
+ let pending = shrank ? [] : [...(prev?.pending ?? [])];
98
+ const isSub = file.includes(SUBAGENTS);
99
+ const handle = (obj) => {
100
+ if (!obj || typeof obj !== 'object')
101
+ return;
102
+ if (obj.type === 'assistant') {
103
+ const m = obj.message;
104
+ const model = m?.model;
105
+ if (!m || !m.usage || typeof model !== 'string' || !model || model === '<synthetic>')
106
+ return;
107
+ lastModel = model;
108
+ if (pending.length) {
109
+ for (const h of pending)
110
+ acc.addConversation(h, 'claude', model);
111
+ pending = [];
112
+ }
113
+ const usage = claudeUsage(m.usage);
114
+ if (!usage)
115
+ return;
116
+ const hour = hourOf(obj.timestamp);
117
+ if (!hour) {
118
+ stats.badLines++;
119
+ return;
120
+ }
121
+ if (typeof m.id !== 'string' || !m.id) {
122
+ acc.addUsage(hour, 'claude', model, usage);
123
+ return;
124
+ }
125
+ // One message is written as several lines whose usage is cumulative: keep the largest.
126
+ const key = `${m.id}:${obj.requestId ?? ''}`;
127
+ const total = totalOf(usage);
128
+ const prevC = contribs.get(key);
129
+ if (prevC) {
130
+ if (total <= prevC.total)
131
+ return;
132
+ acc.addUsage(prevC.hour, 'claude', prevC.model, negate(prevC.usage), -1);
133
+ contribs.delete(key);
134
+ }
135
+ else if (dedup.has(key)) {
136
+ return; // seen long ago; its contribution is no longer kept, so it cannot be replaced
137
+ }
138
+ dedup.add(key);
139
+ contribs.set(key, { hour, model, usage, total });
140
+ if (contribs.size > DEDUP_LIMIT * 1.25) {
141
+ let excess = contribs.size - DEDUP_LIMIT;
142
+ for (const k of contribs.keys()) {
143
+ if (excess-- <= 0)
144
+ break;
145
+ contribs.delete(k);
146
+ }
147
+ }
148
+ acc.addUsage(hour, 'claude', model, usage);
149
+ }
150
+ else if (obj.type === 'user' && !isSub) {
151
+ if (typeof obj.uuid !== 'string' || !isPrompt(obj.message?.content))
152
+ return;
153
+ if (!conv.add(obj.uuid))
154
+ return;
155
+ const hour = hourOf(obj.timestamp);
156
+ if (!hour)
157
+ return;
158
+ // Attributed to the model of the next assistant response in this file.
159
+ pending.push(hour);
160
+ if (pending.length > MAX_PENDING)
161
+ pending.shift();
162
+ }
163
+ };
164
+ const onLine = (line) => {
165
+ if (!(has(line, USAGE) || (!isSub && (has(line, USER) || has(line, USER_SPACED)))))
166
+ return;
167
+ const obj = parseLine(line);
168
+ if (obj === undefined) {
169
+ stats.badLines++;
170
+ return;
171
+ }
172
+ handle(obj);
173
+ };
174
+ let end = start;
175
+ try {
176
+ const res = await scanLines(file, start, st.size, onLine);
177
+ end = res.end;
178
+ stats.bytesRead += st.size - start;
179
+ if (res.tail) {
180
+ const obj = parseLine(res.tail);
181
+ if (obj !== undefined) {
182
+ handle(obj);
183
+ end = st.size;
184
+ }
185
+ }
186
+ }
187
+ catch {
188
+ continue; // unreadable file: leave cursor untouched, retry next sync
189
+ }
190
+ const next = { size: st.size, mtimeMs: st.mtimeMs, offset: end };
191
+ if (lastModel)
192
+ next.model = lastModel;
193
+ if (pending.length)
194
+ next.pending = pending;
195
+ cursor.files[file] = next;
196
+ }
197
+ for (const f of Object.keys(cursor.files))
198
+ if (!live.has(f) && !isFile(f))
199
+ delete cursor.files[f];
200
+ cursor.convDedup = conv.toJSON();
201
+ cursor.recent = saveRecent(contribs);
202
+ return acc.rows();
203
+ }