sigrank 0.15.5 → 0.16.1
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 +132 -77
- package/adapters.mjs +508 -240
- package/badges.mjs +264 -0
- package/cascade.mjs +185 -54
- package/cli.mjs +1311 -893
- package/connect.mjs +2 -2
- package/index.mjs +78 -59
- package/keystore.mjs +64 -44
- package/narrate.mjs +34 -22
- package/package.json +4 -2
- package/preflight.mjs +191 -0
- package/sign.mjs +46 -27
- package/submit.mjs +166 -62
- package/tokenpull.mjs +337 -188
- package/tools.mjs +2154 -534
- package/tui.mjs +1860 -1081
package/tokenpull.mjs
CHANGED
|
@@ -15,22 +15,22 @@
|
|
|
15
15
|
* the same { platform, defaultRoot(), messages(root) } contract — Claude is just the first.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { readdir, readFile, lstat } from
|
|
19
|
-
import { existsSync } from
|
|
20
|
-
import { execFile } from
|
|
21
|
-
import { join, dirname } from
|
|
22
|
-
import { homedir } from
|
|
23
|
-
import { fileURLToPath } from
|
|
24
|
-
import { ADAPTERS } from
|
|
18
|
+
import { readdir, readFile, lstat } from "node:fs/promises";
|
|
19
|
+
import { existsSync } from "node:fs";
|
|
20
|
+
import { execFile } from "node:child_process";
|
|
21
|
+
import { join, dirname } from "node:path";
|
|
22
|
+
import { homedir } from "node:os";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
24
|
+
import { ADAPTERS } from "./adapters.mjs";
|
|
25
25
|
|
|
26
|
-
const DAY_MS = 86_400_000
|
|
26
|
+
const DAY_MS = 86_400_000;
|
|
27
27
|
|
|
28
28
|
// Resolve the package root for finding bundled binaries (ccusage, tokscale, etc.)
|
|
29
|
-
const _pkgRoot = join(dirname(fileURLToPath(import.meta.url)))
|
|
30
|
-
const _localBin = join(_pkgRoot,
|
|
29
|
+
const _pkgRoot = join(dirname(fileURLToPath(import.meta.url)));
|
|
30
|
+
const _localBin = join(_pkgRoot, "node_modules", ".bin");
|
|
31
31
|
// Prepend local node_modules/.bin to PATH so bundled deps are found even when
|
|
32
32
|
// not globally installed (e.g., npx sigrank, local dev).
|
|
33
|
-
const _envPath = `${_localBin}${process.env.PATH ?
|
|
33
|
+
const _envPath = `${_localBin}${process.env.PATH ? ":" + process.env.PATH : ""}`;
|
|
34
34
|
|
|
35
35
|
// ASYNC FIX (2026-06-27): execFile wrapped in a Promise — replaces execSync in the
|
|
36
36
|
// fresh verifier readers. execSync blocks the entire Node event loop (no key handling,
|
|
@@ -43,11 +43,20 @@ function execFileAsync(cmd, args, timeoutMs) {
|
|
|
43
43
|
return new Promise((resolve, reject) => {
|
|
44
44
|
// NOTE: execFile does not accept a `stdio` option (it always pipes + buffers
|
|
45
45
|
// stdout/stderr against maxBuffer) — a previous `stdio` key here was silently ignored.
|
|
46
|
-
execFile(
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
46
|
+
execFile(
|
|
47
|
+
cmd,
|
|
48
|
+
args,
|
|
49
|
+
{
|
|
50
|
+
timeout: timeoutMs,
|
|
51
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
52
|
+
env: { ...process.env, PATH: _envPath },
|
|
53
|
+
},
|
|
54
|
+
(err, stdout) => {
|
|
55
|
+
if (err) reject(err);
|
|
56
|
+
else resolve(stdout.toString());
|
|
57
|
+
},
|
|
58
|
+
);
|
|
59
|
+
});
|
|
51
60
|
}
|
|
52
61
|
|
|
53
62
|
// Background tooling that runs under your account but is NOT your work — memory plugins,
|
|
@@ -57,20 +66,20 @@ function execFileAsync(cmd, args, timeoutMs) {
|
|
|
57
66
|
// token-dashboard / tokscale boards; SigRank filters them, so SigRank stays honest.
|
|
58
67
|
// subagents/ are KEPT (real work). Future-robust signal: also drop entrypoint=sdk-cli.
|
|
59
68
|
export const EXCLUDE_TOOLING =
|
|
60
|
-
/(^|[/-])(claude-mem|mem0|claude-self-reflect|basic-memory|memento|cipher-mem|memory-keeper)\b|observer-(sessions|archive)/i
|
|
69
|
+
/(^|[/-])(claude-mem|mem0|claude-self-reflect|basic-memory|memento|cipher-mem|memory-keeper)\b|observer-(sessions|archive)/i;
|
|
61
70
|
|
|
62
71
|
/** The four windows, in days. `all` = unbounded. */
|
|
63
72
|
export const WINDOWS = [
|
|
64
|
-
{ key:
|
|
65
|
-
{ key:
|
|
66
|
-
{ key:
|
|
67
|
-
{ key:
|
|
68
|
-
]
|
|
73
|
+
{ key: "7d", days: 7 },
|
|
74
|
+
{ key: "30d", days: 30 },
|
|
75
|
+
{ key: "90d", days: 90 },
|
|
76
|
+
{ key: "all", days: Infinity },
|
|
77
|
+
];
|
|
69
78
|
|
|
70
79
|
/** Hard cap: stop walking after this many .jsonl files to prevent OOM on
|
|
71
80
|
* accidentally huge or circularly-symlinked directory trees.
|
|
72
81
|
* Override via SIGRANK_MAX_JSONL_FILES env var. */
|
|
73
|
-
const MAX_JSONL_FILES = Number(process.env.SIGRANK_MAX_JSONL_FILES) || 10_000
|
|
82
|
+
const MAX_JSONL_FILES = Number(process.env.SIGRANK_MAX_JSONL_FILES) || 10_000;
|
|
74
83
|
|
|
75
84
|
/** Recursively yield every *.jsonl path under dir (any depth), sorted. Must be
|
|
76
85
|
* recursive: Claude Code stores sub-agent transcripts in `<project>/subagents/`
|
|
@@ -80,22 +89,30 @@ const MAX_JSONL_FILES = Number(process.env.SIGRANK_MAX_JSONL_FILES) || 10_000
|
|
|
80
89
|
* Hardened: skips symlinked directories (prevents circular traversal) and stops
|
|
81
90
|
* after MAX_JSONL_FILES files (prevents OOM on pathological trees). */
|
|
82
91
|
async function* _walkJsonl(dir, _counter = { n: 0 }) {
|
|
83
|
-
if (_counter.n >= MAX_JSONL_FILES) return
|
|
84
|
-
let entries
|
|
85
|
-
try {
|
|
92
|
+
if (_counter.n >= MAX_JSONL_FILES) return;
|
|
93
|
+
let entries;
|
|
94
|
+
try {
|
|
95
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
96
|
+
} catch {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
86
99
|
for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
87
|
-
if (_counter.n >= MAX_JSONL_FILES) return
|
|
88
|
-
const full = join(dir, e.name)
|
|
100
|
+
if (_counter.n >= MAX_JSONL_FILES) return;
|
|
101
|
+
const full = join(dir, e.name);
|
|
89
102
|
if (e.isDirectory()) {
|
|
90
103
|
// Skip symlinked directories — prevents circular traversal on machines where
|
|
91
104
|
// ~/.claude/projects/ contains a symlink to a large or self-referential tree.
|
|
92
|
-
let stat
|
|
93
|
-
try {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
105
|
+
let stat;
|
|
106
|
+
try {
|
|
107
|
+
stat = await lstat(full);
|
|
108
|
+
} catch {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (stat.isSymbolicLink()) continue;
|
|
112
|
+
yield* _walkJsonl(full, _counter);
|
|
113
|
+
} else if (e.isFile() && e.name.endsWith(".jsonl")) {
|
|
114
|
+
_counter.n++;
|
|
115
|
+
yield full;
|
|
99
116
|
}
|
|
100
117
|
}
|
|
101
118
|
}
|
|
@@ -107,23 +124,31 @@ async function* _walkJsonl(dir, _counter = { n: 0 }) {
|
|
|
107
124
|
* caller can dedup by (session_id, message_id) like token-dashboard.
|
|
108
125
|
*/
|
|
109
126
|
export const claudeAdapter = {
|
|
110
|
-
platform:
|
|
111
|
-
defaultRoot: () => join(homedir(),
|
|
127
|
+
platform: "claude",
|
|
128
|
+
defaultRoot: () => join(homedir(), ".claude", "projects"),
|
|
112
129
|
async *messages(root) {
|
|
113
130
|
for await (const path of _walkJsonl(root)) {
|
|
114
|
-
let text
|
|
115
|
-
try {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const
|
|
124
|
-
if (!
|
|
125
|
-
|
|
126
|
-
|
|
131
|
+
let text;
|
|
132
|
+
try {
|
|
133
|
+
text = await readFile(path, "utf8");
|
|
134
|
+
} catch {
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const rel = path.startsWith(root) ? path.slice(root.length + 1) : path;
|
|
138
|
+
if (EXCLUDE_TOOLING.test(rel)) continue; // skip claude-mem observer & other background tooling
|
|
139
|
+
for (const line of text.split("\n")) {
|
|
140
|
+
const s = line.trim();
|
|
141
|
+
if (!s) continue;
|
|
142
|
+
let ev;
|
|
143
|
+
try {
|
|
144
|
+
ev = JSON.parse(s);
|
|
145
|
+
} catch {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const m = ev && ev.message;
|
|
149
|
+
if (!m || typeof m !== "object") continue;
|
|
150
|
+
const u = m.usage;
|
|
151
|
+
if (!u || typeof u !== "object") continue;
|
|
127
152
|
yield {
|
|
128
153
|
id: m.id || null,
|
|
129
154
|
sid: ev.sessionId || null,
|
|
@@ -133,11 +158,11 @@ export const claudeAdapter = {
|
|
|
133
158
|
cacheCreate: Number(u.cache_creation_input_tokens) || 0,
|
|
134
159
|
cacheRead: Number(u.cache_read_input_tokens) || 0,
|
|
135
160
|
file: rel,
|
|
136
|
-
}
|
|
161
|
+
};
|
|
137
162
|
}
|
|
138
163
|
}
|
|
139
164
|
},
|
|
140
|
-
}
|
|
165
|
+
};
|
|
141
166
|
|
|
142
167
|
/**
|
|
143
168
|
* Pull local usage and slice it into the four windows of raw pillars.
|
|
@@ -145,43 +170,62 @@ export const claudeAdapter = {
|
|
|
145
170
|
* Returns { platform, root, generatedAt, files, totalMessages, windows:[{window,pillars,messages}] }.
|
|
146
171
|
*/
|
|
147
172
|
export async function tokenpull({ adapter = claudeAdapter, root, now } = {}) {
|
|
148
|
-
const r = root || adapter.defaultRoot()
|
|
149
|
-
const nowMs =
|
|
173
|
+
const r = root || adapter.defaultRoot();
|
|
174
|
+
const nowMs =
|
|
175
|
+
now == null ? Date.now() : typeof now === "number" ? now : Date.parse(now);
|
|
150
176
|
|
|
151
177
|
// Dedup by (session_id, message_id), keeping the FINAL snapshot — matches
|
|
152
178
|
// token-dashboard: Claude Code writes 2-3 partial→final lines per response with
|
|
153
179
|
// the same message.id; only the final tally matches billing. No-id records each
|
|
154
180
|
// get a unique synthetic key so they always count.
|
|
155
|
-
const seen = new Map()
|
|
156
|
-
const files = new Set()
|
|
157
|
-
let noId = 0
|
|
181
|
+
const seen = new Map();
|
|
182
|
+
const files = new Set();
|
|
183
|
+
let noId = 0;
|
|
158
184
|
for await (const msg of adapter.messages(r)) {
|
|
159
|
-
const key =
|
|
160
|
-
|
|
161
|
-
|
|
185
|
+
const key =
|
|
186
|
+
msg.sid && msg.id ? `${msg.sid}|${msg.id}` : msg.id || `__noid_${noId++}`;
|
|
187
|
+
seen.set(key, msg); // keep-last (final snapshot wins)
|
|
188
|
+
if (msg.file) files.add(msg.file);
|
|
162
189
|
}
|
|
163
|
-
const msgs = [...seen.values()]
|
|
190
|
+
const msgs = [...seen.values()];
|
|
164
191
|
|
|
165
192
|
const windows = WINDOWS.map((w) => {
|
|
166
|
-
const cutoff = w.days === Infinity ? -Infinity : nowMs - w.days * DAY_MS
|
|
193
|
+
const cutoff = w.days === Infinity ? -Infinity : nowMs - w.days * DAY_MS;
|
|
194
|
+
// NULL-TIMESTAMP WINDOW ASYMMETRY: the `all` window returns true unconditionally,
|
|
195
|
+
// so records whose adapter cannot supply a timestamp (ts: null or unparseable)
|
|
196
|
+
// appear in all-time but disappear from 7d/30d/90d. This is intentional — we do
|
|
197
|
+
// not fabricate timestamps. Session-granularity adapters (goose/droid/hermes)
|
|
198
|
+
// typically have created_at/updated_at, but if those columns are absent the row
|
|
199
|
+
// is all-time-only. A file-mtime fallback was considered but rejected: mtime
|
|
200
|
+
// reflects the log file, not the session, and would misattribute old sessions
|
|
201
|
+
// to recent file-touch times.
|
|
167
202
|
const inWin = msgs.filter((m) => {
|
|
168
|
-
if (w.days === Infinity) return true
|
|
169
|
-
const t = m.ts ? Date.parse(m.ts) : NaN
|
|
170
|
-
return Number.isFinite(t) && t >= cutoff && t <= nowMs
|
|
171
|
-
})
|
|
203
|
+
if (w.days === Infinity) return true;
|
|
204
|
+
const t = m.ts ? Date.parse(m.ts) : NaN;
|
|
205
|
+
return Number.isFinite(t) && t >= cutoff && t <= nowMs;
|
|
206
|
+
});
|
|
172
207
|
const pillars = inWin.reduce(
|
|
173
|
-
(a, m) => ({
|
|
208
|
+
(a, m) => ({
|
|
209
|
+
input: a.input + m.input,
|
|
210
|
+
output: a.output + m.output,
|
|
211
|
+
cacheCreate: a.cacheCreate + m.cacheCreate,
|
|
212
|
+
cacheRead: a.cacheRead + m.cacheRead,
|
|
213
|
+
}),
|
|
174
214
|
{ input: 0, output: 0, cacheCreate: 0, cacheRead: 0 },
|
|
175
|
-
)
|
|
176
|
-
return { window: w.key, pillars, messages: inWin.length }
|
|
177
|
-
})
|
|
215
|
+
);
|
|
216
|
+
return { window: w.key, pillars, messages: inWin.length };
|
|
217
|
+
});
|
|
178
218
|
|
|
179
219
|
// Auto-detect background tooling we excluded → report it (the MCP "asks" by telling).
|
|
180
|
-
let excludedTooling = []
|
|
220
|
+
let excludedTooling = [];
|
|
181
221
|
try {
|
|
182
|
-
const top = await readdir(r, { withFileTypes: true })
|
|
183
|
-
excludedTooling = top
|
|
184
|
-
|
|
222
|
+
const top = await readdir(r, { withFileTypes: true });
|
|
223
|
+
excludedTooling = top
|
|
224
|
+
.filter((d) => d.isDirectory() && EXCLUDE_TOOLING.test(d.name))
|
|
225
|
+
.map((d) => d.name);
|
|
226
|
+
} catch {
|
|
227
|
+
/* ignore */
|
|
228
|
+
}
|
|
185
229
|
|
|
186
230
|
return {
|
|
187
231
|
platform: adapter.platform,
|
|
@@ -191,7 +235,7 @@ export async function tokenpull({ adapter = claudeAdapter, root, now } = {}) {
|
|
|
191
235
|
totalMessages: msgs.length,
|
|
192
236
|
excludedTooling,
|
|
193
237
|
windows,
|
|
194
|
-
}
|
|
238
|
+
};
|
|
195
239
|
}
|
|
196
240
|
|
|
197
241
|
// ── Codex ──────────────────────────────────────────────────────────────────
|
|
@@ -202,38 +246,48 @@ export async function tokenpull({ adapter = claudeAdapter, root, now } = {}) {
|
|
|
202
246
|
// The input/cacheCreate split is WINDOW-level (estInput = output × io_ratio), so Codex
|
|
203
247
|
// has its own pull (tokenpullCodex) instead of the per-message claude pipeline.
|
|
204
248
|
export const codexAdapter = {
|
|
205
|
-
platform:
|
|
206
|
-
defaultRoot: () => join(homedir(),
|
|
249
|
+
platform: "codex",
|
|
250
|
+
defaultRoot: () => join(homedir(), ".codex"),
|
|
207
251
|
async *records(root) {
|
|
208
|
-
for (const sub of [
|
|
252
|
+
for (const sub of ["sessions", "archived_sessions"]) {
|
|
209
253
|
for await (const path of _walkJsonl(join(root, sub))) {
|
|
210
|
-
let text
|
|
211
|
-
try {
|
|
212
|
-
|
|
254
|
+
let text;
|
|
255
|
+
try {
|
|
256
|
+
text = await readFile(path, "utf8");
|
|
257
|
+
} catch {
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
const rel = path.startsWith(root) ? path.slice(root.length + 1) : path;
|
|
213
261
|
// Apply the same background-tooling exclusion as the Claude adapter — skips
|
|
214
262
|
// memory plugins / observers running under the Codex account.
|
|
215
|
-
if (EXCLUDE_TOOLING.test(rel)) continue
|
|
216
|
-
for (const line of text.split(
|
|
217
|
-
if (!line.includes('"token_count"')) continue
|
|
218
|
-
let ev
|
|
219
|
-
try {
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
const
|
|
263
|
+
if (EXCLUDE_TOOLING.test(rel)) continue;
|
|
264
|
+
for (const line of text.split("\n")) {
|
|
265
|
+
if (!line.includes('"token_count"')) continue;
|
|
266
|
+
let ev;
|
|
267
|
+
try {
|
|
268
|
+
ev = JSON.parse(line);
|
|
269
|
+
} catch {
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
const p = ev && ev.payload;
|
|
273
|
+
if (!p || p.type !== "token_count") continue;
|
|
274
|
+
const u = (p.info || {}).last_token_usage || {};
|
|
275
|
+
const inputIncl = Number(u.input_tokens) || 0;
|
|
276
|
+
const cached = Number(u.cached_input_tokens) || 0;
|
|
225
277
|
yield {
|
|
226
278
|
ts: ev.timestamp || null,
|
|
227
|
-
output:
|
|
279
|
+
output:
|
|
280
|
+
(Number(u.output_tokens) || 0) +
|
|
281
|
+
(Number(u.reasoning_output_tokens) || 0),
|
|
228
282
|
cacheRead: cached,
|
|
229
283
|
uncached: Math.max(0, inputIncl - cached), // (true input + cache-write), split window-level
|
|
230
284
|
file: rel,
|
|
231
|
-
}
|
|
285
|
+
};
|
|
232
286
|
}
|
|
233
287
|
}
|
|
234
288
|
}
|
|
235
289
|
},
|
|
236
|
-
}
|
|
290
|
+
};
|
|
237
291
|
|
|
238
292
|
/**
|
|
239
293
|
* Pull local Codex usage → the 4 windows of CANONICAL pillars (always estimated).
|
|
@@ -241,30 +295,63 @@ export const codexAdapter = {
|
|
|
241
295
|
* input/output ratio; Alpha = 2.0 default), cacheCreate = max(0, uncached − input).
|
|
242
296
|
* Inject ioRatio/root/now/adapter for tests.
|
|
243
297
|
*/
|
|
244
|
-
export async function tokenpullCodex({
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
298
|
+
export async function tokenpullCodex({
|
|
299
|
+
adapter = codexAdapter,
|
|
300
|
+
root,
|
|
301
|
+
now,
|
|
302
|
+
ioRatio = 2.0,
|
|
303
|
+
} = {}) {
|
|
304
|
+
const r = root || adapter.defaultRoot();
|
|
305
|
+
const nowMs =
|
|
306
|
+
now == null ? Date.now() : typeof now === "number" ? now : Date.parse(now);
|
|
307
|
+
const recs = [];
|
|
308
|
+
const files = new Set();
|
|
309
|
+
for await (const m of adapter.records(r)) {
|
|
310
|
+
recs.push(m);
|
|
311
|
+
if (m.file) files.add(m.file);
|
|
312
|
+
}
|
|
250
313
|
|
|
251
314
|
const windows = WINDOWS.map((w) => {
|
|
252
|
-
const cutoff = w.days === Infinity ? -Infinity : nowMs - w.days * DAY_MS
|
|
315
|
+
const cutoff = w.days === Infinity ? -Infinity : nowMs - w.days * DAY_MS;
|
|
316
|
+
// NULL-TIMESTAMP WINDOW ASYMMETRY: see tokenpull() above — same behavior.
|
|
317
|
+
// Records without a parseable ts appear in `all` only, not in 7d/30d/90d.
|
|
253
318
|
const inWin = recs.filter((m) => {
|
|
254
|
-
if (w.days === Infinity) return true
|
|
255
|
-
const t = m.ts ? Date.parse(m.ts) : NaN
|
|
256
|
-
return Number.isFinite(t) && t >= cutoff && t <= nowMs
|
|
257
|
-
})
|
|
319
|
+
if (w.days === Infinity) return true;
|
|
320
|
+
const t = m.ts ? Date.parse(m.ts) : NaN;
|
|
321
|
+
return Number.isFinite(t) && t >= cutoff && t <= nowMs;
|
|
322
|
+
});
|
|
258
323
|
const sum = inWin.reduce(
|
|
259
|
-
(a, m) => ({
|
|
324
|
+
(a, m) => ({
|
|
325
|
+
output: a.output + m.output,
|
|
326
|
+
cacheRead: a.cacheRead + m.cacheRead,
|
|
327
|
+
uncached: a.uncached + m.uncached,
|
|
328
|
+
}),
|
|
260
329
|
{ output: 0, cacheRead: 0, uncached: 0 },
|
|
261
|
-
)
|
|
262
|
-
const input = Math.floor(sum.output * ioRatio) // estimated true input
|
|
263
|
-
const cacheCreate = Math.max(0, sum.uncached - input) // split cache-write out of the combined input
|
|
264
|
-
return {
|
|
265
|
-
|
|
330
|
+
);
|
|
331
|
+
const input = Math.floor(sum.output * ioRatio); // estimated true input
|
|
332
|
+
const cacheCreate = Math.max(0, sum.uncached - input); // split cache-write out of the combined input
|
|
333
|
+
return {
|
|
334
|
+
window: w.key,
|
|
335
|
+
messages: inWin.length,
|
|
336
|
+
pillars: {
|
|
337
|
+
input,
|
|
338
|
+
output: sum.output,
|
|
339
|
+
cacheCreate,
|
|
340
|
+
cacheRead: sum.cacheRead,
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
});
|
|
266
344
|
|
|
267
|
-
return {
|
|
345
|
+
return {
|
|
346
|
+
platform: "codex",
|
|
347
|
+
root: r,
|
|
348
|
+
generatedAt: new Date(nowMs).toISOString(),
|
|
349
|
+
files: files.size,
|
|
350
|
+
totalMessages: recs.length,
|
|
351
|
+
estimated: true,
|
|
352
|
+
ioRatio,
|
|
353
|
+
windows,
|
|
354
|
+
};
|
|
268
355
|
}
|
|
269
356
|
|
|
270
357
|
/**
|
|
@@ -278,35 +365,48 @@ export async function tokenpullCodex({ adapter = codexAdapter, root, now, ioRati
|
|
|
278
365
|
* source's log format doesn't expose token counts at all.
|
|
279
366
|
*/
|
|
280
367
|
export async function tokenpullAny(platform, opts = {}) {
|
|
281
|
-
if (!platform || platform ===
|
|
282
|
-
|
|
368
|
+
if (!platform || platform === "claude")
|
|
369
|
+
return tokenpull({ adapter: claudeAdapter, ...opts });
|
|
370
|
+
if (platform === "codex") {
|
|
283
371
|
// Auto-derive io_ratio from the operator's Claude data when not explicitly provided.
|
|
284
|
-
let ioRatio = opts.ioRatio || 2.0
|
|
372
|
+
let ioRatio = opts.ioRatio || 2.0;
|
|
285
373
|
if (!opts.ioRatio) {
|
|
286
374
|
try {
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
375
|
+
// Forward root/now from opts so the Beta-ratio derivation is testable
|
|
376
|
+
// (without this, it always reads the real ~/.claude and can't be injected).
|
|
377
|
+
const c = await tokenpull({
|
|
378
|
+
adapter: claudeAdapter,
|
|
379
|
+
root: opts.root,
|
|
380
|
+
now: opts.now,
|
|
381
|
+
});
|
|
382
|
+
const all = c.windows.find((w) => w.window === "all");
|
|
383
|
+
if (all && all.pillars.output > 0)
|
|
384
|
+
ioRatio = all.pillars.input / all.pillars.output;
|
|
385
|
+
} catch {
|
|
386
|
+
/* no Claude data → Alpha 2.0 */
|
|
387
|
+
}
|
|
291
388
|
}
|
|
292
|
-
return tokenpullCodex({ ioRatio, ...opts })
|
|
389
|
+
return tokenpullCodex({ ioRatio, ...opts });
|
|
293
390
|
}
|
|
294
391
|
// Cloud agents (Devin, etc.) run server-side — no local JSONL to read
|
|
295
|
-
const CLOUD_AGENTS = { devin:
|
|
392
|
+
const CLOUD_AGENTS = { devin: "Cognition/Devin" };
|
|
296
393
|
if (platform in CLOUD_AGENTS) {
|
|
297
394
|
throw new Error(
|
|
298
395
|
`"${platform}" (${CLOUD_AGENTS[platform]}) runs in the cloud — sessions are not written to local JSONL files. ` +
|
|
299
|
-
|
|
300
|
-
)
|
|
396
|
+
`There is no local data source to read. If ${CLOUD_AGENTS[platform]} exposes a usage API in future, an adapter can be added.`,
|
|
397
|
+
);
|
|
301
398
|
}
|
|
302
|
-
const adapter = ADAPTERS[platform]
|
|
303
|
-
if (!adapter)
|
|
304
|
-
|
|
399
|
+
const adapter = ADAPTERS[platform];
|
|
400
|
+
if (!adapter)
|
|
401
|
+
throw new Error(
|
|
402
|
+
`Unknown platform "${platform}". Valid platforms: claude, codex, ${Object.keys(ADAPTERS).join(", ")}`,
|
|
403
|
+
);
|
|
404
|
+
const result = await tokenpull({ adapter, ...opts });
|
|
305
405
|
// Surface adapter-level flags
|
|
306
|
-
if (adapter.estimated) result.estimated = true
|
|
307
|
-
if (adapter.dataGap)
|
|
308
|
-
if (adapter.setupNote) result.setupNote = adapter.setupNote
|
|
309
|
-
return result
|
|
406
|
+
if (adapter.estimated) result.estimated = true;
|
|
407
|
+
if (adapter.dataGap) result.dataGap = adapter.dataGap;
|
|
408
|
+
if (adapter.setupNote) result.setupNote = adapter.setupNote;
|
|
409
|
+
return result;
|
|
310
410
|
}
|
|
311
411
|
|
|
312
412
|
// ── Fresh verifier pull (#9 / C1) ─────────────────────────────────────────────
|
|
@@ -327,36 +427,52 @@ export async function tokenpullAny(platform, opts = {}) {
|
|
|
327
427
|
// ASYNC FIX (2026-06-27): was execSync (blocked the entire event loop for up to 15s,
|
|
328
428
|
// freezing the TUI locked frame + key handling). Now uses execFile (async) so the
|
|
329
429
|
// event loop keeps running — the TUI stays responsive while the external command runs.
|
|
330
|
-
async function _freshCcusage(platform =
|
|
430
|
+
async function _freshCcusage(platform = "claude") {
|
|
331
431
|
try {
|
|
332
|
-
const raw = await execFileAsync(
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
432
|
+
const raw = await execFileAsync(
|
|
433
|
+
"ccusage",
|
|
434
|
+
[platform, "daily", "--json"],
|
|
435
|
+
15000,
|
|
436
|
+
);
|
|
437
|
+
const rows = JSON.parse(raw)?.daily ?? JSON.parse(raw);
|
|
438
|
+
if (!Array.isArray(rows)) return null;
|
|
439
|
+
const now = Date.now();
|
|
440
|
+
const result = {};
|
|
441
|
+
for (const [win, days] of Object.entries({
|
|
442
|
+
"7d": 7,
|
|
443
|
+
"30d": 30,
|
|
444
|
+
"90d": 90,
|
|
445
|
+
})) {
|
|
446
|
+
const since = new Date(now - days * DAY_MS);
|
|
447
|
+
let i = 0,
|
|
448
|
+
o = 0,
|
|
449
|
+
cw = 0,
|
|
450
|
+
cr = 0;
|
|
340
451
|
for (const r of rows) {
|
|
341
|
-
if (new Date(r.date ?? r.day ??
|
|
342
|
-
i
|
|
343
|
-
o
|
|
344
|
-
cw += r.cacheCreationTokens ?? r.cache_create_tokens
|
|
345
|
-
cr += r.cacheReadTokens
|
|
452
|
+
if (new Date(r.date ?? r.day ?? "1970") >= since) {
|
|
453
|
+
i += r.inputTokens ?? r.input_tokens ?? 0;
|
|
454
|
+
o += r.outputTokens ?? r.output_tokens ?? 0;
|
|
455
|
+
cw += r.cacheCreationTokens ?? r.cache_create_tokens ?? 0;
|
|
456
|
+
cr += r.cacheReadTokens ?? r.cache_read_tokens ?? 0;
|
|
346
457
|
}
|
|
347
458
|
}
|
|
348
|
-
result[win] = { input: i, output: o, cacheCreate: cw, cacheRead: cr }
|
|
459
|
+
result[win] = { input: i, output: o, cacheCreate: cw, cacheRead: cr };
|
|
349
460
|
}
|
|
350
|
-
let i = 0,
|
|
461
|
+
let i = 0,
|
|
462
|
+
o = 0,
|
|
463
|
+
cw = 0,
|
|
464
|
+
cr = 0;
|
|
351
465
|
for (const r of rows) {
|
|
352
|
-
i
|
|
353
|
-
o
|
|
354
|
-
cw += r.cacheCreationTokens ?? r.cache_create_tokens
|
|
355
|
-
cr += r.cacheReadTokens
|
|
466
|
+
i += r.inputTokens ?? r.input_tokens ?? 0;
|
|
467
|
+
o += r.outputTokens ?? r.output_tokens ?? 0;
|
|
468
|
+
cw += r.cacheCreationTokens ?? r.cache_create_tokens ?? 0;
|
|
469
|
+
cr += r.cacheReadTokens ?? r.cache_read_tokens ?? 0;
|
|
356
470
|
}
|
|
357
|
-
result[
|
|
358
|
-
return result
|
|
359
|
-
} catch {
|
|
471
|
+
result["all"] = { input: i, output: o, cacheCreate: cw, cacheRead: cr };
|
|
472
|
+
return result;
|
|
473
|
+
} catch {
|
|
474
|
+
return null;
|
|
475
|
+
}
|
|
360
476
|
}
|
|
361
477
|
|
|
362
478
|
// tokendash: REFRESH ~/.claude/token-dashboard.db by running the dashboard's scan, then read
|
|
@@ -366,21 +482,35 @@ async function _freshCcusage(platform = 'claude') {
|
|
|
366
482
|
// ASYNC FIX (2026-06-27): was execSync with a 90s timeout — that blocked the ENTIRE event
|
|
367
483
|
// loop for up to 90 seconds, freezing the TUI completely (no key handling, no frame repaint).
|
|
368
484
|
// Now uses execFile (async) so the TUI stays responsive during the scan.
|
|
369
|
-
async function _freshTokendash(platform =
|
|
370
|
-
if (platform !==
|
|
371
|
-
const dbPath = join(homedir(),
|
|
485
|
+
async function _freshTokendash(platform = "claude") {
|
|
486
|
+
if (platform !== "claude") return null;
|
|
487
|
+
const dbPath = join(homedir(), ".claude", "token-dashboard.db");
|
|
372
488
|
// Read the token-dashboard DB directly with sqlite3 (no external python scan
|
|
373
489
|
// needed — the DB is created by the tokendash dashboard, now bundled as a dep).
|
|
374
490
|
// all-time only; the db doesn't expose windowing here.
|
|
375
|
-
if (!existsSync(dbPath)) return null
|
|
491
|
+
if (!existsSync(dbPath)) return null;
|
|
376
492
|
try {
|
|
377
|
-
const raw = await execFileAsync(
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
493
|
+
const raw = await execFileAsync(
|
|
494
|
+
"sqlite3",
|
|
495
|
+
[
|
|
496
|
+
dbPath,
|
|
497
|
+
"SELECT SUM(input_tokens),SUM(output_tokens),SUM(cache_create_5m_tokens)+SUM(cache_create_1h_tokens),SUM(cache_read_tokens) FROM messages",
|
|
498
|
+
],
|
|
499
|
+
5000,
|
|
500
|
+
);
|
|
501
|
+
const [i, o, cw, cr] = raw.trim().split("|").map(Number);
|
|
502
|
+
if (![i, o, cw, cr].some((n) => Number.isFinite(n) && n > 0)) return null;
|
|
503
|
+
return {
|
|
504
|
+
all: {
|
|
505
|
+
input: i || 0,
|
|
506
|
+
output: o || 0,
|
|
507
|
+
cacheCreate: cw || 0,
|
|
508
|
+
cacheRead: cr || 0,
|
|
509
|
+
},
|
|
510
|
+
};
|
|
511
|
+
} catch {
|
|
512
|
+
return null;
|
|
513
|
+
}
|
|
384
514
|
}
|
|
385
515
|
|
|
386
516
|
// tokscale: run `tokscale models --json` (bundled as npm dep — no bunx needed)
|
|
@@ -393,24 +523,37 @@ async function _freshTokendash(platform = 'claude') {
|
|
|
393
523
|
//
|
|
394
524
|
// ASYNC FIX (2026-06-27): was execSync with a 60s timeout — blocked the event loop entirely.
|
|
395
525
|
// Now uses execFile (async) so the TUI stays responsive.
|
|
396
|
-
async function _freshTokscale(platform =
|
|
526
|
+
async function _freshTokscale(platform = "claude") {
|
|
397
527
|
try {
|
|
398
|
-
const raw = await execFileAsync(
|
|
399
|
-
const data = JSON.parse(raw)
|
|
400
|
-
const entries = Array.isArray(data?.entries)
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
528
|
+
const raw = await execFileAsync("tokscale", ["models", "--json"], 60000);
|
|
529
|
+
const data = JSON.parse(raw);
|
|
530
|
+
const entries = Array.isArray(data?.entries)
|
|
531
|
+
? data.entries
|
|
532
|
+
: Array.isArray(data)
|
|
533
|
+
? data
|
|
534
|
+
: [];
|
|
535
|
+
const rows = entries.filter(
|
|
536
|
+
(e) =>
|
|
537
|
+
e &&
|
|
538
|
+
e.client === platform &&
|
|
539
|
+
e.model !== "<synthetic>" &&
|
|
540
|
+
e.model !== "unknown" &&
|
|
541
|
+
((Number(e.input) || 0) > 0 || (Number(e.output) || 0) > 0),
|
|
542
|
+
);
|
|
543
|
+
if (!rows.length) return null;
|
|
544
|
+
const acc = rows.reduce(
|
|
545
|
+
(a, e) => ({
|
|
546
|
+
input: a.input + (Number(e.input) || 0),
|
|
547
|
+
output: a.output + (Number(e.output) || 0),
|
|
548
|
+
cacheCreate: a.cacheCreate + (Number(e.cacheWrite) || 0),
|
|
549
|
+
cacheRead: a.cacheRead + (Number(e.cacheRead) || 0),
|
|
550
|
+
}),
|
|
551
|
+
{ input: 0, output: 0, cacheCreate: 0, cacheRead: 0 },
|
|
552
|
+
);
|
|
553
|
+
return { all: acc };
|
|
554
|
+
} catch {
|
|
555
|
+
return null;
|
|
556
|
+
}
|
|
414
557
|
}
|
|
415
558
|
|
|
416
559
|
/**
|
|
@@ -422,14 +565,20 @@ async function _freshTokscale(platform = 'claude') {
|
|
|
422
565
|
* - tokendash: refresh + read ~/.claude/token-dashboard.db → all (claude only)
|
|
423
566
|
* - tokscale : `bunx tokscale@latest models --json` → all
|
|
424
567
|
*/
|
|
425
|
-
export async function freshVerifierPillars(platform =
|
|
426
|
-
const p = platform ||
|
|
568
|
+
export async function freshVerifierPillars(platform = "claude") {
|
|
569
|
+
const p = platform || "claude";
|
|
427
570
|
// Each call is internally try/catch → null; await Promise.all of resolved values keeps the
|
|
428
571
|
// helper async (per the contract) without any individual failure rejecting the batch.
|
|
429
572
|
const [ccusage, tokendash, tokscale] = await Promise.all([
|
|
430
|
-
Promise.resolve()
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
573
|
+
Promise.resolve()
|
|
574
|
+
.then(() => _freshCcusage(p))
|
|
575
|
+
.catch(() => null),
|
|
576
|
+
Promise.resolve()
|
|
577
|
+
.then(() => _freshTokendash(p))
|
|
578
|
+
.catch(() => null),
|
|
579
|
+
Promise.resolve()
|
|
580
|
+
.then(() => _freshTokscale(p))
|
|
581
|
+
.catch(() => null),
|
|
582
|
+
]);
|
|
583
|
+
return { ccusage, tokscale, tokendash };
|
|
435
584
|
}
|