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/adapters.mjs CHANGED
@@ -32,46 +32,65 @@
32
32
  * adapter object as `dataGap: string` so tokenpull() can surface them to the user.
33
33
  */
34
34
 
35
- import { readdir, readFile } from 'node:fs/promises'
36
- import { join, extname } from 'node:path'
37
- import { homedir } from 'node:os'
38
- import { execFile as execFileCb } from 'node:child_process'
39
- import { promisify } from 'node:util'
35
+ import { readdir, readFile } from "node:fs/promises";
36
+ import { join, dirname } from "node:path";
37
+ import { homedir } from "node:os";
38
+ import { execFile as execFileCb } from "node:child_process";
39
+ import { promisify } from "node:util";
40
40
 
41
- const execFileP = promisify(execFileCb)
42
- const DAY_MS = 86_400_000 // shared with tokenpull.mjs but kept local to avoid circular import
41
+ const execFileP = promisify(execFileCb);
42
+ const DAY_MS = 86_400_000; // shared with tokenpull.mjs but kept local to avoid circular import
43
43
 
44
44
  // ── File-system helpers ───────────────────────────────────────────────────────
45
45
 
46
46
  /** Recursively yield every file whose name matches `pred` under dir (skips symlink dirs). */
47
47
  async function* walkFiles(dir, pred, counter = { n: 0 }, max = 10_000) {
48
- if (counter.n >= max) return
49
- let entries
50
- try { entries = await readdir(dir, { withFileTypes: true }) } catch { return }
48
+ if (counter.n >= max) return;
49
+ let entries;
50
+ try {
51
+ entries = await readdir(dir, { withFileTypes: true });
52
+ } catch {
53
+ return;
54
+ }
51
55
  for (const e of entries) {
52
- if (counter.n >= max) return
53
- const full = join(dir, e.name)
54
- if (e.isSymbolicLink()) continue
55
- if (e.isDirectory()) { yield* walkFiles(full, pred, counter, max) }
56
- else if (e.isFile() && pred(e.name)) { counter.n++; yield full }
56
+ if (counter.n >= max) return;
57
+ const full = join(dir, e.name);
58
+ if (e.isSymbolicLink()) continue;
59
+ if (e.isDirectory()) {
60
+ yield* walkFiles(full, pred, counter, max);
61
+ } else if (e.isFile() && pred(e.name)) {
62
+ counter.n++;
63
+ yield full;
64
+ }
57
65
  }
58
66
  }
59
67
 
60
- const isJsonl = (n) => n.endsWith('.jsonl') || n.endsWith('.jsonl.deleted') || n.match(/\.jsonl\.reset\.\d+$/)
61
- const isJson = (n) => n.endsWith('.json') && !n.endsWith('.jsonl')
68
+ const isJsonl = (n) =>
69
+ n.endsWith(".jsonl") ||
70
+ n.endsWith(".jsonl.deleted") ||
71
+ n.match(/\.jsonl\.reset\.\d+$/);
72
+ const isJson = (n) => n.endsWith(".json") && !n.endsWith(".jsonl");
62
73
 
63
74
  /** Read a file as UTF-8, return null on error. */
64
75
  async function readUtf8(path) {
65
- try { return await readFile(path, 'utf8') } catch { return null }
76
+ try {
77
+ return await readFile(path, "utf8");
78
+ } catch {
79
+ return null;
80
+ }
66
81
  }
67
82
 
68
83
  /** Parse each newline-delimited JSON line, yield parsed objects silently skipping bad lines. */
69
84
  function* parseJsonl(text, filePath) {
70
- if (!text) return
71
- for (const line of text.split('\n')) {
72
- const s = line.trim()
73
- if (!s) continue
74
- try { yield [JSON.parse(s), filePath] } catch { /* skip malformed */ }
85
+ if (!text) return;
86
+ for (const line of text.split("\n")) {
87
+ const s = line.trim();
88
+ if (!s) continue;
89
+ try {
90
+ yield [JSON.parse(s), filePath];
91
+ } catch {
92
+ /* skip malformed */
93
+ }
75
94
  }
76
95
  }
77
96
 
@@ -80,223 +99,340 @@ function* parseJsonl(text, filePath) {
80
99
  * (matches the execFile hardening used in tools.mjs / tokenpull.mjs). */
81
100
  async function sqliteJson(dbPath, sql) {
82
101
  try {
83
- const { stdout } = await execFileP('sqlite3', ['-json', dbPath, sql], { timeout: 10_000 })
84
- return JSON.parse(stdout || '[]')
85
- } catch { return [] }
102
+ const { stdout } = await execFileP("sqlite3", ["-json", dbPath, sql], {
103
+ timeout: 10_000,
104
+ });
105
+ return JSON.parse(stdout || "[]");
106
+ } catch {
107
+ return [];
108
+ }
86
109
  }
87
110
 
88
111
  // ── Env-var helper ────────────────────────────────────────────────────────────
89
112
  /** Resolve roots from env var or default. Supports comma-separated list. */
90
113
  function roots(envVar, defaultPath) {
91
- const v = process.env[envVar]
92
- if (v) return v.split(',').map((s) => s.trim()).filter(Boolean)
93
- return [defaultPath]
114
+ const v = process.env[envVar];
115
+ if (v)
116
+ return v
117
+ .split(",")
118
+ .map((s) => s.trim())
119
+ .filter(Boolean);
120
+ return [defaultPath];
94
121
  }
95
122
 
96
123
  // ── 1. Amp ────────────────────────────────────────────────────────────────────
97
124
  // ~/.local/share/amp/threads/**/*.json
98
125
  // Fields: assistant message usage: input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens
99
126
  export const ampAdapter = {
100
- platform: 'amp',
101
- defaultRoot: () => join(homedir(), '.local', 'share', 'amp'),
127
+ platform: "amp",
128
+ defaultRoot: () => join(homedir(), ".local", "share", "amp"),
102
129
  async *messages(root) {
103
- for (const r of roots('AMP_DATA_DIR', root)) {
104
- const threadsDir = join(r, 'threads')
130
+ for (const r of roots("AMP_DATA_DIR", root)) {
131
+ const threadsDir = join(r, "threads");
105
132
  for await (const path of walkFiles(threadsDir, isJson)) {
106
- const text = await readUtf8(path)
107
- if (!text) continue
108
- let thread
109
- try { thread = JSON.parse(text) } catch { continue }
133
+ const text = await readUtf8(path);
134
+ if (!text) continue;
135
+ let thread;
136
+ try {
137
+ thread = JSON.parse(text);
138
+ } catch {
139
+ continue;
140
+ }
110
141
  // Amp thread is an array or object with messages
111
- const msgs = Array.isArray(thread) ? thread : (thread.messages || [])
142
+ const msgs = Array.isArray(thread) ? thread : thread.messages || [];
112
143
  for (const msg of msgs) {
113
- if (!msg || msg.role !== 'assistant') continue
114
- const u = msg.usage || (msg.metadata && msg.metadata.usage) || {}
115
- const input = Number(u.input_tokens || u.inputTokens || 0)
116
- const output = Number(u.output_tokens || u.outputTokens || 0)
117
- const cacheCreate = Number(u.cache_creation_tokens || u.cacheCreationTokens || 0)
118
- const cacheRead = Number(u.cache_read_tokens || u.cacheReadTokens || 0)
119
- if (input + output + cacheCreate + cacheRead === 0) continue
120
- yield { id: msg.id || null, sid: thread.id || null, ts: msg.timestamp || msg.created_at || null, input, output, cacheCreate, cacheRead, file: path }
144
+ if (!msg || msg.role !== "assistant") continue;
145
+ const u = msg.usage || (msg.metadata && msg.metadata.usage) || {};
146
+ const input = Number(u.input_tokens || u.inputTokens || 0);
147
+ const output = Number(u.output_tokens || u.outputTokens || 0);
148
+ const cacheCreate = Number(
149
+ u.cache_creation_tokens || u.cacheCreationTokens || 0,
150
+ );
151
+ const cacheRead = Number(
152
+ u.cache_read_tokens || u.cacheReadTokens || 0,
153
+ );
154
+ if (input + output + cacheCreate + cacheRead === 0) continue;
155
+ yield {
156
+ id: msg.id || null,
157
+ sid: thread.id || null,
158
+ ts: msg.timestamp || msg.created_at || null,
159
+ input,
160
+ output,
161
+ cacheCreate,
162
+ cacheRead,
163
+ file: path,
164
+ };
121
165
  }
122
166
  }
123
167
  }
124
168
  },
125
- }
169
+ };
126
170
 
127
171
  // ── 2. Kimi ───────────────────────────────────────────────────────────────────
128
172
  // ~/.kimi/sessions/<group-id>/<session-id>/wire.jsonl
129
173
  // StatusUpdate lines only; token_usage: { input_other, output, input_cache_read, input_cache_creation }
130
174
  export const kimiAdapter = {
131
- platform: 'kimi',
132
- defaultRoot: () => join(homedir(), '.kimi'),
175
+ platform: "kimi",
176
+ defaultRoot: () => join(homedir(), ".kimi"),
133
177
  async *messages(root) {
134
- const sessionsDir = join(roots('KIMI_DATA_DIR', root)[0], 'sessions')
178
+ const sessionsDir = join(roots("KIMI_DATA_DIR", root)[0], "sessions");
135
179
  for await (const path of walkFiles(sessionsDir, isJsonl)) {
136
- const text = await readUtf8(path)
180
+ const text = await readUtf8(path);
137
181
  for (const [ev] of parseJsonl(text, path)) {
138
- if (!ev || ev.type !== 'StatusUpdate') continue
139
- const u = ev.token_usage || {}
140
- const input = Number(u.input_other || 0)
141
- const output = Number(u.output || 0)
142
- const cacheCreate = Number(u.input_cache_creation || 0)
143
- const cacheRead = Number(u.input_cache_read || 0)
144
- if (input + output + cacheCreate + cacheRead === 0) continue
145
- yield { id: ev.id || null, sid: null, ts: ev.timestamp || ev.created_at || null, input, output, cacheCreate, cacheRead, file: path }
182
+ if (!ev || ev.type !== "StatusUpdate") continue;
183
+ const u = ev.token_usage || {};
184
+ const input = Number(u.input_other || 0);
185
+ const output = Number(u.output || 0);
186
+ const cacheCreate = Number(u.input_cache_creation || 0);
187
+ const cacheRead = Number(u.input_cache_read || 0);
188
+ if (input + output + cacheCreate + cacheRead === 0) continue;
189
+ yield {
190
+ id: ev.id || null,
191
+ sid: null,
192
+ ts: ev.timestamp || ev.created_at || null,
193
+ input,
194
+ output,
195
+ cacheCreate,
196
+ cacheRead,
197
+ file: path,
198
+ };
146
199
  }
147
200
  }
148
201
  },
149
- }
202
+ };
150
203
 
151
204
  // ── 3. Qwen ───────────────────────────────────────────────────────────────────
152
205
  // ~/.qwen/projects/{project}/chats/*.jsonl
153
206
  // usageMetadata: { promptTokenCount, candidatesTokenCount, cachedContentTokenCount, thoughtsTokenCount }
154
207
  // No cache creation field. Reasoning (thoughtsTokenCount) → output.
155
208
  export const qwenAdapter = {
156
- platform: 'qwen',
209
+ platform: "qwen",
157
210
  estimated: true, // no cacheCreate in logs
158
- defaultRoot: () => join(homedir(), '.qwen'),
211
+ defaultRoot: () => join(homedir(), ".qwen"),
159
212
  async *messages(root) {
160
- const projectsDir = join(roots('QWEN_DATA_DIR', root)[0], 'projects')
213
+ const projectsDir = join(roots("QWEN_DATA_DIR", root)[0], "projects");
161
214
  for await (const path of walkFiles(projectsDir, isJsonl)) {
162
- const text = await readUtf8(path)
215
+ const text = await readUtf8(path);
163
216
  for (const [ev] of parseJsonl(text, path)) {
164
- if (!ev || !ev.usageMetadata) continue
165
- const u = ev.usageMetadata
166
- const rawInput = Number(u.promptTokenCount || 0)
167
- const rawOutput = Number(u.candidatesTokenCount || 0)
168
- const thoughts = Number(u.thoughtsTokenCount || 0) // reasoning → output
169
- const cacheRead = Number(u.cachedContentTokenCount || 0)
217
+ if (!ev || !ev.usageMetadata) continue;
218
+ const u = ev.usageMetadata;
219
+ const rawInput = Number(u.promptTokenCount || 0);
220
+ const rawOutput = Number(u.candidatesTokenCount || 0);
221
+ const thoughts = Number(u.thoughtsTokenCount || 0); // reasoning → output
222
+ const cacheRead = Number(u.cachedContentTokenCount || 0);
170
223
  // promptTokenCount is inclusive of cached; subtract to get fresh input
171
- const input = Math.max(0, rawInput - cacheRead)
172
- const output = rawOutput + thoughts
173
- if (input + output + cacheRead === 0) continue
174
- yield { id: ev.id || null, sid: null, ts: ev.timestamp || null, input, output, cacheCreate: 0, cacheRead, file: path }
224
+ const input = Math.max(0, rawInput - cacheRead);
225
+ const output = rawOutput + thoughts;
226
+ if (input + output + cacheRead === 0) continue;
227
+ yield {
228
+ id: ev.id || null,
229
+ sid: null,
230
+ ts: ev.timestamp || null,
231
+ input,
232
+ output,
233
+ cacheCreate: 0,
234
+ cacheRead,
235
+ file: path,
236
+ };
175
237
  }
176
238
  }
177
239
  },
178
- }
240
+ };
179
241
 
180
242
  // ── 4. pi-agent ───────────────────────────────────────────────────────────────
181
243
  // ~/.pi/agent/sessions/**/*.jsonl
182
244
  // Fields: inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens
183
245
  export const piAdapter = {
184
- platform: 'pi',
185
- defaultRoot: () => join(homedir(), '.pi', 'agent', 'sessions'),
246
+ platform: "pi",
247
+ defaultRoot: () => join(homedir(), ".pi", "agent", "sessions"),
186
248
  async *messages(root) {
187
- for (const r of roots('PI_AGENT_DIR', root)) {
249
+ for (const r of roots("PI_AGENT_DIR", root)) {
188
250
  for await (const path of walkFiles(r, isJsonl)) {
189
- const text = await readUtf8(path)
251
+ const text = await readUtf8(path);
190
252
  for (const [ev] of parseJsonl(text, path)) {
191
- if (!ev) continue
253
+ if (!ev) continue;
192
254
  // pi-agent stores usage in assistant messages or usage events
193
- const u = ev.usage || ev
194
- const input = Number(u.inputTokens || u.input_tokens || 0)
195
- const output = Number(u.outputTokens || u.output_tokens || 0)
196
- const cacheCreate = Number(u.cacheCreationTokens || u.cache_creation_tokens || 0)
197
- const cacheRead = Number(u.cacheReadTokens || u.cache_read_tokens || 0)
198
- if (input + output + cacheCreate + cacheRead === 0) continue
199
- yield { id: ev.id || null, sid: ev.sessionId || null, ts: ev.timestamp || null, input, output, cacheCreate, cacheRead, file: path }
255
+ const u = ev.usage || ev;
256
+ const input = Number(u.inputTokens || u.input_tokens || 0);
257
+ const output = Number(u.outputTokens || u.output_tokens || 0);
258
+ const cacheCreate = Number(
259
+ u.cacheCreationTokens || u.cache_creation_tokens || 0,
260
+ );
261
+ const cacheRead = Number(
262
+ u.cacheReadTokens || u.cache_read_tokens || 0,
263
+ );
264
+ if (input + output + cacheCreate + cacheRead === 0) continue;
265
+ yield {
266
+ id: ev.id || null,
267
+ sid: ev.sessionId || null,
268
+ ts: ev.timestamp || null,
269
+ input,
270
+ output,
271
+ cacheCreate,
272
+ cacheRead,
273
+ file: path,
274
+ };
200
275
  }
201
276
  }
202
277
  }
203
278
  },
204
- }
279
+ };
205
280
 
206
281
  // ── 5. OpenClaw ───────────────────────────────────────────────────────────────
207
282
  // ~/.openclaw/ (also ~/.clawdbot/, ~/.moltbot/, ~/.moldbot/)
208
283
  // agents/<agentId>/sessions/<uuid>.jsonl (+ .deleted.<ts> and .reset.<ts> variants)
209
284
  // Per-message: input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens
210
285
  export const openclawAdapter = {
211
- platform: 'openclaw',
286
+ platform: "openclaw",
212
287
  defaultRoot: () => {
213
- const env = process.env.OPENCLAW_DIR
214
- if (env) return env.split(',')[0].trim()
215
- for (const d of ['.openclaw', '.clawdbot', '.moltbot', '.moldbot']) {
216
- const p = join(homedir(), d)
217
- return p // return first; walkFiles silently skips missing
218
- }
219
- return join(homedir(), '.openclaw')
288
+ const env = process.env.OPENCLAW_DIR;
289
+ if (env) return env.split(",")[0].trim();
290
+ return join(homedir(), ".openclaw");
220
291
  },
221
292
  async *messages(root) {
222
293
  const dirs = process.env.OPENCLAW_DIR
223
- ? process.env.OPENCLAW_DIR.split(',').map((s) => s.trim())
224
- : [join(homedir(), '.openclaw'), join(homedir(), '.clawdbot'), join(homedir(), '.moltbot'), join(homedir(), '.moldbot')]
225
- const pred = (n) => isJsonl(n) || n.endsWith('.json') // covers archived variants
294
+ ? process.env.OPENCLAW_DIR.split(",").map((s) => s.trim())
295
+ : [
296
+ join(homedir(), ".openclaw"),
297
+ join(homedir(), ".clawdbot"),
298
+ join(homedir(), ".moltbot"),
299
+ join(homedir(), ".moldbot"),
300
+ ];
301
+ const pred = (n) => isJsonl(n) || n.endsWith(".json"); // covers archived variants
226
302
  for (const dir of dirs) {
227
303
  for await (const path of walkFiles(dir, pred)) {
228
- const text = await readUtf8(path)
304
+ const text = await readUtf8(path);
229
305
  for (const [ev] of parseJsonl(text, path)) {
230
- if (!ev || ev.role !== 'assistant') continue
231
- const u = ev.usage || ev.tokens || {}
232
- const input = Number(u.input_tokens || u.inputTokens || 0)
233
- const output = Number(u.output_tokens || u.outputTokens || 0)
234
- const cacheCreate = Number(u.cache_creation_tokens || u.cacheCreationTokens || 0)
235
- const cacheRead = Number(u.cache_read_tokens || u.cacheReadTokens || 0)
236
- if (input + output + cacheCreate + cacheRead === 0) continue
237
- yield { id: ev.id || null, sid: null, ts: ev.timestamp || null, input, output, cacheCreate, cacheRead, file: path }
306
+ if (!ev || ev.role !== "assistant") continue;
307
+ const u = ev.usage || ev.tokens || {};
308
+ const input = Number(u.input_tokens || u.inputTokens || 0);
309
+ const output = Number(u.output_tokens || u.outputTokens || 0);
310
+ const cacheCreate = Number(
311
+ u.cache_creation_tokens || u.cacheCreationTokens || 0,
312
+ );
313
+ const cacheRead = Number(
314
+ u.cache_read_tokens || u.cacheReadTokens || 0,
315
+ );
316
+ if (input + output + cacheCreate + cacheRead === 0) continue;
317
+ yield {
318
+ id: ev.id || null,
319
+ sid: null,
320
+ ts: ev.timestamp || null,
321
+ input,
322
+ output,
323
+ cacheCreate,
324
+ cacheRead,
325
+ file: path,
326
+ };
238
327
  }
239
328
  }
240
329
  }
241
330
  },
242
- }
331
+ };
243
332
 
244
333
  // ── 6. Droid ──────────────────────────────────────────────────────────────────
245
334
  // ~/.factory/sessions/**/*.settings.json
246
335
  // Fields: input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, thinking_tokens
247
336
  // Thinking tokens → output. Per-settings-file (session-level granularity).
337
+ // CUMULATIVE-COLUMN GUARD: settings files contain session-level totals. If the same session
338
+ // appears in multiple files, summing double-counts. We set both id and sid to the session id
339
+ // so tokenpull()'s (sid|id) keep-last dedup collapses duplicates to the latest values.
248
340
  export const droidAdapter = {
249
- platform: 'droid',
250
- defaultRoot: () => join(homedir(), '.factory', 'sessions'),
341
+ platform: "droid",
342
+ defaultRoot: () => join(homedir(), ".factory", "sessions"),
251
343
  async *messages(root) {
252
- for (const r of roots('DROID_SESSIONS_DIR', root)) {
253
- for await (const path of walkFiles(r, (n) => n.endsWith('.settings.json'))) {
254
- const text = await readUtf8(path)
255
- if (!text) continue
256
- let s
257
- try { s = JSON.parse(text) } catch { continue }
258
- const input = Number(s.input_tokens || 0)
259
- const output = Number(s.output_tokens || 0) + Number(s.thinking_tokens || 0)
260
- const cacheCreate = Number(s.cache_creation_tokens || 0)
261
- const cacheRead = Number(s.cache_read_tokens || 0)
262
- if (input + output + cacheCreate + cacheRead === 0) continue
263
- yield { id: null, sid: s.session_id || s.id || null, ts: s.updated_at || s.created_at || null, input, output, cacheCreate, cacheRead, file: path }
344
+ for (const r of roots("DROID_SESSIONS_DIR", root)) {
345
+ for await (const path of walkFiles(r, (n) =>
346
+ n.endsWith(".settings.json"),
347
+ )) {
348
+ const text = await readUtf8(path);
349
+ if (!text) continue;
350
+ let s;
351
+ try {
352
+ s = JSON.parse(text);
353
+ } catch {
354
+ continue;
355
+ }
356
+ const input = Number(s.input_tokens || 0);
357
+ const output =
358
+ Number(s.output_tokens || 0) + Number(s.thinking_tokens || 0);
359
+ const cacheCreate = Number(s.cache_creation_tokens || 0);
360
+ const cacheRead = Number(s.cache_read_tokens || 0);
361
+ if (input + output + cacheCreate + cacheRead === 0) continue;
362
+ const sessionId = String(s.session_id || s.id || "");
363
+ yield {
364
+ id: sessionId,
365
+ sid: sessionId,
366
+ ts: s.updated_at || s.created_at || null,
367
+ input,
368
+ output,
369
+ cacheCreate,
370
+ cacheRead,
371
+ file: path,
372
+ };
264
373
  }
265
374
  }
266
375
  },
267
- }
376
+ };
268
377
 
269
378
  // ── 7. Codebuff ───────────────────────────────────────────────────────────────
270
379
  // ~/.config/manicode/projects/<project>/chats/<chat-id>/chat-messages.json
271
380
  // assistant messages: metadata.usage or metadata.codebuff.usage
272
381
  export const codebuffAdapter = {
273
- platform: 'codebuff',
274
- defaultRoot: () => join(homedir(), '.config', 'manicode'),
382
+ platform: "codebuff",
383
+ defaultRoot: () => join(homedir(), ".config", "manicode"),
275
384
  async *messages(root) {
276
385
  const dirs = process.env.CODEBUFF_DATA_DIR
277
- ? process.env.CODEBUFF_DATA_DIR.split(',').map((s) => s.trim())
278
- : [join(homedir(), '.config', 'manicode'), join(homedir(), '.config', 'manicode-dev'), join(homedir(), '.config', 'manicode-staging')]
386
+ ? process.env.CODEBUFF_DATA_DIR.split(",").map((s) => s.trim())
387
+ : [
388
+ join(homedir(), ".config", "manicode"),
389
+ join(homedir(), ".config", "manicode-dev"),
390
+ join(homedir(), ".config", "manicode-staging"),
391
+ ];
279
392
  for (const dir of dirs) {
280
- for await (const path of walkFiles(dir, (n) => n === 'chat-messages.json')) {
281
- const text = await readUtf8(path)
282
- if (!text) continue
283
- let msgs
284
- try { msgs = JSON.parse(text) } catch { continue }
285
- if (!Array.isArray(msgs)) continue
393
+ for await (const path of walkFiles(
394
+ dir,
395
+ (n) => n === "chat-messages.json",
396
+ )) {
397
+ const text = await readUtf8(path);
398
+ if (!text) continue;
399
+ let msgs;
400
+ try {
401
+ msgs = JSON.parse(text);
402
+ } catch {
403
+ continue;
404
+ }
405
+ if (!Array.isArray(msgs)) continue;
286
406
  for (const msg of msgs) {
287
- if (!msg || msg.role !== 'assistant') continue
288
- const u = (msg.metadata && (msg.metadata.usage || msg.metadata.codebuff?.usage)) || {}
289
- const input = Number(u.input_tokens || u.inputTokens || 0)
290
- const output = Number(u.output_tokens || u.outputTokens || 0)
291
- const cacheCreate = Number(u.cache_creation_tokens || u.cacheCreationTokens || 0)
292
- const cacheRead = Number(u.cache_read_tokens || u.cacheReadTokens || 0)
293
- if (input + output + cacheCreate + cacheRead === 0) continue
294
- yield { id: msg.id || null, sid: null, ts: msg.timestamp || msg.created_at || null, input, output, cacheCreate, cacheRead, file: path }
407
+ if (!msg || msg.role !== "assistant") continue;
408
+ const u =
409
+ (msg.metadata &&
410
+ (msg.metadata.usage || msg.metadata.codebuff?.usage)) ||
411
+ {};
412
+ const input = Number(u.input_tokens || u.inputTokens || 0);
413
+ const output = Number(u.output_tokens || u.outputTokens || 0);
414
+ const cacheCreate = Number(
415
+ u.cache_creation_tokens || u.cacheCreationTokens || 0,
416
+ );
417
+ const cacheRead = Number(
418
+ u.cache_read_tokens || u.cacheReadTokens || 0,
419
+ );
420
+ if (input + output + cacheCreate + cacheRead === 0) continue;
421
+ yield {
422
+ id: msg.id || null,
423
+ sid: null,
424
+ ts: msg.timestamp || msg.created_at || null,
425
+ input,
426
+ output,
427
+ cacheCreate,
428
+ cacheRead,
429
+ file: path,
430
+ };
295
431
  }
296
432
  }
297
433
  }
298
434
  },
299
- }
435
+ };
300
436
 
301
437
  // ── 8. Gemini CLI ─────────────────────────────────────────────────────────────
302
438
  // ~/.gemini/tmp/*/chats/*.json and *.jsonl
@@ -304,179 +440,311 @@ export const codebuffAdapter = {
304
440
  // SigRank mapping: input = input−cached (fresh), cacheRead = cached, cacheCreate = 0 (not exposed),
305
441
  // output = output + thought (reasoning→output)
306
442
  export const geminiAdapter = {
307
- platform: 'gemini',
443
+ platform: "gemini",
308
444
  estimated: true, // no cacheCreate field in Gemini logs
309
- defaultRoot: () => join(homedir(), '.gemini', 'tmp'),
445
+ defaultRoot: () => join(homedir(), ".gemini", "tmp"),
310
446
  async *messages(root) {
311
- for (const r of roots('GEMINI_DATA_DIR', root)) {
312
- for await (const path of walkFiles(r, (n) => n.endsWith('.json') || n.endsWith('.jsonl'))) {
313
- const text = await readUtf8(path)
314
- if (!text) continue
447
+ for (const r of roots("GEMINI_DATA_DIR", root)) {
448
+ for await (const path of walkFiles(
449
+ r,
450
+ (n) => n.endsWith(".json") || n.endsWith(".jsonl"),
451
+ )) {
452
+ const text = await readUtf8(path);
453
+ if (!text) continue;
315
454
  // Try JSONL first, then single JSON
316
- let parsed = []
455
+ let parsed = [];
317
456
  try {
318
- parsed = text.trim().split('\n').filter(Boolean).map((l) => JSON.parse(l))
457
+ parsed = text
458
+ .trim()
459
+ .split("\n")
460
+ .filter(Boolean)
461
+ .map((l) => JSON.parse(l));
319
462
  } catch {
320
- try { parsed = [JSON.parse(text)] } catch { continue }
463
+ try {
464
+ parsed = [JSON.parse(text)];
465
+ } catch {
466
+ continue;
467
+ }
321
468
  }
322
469
  for (const ev of parsed) {
323
- if (!ev) continue
470
+ if (!ev) continue;
324
471
  // Gemini usage may be at top level or nested in usageMetadata
325
- const u = ev.usageMetadata || ev.usage || ev
326
- const rawInput = Number(u.input || u.promptTokenCount || 0)
327
- const rawOutput = Number(u.output || u.candidatesTokenCount || 0)
328
- const cached = Number(u.cached || u.cachedContentTokenCount || 0)
329
- const thought = Number(u.thought || u.thoughtsTokenCount || 0)
330
- if (rawInput + rawOutput + cached + thought === 0) continue
331
- const input = Math.max(0, rawInput - cached) // strip cached from input
332
- const output = rawOutput + thought // reasoning → output
333
- yield { id: ev.id || null, sid: null, ts: ev.timestamp || null, input, output, cacheCreate: 0, cacheRead: cached, file: path }
472
+ const u = ev.usageMetadata || ev.usage || ev;
473
+ const rawInput = Number(u.input || u.promptTokenCount || 0);
474
+ const rawOutput = Number(u.output || u.candidatesTokenCount || 0);
475
+ const cached = Number(u.cached || u.cachedContentTokenCount || 0);
476
+ const thought = Number(u.thought || u.thoughtsTokenCount || 0);
477
+ if (rawInput + rawOutput + cached + thought === 0) continue;
478
+ const input = Math.max(0, rawInput - cached); // strip cached from input
479
+ const output = rawOutput + thought; // reasoning → output
480
+ yield {
481
+ id: ev.id || null,
482
+ sid: null,
483
+ ts: ev.timestamp || null,
484
+ input,
485
+ output,
486
+ cacheCreate: 0,
487
+ cacheRead: cached,
488
+ file: path,
489
+ };
334
490
  }
335
491
  }
336
492
  }
337
493
  },
338
- }
494
+ };
339
495
 
340
496
  // ── 9. GitHub Copilot CLI ─────────────────────────────────────────────────────
341
497
  // ~/.copilot/otel/*.jsonl (requires COPILOT_OTEL_ENABLED=true before session start)
342
498
  // OpenTelemetry spans; looks for llm.token_count.prompt / completion / cached attributes
343
499
  export const copilotAdapter = {
344
- platform: 'copilot',
345
- defaultRoot: () => join(homedir(), '.copilot', 'otel'),
500
+ platform: "copilot",
501
+ defaultRoot: () => join(homedir(), ".copilot", "otel"),
346
502
  async *messages(root) {
347
503
  const dir = process.env.COPILOT_OTEL_FILE_EXPORTER_PATH
348
- ? require('node:path').dirname(process.env.COPILOT_OTEL_FILE_EXPORTER_PATH)
349
- : root
504
+ ? dirname(process.env.COPILOT_OTEL_FILE_EXPORTER_PATH)
505
+ : root;
350
506
  for await (const path of walkFiles(dir, isJsonl)) {
351
- const text = await readUtf8(path)
507
+ const text = await readUtf8(path);
352
508
  for (const [ev] of parseJsonl(text, path)) {
353
- if (!ev) continue
509
+ if (!ev) continue;
354
510
  // OTel span: attributes may be an object or array of {key,value} pairs
355
- const attrs = ev.attributes || ev.resource?.attributes || {}
511
+ const attrs = ev.attributes || ev.resource?.attributes || {};
356
512
  const get = (k) => {
357
- if (typeof attrs === 'object' && !Array.isArray(attrs)) return attrs[k]
358
- if (Array.isArray(attrs)) { const a = attrs.find((x) => x.key === k); return a?.value?.intValue ?? a?.value?.stringValue ?? null }
359
- return null
360
- }
361
- const input = Number(get('llm.token_count.prompt') || get('gen_ai.usage.input_tokens') || 0)
362
- const output = Number(get('llm.token_count.completion') || get('gen_ai.usage.output_tokens') || 0)
363
- const cacheCreate = Number(get('llm.token_count.cache_creation') || 0)
364
- const cacheRead = Number(get('llm.token_count.cache_read') || get('gen_ai.usage.cache_read_input_tokens') || 0)
365
- if (input + output + cacheCreate + cacheRead === 0) continue
366
- yield { id: ev.traceId || ev.spanId || null, sid: null, ts: ev.startTimeUnixNano ? new Date(Number(ev.startTimeUnixNano) / 1e6).toISOString() : null, input, output, cacheCreate, cacheRead, file: path }
513
+ if (typeof attrs === "object" && !Array.isArray(attrs))
514
+ return attrs[k];
515
+ if (Array.isArray(attrs)) {
516
+ const a = attrs.find((x) => x.key === k);
517
+ return a?.value?.intValue ?? a?.value?.stringValue ?? null;
518
+ }
519
+ return null;
520
+ };
521
+ const input = Number(
522
+ get("llm.token_count.prompt") ||
523
+ get("gen_ai.usage.input_tokens") ||
524
+ 0,
525
+ );
526
+ const output = Number(
527
+ get("llm.token_count.completion") ||
528
+ get("gen_ai.usage.output_tokens") ||
529
+ 0,
530
+ );
531
+ const cacheCreate = Number(get("llm.token_count.cache_creation") || 0);
532
+ const cacheRead = Number(
533
+ get("llm.token_count.cache_read") ||
534
+ get("gen_ai.usage.cache_read_input_tokens") ||
535
+ 0,
536
+ );
537
+ if (input + output + cacheCreate + cacheRead === 0) continue;
538
+ yield {
539
+ id: ev.traceId || ev.spanId || null,
540
+ sid: null,
541
+ ts: ev.startTimeUnixNano
542
+ ? new Date(Number(ev.startTimeUnixNano) / 1e6).toISOString()
543
+ : null,
544
+ input,
545
+ output,
546
+ cacheCreate,
547
+ cacheRead,
548
+ file: path,
549
+ };
367
550
  }
368
551
  }
369
552
  },
370
- setupNote: 'Requires COPILOT_OTEL_ENABLED=true and COPILOT_OTEL_EXPORTER_TYPE=file set BEFORE starting the Copilot session. Without this, no local token logs are written.',
371
- }
553
+ setupNote:
554
+ "Requires COPILOT_OTEL_ENABLED=true and COPILOT_OTEL_EXPORTER_TYPE=file set BEFORE starting the Copilot session. Without this, no local token logs are written.",
555
+ };
372
556
 
373
557
  // ── 10. OpenCode ──────────────────────────────────────────────────────────────
374
558
  // ~/.local/share/opencode — JSON message files, but costs stored as 0 and token fields
375
559
  // are calculated via LiteLLM pricing (not stored in the log). No raw token fields.
376
560
  // SigRank cannot derive pillars from OpenCode logs with current log format.
377
561
  export const opencodeAdapter = {
378
- platform: 'opencode',
379
- defaultRoot: () => join(homedir(), '.local', 'share', 'opencode'),
562
+ platform: "opencode",
563
+ defaultRoot: () => join(homedir(), ".local", "share", "opencode"),
380
564
  estimated: true,
381
- dataGap: 'OpenCode logs store cost:0 and derive tokens via LiteLLM at runtime — raw token counts are not persisted. SigRank cannot read pillars from OpenCode logs with the current format. Track https://github.com/ccusage/ccusage for format changes.',
565
+ dataGap:
566
+ "OpenCode logs store cost:0 and derive tokens via LiteLLM at runtime — raw token counts are not persisted. SigRank cannot read pillars from OpenCode logs with the current format. Track https://github.com/ccusage/ccusage for format changes.",
382
567
  // eslint-disable-next-line require-yield
383
- async *messages() { /* no data available */ },
384
- }
568
+ async *messages() {
569
+ /* no data available */
570
+ },
571
+ };
385
572
 
386
573
  // ── 11. Goose ─────────────────────────────────────────────────────────────────
387
574
  // SQLite: sessions.db at standard Goose data roots or $GOOSE_PATH_ROOT/data/sessions/sessions.db
388
575
  // Columns: accumulated_input_tokens (or input_tokens), accumulated_output_tokens (or output_tokens),
389
576
  // accumulated_total_tokens (or total_tokens). NO cache fields. Reasoning = total-input-output.
577
+ // CUMULATIVE-COLUMN GUARD: accumulated_* columns are per-session running totals. If the sessions
578
+ // table has multiple rows per session id, summing them downstream double-counts. We set both sid
579
+ // and id to the session id and ORDER BY updated_at so tokenpull()'s (sid|id) keep-last dedup
580
+ // collapses duplicate session rows to the latest (max accumulated) values.
390
581
  export const gooseAdapter = {
391
- platform: 'goose',
582
+ platform: "goose",
392
583
  estimated: true, // no cacheCreate or cacheRead
393
584
  defaultRoot: () => {
394
- const env = process.env.GOOSE_PATH_ROOT
395
- if (env) return env
585
+ const env = process.env.GOOSE_PATH_ROOT;
586
+ if (env) return env;
396
587
  // Standard locations (macOS first, then XDG)
397
- return join(homedir(), 'Library', 'Application Support', 'goose')
588
+ return join(homedir(), "Library", "Application Support", "goose");
398
589
  },
399
590
  async *messages(root) {
400
591
  const dbCandidates = process.env.GOOSE_PATH_ROOT
401
- ? [join(process.env.GOOSE_PATH_ROOT, 'data', 'sessions', 'sessions.db')]
592
+ ? [join(process.env.GOOSE_PATH_ROOT, "data", "sessions", "sessions.db")]
402
593
  : [
403
- join(homedir(), 'Library', 'Application Support', 'goose', 'sessions', 'sessions.db'),
404
- join(homedir(), '.local', 'share', 'goose', 'sessions', 'sessions.db'),
405
- join(homedir(), '.local', 'share', 'Block', 'goose', 'sessions', 'sessions.db'),
406
- ]
594
+ join(
595
+ homedir(),
596
+ "Library",
597
+ "Application Support",
598
+ "goose",
599
+ "sessions",
600
+ "sessions.db",
601
+ ),
602
+ join(
603
+ homedir(),
604
+ ".local",
605
+ "share",
606
+ "goose",
607
+ "sessions",
608
+ "sessions.db",
609
+ ),
610
+ join(
611
+ homedir(),
612
+ ".local",
613
+ "share",
614
+ "Block",
615
+ "goose",
616
+ "sessions",
617
+ "sessions.db",
618
+ ),
619
+ ];
407
620
  for (const db of dbCandidates) {
408
- const rows = await sqliteJson(db, 'SELECT * FROM sessions')
621
+ // ORDER BY updated_at so the latest cumulative row per session is yielded last
622
+ // (tokenpull keep-last dedup picks the final row = max accumulated values).
623
+ const rows = await sqliteJson(
624
+ db,
625
+ "SELECT * FROM sessions ORDER BY updated_at",
626
+ );
409
627
  for (const row of rows) {
410
- const input = Number(row.accumulated_input_tokens || row.input_tokens || 0)
411
- const output = Number(row.accumulated_output_tokens || row.output_tokens || 0)
412
- const total = Number(row.accumulated_total_tokens || row.total_tokens || 0)
413
- const reasoning = Math.max(0, total - input - output) // folded into output
414
- if (input + output === 0) continue
415
- yield { id: String(row.id || ''), sid: null, ts: row.created_at || row.updated_at || null, input, output: output + reasoning, cacheCreate: 0, cacheRead: 0, file: db }
628
+ const input = Number(
629
+ row.accumulated_input_tokens || row.input_tokens || 0,
630
+ );
631
+ const output = Number(
632
+ row.accumulated_output_tokens || row.output_tokens || 0,
633
+ );
634
+ const total = Number(
635
+ row.accumulated_total_tokens || row.total_tokens || 0,
636
+ );
637
+ const reasoning = Math.max(0, total - input - output); // folded into output
638
+ if (input + output === 0) continue;
639
+ const sessionId = String(row.id || row.session_id || "");
640
+ yield {
641
+ id: sessionId,
642
+ sid: sessionId,
643
+ ts: row.created_at || row.updated_at || null,
644
+ input,
645
+ output: output + reasoning,
646
+ cacheCreate: 0,
647
+ cacheRead: 0,
648
+ file: db,
649
+ };
416
650
  }
417
651
  }
418
652
  },
419
- }
653
+ };
420
654
 
421
655
  // ── 12. Kilo ──────────────────────────────────────────────────────────────────
422
656
  // SQLite: ~/.local/share/kilo/kilo.db
423
657
  // Per-message rows with model, input/output/cache token columns.
424
658
  export const kiloAdapter = {
425
- platform: 'kilo',
426
- defaultRoot: () => join(homedir(), '.local', 'share', 'kilo'),
659
+ platform: "kilo",
660
+ defaultRoot: () => join(homedir(), ".local", "share", "kilo"),
427
661
  async *messages(root) {
428
- const dbPath = join(roots('KILO_DATA_DIR', root)[0], 'kilo.db')
429
- const rows = await sqliteJson(dbPath, 'SELECT * FROM messages WHERE role="assistant"')
662
+ const dbPath = join(roots("KILO_DATA_DIR", root)[0], "kilo.db");
663
+ const rows = await sqliteJson(
664
+ dbPath,
665
+ 'SELECT * FROM messages WHERE role="assistant"',
666
+ );
430
667
  for (const row of rows) {
431
- const input = Number(row.input_tokens || row.inputTokens || 0)
432
- const output = Number(row.output_tokens || row.outputTokens || 0)
433
- const cacheCreate = Number(row.cache_creation_tokens || row.cacheCreationTokens || 0)
434
- const cacheRead = Number(row.cache_read_tokens || row.cacheReadTokens || 0)
435
- if (input + output + cacheCreate + cacheRead === 0) continue
436
- yield { id: String(row.id || ''), sid: String(row.session_id || row.sessionId || ''), ts: row.created_at || row.timestamp || null, input, output, cacheCreate, cacheRead, file: dbPath }
668
+ const input = Number(row.input_tokens || row.inputTokens || 0);
669
+ const output = Number(row.output_tokens || row.outputTokens || 0);
670
+ const cacheCreate = Number(
671
+ row.cache_creation_tokens || row.cacheCreationTokens || 0,
672
+ );
673
+ const cacheRead = Number(
674
+ row.cache_read_tokens || row.cacheReadTokens || 0,
675
+ );
676
+ if (input + output + cacheCreate + cacheRead === 0) continue;
677
+ yield {
678
+ id: String(row.id || ""),
679
+ sid: String(row.session_id || row.sessionId || ""),
680
+ ts: row.created_at || row.timestamp || null,
681
+ input,
682
+ output,
683
+ cacheCreate,
684
+ cacheRead,
685
+ file: dbPath,
686
+ };
437
687
  }
438
688
  },
439
- }
689
+ };
440
690
 
441
691
  // ── 13. Hermes Agent ─────────────────────────────────────────────────────────
442
692
  // SQLite: ~/.hermes/state.db
443
693
  // Per-session rows: input, output, cache_read, cache_write (=cacheCreate), reasoning_tokens → output
694
+ // CUMULATIVE-COLUMN GUARD: session rows may contain cumulative totals. If the same session
695
+ // appears in multiple rows, summing double-counts. We set both id and sid to the session id
696
+ // and ORDER BY updated_at so tokenpull()'s (sid|id) keep-last dedup collapses duplicates
697
+ // to the latest values.
444
698
  export const hermesAdapter = {
445
- platform: 'hermes',
446
- defaultRoot: () => join(homedir(), '.hermes'),
699
+ platform: "hermes",
700
+ defaultRoot: () => join(homedir(), ".hermes"),
447
701
  async *messages(root) {
448
- for (const r of roots('HERMES_HOME', root)) {
449
- const dbPath = join(r, 'state.db')
450
- const rows = await sqliteJson(dbPath, 'SELECT * FROM sessions')
702
+ for (const r of roots("HERMES_HOME", root)) {
703
+ const dbPath = join(r, "state.db");
704
+ // ORDER BY updated_at so the latest cumulative row per session is yielded last.
705
+ const rows = await sqliteJson(
706
+ dbPath,
707
+ "SELECT * FROM sessions ORDER BY updated_at",
708
+ );
451
709
  for (const row of rows) {
452
- const input = Number(row.input || 0)
453
- const reasoning = Number(row.reasoning_tokens || 0)
454
- const output = Number(row.output || 0) + reasoning
455
- const cacheCreate = Number(row.cache_write || row.cache_creation || 0)
456
- const cacheRead = Number(row.cache_read || 0)
457
- if (input + output + cacheCreate + cacheRead === 0) continue
458
- yield { id: String(row.id || ''), sid: null, ts: row.created_at || row.updated_at || null, input, output, cacheCreate, cacheRead, file: dbPath }
710
+ const input = Number(row.input || 0);
711
+ const reasoning = Number(row.reasoning_tokens || 0);
712
+ const output = Number(row.output || 0) + reasoning;
713
+ const cacheCreate = Number(row.cache_write || row.cache_creation || 0);
714
+ const cacheRead = Number(row.cache_read || 0);
715
+ if (input + output + cacheCreate + cacheRead === 0) continue;
716
+ const sessionId = String(row.id || row.session_id || "");
717
+ yield {
718
+ id: sessionId,
719
+ sid: sessionId,
720
+ ts: row.created_at || row.updated_at || null,
721
+ input,
722
+ output,
723
+ cacheCreate,
724
+ cacheRead,
725
+ file: dbPath,
726
+ };
459
727
  }
460
728
  }
461
729
  },
462
- }
730
+ };
463
731
 
464
732
  // ── Registry ──────────────────────────────────────────────────────────────────
465
733
  /** All non-Claude, non-Codex adapters keyed by platform ID. */
466
734
  export const ADAPTERS = {
467
- amp: ampAdapter,
468
- kimi: kimiAdapter,
469
- qwen: qwenAdapter,
470
- pi: piAdapter,
735
+ amp: ampAdapter,
736
+ kimi: kimiAdapter,
737
+ qwen: qwenAdapter,
738
+ pi: piAdapter,
471
739
  openclaw: openclawAdapter,
472
- droid: droidAdapter,
740
+ droid: droidAdapter,
473
741
  codebuff: codebuffAdapter,
474
- gemini: geminiAdapter,
475
- copilot: copilotAdapter,
742
+ gemini: geminiAdapter,
743
+ copilot: copilotAdapter,
476
744
  opencode: opencodeAdapter,
477
- goose: gooseAdapter,
478
- kilo: kiloAdapter,
479
- hermes: hermesAdapter,
480
- }
745
+ goose: gooseAdapter,
746
+ kilo: kiloAdapter,
747
+ hermes: hermesAdapter,
748
+ };
481
749
 
482
- export const ALL_PLATFORMS = Object.keys(ADAPTERS).concat(['claude', 'codex'])
750
+ export const ALL_PLATFORMS = Object.keys(ADAPTERS).concat(["claude", "codex"]);