sigrank 0.19.0 → 1.0.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.
Files changed (80) hide show
  1. package/CHANGELOG.md +158 -0
  2. package/MCP_SERVER_DESCRIPTION.md +172 -0
  3. package/README.md +89 -41
  4. package/adapters/index.mjs +1100 -0
  5. package/adapters/tokenpull.mjs +568 -0
  6. package/adapters.mjs +1 -865
  7. package/analytics/cascade.mjs +338 -0
  8. package/cascade.mjs +1 -274
  9. package/cli.mjs +1 -1853
  10. package/connect.mjs +1 -13
  11. package/identity/connect.mjs +13 -0
  12. package/identity/keystore.mjs +231 -0
  13. package/identity/sign.mjs +106 -0
  14. package/index.mjs +47 -162
  15. package/keystore.mjs +1 -242
  16. package/lib/constants.mjs +137 -0
  17. package/lib/pkg-version.mjs +59 -0
  18. package/manifest.json +46 -0
  19. package/narrate.mjs +1 -77
  20. package/package.json +41 -16
  21. package/preflight.mjs +1 -191
  22. package/presentation/cli.mjs +1926 -0
  23. package/presentation/narrate.mjs +219 -0
  24. package/presentation/tui-themes.mjs +189 -0
  25. package/presentation/tui.mjs +3316 -0
  26. package/prompts/check-my-efficiency.md +1 -0
  27. package/prompts/compare-with-leader.md +1 -0
  28. package/prompts/simulate-improvement.md +1 -0
  29. package/proxy.mjs +492 -0
  30. package/resources/class-tiers.md +81 -0
  31. package/resources/data-policy.md +31 -0
  32. package/resources/install-guide.md +59 -0
  33. package/resources/privacy-model.md +44 -0
  34. package/resources/scoring-formula.md +24 -0
  35. package/review.mjs +263 -0
  36. package/sign.mjs +1 -106
  37. package/submit/index.mjs +297 -0
  38. package/submit/preflight.mjs +191 -0
  39. package/submit.mjs +1 -303
  40. package/tokenpull.mjs +1 -576
  41. package/tokscale_analytics.mjs +864 -0
  42. package/tools/_framing.mjs +217 -0
  43. package/tools/_helpers.mjs +253 -0
  44. package/tools/_peer-matching.mjs +361 -0
  45. package/tools/_report.mjs +262 -0
  46. package/tools/_schemas.mjs +492 -0
  47. package/tools/_verifiers.mjs +148 -0
  48. package/tools/compare-operators.mjs +80 -0
  49. package/tools/compare-self.mjs +124 -0
  50. package/tools/describe-power-user.mjs +78 -0
  51. package/tools/diagnose-cascade.mjs +192 -0
  52. package/tools/discover-peers.mjs +198 -0
  53. package/tools/enroll.mjs +119 -0
  54. package/tools/get-best-operator.mjs +59 -0
  55. package/tools/get-leaderboard.mjs +27 -0
  56. package/tools/get-operator.mjs +33 -0
  57. package/tools/index.mjs +229 -0
  58. package/tools/optimize-efficiency.mjs +170 -0
  59. package/tools/rank-paste.mjs +51 -0
  60. package/tools/rank-windows.mjs +120 -0
  61. package/tools/self-improve.mjs +423 -0
  62. package/tools/simulate-change.mjs +207 -0
  63. package/tools/submit-paste.mjs +121 -0
  64. package/tools/submit-verified.mjs +153 -0
  65. package/tools/suggest-improvements.mjs +168 -0
  66. package/tools/tokenpull-compare.mjs +123 -0
  67. package/tools/tokenpull-submit.mjs +235 -0
  68. package/tools/tokenpull.mjs +113 -0
  69. package/tools/tokscale-breakdown.mjs +119 -0
  70. package/tools/tokscale-competitive-intel.mjs +45 -0
  71. package/tools/tokscale-cost-analysis.mjs +31 -0
  72. package/tools/tokscale-developer-profile.mjs +36 -0
  73. package/tools/tokscale-device-profile.mjs +35 -0
  74. package/tools/tokscale-market-share.mjs +38 -0
  75. package/tools/tokscale-mcp-usage.mjs +33 -0
  76. package/tools/tokscale-model-trends.mjs +32 -0
  77. package/tools/watch-tokenpull.mjs +134 -0
  78. package/tools.mjs +1 -3897
  79. package/tui.mjs +4 -2650
  80. package/banner.svg +0 -7
@@ -0,0 +1,1100 @@
1
+ /**
2
+ * adapters.mjs — SigRank tokenpull adapters for all supported platforms.
3
+ *
4
+ * OKF (code-file form — fields live in this comment, not raw frontmatter, so the .mjs
5
+ * still parses as valid JS):
6
+ * type: Reference
7
+ * title: SigRank tokenpull adapters
8
+ * description: Per-platform adapters implementing the tokenpull contract — async-generate
9
+ * {id,sid,ts,input,output,cacheCreate,cacheRead,file} from local logs. Reasoning→output,
10
+ * cost fields dropped, missing cacheCreate → estimated:true. Token-only, read-only.
11
+ * tags: [sigrank, mcp, tokenpull, adapters, reference]
12
+ * timestamp: 2026-06-23
13
+ *
14
+ * Each adapter implements the tokenpull contract:
15
+ * messages(root): async generator → { id?, sid?, ts, input, output, cacheCreate, cacheRead, file }
16
+ *
17
+ * SigRank-specific mapping rules (applied consistently across all adapters):
18
+ * - Claude = parsed natively (4 pillars exact, no conversion)
19
+ * - Non-Claude systems = combined input → split via ioRatio:
20
+ * input = floor(output × ioRatio) // Beta from Claude, else Alpha 2.0
21
+ * cacheCreate = max(0, uncached − input)
22
+ * cacheRead = exact
23
+ * (Adapters that yield {ts, output, cacheRead, uncached} via records() go through
24
+ * tokenpullCodex(); adapters that yield native 4-pillar via messages() go through
25
+ * tokenpull() directly. The split happens at the tokenpullAny() routing layer.)
26
+ * - Reasoning / thinking tokens → folded into `output` (they are output-side spend)
27
+ * EXCEPTION: omp already reports reasoningTokens INSIDE output — see adapter #16
28
+ * - No cache-creation data available → cacheCreate: 0 + adapter sets `estimated: true`
29
+ * - Cost fields (USD) → NEVER used or forwarded (SigRank scores cost efficiency from
30
+ * token ratios, not from dollar amounts — cost efficiency is derived, not ingested)
31
+ * - Credits / provider-specific fields → dropped
32
+ *
33
+ * "estimated" flag: set on the ADAPTER OBJECT (not per-record) when the adapter
34
+ * cannot provide native cacheCreate values. tokenpull() and tokenpullCodex() already
35
+ * handle this pattern; new adapters with estimated=true get the same treatment.
36
+ *
37
+ * SQLite adapters shell out to `sqlite3 -json` (macOS/Linux system tool, no npm dep).
38
+ * If sqlite3 is unavailable the adapter returns an empty generator with a dataGap note.
39
+ *
40
+ * Data-gap notes (sources that can't provide full 4-pillar data) are attached on the
41
+ * adapter object as `dataGap: string` so tokenpull() can surface them to the user.
42
+ */
43
+
44
+ import { readdir, readFile } from "node:fs/promises";
45
+ import { join, dirname } from "node:path";
46
+ import { homedir } from "node:os";
47
+ import { execFile as execFileCb } from "node:child_process";
48
+ import { promisify } from "node:util";
49
+ import { cachedOmpScan, ompCacheEnabled } from "../omp-cache.mjs";
50
+
51
+ const execFileP = promisify(execFileCb);
52
+ const DAY_MS = 86_400_000; // shared with tokenpull.mjs but kept local to avoid circular import
53
+
54
+ // ── File-system helpers ───────────────────────────────────────────────────────
55
+
56
+ /** Recursively yield every file whose name matches `pred` under dir (skips
57
+ * symlink dirs, stops after `max` files). Exported so adapters/tokenpull.mjs
58
+ * can reuse it instead of maintaining a second near-identical walker
59
+ * (_walkJsonl) — the two had drifted on the symlink-skip + max-files guards. */
60
+ export async function* walkFiles(dir, pred, counter = { n: 0 }, max = 10_000) {
61
+ if (counter.n >= max) return;
62
+ let entries;
63
+ try {
64
+ entries = await readdir(dir, { withFileTypes: true });
65
+ } catch {
66
+ return;
67
+ }
68
+ for (const e of entries) {
69
+ if (counter.n >= max) return;
70
+ const full = join(dir, e.name);
71
+ if (e.isSymbolicLink()) continue;
72
+ if (e.isDirectory()) {
73
+ yield* walkFiles(full, pred, counter, max);
74
+ } else if (e.isFile() && pred(e.name)) {
75
+ counter.n++;
76
+ yield full;
77
+ }
78
+ }
79
+ }
80
+
81
+ export const isJsonl = (n) =>
82
+ n.endsWith(".jsonl") ||
83
+ n.endsWith(".jsonl.deleted") ||
84
+ n.match(/\.jsonl\.reset\.\d+$/);
85
+ const isJson = (n) => n.endsWith(".json") && !n.endsWith(".jsonl");
86
+
87
+ /** Read a file as UTF-8, return null on error. */
88
+ async function readUtf8(path) {
89
+ try {
90
+ return await readFile(path, "utf8");
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ /** Parse each newline-delimited JSON line, yield parsed objects silently skipping bad lines. */
97
+ function* parseJsonl(text, filePath) {
98
+ if (!text) return;
99
+ for (const line of text.split("\n")) {
100
+ const s = line.trim();
101
+ if (!s) continue;
102
+ try {
103
+ yield [JSON.parse(s), filePath];
104
+ } catch {
105
+ /* skip malformed */
106
+ }
107
+ }
108
+ }
109
+
110
+ /** Run sqlite3 -json and return parsed rows, or [] on error/unavailability.
111
+ * execFile with an args array — no shell, so dbPath/sql need no quoting/escaping
112
+ * (matches the execFile hardening used in tools.mjs / tokenpull.mjs). */
113
+ async function sqliteJson(dbPath, sql, timeoutMs = 10_000) {
114
+ try {
115
+ const { stdout } = await execFileP("sqlite3", ["-json", dbPath, sql], {
116
+ timeout: timeoutMs,
117
+ maxBuffer: 256 * 1024 * 1024, // 256MB — Devin's sessions.db yields ~15MB JSON
118
+ });
119
+ return JSON.parse(stdout || "[]");
120
+ } catch {
121
+ return [];
122
+ }
123
+ }
124
+
125
+ // ── Env-var helper ────────────────────────────────────────────────────────────
126
+ /** Resolve roots from env var or default. Supports comma-separated list. */
127
+ function roots(envVar, defaultPath) {
128
+ const v = process.env[envVar];
129
+ if (v)
130
+ return v
131
+ .split(",")
132
+ .map((s) => s.trim())
133
+ .filter(Boolean);
134
+ return [defaultPath];
135
+ }
136
+
137
+ // ── 1. Amp ────────────────────────────────────────────────────────────────────
138
+ // ~/.local/share/amp/threads/**/*.json
139
+ // Fields: assistant message usage: input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens
140
+ export const ampAdapter = {
141
+ platform: "amp",
142
+ defaultRoot: () => join(homedir(), ".local", "share", "amp"),
143
+ async *messages(root) {
144
+ for (const r of roots("AMP_DATA_DIR", root)) {
145
+ const threadsDir = join(r, "threads");
146
+ for await (const path of walkFiles(threadsDir, isJson)) {
147
+ const text = await readUtf8(path);
148
+ if (!text) continue;
149
+ let thread;
150
+ try {
151
+ thread = JSON.parse(text);
152
+ } catch {
153
+ continue;
154
+ }
155
+ // Amp thread is an array or object with messages
156
+ const msgs = Array.isArray(thread) ? thread : thread.messages || [];
157
+ for (const msg of msgs) {
158
+ if (!msg || msg.role !== "assistant") continue;
159
+ const u = msg.usage || (msg.metadata && msg.metadata.usage) || {};
160
+ const input = Number(u.input_tokens || u.inputTokens || 0);
161
+ const output = Number(u.output_tokens || u.outputTokens || 0);
162
+ const cacheCreate = Number(
163
+ u.cache_creation_tokens || u.cacheCreationTokens || 0,
164
+ );
165
+ const cacheRead = Number(
166
+ u.cache_read_tokens || u.cacheReadTokens || 0,
167
+ );
168
+ if (input + output + cacheCreate + cacheRead === 0) continue;
169
+ yield {
170
+ id: msg.id || null,
171
+ sid: thread.id || null,
172
+ ts: msg.timestamp || msg.created_at || null,
173
+ input,
174
+ output,
175
+ cacheCreate,
176
+ cacheRead,
177
+ file: path,
178
+ };
179
+ }
180
+ }
181
+ }
182
+ },
183
+ };
184
+
185
+ // ── 2. Kimi ───────────────────────────────────────────────────────────────────
186
+ // ~/.kimi/sessions/<group-id>/<session-id>/wire.jsonl
187
+ // StatusUpdate lines only; token_usage: { input_other, output, input_cache_read, input_cache_creation }
188
+ export const kimiAdapter = {
189
+ platform: "kimi",
190
+ defaultRoot: () => join(homedir(), ".kimi"),
191
+ async *messages(root) {
192
+ const sessionsDir = join(roots("KIMI_DATA_DIR", root)[0], "sessions");
193
+ for await (const path of walkFiles(sessionsDir, isJsonl)) {
194
+ const text = await readUtf8(path);
195
+ for (const [ev] of parseJsonl(text, path)) {
196
+ if (!ev || ev.type !== "StatusUpdate") continue;
197
+ const u = ev.token_usage || {};
198
+ const input = Number(u.input_other || 0);
199
+ const output = Number(u.output || 0);
200
+ const cacheCreate = Number(u.input_cache_creation || 0);
201
+ const cacheRead = Number(u.input_cache_read || 0);
202
+ if (input + output + cacheCreate + cacheRead === 0) continue;
203
+ yield {
204
+ id: ev.id || null,
205
+ sid: null,
206
+ ts: ev.timestamp || ev.created_at || null,
207
+ input,
208
+ output,
209
+ cacheCreate,
210
+ cacheRead,
211
+ file: path,
212
+ };
213
+ }
214
+ }
215
+ },
216
+ };
217
+
218
+ // ── 3. Qwen ───────────────────────────────────────────────────────────────────
219
+ // ~/.qwen/projects/{project}/chats/*.jsonl
220
+ // usageMetadata: { promptTokenCount, candidatesTokenCount, cachedContentTokenCount, thoughtsTokenCount }
221
+ // No cache creation field. Reasoning (thoughtsTokenCount) → output.
222
+ export const qwenAdapter = {
223
+ platform: "qwen",
224
+ estimated: true, // no cacheCreate in logs
225
+ defaultRoot: () => join(homedir(), ".qwen"),
226
+ async *messages(root) {
227
+ const projectsDir = join(roots("QWEN_DATA_DIR", root)[0], "projects");
228
+ for await (const path of walkFiles(projectsDir, isJsonl)) {
229
+ const text = await readUtf8(path);
230
+ for (const [ev] of parseJsonl(text, path)) {
231
+ if (!ev || !ev.usageMetadata) continue;
232
+ const u = ev.usageMetadata;
233
+ const rawInput = Number(u.promptTokenCount || 0);
234
+ const rawOutput = Number(u.candidatesTokenCount || 0);
235
+ const thoughts = Number(u.thoughtsTokenCount || 0); // reasoning → output
236
+ const cacheRead = Number(u.cachedContentTokenCount || 0);
237
+ // promptTokenCount is inclusive of cached; subtract to get fresh input
238
+ const input = Math.max(0, rawInput - cacheRead);
239
+ const output = rawOutput + thoughts;
240
+ if (input + output + cacheRead === 0) continue;
241
+ yield {
242
+ id: ev.id || null,
243
+ sid: null,
244
+ ts: ev.timestamp || null,
245
+ input,
246
+ output,
247
+ cacheCreate: 0,
248
+ cacheRead,
249
+ file: path,
250
+ };
251
+ }
252
+ }
253
+ },
254
+ };
255
+
256
+ // ── 4. pi-agent ───────────────────────────────────────────────────────────────
257
+ // ~/.pi/agent/sessions/**/*.jsonl
258
+ // Fields: inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens
259
+ export const piAdapter = {
260
+ platform: "pi",
261
+ defaultRoot: () => join(homedir(), ".pi", "agent", "sessions"),
262
+ async *messages(root) {
263
+ for (const r of roots("PI_AGENT_DIR", root)) {
264
+ for await (const path of walkFiles(r, isJsonl)) {
265
+ const text = await readUtf8(path);
266
+ for (const [ev] of parseJsonl(text, path)) {
267
+ if (!ev) continue;
268
+ // pi-agent stores usage in assistant messages or usage events
269
+ const u = ev.usage || ev;
270
+ const input = Number(u.inputTokens || u.input_tokens || 0);
271
+ const output = Number(u.outputTokens || u.output_tokens || 0);
272
+ const cacheCreate = Number(
273
+ u.cacheCreationTokens || u.cache_creation_tokens || 0,
274
+ );
275
+ const cacheRead = Number(
276
+ u.cacheReadTokens || u.cache_read_tokens || 0,
277
+ );
278
+ if (input + output + cacheCreate + cacheRead === 0) continue;
279
+ yield {
280
+ id: ev.id || null,
281
+ sid: ev.sessionId || null,
282
+ ts: ev.timestamp || null,
283
+ input,
284
+ output,
285
+ cacheCreate,
286
+ cacheRead,
287
+ file: path,
288
+ };
289
+ }
290
+ }
291
+ }
292
+ },
293
+ };
294
+
295
+ // ── 5. OpenClaw ───────────────────────────────────────────────────────────────
296
+ // ~/.openclaw/ (also ~/.clawdbot/, ~/.moltbot/, ~/.moldbot/)
297
+ // agents/<agentId>/sessions/<uuid>.jsonl (+ .deleted.<ts> and .reset.<ts> variants)
298
+ // Per-message: input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens
299
+ export const openclawAdapter = {
300
+ platform: "openclaw",
301
+ defaultRoot: () => {
302
+ const env = process.env.OPENCLAW_DIR;
303
+ if (env) return env.split(",")[0].trim();
304
+ return join(homedir(), ".openclaw");
305
+ },
306
+ async *messages(root) {
307
+ const dirs = process.env.OPENCLAW_DIR
308
+ ? process.env.OPENCLAW_DIR.split(",").map((s) => s.trim())
309
+ : [
310
+ join(homedir(), ".openclaw"),
311
+ join(homedir(), ".clawdbot"),
312
+ join(homedir(), ".moltbot"),
313
+ join(homedir(), ".moldbot"),
314
+ ];
315
+ const pred = (n) => isJsonl(n) || n.endsWith(".json"); // covers archived variants
316
+ for (const dir of dirs) {
317
+ for await (const path of walkFiles(dir, pred)) {
318
+ const text = await readUtf8(path);
319
+ for (const [ev] of parseJsonl(text, path)) {
320
+ if (!ev || ev.role !== "assistant") continue;
321
+ const u = ev.usage || ev.tokens || {};
322
+ const input = Number(u.input_tokens || u.inputTokens || 0);
323
+ const output = Number(u.output_tokens || u.outputTokens || 0);
324
+ const cacheCreate = Number(
325
+ u.cache_creation_tokens || u.cacheCreationTokens || 0,
326
+ );
327
+ const cacheRead = Number(
328
+ u.cache_read_tokens || u.cacheReadTokens || 0,
329
+ );
330
+ if (input + output + cacheCreate + cacheRead === 0) continue;
331
+ yield {
332
+ id: ev.id || null,
333
+ sid: null,
334
+ ts: ev.timestamp || null,
335
+ input,
336
+ output,
337
+ cacheCreate,
338
+ cacheRead,
339
+ file: path,
340
+ };
341
+ }
342
+ }
343
+ }
344
+ },
345
+ };
346
+
347
+ // ── 6. Droid ──────────────────────────────────────────────────────────────────
348
+ // ~/.factory/sessions/**/*.settings.json
349
+ // Fields: input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, thinking_tokens
350
+ // Thinking tokens → output. Per-settings-file (session-level granularity).
351
+ // CUMULATIVE-COLUMN GUARD: settings files contain session-level totals. If the same session
352
+ // appears in multiple files, summing double-counts. We set both id and sid to the session id
353
+ // so tokenpull()'s (sid|id) keep-last dedup collapses duplicates to the latest values.
354
+ export const droidAdapter = {
355
+ platform: "droid",
356
+ defaultRoot: () => join(homedir(), ".factory", "sessions"),
357
+ async *messages(root) {
358
+ for (const r of roots("DROID_SESSIONS_DIR", root)) {
359
+ for await (const path of walkFiles(r, (n) =>
360
+ n.endsWith(".settings.json"),
361
+ )) {
362
+ const text = await readUtf8(path);
363
+ if (!text) continue;
364
+ let s;
365
+ try {
366
+ s = JSON.parse(text);
367
+ } catch {
368
+ continue;
369
+ }
370
+ const input = Number(s.input_tokens || 0);
371
+ const output =
372
+ Number(s.output_tokens || 0) + Number(s.thinking_tokens || 0);
373
+ const cacheCreate = Number(s.cache_creation_tokens || 0);
374
+ const cacheRead = Number(s.cache_read_tokens || 0);
375
+ if (input + output + cacheCreate + cacheRead === 0) continue;
376
+ const sessionId = String(s.session_id || s.id || "");
377
+ yield {
378
+ id: sessionId,
379
+ sid: sessionId,
380
+ ts: s.updated_at || s.created_at || null,
381
+ input,
382
+ output,
383
+ cacheCreate,
384
+ cacheRead,
385
+ file: path,
386
+ };
387
+ }
388
+ }
389
+ },
390
+ };
391
+
392
+ // ── 7. Codebuff ───────────────────────────────────────────────────────────────
393
+ // ~/.config/manicode/projects/<project>/chats/<chat-id>/chat-messages.json
394
+ // assistant messages: metadata.usage or metadata.codebuff.usage
395
+ export const codebuffAdapter = {
396
+ platform: "codebuff",
397
+ defaultRoot: () => join(homedir(), ".config", "manicode"),
398
+ async *messages(root) {
399
+ const dirs = process.env.CODEBUFF_DATA_DIR
400
+ ? process.env.CODEBUFF_DATA_DIR.split(",").map((s) => s.trim())
401
+ : [
402
+ join(homedir(), ".config", "manicode"),
403
+ join(homedir(), ".config", "manicode-dev"),
404
+ join(homedir(), ".config", "manicode-staging"),
405
+ ];
406
+ for (const dir of dirs) {
407
+ for await (const path of walkFiles(
408
+ dir,
409
+ (n) => n === "chat-messages.json",
410
+ )) {
411
+ const text = await readUtf8(path);
412
+ if (!text) continue;
413
+ let msgs;
414
+ try {
415
+ msgs = JSON.parse(text);
416
+ } catch {
417
+ continue;
418
+ }
419
+ if (!Array.isArray(msgs)) continue;
420
+ for (const msg of msgs) {
421
+ if (!msg || msg.role !== "assistant") continue;
422
+ const u =
423
+ (msg.metadata &&
424
+ (msg.metadata.usage || msg.metadata.codebuff?.usage)) ||
425
+ {};
426
+ const input = Number(u.input_tokens || u.inputTokens || 0);
427
+ const output = Number(u.output_tokens || u.outputTokens || 0);
428
+ const cacheCreate = Number(
429
+ u.cache_creation_tokens || u.cacheCreationTokens || 0,
430
+ );
431
+ const cacheRead = Number(
432
+ u.cache_read_tokens || u.cacheReadTokens || 0,
433
+ );
434
+ if (input + output + cacheCreate + cacheRead === 0) continue;
435
+ yield {
436
+ id: msg.id || null,
437
+ sid: null,
438
+ ts: msg.timestamp || msg.created_at || null,
439
+ input,
440
+ output,
441
+ cacheCreate,
442
+ cacheRead,
443
+ file: path,
444
+ };
445
+ }
446
+ }
447
+ }
448
+ },
449
+ };
450
+
451
+ // ── 8. Gemini CLI ─────────────────────────────────────────────────────────────
452
+ // ~/.gemini/tmp/*/chats/*.json and *.jsonl
453
+ // Fields: input, output, cached, thought (reasoning), tool, total
454
+ // SigRank mapping: input = input−cached (fresh), cacheRead = cached, cacheCreate = 0 (not exposed),
455
+ // output = output + thought (reasoning→output)
456
+ export const geminiAdapter = {
457
+ platform: "gemini",
458
+ estimated: true, // no cacheCreate field in Gemini logs
459
+ defaultRoot: () => join(homedir(), ".gemini", "tmp"),
460
+ async *messages(root) {
461
+ for (const r of roots("GEMINI_DATA_DIR", root)) {
462
+ for await (const path of walkFiles(
463
+ r,
464
+ (n) => n.endsWith(".json") || n.endsWith(".jsonl"),
465
+ )) {
466
+ const text = await readUtf8(path);
467
+ if (!text) continue;
468
+ // Try JSONL first, then single JSON
469
+ let parsed = [];
470
+ try {
471
+ parsed = text
472
+ .trim()
473
+ .split("\n")
474
+ .filter(Boolean)
475
+ .map((l) => JSON.parse(l));
476
+ } catch {
477
+ try {
478
+ parsed = [JSON.parse(text)];
479
+ } catch {
480
+ continue;
481
+ }
482
+ }
483
+ for (const ev of parsed) {
484
+ if (!ev) continue;
485
+ // Gemini usage may be at top level or nested in usageMetadata
486
+ const u = ev.usageMetadata || ev.usage || ev;
487
+ const rawInput = Number(u.input || u.promptTokenCount || 0);
488
+ const rawOutput = Number(u.output || u.candidatesTokenCount || 0);
489
+ const cached = Number(u.cached || u.cachedContentTokenCount || 0);
490
+ const thought = Number(u.thought || u.thoughtsTokenCount || 0);
491
+ if (rawInput + rawOutput + cached + thought === 0) continue;
492
+ const input = Math.max(0, rawInput - cached); // strip cached from input
493
+ const output = rawOutput + thought; // reasoning → output
494
+ yield {
495
+ id: ev.id || null,
496
+ sid: null,
497
+ ts: ev.timestamp || null,
498
+ input,
499
+ output,
500
+ cacheCreate: 0,
501
+ cacheRead: cached,
502
+ file: path,
503
+ };
504
+ }
505
+ }
506
+ }
507
+ },
508
+ };
509
+
510
+ // ── 9. GitHub Copilot CLI ─────────────────────────────────────────────────────
511
+ // ~/.copilot/otel/*.jsonl (requires COPILOT_OTEL_ENABLED=true before session start)
512
+ // OpenTelemetry spans; looks for llm.token_count.prompt / completion / cached attributes
513
+ export const copilotAdapter = {
514
+ platform: "copilot",
515
+ defaultRoot: () => join(homedir(), ".copilot", "otel"),
516
+ async *messages(root) {
517
+ const dir = process.env.COPILOT_OTEL_FILE_EXPORTER_PATH
518
+ ? dirname(process.env.COPILOT_OTEL_FILE_EXPORTER_PATH)
519
+ : root;
520
+ for await (const path of walkFiles(dir, isJsonl)) {
521
+ const text = await readUtf8(path);
522
+ for (const [ev] of parseJsonl(text, path)) {
523
+ if (!ev) continue;
524
+ // OTel span: attributes may be an object or array of {key,value} pairs
525
+ const attrs = ev.attributes || ev.resource?.attributes || {};
526
+ const get = (k) => {
527
+ if (typeof attrs === "object" && !Array.isArray(attrs))
528
+ return attrs[k];
529
+ if (Array.isArray(attrs)) {
530
+ const a = attrs.find((x) => x.key === k);
531
+ return a?.value?.intValue ?? a?.value?.stringValue ?? null;
532
+ }
533
+ return null;
534
+ };
535
+ const input = Number(
536
+ get("llm.token_count.prompt") ||
537
+ get("gen_ai.usage.input_tokens") ||
538
+ 0,
539
+ );
540
+ const output = Number(
541
+ get("llm.token_count.completion") ||
542
+ get("gen_ai.usage.output_tokens") ||
543
+ 0,
544
+ );
545
+ const cacheCreate = Number(get("llm.token_count.cache_creation") || 0);
546
+ const cacheRead = Number(
547
+ get("llm.token_count.cache_read") ||
548
+ get("gen_ai.usage.cache_read_input_tokens") ||
549
+ 0,
550
+ );
551
+ if (input + output + cacheCreate + cacheRead === 0) continue;
552
+ yield {
553
+ id: ev.traceId || ev.spanId || null,
554
+ sid: null,
555
+ ts: ev.startTimeUnixNano
556
+ ? new Date(Number(ev.startTimeUnixNano) / 1e6).toISOString()
557
+ : null,
558
+ input,
559
+ output,
560
+ cacheCreate,
561
+ cacheRead,
562
+ file: path,
563
+ };
564
+ }
565
+ }
566
+ },
567
+ setupNote:
568
+ "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.",
569
+ };
570
+
571
+ // ── 10. OpenCode ──────────────────────────────────────────────────────────────
572
+ // ~/.local/share/opencode — JSON message files, but costs stored as 0 and token fields
573
+ // are calculated via LiteLLM pricing (not stored in the log). No raw token fields.
574
+ // SigRank cannot derive pillars from OpenCode logs with current log format.
575
+ export const opencodeAdapter = {
576
+ platform: "opencode",
577
+ defaultRoot: () => join(homedir(), ".local", "share", "opencode"),
578
+ estimated: true,
579
+ dataGap:
580
+ "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.",
581
+ // eslint-disable-next-line require-yield
582
+ async *messages() {
583
+ /* no data available */
584
+ },
585
+ };
586
+
587
+ // ── 11. Goose ─────────────────────────────────────────────────────────────────
588
+ // SQLite: sessions.db at standard Goose data roots or $GOOSE_PATH_ROOT/data/sessions/sessions.db
589
+ // Columns: accumulated_input_tokens (or input_tokens), accumulated_output_tokens (or output_tokens),
590
+ // accumulated_total_tokens (or total_tokens). NO cache fields. Reasoning = total-input-output.
591
+ // CUMULATIVE-COLUMN GUARD: accumulated_* columns are per-session running totals. If the sessions
592
+ // table has multiple rows per session id, summing them downstream double-counts. We set both sid
593
+ // and id to the session id and ORDER BY updated_at so tokenpull()'s (sid|id) keep-last dedup
594
+ // collapses duplicate session rows to the latest (max accumulated) values.
595
+ export const gooseAdapter = {
596
+ platform: "goose",
597
+ estimated: true, // no cacheCreate or cacheRead
598
+ defaultRoot: () => {
599
+ const env = process.env.GOOSE_PATH_ROOT;
600
+ if (env) return env;
601
+ // Standard locations (macOS first, then XDG)
602
+ return join(homedir(), "Library", "Application Support", "goose");
603
+ },
604
+ async *messages(root) {
605
+ const dbCandidates = process.env.GOOSE_PATH_ROOT
606
+ ? [join(process.env.GOOSE_PATH_ROOT, "data", "sessions", "sessions.db")]
607
+ : [
608
+ join(
609
+ homedir(),
610
+ "Library",
611
+ "Application Support",
612
+ "goose",
613
+ "sessions",
614
+ "sessions.db",
615
+ ),
616
+ join(
617
+ homedir(),
618
+ ".local",
619
+ "share",
620
+ "goose",
621
+ "sessions",
622
+ "sessions.db",
623
+ ),
624
+ join(
625
+ homedir(),
626
+ ".local",
627
+ "share",
628
+ "Block",
629
+ "goose",
630
+ "sessions",
631
+ "sessions.db",
632
+ ),
633
+ ];
634
+ for (const db of dbCandidates) {
635
+ // ORDER BY updated_at so the latest cumulative row per session is yielded last
636
+ // (tokenpull keep-last dedup picks the final row = max accumulated values).
637
+ const rows = await sqliteJson(
638
+ db,
639
+ "SELECT * FROM sessions ORDER BY updated_at",
640
+ );
641
+ for (const row of rows) {
642
+ const input = Number(
643
+ row.accumulated_input_tokens || row.input_tokens || 0,
644
+ );
645
+ const output = Number(
646
+ row.accumulated_output_tokens || row.output_tokens || 0,
647
+ );
648
+ const total = Number(
649
+ row.accumulated_total_tokens || row.total_tokens || 0,
650
+ );
651
+ const reasoning = Math.max(0, total - input - output); // folded into output
652
+ if (input + output === 0) continue;
653
+ const sessionId = String(row.id || row.session_id || "");
654
+ yield {
655
+ id: sessionId,
656
+ sid: sessionId,
657
+ ts: row.created_at || row.updated_at || null,
658
+ input,
659
+ output: output + reasoning,
660
+ cacheCreate: 0,
661
+ cacheRead: 0,
662
+ file: db,
663
+ };
664
+ }
665
+ }
666
+ },
667
+ };
668
+
669
+ // ── 12. Kilo ──────────────────────────────────────────────────────────────────
670
+ // SQLite: ~/.local/share/kilo/kilo.db
671
+ // Per-message rows with model, input/output/cache token columns.
672
+ export const kiloAdapter = {
673
+ platform: "kilo",
674
+ defaultRoot: () => join(homedir(), ".local", "share", "kilo"),
675
+ async *messages(root) {
676
+ const dbPath = join(roots("KILO_DATA_DIR", root)[0], "kilo.db");
677
+ const rows = await sqliteJson(
678
+ dbPath,
679
+ 'SELECT * FROM messages WHERE role="assistant"',
680
+ );
681
+ for (const row of rows) {
682
+ const input = Number(row.input_tokens || row.inputTokens || 0);
683
+ const output = Number(row.output_tokens || row.outputTokens || 0);
684
+ const cacheCreate = Number(
685
+ row.cache_creation_tokens || row.cacheCreationTokens || 0,
686
+ );
687
+ const cacheRead = Number(
688
+ row.cache_read_tokens || row.cacheReadTokens || 0,
689
+ );
690
+ if (input + output + cacheCreate + cacheRead === 0) continue;
691
+ yield {
692
+ id: String(row.id || ""),
693
+ sid: String(row.session_id || row.sessionId || ""),
694
+ ts: row.created_at || row.timestamp || null,
695
+ input,
696
+ output,
697
+ cacheCreate,
698
+ cacheRead,
699
+ file: dbPath,
700
+ };
701
+ }
702
+ },
703
+ };
704
+
705
+ // ── 13. Hermes Agent ─────────────────────────────────────────────────────────
706
+ // SQLite: ~/.hermes/state.db
707
+ // Per-session rows: input, output, cache_read, cache_write (=cacheCreate), reasoning_tokens → output
708
+ // CUMULATIVE-COLUMN GUARD: session rows may contain cumulative totals. If the same session
709
+ // appears in multiple rows, summing double-counts. We set both id and sid to the session id
710
+ // and ORDER BY updated_at so tokenpull()'s (sid|id) keep-last dedup collapses duplicates
711
+ // to the latest values.
712
+ export const hermesAdapter = {
713
+ platform: "hermes",
714
+ defaultRoot: () => join(homedir(), ".hermes"),
715
+ async *messages(root) {
716
+ for (const r of roots("HERMES_HOME", root)) {
717
+ const dbPath = join(r, "state.db");
718
+ // ORDER BY updated_at so the latest cumulative row per session is yielded last.
719
+ const rows = await sqliteJson(
720
+ dbPath,
721
+ "SELECT * FROM sessions ORDER BY updated_at",
722
+ );
723
+ for (const row of rows) {
724
+ const input = Number(row.input || 0);
725
+ const reasoning = Number(row.reasoning_tokens || 0);
726
+ const output = Number(row.output || 0) + reasoning;
727
+ const cacheCreate = Number(row.cache_write || row.cache_creation || 0);
728
+ const cacheRead = Number(row.cache_read || 0);
729
+ if (input + output + cacheCreate + cacheRead === 0) continue;
730
+ const sessionId = String(row.id || row.session_id || "");
731
+ yield {
732
+ id: sessionId,
733
+ sid: sessionId,
734
+ ts: row.created_at || row.updated_at || null,
735
+ input,
736
+ output,
737
+ cacheCreate,
738
+ cacheRead,
739
+ file: dbPath,
740
+ };
741
+ }
742
+ }
743
+ },
744
+ };
745
+
746
+ // ── 14. Devin CLI ────────────────────────────────────────────────────────────
747
+ // SQLite: ~/.local/share/devin/cli/sessions.db
748
+ // Same combined-input problem as Codex: input_tokens INCLUDES cache write, so we
749
+ // yield { ts, output, cacheRead, uncached } and let tokenpullCodex() do the
750
+ // ioRatio split (input = output × ioRatio, cacheCreate = uncached − input).
751
+ // ioRatio comes from Claude (Beta = operator's Claude input/output ratio) or
752
+ // the Alpha 2.0 default (matches Codex; the "7:1:2 average → 0.5" note in a
753
+ // prior revision was wrong — tokenpullAny defaults ioRatio to 2.0 for both).
754
+ export const devinAdapter = {
755
+ platform: "devin",
756
+ defaultRoot: () => join(homedir(), ".local", "share", "devin", "cli"),
757
+ async *records(root) {
758
+ for (const r of roots("DEVIN_HOME", root)) {
759
+ const dbPath = join(r, "sessions.db");
760
+ const rows = await sqliteJson(
761
+ dbPath,
762
+ `SELECT row_id, session_id,
763
+ json_extract(chat_message, '$.metadata.metrics.input_tokens') as input_tokens,
764
+ json_extract(chat_message, '$.metadata.metrics.output_tokens') as output_tokens,
765
+ json_extract(chat_message, '$.metadata.metrics.cache_read_tokens') as cache_read_tokens,
766
+ json_extract(chat_message, '$.metadata.created_at') as created_at
767
+ FROM message_nodes
768
+ WHERE json_extract(chat_message, '$.role') = 'assistant'
769
+ AND json_extract(chat_message, '$.metadata.metrics.input_tokens') IS NOT NULL
770
+ ORDER BY created_at`,
771
+ 60_000,
772
+ );
773
+ for (const row of rows) {
774
+ const inputIncl = Number(row.input_tokens || 0);
775
+ const cached = Number(row.cache_read_tokens || 0);
776
+ const output = Number(row.output_tokens || 0);
777
+ if (inputIncl + output + cached === 0) continue;
778
+ yield {
779
+ ts: row.created_at || null,
780
+ output,
781
+ cacheRead: cached,
782
+ uncached: Math.max(0, inputIncl - cached),
783
+ file: dbPath,
784
+ };
785
+ }
786
+ }
787
+ },
788
+ };
789
+
790
+ // ── 15. Other (user-supplied JSON) ───────────────────────────────────────────
791
+ // For platforms/models not yet on the adapter list. The user writes a JSON file
792
+ // with the 4 pillars per window and points SIGRANK_OTHER_PATH at it. This lets
793
+ // anyone with a new tool submit without needing a custom adapter.
794
+ //
795
+ // JSON format (all fields optional, missing windows are skipped):
796
+ // {
797
+ // "platform": "my-tool", // optional, defaults to "other"
798
+ // "windows": {
799
+ // "all": { "input": 1000, "output": 2000, "cacheCreate": 500, "cacheRead": 8000 },
800
+ // "30d": { "input": 500, "output": 1000, "cacheCreate": 200, "cacheRead": 4000 },
801
+ // "7d": { "input": 100, "output": 200, "cacheCreate": 50, "cacheRead": 800 }
802
+ // }
803
+ // }
804
+ //
805
+ // The adapter yields ONE message carrying the all-time pillars. The generic
806
+ // tokenpull aggregator buckets by timestamp, and these records carry no
807
+ // timestamp (ts: null → "all" window only, same as null-ts records in other
808
+ // adapters). Yielding one message PER window key — as a prior version did —
809
+ // caused the "all" total to be the SUM of every window entry (all + 30d + 7d
810
+ // …), i.e. a multi-count. This is the only user-facing wrong-number bug in
811
+ // ingestion: an operator who filled in all four windows saw an "all" Υ
812
+ // computed from 2–4× the real pillars.
813
+ //
814
+ // Fix: yield exactly one record. If the JSON has an "all" entry, use it
815
+ // verbatim; otherwise sum the provided windows into a synthetic all-time
816
+ // total. The 7d/30d/90d entries are accepted (so the file format stays
817
+ // forward-compatible) but are NOT yielded as separate records — the generic
818
+ // aggregator has no way to route a timestamp-less record into a bounded
819
+ // window, so emitting them would only re-introduce the overcount. An operator
820
+ // who wants windowed data must use a real adapter that carries timestamps.
821
+ export const otherAdapter = {
822
+ platform: "other",
823
+ defaultRoot: () => process.env.SIGRANK_OTHER_PATH || "",
824
+ async *messages(root) {
825
+ const filePath = root || process.env.SIGRANK_OTHER_PATH;
826
+ if (!filePath) {
827
+ throw new Error(
828
+ "other adapter: set SIGRANK_OTHER_PATH to a JSON file with your token pillars. " +
829
+ 'Format: { "windows": { "all": { "input": N, "output": N, "cacheCreate": N, "cacheRead": N } } }',
830
+ );
831
+ }
832
+ let text;
833
+ try {
834
+ text = await readFile(filePath, "utf8");
835
+ } catch {
836
+ throw new Error(`other adapter: cannot read ${filePath} — check SIGRANK_OTHER_PATH`);
837
+ }
838
+ let data;
839
+ try {
840
+ data = JSON.parse(text);
841
+ } catch {
842
+ throw new Error(`other adapter: invalid JSON in ${filePath}`);
843
+ }
844
+ const windows = data.windows || {};
845
+ const norm = (p) => ({
846
+ input: Number(p?.input || 0),
847
+ output: Number(p?.output || 0),
848
+ cacheCreate: Number(p?.cacheCreate || p?.cache_create || 0),
849
+ cacheRead: Number(p?.cacheRead || p?.cache_read || 0),
850
+ });
851
+ let all;
852
+ if (windows.all) {
853
+ // Explicit all-time entry → use verbatim (do NOT add 7d/30d/90d on top).
854
+ all = norm(windows.all);
855
+ } else {
856
+ // No "all" key → fold the provided windows into a synthetic all-time.
857
+ all = { input: 0, output: 0, cacheCreate: 0, cacheRead: 0 };
858
+ for (const pillars of Object.values(windows)) {
859
+ const n = norm(pillars);
860
+ all.input += n.input;
861
+ all.output += n.output;
862
+ all.cacheCreate += n.cacheCreate;
863
+ all.cacheRead += n.cacheRead;
864
+ }
865
+ }
866
+ if (all.input + all.output + all.cacheCreate + all.cacheRead === 0) return;
867
+ yield {
868
+ id: `other:all`,
869
+ sid: `other-all`,
870
+ ts: null, // no timestamp → lands in "all" window only
871
+ input: all.input,
872
+ output: all.output,
873
+ cacheCreate: all.cacheCreate,
874
+ cacheRead: all.cacheRead,
875
+ file: filePath,
876
+ };
877
+ },
878
+ };
879
+
880
+ // ── 16. oh-my-pi (omp) ───────────────────────────────────────────────────────
881
+ // ~/.omp/agent/sessions/<bucket>/<timestamp>_<sessionId>/<name>.jsonl (recursive)
882
+ // NOT pi-agent (#4): oh-my-pi forked from pi long ago and is a separate harness
883
+ // with its own on-disk format. Line 1 is a fixed-width 256-byte {"type":"title"}
884
+ // slot; line 2 is {"type":"session"} and carries the session `.id`. Neither line
885
+ // holds usage. Other observed `.type` values (all usage-free): custom,
886
+ // custom_message, model_change, thinking_level_change, session_init, credential_pin.
887
+ // Usage lives at `.message.usage` on `.type === "message"` entries:
888
+ // { input, output, cacheRead, cacheWrite, totalTokens, reasoningTokens, cost:{…} }
889
+ // Native 4-pillar (no estimation): input→input, output→output,
890
+ // cacheWrite→cacheCreate, cacheRead→cacheRead.
891
+ // TRAP 1 — reasoningTokens is ALREADY INSIDE output. Do NOT fold it in. Every other
892
+ // adapter here folds reasoning into output, so the reflex is wrong for omp; adding
893
+ // it double-counts. Proof: totalTokens === input+output+cacheRead+cacheWrite
894
+ // exactly, and reasoningTokens is always ≤ output.
895
+ // TRAP 2 — usage.cost reuses the SAME four key names (input/output/cacheRead/
896
+ // cacheWrite, plus total) for USD floats. Read the four counts straight off
897
+ // `usage`; never descend into `cost`. Cost fields are never forwarded (file header).
898
+ // Nested subagent transcripts (critic.jsonl beside critic/__advisor.jsonl) are real
899
+ // operator work and ARE counted — same policy as Claude's `subagents/`.
900
+ // Dedup key is (session header `.id`, entry top-level `.id`) — `.message` has no id.
901
+ // File cap: a real omp tree runs to 20k+ transcripts, past walkFiles' 10_000 default,
902
+ // which would silently drop half the operator's tokens — pass an explicit cap.
903
+ const OMP_MAX_FILES = 500_000;
904
+
905
+ // ── Stage 1: bounded-concurrency + line-guard optimization ───────────────────
906
+ // Based on George-RD's measured findings (PR #32 review comment):
907
+ // - CPU-bound, not disk-bound: 90.5% of one thread, tree fully page-cached
908
+ // - c=8 is optimal: 45.06s → 34.92s (1.29x), regresses at c12/c16
909
+ // - Line guard MUST pass both "message" AND "session" lines, or it breaks
910
+ // the (session_id, entry_id) dedup key — session header lines carry the id
911
+ // - Two-term guard: 0 false negatives across 2.18M lines
912
+ // - All 504 duplicate keys carry byte-identical pillar tuples → order-safe
913
+ // - Only ~1-3% end-to-end win from the guard alone (omp's skippable lines are
914
+ // 19.8% of lines but 15.3% of bytes); the guard costs 1.2s but saves parse
915
+ // on lines that can't contain usage. Folded into concurrency, not standalone.
916
+ const OMP_CONCURRENCY = 8;
917
+
918
+ /** Check if a line could contain an omp usage record or session header.
919
+ * Conservative: false positives are fine, false negatives are not.
920
+ * Must match both "message" (usage) and "session" (dedup key) lines. */
921
+ function ompLineCouldMatter(line) {
922
+ // Fast substring check before JSON.parse — rejects ~20% of lines that are
923
+ // neither message nor session entries (tool calls, reasoning, etc.)
924
+ return line.includes('"message"') || line.includes('"session"');
925
+ }
926
+
927
+ /** Parse a single omp transcript file and yield usage records.
928
+ * Isolated so it can be called concurrently from the bounded pool.
929
+ * Exported for omp-cache.mjs and tests. */
930
+ export function* parseOmpFile(text, path) {
931
+ if (!text) return;
932
+ let sid = null;
933
+ for (const line of text.split("\n")) {
934
+ const s = line.trim();
935
+ if (!s) continue;
936
+ // Line guard: skip lines that can't be message or session entries.
937
+ // George-RD verified 0 false negatives across 2.18M lines with this guard.
938
+ if (!ompLineCouldMatter(s)) continue;
939
+ let ev;
940
+ try {
941
+ ev = JSON.parse(s);
942
+ } catch {
943
+ continue;
944
+ }
945
+ if (!ev) continue;
946
+ if (ev.type === "session") {
947
+ sid = ev.id || null;
948
+ continue;
949
+ }
950
+ if (ev.type !== "message") continue;
951
+ const u = ev.message && ev.message.usage;
952
+ if (!u) continue;
953
+ const input = Number(u.input || 0);
954
+ const output = Number(u.output || 0); // reasoningTokens ALREADY inside — TRAP 1
955
+ const cacheCreate = Number(u.cacheWrite || 0);
956
+ const cacheRead = Number(u.cacheRead || 0);
957
+ if (input + output + cacheCreate + cacheRead === 0) continue;
958
+ yield {
959
+ id: ev.id || null,
960
+ sid,
961
+ ts: ev.timestamp || null,
962
+ input,
963
+ output,
964
+ cacheCreate,
965
+ cacheRead,
966
+ file: path,
967
+ };
968
+ }
969
+ }
970
+
971
+ export const ompAdapter = {
972
+ platform: "omp",
973
+ defaultRoot: () => join(homedir(), ".omp", "agent", "sessions"),
974
+ async *messages(root) {
975
+ for (const r of roots("OMP_DATA_DIR", root)) {
976
+ // Stage 2: if SIGRANK_OMP_CACHE is set, use the incremental SQLite cache.
977
+ // The cache wraps the Stage 1 scanner — on a warm run with no changed
978
+ // files, it serves records from SQLite without reading transcripts.
979
+ // Falls back to uncached on any error.
980
+ if (ompCacheEnabled()) {
981
+ yield* cachedOmpScan({
982
+ rootDir: r,
983
+ uncachedScan: async function* () {
984
+ yield* ompUncachedScan(r);
985
+ },
986
+ parseOmpFile,
987
+ readUtf8,
988
+ walkFiles,
989
+ isJsonl,
990
+ maxFiles: OMP_MAX_FILES,
991
+ });
992
+ continue;
993
+ }
994
+
995
+ // Default: uncached Stage 1 scan
996
+ yield* ompUncachedScan(r);
997
+ }
998
+ },
999
+ };
1000
+
1001
+ /** Stage 1 uncached scan — bounded concurrency + line guard.
1002
+ * Isolated so the cache wrapper can call it as the fallback/parity reference. */
1003
+ async function* ompUncachedScan(rootDir) {
1004
+ // Collect all file paths first (readdir is 0.8% of scan time — negligible)
1005
+ const paths = [];
1006
+ for await (const path of walkFiles(rootDir, isJsonl, { n: 0 }, OMP_MAX_FILES)) {
1007
+ paths.push(path);
1008
+ }
1009
+
1010
+ // Bounded-concurrency read+parse: George-RD measured c=8 as optimal
1011
+ // (1.29x speedup, 45s → 35s). Order-safe: all 504 duplicate keys carry
1012
+ // byte-identical pillar tuples, so completion order can't change totals.
1013
+ const concurrency = OMP_CONCURRENCY;
1014
+ const inflight = new Set();
1015
+ const queue = [...paths];
1016
+
1017
+ while (queue.length > 0 || inflight.size > 0) {
1018
+ while (inflight.size < concurrency && queue.length > 0) {
1019
+ const path = queue.shift();
1020
+ const p = (async () => {
1021
+ const text = await readUtf8(path);
1022
+ return { path, records: [...parseOmpFile(text, path)] };
1023
+ })();
1024
+ inflight.add(p);
1025
+ p.finally(() => inflight.delete(p));
1026
+ }
1027
+ const done = await Promise.race(inflight);
1028
+ inflight.delete(done);
1029
+ for (const record of done.records) {
1030
+ yield record;
1031
+ }
1032
+ }
1033
+ }
1034
+
1035
+ // ── SigRank local API proxy ──────────────────────────────────────────────────
1036
+ // ~/.sigrank-mcp/proxy-sessions.jsonl — one provider-reported usage record per
1037
+ // API call. Native 4-pillar data; OpenAI's inclusive input count is normalized
1038
+ // by proxy.mjs before it reaches this adapter.
1039
+ export const proxyAdapter = {
1040
+ platform: "proxy",
1041
+ defaultRoot: () =>
1042
+ join(homedir(), ".sigrank-mcp", "proxy-sessions.jsonl"),
1043
+ async *messages(root) {
1044
+ const path = root || this.defaultRoot();
1045
+ const text = await readUtf8(path);
1046
+ for (const [ev] of parseJsonl(text, path)) {
1047
+ if (!ev || typeof ev !== "object") continue;
1048
+ const input = Number(ev.input);
1049
+ const output = Number(ev.output);
1050
+ const cacheCreate = Number(ev.cacheCreate);
1051
+ const cacheRead = Number(ev.cacheRead);
1052
+ if (
1053
+ ![input, output, cacheCreate, cacheRead].every(
1054
+ (n) => Number.isFinite(n) && n >= 0,
1055
+ )
1056
+ ) {
1057
+ continue;
1058
+ }
1059
+ if (input + output + cacheCreate + cacheRead === 0) continue;
1060
+ yield {
1061
+ // tokenpull() keeps the final record for a duplicate id, which gives
1062
+ // this adapter the requested same-timestamp keep-last behavior.
1063
+ id: typeof ev.ts === "string" && ev.ts ? ev.ts : null,
1064
+ sid: null,
1065
+ ts: typeof ev.ts === "string" ? ev.ts : null,
1066
+ input,
1067
+ output,
1068
+ cacheCreate,
1069
+ cacheRead,
1070
+ model: typeof ev.model === "string" ? ev.model : null,
1071
+ backend: typeof ev.backend === "string" ? ev.backend : null,
1072
+ file: path,
1073
+ };
1074
+ }
1075
+ },
1076
+ };
1077
+
1078
+ // ── Registry ──────────────────────────────────────────────────────────────────
1079
+ /** All non-Claude, non-Codex adapters keyed by platform ID. */
1080
+ export const ADAPTERS = {
1081
+ amp: ampAdapter,
1082
+ kimi: kimiAdapter,
1083
+ qwen: qwenAdapter,
1084
+ pi: piAdapter,
1085
+ openclaw: openclawAdapter,
1086
+ droid: droidAdapter,
1087
+ codebuff: codebuffAdapter,
1088
+ gemini: geminiAdapter,
1089
+ copilot: copilotAdapter,
1090
+ opencode: opencodeAdapter,
1091
+ goose: gooseAdapter,
1092
+ kilo: kiloAdapter,
1093
+ hermes: hermesAdapter,
1094
+ devin: devinAdapter,
1095
+ other: otherAdapter,
1096
+ omp: ompAdapter,
1097
+ proxy: proxyAdapter,
1098
+ };
1099
+
1100
+ export const ALL_PLATFORMS = Object.keys(ADAPTERS).concat(["claude", "codex", "grok"]);