sigrank 0.16.0 → 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 +117 -77
- package/adapters.mjs +493 -238
- package/badges.mjs +157 -56
- package/cascade.mjs +132 -70
- 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 +1 -1
- package/preflight.mjs +101 -29
- package/sign.mjs +46 -27
- package/submit.mjs +137 -71
- package/tokenpull.mjs +325 -188
- package/tools.mjs +1702 -951
- 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,25 +170,27 @@ 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;
|
|
167
194
|
// NULL-TIMESTAMP WINDOW ASYMMETRY: the `all` window returns true unconditionally,
|
|
168
195
|
// so records whose adapter cannot supply a timestamp (ts: null or unparseable)
|
|
169
196
|
// appear in all-time but disappear from 7d/30d/90d. This is intentional — we do
|
|
@@ -173,23 +200,32 @@ export async function tokenpull({ adapter = claudeAdapter, root, now } = {}) {
|
|
|
173
200
|
// reflects the log file, not the session, and would misattribute old sessions
|
|
174
201
|
// to recent file-touch times.
|
|
175
202
|
const inWin = msgs.filter((m) => {
|
|
176
|
-
if (w.days === Infinity) return true
|
|
177
|
-
const t = m.ts ? Date.parse(m.ts) : NaN
|
|
178
|
-
return Number.isFinite(t) && t >= cutoff && t <= nowMs
|
|
179
|
-
})
|
|
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
|
+
});
|
|
180
207
|
const pillars = inWin.reduce(
|
|
181
|
-
(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
|
+
}),
|
|
182
214
|
{ input: 0, output: 0, cacheCreate: 0, cacheRead: 0 },
|
|
183
|
-
)
|
|
184
|
-
return { window: w.key, pillars, messages: inWin.length }
|
|
185
|
-
})
|
|
215
|
+
);
|
|
216
|
+
return { window: w.key, pillars, messages: inWin.length };
|
|
217
|
+
});
|
|
186
218
|
|
|
187
219
|
// Auto-detect background tooling we excluded → report it (the MCP "asks" by telling).
|
|
188
|
-
let excludedTooling = []
|
|
220
|
+
let excludedTooling = [];
|
|
189
221
|
try {
|
|
190
|
-
const top = await readdir(r, { withFileTypes: true })
|
|
191
|
-
excludedTooling = top
|
|
192
|
-
|
|
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
|
+
}
|
|
193
229
|
|
|
194
230
|
return {
|
|
195
231
|
platform: adapter.platform,
|
|
@@ -199,7 +235,7 @@ export async function tokenpull({ adapter = claudeAdapter, root, now } = {}) {
|
|
|
199
235
|
totalMessages: msgs.length,
|
|
200
236
|
excludedTooling,
|
|
201
237
|
windows,
|
|
202
|
-
}
|
|
238
|
+
};
|
|
203
239
|
}
|
|
204
240
|
|
|
205
241
|
// ── Codex ──────────────────────────────────────────────────────────────────
|
|
@@ -210,38 +246,48 @@ export async function tokenpull({ adapter = claudeAdapter, root, now } = {}) {
|
|
|
210
246
|
// The input/cacheCreate split is WINDOW-level (estInput = output × io_ratio), so Codex
|
|
211
247
|
// has its own pull (tokenpullCodex) instead of the per-message claude pipeline.
|
|
212
248
|
export const codexAdapter = {
|
|
213
|
-
platform:
|
|
214
|
-
defaultRoot: () => join(homedir(),
|
|
249
|
+
platform: "codex",
|
|
250
|
+
defaultRoot: () => join(homedir(), ".codex"),
|
|
215
251
|
async *records(root) {
|
|
216
|
-
for (const sub of [
|
|
252
|
+
for (const sub of ["sessions", "archived_sessions"]) {
|
|
217
253
|
for await (const path of _walkJsonl(join(root, sub))) {
|
|
218
|
-
let text
|
|
219
|
-
try {
|
|
220
|
-
|
|
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;
|
|
221
261
|
// Apply the same background-tooling exclusion as the Claude adapter — skips
|
|
222
262
|
// memory plugins / observers running under the Codex account.
|
|
223
|
-
if (EXCLUDE_TOOLING.test(rel)) continue
|
|
224
|
-
for (const line of text.split(
|
|
225
|
-
if (!line.includes('"token_count"')) continue
|
|
226
|
-
let ev
|
|
227
|
-
try {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
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;
|
|
233
277
|
yield {
|
|
234
278
|
ts: ev.timestamp || null,
|
|
235
|
-
output:
|
|
279
|
+
output:
|
|
280
|
+
(Number(u.output_tokens) || 0) +
|
|
281
|
+
(Number(u.reasoning_output_tokens) || 0),
|
|
236
282
|
cacheRead: cached,
|
|
237
283
|
uncached: Math.max(0, inputIncl - cached), // (true input + cache-write), split window-level
|
|
238
284
|
file: rel,
|
|
239
|
-
}
|
|
285
|
+
};
|
|
240
286
|
}
|
|
241
287
|
}
|
|
242
288
|
}
|
|
243
289
|
},
|
|
244
|
-
}
|
|
290
|
+
};
|
|
245
291
|
|
|
246
292
|
/**
|
|
247
293
|
* Pull local Codex usage → the 4 windows of CANONICAL pillars (always estimated).
|
|
@@ -249,32 +295,63 @@ export const codexAdapter = {
|
|
|
249
295
|
* input/output ratio; Alpha = 2.0 default), cacheCreate = max(0, uncached − input).
|
|
250
296
|
* Inject ioRatio/root/now/adapter for tests.
|
|
251
297
|
*/
|
|
252
|
-
export async function tokenpullCodex({
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
+
}
|
|
258
313
|
|
|
259
314
|
const windows = WINDOWS.map((w) => {
|
|
260
|
-
const cutoff = w.days === Infinity ? -Infinity : nowMs - w.days * DAY_MS
|
|
315
|
+
const cutoff = w.days === Infinity ? -Infinity : nowMs - w.days * DAY_MS;
|
|
261
316
|
// NULL-TIMESTAMP WINDOW ASYMMETRY: see tokenpull() above — same behavior.
|
|
262
317
|
// Records without a parseable ts appear in `all` only, not in 7d/30d/90d.
|
|
263
318
|
const inWin = recs.filter((m) => {
|
|
264
|
-
if (w.days === Infinity) return true
|
|
265
|
-
const t = m.ts ? Date.parse(m.ts) : NaN
|
|
266
|
-
return Number.isFinite(t) && t >= cutoff && t <= nowMs
|
|
267
|
-
})
|
|
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
|
+
});
|
|
268
323
|
const sum = inWin.reduce(
|
|
269
|
-
(a, m) => ({
|
|
324
|
+
(a, m) => ({
|
|
325
|
+
output: a.output + m.output,
|
|
326
|
+
cacheRead: a.cacheRead + m.cacheRead,
|
|
327
|
+
uncached: a.uncached + m.uncached,
|
|
328
|
+
}),
|
|
270
329
|
{ output: 0, cacheRead: 0, uncached: 0 },
|
|
271
|
-
)
|
|
272
|
-
const input = Math.floor(sum.output * ioRatio) // estimated true input
|
|
273
|
-
const cacheCreate = Math.max(0, sum.uncached - input) // split cache-write out of the combined input
|
|
274
|
-
return {
|
|
275
|
-
|
|
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
|
+
});
|
|
276
344
|
|
|
277
|
-
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
|
+
};
|
|
278
355
|
}
|
|
279
356
|
|
|
280
357
|
/**
|
|
@@ -288,37 +365,48 @@ export async function tokenpullCodex({ adapter = codexAdapter, root, now, ioRati
|
|
|
288
365
|
* source's log format doesn't expose token counts at all.
|
|
289
366
|
*/
|
|
290
367
|
export async function tokenpullAny(platform, opts = {}) {
|
|
291
|
-
if (!platform || platform ===
|
|
292
|
-
|
|
368
|
+
if (!platform || platform === "claude")
|
|
369
|
+
return tokenpull({ adapter: claudeAdapter, ...opts });
|
|
370
|
+
if (platform === "codex") {
|
|
293
371
|
// Auto-derive io_ratio from the operator's Claude data when not explicitly provided.
|
|
294
|
-
let ioRatio = opts.ioRatio || 2.0
|
|
372
|
+
let ioRatio = opts.ioRatio || 2.0;
|
|
295
373
|
if (!opts.ioRatio) {
|
|
296
374
|
try {
|
|
297
375
|
// Forward root/now from opts so the Beta-ratio derivation is testable
|
|
298
376
|
// (without this, it always reads the real ~/.claude and can't be injected).
|
|
299
|
-
const c = await tokenpull({
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
+
}
|
|
303
388
|
}
|
|
304
|
-
return tokenpullCodex({ ioRatio, ...opts })
|
|
389
|
+
return tokenpullCodex({ ioRatio, ...opts });
|
|
305
390
|
}
|
|
306
391
|
// Cloud agents (Devin, etc.) run server-side — no local JSONL to read
|
|
307
|
-
const CLOUD_AGENTS = { devin:
|
|
392
|
+
const CLOUD_AGENTS = { devin: "Cognition/Devin" };
|
|
308
393
|
if (platform in CLOUD_AGENTS) {
|
|
309
394
|
throw new Error(
|
|
310
395
|
`"${platform}" (${CLOUD_AGENTS[platform]}) runs in the cloud — sessions are not written to local JSONL files. ` +
|
|
311
|
-
|
|
312
|
-
)
|
|
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
|
+
);
|
|
313
398
|
}
|
|
314
|
-
const adapter = ADAPTERS[platform]
|
|
315
|
-
if (!adapter)
|
|
316
|
-
|
|
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 });
|
|
317
405
|
// Surface adapter-level flags
|
|
318
|
-
if (adapter.estimated) result.estimated = true
|
|
319
|
-
if (adapter.dataGap)
|
|
320
|
-
if (adapter.setupNote) result.setupNote = adapter.setupNote
|
|
321
|
-
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;
|
|
322
410
|
}
|
|
323
411
|
|
|
324
412
|
// ── Fresh verifier pull (#9 / C1) ─────────────────────────────────────────────
|
|
@@ -339,36 +427,52 @@ export async function tokenpullAny(platform, opts = {}) {
|
|
|
339
427
|
// ASYNC FIX (2026-06-27): was execSync (blocked the entire event loop for up to 15s,
|
|
340
428
|
// freezing the TUI locked frame + key handling). Now uses execFile (async) so the
|
|
341
429
|
// event loop keeps running — the TUI stays responsive while the external command runs.
|
|
342
|
-
async function _freshCcusage(platform =
|
|
430
|
+
async function _freshCcusage(platform = "claude") {
|
|
343
431
|
try {
|
|
344
|
-
const raw = await execFileAsync(
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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;
|
|
352
451
|
for (const r of rows) {
|
|
353
|
-
if (new Date(r.date ?? r.day ??
|
|
354
|
-
i
|
|
355
|
-
o
|
|
356
|
-
cw += r.cacheCreationTokens ?? r.cache_create_tokens
|
|
357
|
-
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;
|
|
358
457
|
}
|
|
359
458
|
}
|
|
360
|
-
result[win] = { input: i, output: o, cacheCreate: cw, cacheRead: cr }
|
|
459
|
+
result[win] = { input: i, output: o, cacheCreate: cw, cacheRead: cr };
|
|
361
460
|
}
|
|
362
|
-
let i = 0,
|
|
461
|
+
let i = 0,
|
|
462
|
+
o = 0,
|
|
463
|
+
cw = 0,
|
|
464
|
+
cr = 0;
|
|
363
465
|
for (const r of rows) {
|
|
364
|
-
i
|
|
365
|
-
o
|
|
366
|
-
cw += r.cacheCreationTokens ?? r.cache_create_tokens
|
|
367
|
-
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;
|
|
368
470
|
}
|
|
369
|
-
result[
|
|
370
|
-
return result
|
|
371
|
-
} catch {
|
|
471
|
+
result["all"] = { input: i, output: o, cacheCreate: cw, cacheRead: cr };
|
|
472
|
+
return result;
|
|
473
|
+
} catch {
|
|
474
|
+
return null;
|
|
475
|
+
}
|
|
372
476
|
}
|
|
373
477
|
|
|
374
478
|
// tokendash: REFRESH ~/.claude/token-dashboard.db by running the dashboard's scan, then read
|
|
@@ -378,21 +482,35 @@ async function _freshCcusage(platform = 'claude') {
|
|
|
378
482
|
// ASYNC FIX (2026-06-27): was execSync with a 90s timeout — that blocked the ENTIRE event
|
|
379
483
|
// loop for up to 90 seconds, freezing the TUI completely (no key handling, no frame repaint).
|
|
380
484
|
// Now uses execFile (async) so the TUI stays responsive during the scan.
|
|
381
|
-
async function _freshTokendash(platform =
|
|
382
|
-
if (platform !==
|
|
383
|
-
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");
|
|
384
488
|
// Read the token-dashboard DB directly with sqlite3 (no external python scan
|
|
385
489
|
// needed — the DB is created by the tokendash dashboard, now bundled as a dep).
|
|
386
490
|
// all-time only; the db doesn't expose windowing here.
|
|
387
|
-
if (!existsSync(dbPath)) return null
|
|
491
|
+
if (!existsSync(dbPath)) return null;
|
|
388
492
|
try {
|
|
389
|
-
const raw = await execFileAsync(
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
+
}
|
|
396
514
|
}
|
|
397
515
|
|
|
398
516
|
// tokscale: run `tokscale models --json` (bundled as npm dep — no bunx needed)
|
|
@@ -405,24 +523,37 @@ async function _freshTokendash(platform = 'claude') {
|
|
|
405
523
|
//
|
|
406
524
|
// ASYNC FIX (2026-06-27): was execSync with a 60s timeout — blocked the event loop entirely.
|
|
407
525
|
// Now uses execFile (async) so the TUI stays responsive.
|
|
408
|
-
async function _freshTokscale(platform =
|
|
526
|
+
async function _freshTokscale(platform = "claude") {
|
|
409
527
|
try {
|
|
410
|
-
const raw = await execFileAsync(
|
|
411
|
-
const data = JSON.parse(raw)
|
|
412
|
-
const entries = Array.isArray(data?.entries)
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
+
}
|
|
426
557
|
}
|
|
427
558
|
|
|
428
559
|
/**
|
|
@@ -434,14 +565,20 @@ async function _freshTokscale(platform = 'claude') {
|
|
|
434
565
|
* - tokendash: refresh + read ~/.claude/token-dashboard.db → all (claude only)
|
|
435
566
|
* - tokscale : `bunx tokscale@latest models --json` → all
|
|
436
567
|
*/
|
|
437
|
-
export async function freshVerifierPillars(platform =
|
|
438
|
-
const p = platform ||
|
|
568
|
+
export async function freshVerifierPillars(platform = "claude") {
|
|
569
|
+
const p = platform || "claude";
|
|
439
570
|
// Each call is internally try/catch → null; await Promise.all of resolved values keeps the
|
|
440
571
|
// helper async (per the contract) without any individual failure rejecting the batch.
|
|
441
572
|
const [ccusage, tokendash, tokscale] = await Promise.all([
|
|
442
|
-
Promise.resolve()
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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 };
|
|
447
584
|
}
|