sigrank 0.11.4

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/tokenpull.mjs ADDED
@@ -0,0 +1,282 @@
1
+ /**
2
+ * tokenpull.mjs — SigRank's in-house local usage reader.
3
+ *
4
+ * Reads token telemetry straight from the platform's local session logs (Claude Code
5
+ * first: ~/.claude/projects/<project>/<session>.jsonl) and slices it into the four
6
+ * windows (7d / 30d / 90d / all) of raw pillars — the WINDOWED_PROFILES payload, pulled
7
+ * with zero paste. No ccusage, no tokscale: our own reader, our own numbers.
8
+ *
9
+ * Token-only: we read usage counts (input/output/cache), message id, and timestamp —
10
+ * never message text. Billing-accurate: messages are deduped by `message.id` so the
11
+ * tally matches what the API actually billed (the one trick token-dashboard gets right
12
+ * and sigrank-agent's line-index keying does not).
13
+ *
14
+ * Adapter-shaped for multi-system: add a Codex / Cursor / Gemini reader by implementing
15
+ * the same { platform, defaultRoot(), messages(root) } contract — Claude is just the first.
16
+ */
17
+
18
+ import { readdir, readFile, lstat } from 'node:fs/promises'
19
+ import { join } from 'node:path'
20
+ import { homedir } from 'node:os'
21
+ import { ADAPTERS } from './adapters.mjs'
22
+
23
+ const DAY_MS = 86_400_000
24
+
25
+ // Background tooling that runs under your account but is NOT your work — memory plugins,
26
+ // observers, summarizers, sub-agent fleets. They pad output/Υ (claude-mem did ~27%).
27
+ // Excluded so pillars reflect the operator, not their tools. EXTENSIBLE — add tool
28
+ // dir-name patterns here. Strategic note: others' tools inflate the PUBLIC
29
+ // token-dashboard / tokscale boards; SigRank filters them, so SigRank stays honest.
30
+ // subagents/ are KEPT (real work). Future-robust signal: also drop entrypoint=sdk-cli.
31
+ export const EXCLUDE_TOOLING =
32
+ /(^|[/-])(claude-mem|mem0|claude-self-reflect|basic-memory|memento|cipher-mem|memory-keeper)\b|observer-(sessions|archive)/i
33
+
34
+ /** The four windows, in days. `all` = unbounded. */
35
+ export const WINDOWS = [
36
+ { key: '7d', days: 7 },
37
+ { key: '30d', days: 30 },
38
+ { key: '90d', days: 90 },
39
+ { key: 'all', days: Infinity },
40
+ ]
41
+
42
+ /** Hard cap: stop walking after this many .jsonl files to prevent OOM on
43
+ * accidentally huge or circularly-symlinked directory trees.
44
+ * Override via SIGRANK_MAX_JSONL_FILES env var. */
45
+ const MAX_JSONL_FILES = Number(process.env.SIGRANK_MAX_JSONL_FILES) || 10_000
46
+
47
+ /** Recursively yield every *.jsonl path under dir (any depth), sorted. Must be
48
+ * recursive: Claude Code stores sub-agent transcripts in `<project>/subagents/`
49
+ * (and other nested logs) — a 2-level readdir silently drops them, which badly
50
+ * under-counts input (sub-agent runs are input-heavy). token-dashboard's rglob.
51
+ *
52
+ * Hardened: skips symlinked directories (prevents circular traversal) and stops
53
+ * after MAX_JSONL_FILES files (prevents OOM on pathological trees). */
54
+ async function* _walkJsonl(dir, _counter = { n: 0 }) {
55
+ if (_counter.n >= MAX_JSONL_FILES) return
56
+ let entries
57
+ try { entries = await readdir(dir, { withFileTypes: true }) } catch { return }
58
+ for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
59
+ if (_counter.n >= MAX_JSONL_FILES) return
60
+ const full = join(dir, e.name)
61
+ if (e.isDirectory()) {
62
+ // Skip symlinked directories — prevents circular traversal on machines where
63
+ // ~/.claude/projects/ contains a symlink to a large or self-referential tree.
64
+ let stat
65
+ try { stat = await lstat(full) } catch { continue }
66
+ if (stat.isSymbolicLink()) continue
67
+ yield* _walkJsonl(full, _counter)
68
+ } else if (e.isFile() && e.name.endsWith('.jsonl')) {
69
+ _counter.n++
70
+ yield full
71
+ }
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Claude Code adapter. Yields one record per billed assistant message:
77
+ * { id, sid, ts, input, output, cacheCreate, cacheRead, file }. Walks
78
+ * ~/.claude/projects RECURSIVELY (incl. subagents/) and carries sessionId so the
79
+ * caller can dedup by (session_id, message_id) like token-dashboard.
80
+ */
81
+ export const claudeAdapter = {
82
+ platform: 'claude',
83
+ defaultRoot: () => join(homedir(), '.claude', 'projects'),
84
+ async *messages(root) {
85
+ for await (const path of _walkJsonl(root)) {
86
+ let text
87
+ try { text = await readFile(path, 'utf8') } catch { continue }
88
+ const rel = path.startsWith(root) ? path.slice(root.length + 1) : path
89
+ if (EXCLUDE_TOOLING.test(rel)) continue // skip claude-mem observer & other background tooling
90
+ for (const line of text.split('\n')) {
91
+ const s = line.trim()
92
+ if (!s) continue
93
+ let ev
94
+ try { ev = JSON.parse(s) } catch { continue }
95
+ const m = ev && ev.message
96
+ if (!m || typeof m !== 'object') continue
97
+ const u = m.usage
98
+ if (!u || typeof u !== 'object') continue
99
+ yield {
100
+ id: m.id || null,
101
+ sid: ev.sessionId || null,
102
+ ts: ev.timestamp || ev.ts || null,
103
+ input: Number(u.input_tokens) || 0,
104
+ output: Number(u.output_tokens) || 0,
105
+ cacheCreate: Number(u.cache_creation_input_tokens) || 0,
106
+ cacheRead: Number(u.cache_read_input_tokens) || 0,
107
+ file: rel,
108
+ }
109
+ }
110
+ }
111
+ },
112
+ }
113
+
114
+ /**
115
+ * Pull local usage and slice it into the four windows of raw pillars.
116
+ * Deterministic given (adapter output, now). Inject `now`/`root`/`adapter` for tests.
117
+ * Returns { platform, root, generatedAt, files, totalMessages, windows:[{window,pillars,messages}] }.
118
+ */
119
+ export async function tokenpull({ adapter = claudeAdapter, root, now } = {}) {
120
+ const r = root || adapter.defaultRoot()
121
+ const nowMs = now == null ? Date.now() : (typeof now === 'number' ? now : Date.parse(now))
122
+
123
+ // Dedup by (session_id, message_id), keeping the FINAL snapshot — matches
124
+ // token-dashboard: Claude Code writes 2-3 partial→final lines per response with
125
+ // the same message.id; only the final tally matches billing. No-id records each
126
+ // get a unique synthetic key so they always count.
127
+ const seen = new Map()
128
+ const files = new Set()
129
+ let noId = 0
130
+ for await (const msg of adapter.messages(r)) {
131
+ const key = msg.sid && msg.id ? `${msg.sid}|${msg.id}` : (msg.id || `__noid_${noId++}`)
132
+ seen.set(key, msg) // keep-last (final snapshot wins)
133
+ if (msg.file) files.add(msg.file)
134
+ }
135
+ const msgs = [...seen.values()]
136
+
137
+ const windows = WINDOWS.map((w) => {
138
+ const cutoff = w.days === Infinity ? -Infinity : nowMs - w.days * DAY_MS
139
+ const inWin = msgs.filter((m) => {
140
+ if (w.days === Infinity) return true
141
+ const t = m.ts ? Date.parse(m.ts) : NaN
142
+ return Number.isFinite(t) && t >= cutoff && t <= nowMs
143
+ })
144
+ const pillars = inWin.reduce(
145
+ (a, m) => ({ input: a.input + m.input, output: a.output + m.output, cacheCreate: a.cacheCreate + m.cacheCreate, cacheRead: a.cacheRead + m.cacheRead }),
146
+ { input: 0, output: 0, cacheCreate: 0, cacheRead: 0 },
147
+ )
148
+ return { window: w.key, pillars, messages: inWin.length }
149
+ })
150
+
151
+ // Auto-detect background tooling we excluded → report it (the MCP "asks" by telling).
152
+ let excludedTooling = []
153
+ try {
154
+ const top = await readdir(r, { withFileTypes: true })
155
+ excludedTooling = top.filter((d) => d.isDirectory() && EXCLUDE_TOOLING.test(d.name)).map((d) => d.name)
156
+ } catch { /* ignore */ }
157
+
158
+ return {
159
+ platform: adapter.platform,
160
+ root: r,
161
+ generatedAt: new Date(nowMs).toISOString(),
162
+ files: files.size,
163
+ totalMessages: msgs.length,
164
+ excludedTooling,
165
+ windows,
166
+ }
167
+ }
168
+
169
+ // ── Codex ──────────────────────────────────────────────────────────────────
170
+ // Logs at ~/.codex/sessions/**/rollout-*.jsonl (+ archived_sessions). Token usage is
171
+ // on `payload.type=='token_count'` lines; `payload.info.last_token_usage` is the
172
+ // per-turn delta. Codex `input_tokens` is INCLUSIVE of cached, so the combined input to
173
+ // split = input_tokens − cached_input_tokens (= ccusage's inputTokens, verified ~1%).
174
+ // The input/cacheCreate split is WINDOW-level (estInput = output × io_ratio), so Codex
175
+ // has its own pull (tokenpullCodex) instead of the per-message claude pipeline.
176
+ export const codexAdapter = {
177
+ platform: 'codex',
178
+ defaultRoot: () => join(homedir(), '.codex'),
179
+ async *records(root) {
180
+ for (const sub of ['sessions', 'archived_sessions']) {
181
+ for await (const path of _walkJsonl(join(root, sub))) {
182
+ let text
183
+ try { text = await readFile(path, 'utf8') } catch { continue }
184
+ const rel = path.startsWith(root) ? path.slice(root.length + 1) : path
185
+ // Apply the same background-tooling exclusion as the Claude adapter — skips
186
+ // memory plugins / observers running under the Codex account.
187
+ if (EXCLUDE_TOOLING.test(rel)) continue
188
+ for (const line of text.split('\n')) {
189
+ if (!line.includes('"token_count"')) continue
190
+ let ev
191
+ try { ev = JSON.parse(line) } catch { continue }
192
+ const p = ev && ev.payload
193
+ if (!p || p.type !== 'token_count') continue
194
+ const u = (p.info || {}).last_token_usage || {}
195
+ const inputIncl = Number(u.input_tokens) || 0
196
+ const cached = Number(u.cached_input_tokens) || 0
197
+ yield {
198
+ ts: ev.timestamp || null,
199
+ output: (Number(u.output_tokens) || 0) + (Number(u.reasoning_output_tokens) || 0),
200
+ cacheRead: cached,
201
+ uncached: Math.max(0, inputIncl - cached), // (true input + cache-write), split window-level
202
+ file: rel,
203
+ }
204
+ }
205
+ }
206
+ }
207
+ },
208
+ }
209
+
210
+ /**
211
+ * Pull local Codex usage → the 4 windows of CANONICAL pillars (always estimated).
212
+ * Window-level conversion: input = floor(output × ioRatio) (Beta = operator's Claude
213
+ * input/output ratio; Alpha = 2.0 default), cacheCreate = max(0, uncached − input).
214
+ * Inject ioRatio/root/now/adapter for tests.
215
+ */
216
+ export async function tokenpullCodex({ adapter = codexAdapter, root, now, ioRatio = 2.0 } = {}) {
217
+ const r = root || adapter.defaultRoot()
218
+ const nowMs = now == null ? Date.now() : (typeof now === 'number' ? now : Date.parse(now))
219
+ const recs = []
220
+ const files = new Set()
221
+ for await (const m of adapter.records(r)) { recs.push(m); if (m.file) files.add(m.file) }
222
+
223
+ const windows = WINDOWS.map((w) => {
224
+ const cutoff = w.days === Infinity ? -Infinity : nowMs - w.days * DAY_MS
225
+ const inWin = recs.filter((m) => {
226
+ if (w.days === Infinity) return true
227
+ const t = m.ts ? Date.parse(m.ts) : NaN
228
+ return Number.isFinite(t) && t >= cutoff && t <= nowMs
229
+ })
230
+ const sum = inWin.reduce(
231
+ (a, m) => ({ output: a.output + m.output, cacheRead: a.cacheRead + m.cacheRead, uncached: a.uncached + m.uncached }),
232
+ { output: 0, cacheRead: 0, uncached: 0 },
233
+ )
234
+ const input = Math.floor(sum.output * ioRatio) // estimated true input
235
+ const cacheCreate = Math.max(0, sum.uncached - input) // split cache-write out of the combined input
236
+ return { window: w.key, messages: inWin.length, pillars: { input, output: sum.output, cacheCreate, cacheRead: sum.cacheRead } }
237
+ })
238
+
239
+ return { platform: 'codex', root: r, generatedAt: new Date(nowMs).toISOString(), files: files.size, totalMessages: recs.length, estimated: true, ioRatio, windows }
240
+ }
241
+
242
+ /**
243
+ * Unified pull for ANY platform by name. Routes to the right adapter:
244
+ * - 'claude' → tokenpull()
245
+ * - 'codex' → tokenpullCodex() (requires ioRatio via opts; auto-derives from Claude if available)
246
+ * - other → tokenpull() with the adapter from ADAPTERS registry
247
+ *
248
+ * Returns the same shape as tokenpull() with an added `estimated` flag when the
249
+ * adapter cannot provide full cacheCreate data, and a `dataGap` string when the
250
+ * source's log format doesn't expose token counts at all.
251
+ */
252
+ export async function tokenpullAny(platform, opts = {}) {
253
+ if (!platform || platform === 'claude') return tokenpull({ adapter: claudeAdapter, ...opts })
254
+ if (platform === 'codex') {
255
+ // Auto-derive io_ratio from the operator's Claude data when not explicitly provided.
256
+ let ioRatio = opts.ioRatio || 2.0
257
+ if (!opts.ioRatio) {
258
+ try {
259
+ const c = await tokenpull({ adapter: claudeAdapter })
260
+ const all = c.windows.find((w) => w.window === 'all')
261
+ if (all && all.pillars.output > 0) ioRatio = all.pillars.input / all.pillars.output
262
+ } catch { /* no Claude data → Alpha 2.0 */ }
263
+ }
264
+ return tokenpullCodex({ ioRatio, ...opts })
265
+ }
266
+ // Cloud agents (Devin, etc.) run server-side — no local JSONL to read
267
+ const CLOUD_AGENTS = { devin: 'Cognition/Devin', }
268
+ if (platform in CLOUD_AGENTS) {
269
+ throw new Error(
270
+ `"${platform}" (${CLOUD_AGENTS[platform]}) runs in the cloud — sessions are not written to local JSONL files. ` +
271
+ `There is no local data source to read. If ${CLOUD_AGENTS[platform]} exposes a usage API in future, an adapter can be added.`
272
+ )
273
+ }
274
+ const adapter = ADAPTERS[platform]
275
+ if (!adapter) throw new Error(`Unknown platform "${platform}". Valid platforms: claude, codex, ${Object.keys(ADAPTERS).join(', ')}`)
276
+ const result = await tokenpull({ adapter, ...opts })
277
+ // Surface adapter-level flags
278
+ if (adapter.estimated) result.estimated = true
279
+ if (adapter.dataGap) result.dataGap = adapter.dataGap
280
+ if (adapter.setupNote) result.setupNote = adapter.setupNote
281
+ return result
282
+ }