sigrank-mcp 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,93 @@
1
+ ---
2
+ type: Reference
3
+ title: SigRank MCP server
4
+ description: The SigRank MCP — exposes the leaderboard as tools any agent can call (rank_paste, get_leaderboard, get_operator, submit_paste, tokenpull, tokenpull_submit). tokenpull is the zero-paste on-device reader (4-window cascade, verified vs token-dashboard). Token-only, read-only, no transcript content. Cascade math mirrors lib/ingest/bridge.ts; proprietary threshold cuts stay server-side.
5
+ tags: [sigrank, mcp, tokenpull, agent, ingest, reference]
6
+ timestamp: 2026-06-23
7
+ ---
8
+
9
+ # SigRank MCP server
10
+
11
+ Exposes SigRank as MCP tools any agent (Claude Code, Cursor, …) can call — turning the
12
+ leaderboard into a tool every agent can invoke (distribution moat). Token-only, no auth,
13
+ no transcript content.
14
+
15
+ ## Tools
16
+ | tool | what |
17
+ |---|---|
18
+ | `rank_paste(text)` | paste ccusage token counts → **Υ Yield / SNR / Leverage / Velocity / 10xDEV + class + a deterministic prose `card`**. Accepts JSON `{input,output,cacheCreate,cacheRead}` or 4 whitespace numbers in that order. |
19
+ | `get_leaderboard()` | the live public board (signalaf.com) |
20
+ | `get_operator(codename)` | one operator's live profile |
21
+ | `submit_paste(text, codename)` | **rank AND publish** in one call: local cascade + card, then POSTs the raw paste to the board's web-paste endpoint (server re-scores authoritatively). `codename` required to publish; omit for preview-only. |
22
+ | `tokenpull(platform?)` | **in-house local reader** (no ccusage/tokscale): scans local logs → the 4 windows (7d/30d/90d/all) each cascaded. **Claude** (native, recursive incl. `subagents/`, dedup `(session,message)` keep-final, verified vs token-dashboard) + **Codex** (reads `~/.codex/sessions`, estimated via `io_ratio` — Beta from the operator's Claude ratio, else Alpha 2.0; verified vs `ccusage codex`). Zero paste, on-device, token-only. |
23
+ | `tokenpull_submit(codename, window?)` | **the zero-paste flow**: `tokenpull` → publish each window's canonical pillars to the board (server re-scores), tagged with `platform`. `codename` required to publish; omit for preview. |
24
+
25
+ The cascade math (`cascade.mjs`) mirrors `sigrank-app/lib/ingest/bridge.ts` — Υ = (Cr·O)/I².
26
+ Open by design; the proprietary threshold cuts / weights stay server-side.
27
+
28
+ ## Privacy
29
+ - **Token-only, always.** No message content is ever read, logged, or transmitted — only token counts (`input`, `output`, `cache_creation`, `cache_read`), message IDs, and timestamps.
30
+ - **Local by default.** `tokenpull` and `tokenpull_submit` read only `~/.claude/projects` (Claude) or `~/.codex` (Codex) on your device. The numbers stay on your machine unless you explicitly call `_submit` with a codename.
31
+ - **Background tooling excluded.** Memory plugins, observers, and summarizers (e.g. `claude-mem`, `mem0`, `observer-sessions`) are filtered out of both Claude and Codex reads. `subagents/` are kept — they represent real operator work. The filter list is in `EXCLUDE_TOOLING` in `tokenpull.mjs` and is extensible.
32
+ - **No auth required.** All board reads and the web-paste submit path are anonymous. No credentials are stored or transmitted.
33
+ - **Content hash per upload.** Every `_submit` call attaches a SHA-256 hash of the pillar payload + a `ddmmyy` datestamp. No personal identifiers beyond the operator-chosen codename.
34
+
35
+ ## Verified
36
+ `node test.mjs` → `rank_paste` reproduces canon: **MO§ES `1251211 11296121 128196310 2555179769`
37
+ → Υ 18436.98 · lev 2042.2 · TRANSMITTER.** ✅ (math is dependency-free, runs without install.)
38
+
39
+ ## Run
40
+ ```bash
41
+ npm install # installs @modelcontextprotocol/sdk
42
+ node index.mjs # stdio MCP server
43
+ ```
44
+ Add to an MCP client (e.g. Claude Code `.mcp.json`):
45
+ ```json
46
+ { "mcpServers": { "sigrank": { "command": "node", "args": ["/Users/dericmchenry/Desktop/SigRank/sigrank-mcp/index.mjs"] } } }
47
+ ```
48
+ `SIGRANK_API_BASE` overrides the board host (default `https://signalaf.com`).
49
+
50
+ ## Status (MVP)
51
+ - ✅ cascade math verified (`rank_paste` → canon Υ). `cascade.mjs` is the testable core.
52
+ - ✅ **Runtime smoke PASS** (2026-06-19): `npm install` (0 vuln) + live MCP-client `tools/list`
53
+ + `rank_paste` round-trip + `get_leaderboard`/`get_operator` HTTP 200 against signalaf.com.
54
+ - ✅ **3a insight card** (`narrate.mjs`): `rank_paste` returns a deterministic prose `card`
55
+ ported from `moses-sigrank/narrate.py` `_template` (model path skipped on purpose).
56
+ - ✅ **3b `submit_paste`** (first write op): ranks locally then POSTs the raw paste to the
57
+ existing anonymous `/api/v1/ingest-paste` (web-paste path — `source='web_paste'`, no auth).
58
+ Verified via injected fetch (no live write). ⚠️ The **first live submit writes production
59
+ Supabase** — fire it once yourself: `SIGRANK_API_BASE=https://signalaf.com` + a real paste.
60
+ - ✅ **tokenpull** (`tokenpull.mjs`): in-house local usage reader (Claude + Codex). Recursive scan
61
+ (incl. `subagents/`), dedup by `(session_id, message_id)` keep-final, 4-window cascade.
62
+ **Verified against token-dashboard (nateherkai): 7d input 3.44M EXACT match** (~1208 files).
63
+ Bug fixed: a 2-level readdir was dropping sub-agent transcripts → 4× input under-count.
64
+ - ✅ **Hardened** (2026-06-23): div-by-zero guards in cascade, `_parseWarnings` on suspicious input,
65
+ AbortController fetch timeout (10s, env-overridable), symlink-safe `_walkJsonl` with MAX_JSONL_FILES
66
+ cap, `EXCLUDE_TOOLING` applied to Codex, uncaughtException/unhandledRejection handlers.
67
+
68
+ ## Multi-model adapter support
69
+ All adapters are token-only (no message content, no cost fields, no credentials). Numbers are
70
+ refined as data accumulates — SigRank is continuously improving methods as more operator data arrives.
71
+
72
+ | Platform | Path | Notes |
73
+ |---|---|---|
74
+ | Claude Code | ✅ `~/.claude/projects` | native, verified; dedup by `(session_id, message_id)` |
75
+ | Codex | ✅ `~/.codex/sessions` | estimated via `io_ratio`; verified vs `ccusage codex` |
76
+ | Amp | ✅ `~/.local/share/amp/threads` | full 4-pillar; per-message |
77
+ | Kimi | ✅ `~/.kimi/sessions` | full 4-pillar; `StatusUpdate` lines only |
78
+ | pi-agent | ✅ `~/.pi/agent/sessions` | full 4-pillar; per-message JSONL |
79
+ | OpenClaw | ✅ `~/.openclaw` (+ `.clawdbot`, `.moltbot`, `.moldbot`) | full 4-pillar; per-message JSONL |
80
+ | Droid | ✅ `~/.factory/sessions/*.settings.json` | full 4-pillar; per-session JSON; thinking→output |
81
+ | Codebuff | ✅ `~/.config/manicode` | full 4-pillar; `chat-messages.json` |
82
+ | Hermes | ✅ `~/.hermes/state.db` | full 4-pillar; SQLite; reasoning→output |
83
+ | Kilo | ✅ `~/.local/share/kilo/kilo.db` | full 4-pillar; SQLite |
84
+ | Qwen | ✅ `~/.qwen/projects` | cacheCreate=0 (`estimated`); no create field in logs; thought→output |
85
+ | Goose | ✅ `~/.local/share/goose/sessions/sessions.db` | cacheCreate=cacheRead=0 (`estimated`); SQLite |
86
+ | Gemini CLI | ✅ `~/.gemini/tmp` | cacheCreate=0 (`estimated`); cache extracted from input field |
87
+ | GitHub Copilot CLI | ✅ `~/.copilot/otel` | OTel JSONL; requires `COPILOT_OTEL_ENABLED=true` before session |
88
+ | OpenCode | ⚠️ `~/.local/share/opencode` | `dataGap`: raw token counts not persisted in log format |
89
+ | Cursor | 🔜 | chat log path TBD; token usage varies by plan |
90
+ | Windsurf | 🔜 | session logs at `~/.codeium/windsurf/` |
91
+
92
+ `estimated=true` means `cacheCreate` is unavailable — the other 3 pillars are native. The server
93
+ re-scores all submitted pillars authoritatively; local preview Υ is indicative only.
package/adapters.mjs ADDED
@@ -0,0 +1,480 @@
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
+ * - Reasoning / thinking tokens → folded into `output` (they are output-side spend)
19
+ * - No cache-creation data available → cacheCreate: 0 + adapter sets `estimated: true`
20
+ * - Cost fields (USD) → NEVER used or forwarded (SigRank scores cost efficiency from
21
+ * token ratios, not from dollar amounts — cost efficiency is derived, not ingested)
22
+ * - Credits / provider-specific fields → dropped
23
+ *
24
+ * "estimated" flag: set on the ADAPTER OBJECT (not per-record) when the adapter
25
+ * cannot provide native cacheCreate values. tokenpull() and tokenpullCodex() already
26
+ * handle this pattern; new adapters with estimated=true get the same treatment.
27
+ *
28
+ * SQLite adapters shell out to `sqlite3 -json` (macOS/Linux system tool, no npm dep).
29
+ * If sqlite3 is unavailable the adapter returns an empty generator with a dataGap note.
30
+ *
31
+ * Data-gap notes (sources that can't provide full 4-pillar data) are attached on the
32
+ * adapter object as `dataGap: string` so tokenpull() can surface them to the user.
33
+ */
34
+
35
+ import { readdir, readFile } from 'node:fs/promises'
36
+ import { join, extname } from 'node:path'
37
+ import { homedir } from 'node:os'
38
+ import { exec as execCb } from 'node:child_process'
39
+ import { promisify } from 'node:util'
40
+
41
+ const exec = promisify(execCb)
42
+ const DAY_MS = 86_400_000 // shared with tokenpull.mjs but kept local to avoid circular import
43
+
44
+ // ── File-system helpers ───────────────────────────────────────────────────────
45
+
46
+ /** Recursively yield every file whose name matches `pred` under dir (skips symlink dirs). */
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 }
51
+ 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 }
57
+ }
58
+ }
59
+
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')
62
+
63
+ /** Read a file as UTF-8, return null on error. */
64
+ async function readUtf8(path) {
65
+ try { return await readFile(path, 'utf8') } catch { return null }
66
+ }
67
+
68
+ /** Parse each newline-delimited JSON line, yield parsed objects silently skipping bad lines. */
69
+ 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 */ }
75
+ }
76
+ }
77
+
78
+ /** Shell out to sqlite3 and return parsed JSON rows, or [] on error/unavailability. */
79
+ async function sqliteJson(dbPath, sql) {
80
+ try {
81
+ const { stdout } = await exec(`sqlite3 -json "${dbPath}" "${sql.replace(/"/g, '\\"')}"`, { timeout: 10_000 })
82
+ return JSON.parse(stdout || '[]')
83
+ } catch { return [] }
84
+ }
85
+
86
+ // ── Env-var helper ────────────────────────────────────────────────────────────
87
+ /** Resolve roots from env var or default. Supports comma-separated list. */
88
+ function roots(envVar, defaultPath) {
89
+ const v = process.env[envVar]
90
+ if (v) return v.split(',').map((s) => s.trim()).filter(Boolean)
91
+ return [defaultPath]
92
+ }
93
+
94
+ // ── 1. Amp ────────────────────────────────────────────────────────────────────
95
+ // ~/.local/share/amp/threads/**/*.json
96
+ // Fields: assistant message usage: input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens
97
+ export const ampAdapter = {
98
+ platform: 'amp',
99
+ defaultRoot: () => join(homedir(), '.local', 'share', 'amp'),
100
+ async *messages(root) {
101
+ for (const r of roots('AMP_DATA_DIR', root)) {
102
+ const threadsDir = join(r, 'threads')
103
+ for await (const path of walkFiles(threadsDir, isJson)) {
104
+ const text = await readUtf8(path)
105
+ if (!text) continue
106
+ let thread
107
+ try { thread = JSON.parse(text) } catch { continue }
108
+ // Amp thread is an array or object with messages
109
+ const msgs = Array.isArray(thread) ? thread : (thread.messages || [])
110
+ for (const msg of msgs) {
111
+ if (!msg || msg.role !== 'assistant') continue
112
+ const u = msg.usage || (msg.metadata && msg.metadata.usage) || {}
113
+ const input = Number(u.input_tokens || u.inputTokens || 0)
114
+ const output = Number(u.output_tokens || u.outputTokens || 0)
115
+ const cacheCreate = Number(u.cache_creation_tokens || u.cacheCreationTokens || 0)
116
+ const cacheRead = Number(u.cache_read_tokens || u.cacheReadTokens || 0)
117
+ if (input + output + cacheCreate + cacheRead === 0) continue
118
+ yield { id: msg.id || null, sid: thread.id || null, ts: msg.timestamp || msg.created_at || null, input, output, cacheCreate, cacheRead, file: path }
119
+ }
120
+ }
121
+ }
122
+ },
123
+ }
124
+
125
+ // ── 2. Kimi ───────────────────────────────────────────────────────────────────
126
+ // ~/.kimi/sessions/<group-id>/<session-id>/wire.jsonl
127
+ // StatusUpdate lines only; token_usage: { input_other, output, input_cache_read, input_cache_creation }
128
+ export const kimiAdapter = {
129
+ platform: 'kimi',
130
+ defaultRoot: () => join(homedir(), '.kimi'),
131
+ async *messages(root) {
132
+ const sessionsDir = join(roots('KIMI_DATA_DIR', root)[0], 'sessions')
133
+ for await (const path of walkFiles(sessionsDir, isJsonl)) {
134
+ const text = await readUtf8(path)
135
+ for (const [ev] of parseJsonl(text, path)) {
136
+ if (!ev || ev.type !== 'StatusUpdate') continue
137
+ const u = ev.token_usage || {}
138
+ const input = Number(u.input_other || 0)
139
+ const output = Number(u.output || 0)
140
+ const cacheCreate = Number(u.input_cache_creation || 0)
141
+ const cacheRead = Number(u.input_cache_read || 0)
142
+ if (input + output + cacheCreate + cacheRead === 0) continue
143
+ yield { id: ev.id || null, sid: null, ts: ev.timestamp || ev.created_at || null, input, output, cacheCreate, cacheRead, file: path }
144
+ }
145
+ }
146
+ },
147
+ }
148
+
149
+ // ── 3. Qwen ───────────────────────────────────────────────────────────────────
150
+ // ~/.qwen/projects/{project}/chats/*.jsonl
151
+ // usageMetadata: { promptTokenCount, candidatesTokenCount, cachedContentTokenCount, thoughtsTokenCount }
152
+ // No cache creation field. Reasoning (thoughtsTokenCount) → output.
153
+ export const qwenAdapter = {
154
+ platform: 'qwen',
155
+ estimated: true, // no cacheCreate in logs
156
+ defaultRoot: () => join(homedir(), '.qwen'),
157
+ async *messages(root) {
158
+ const projectsDir = join(roots('QWEN_DATA_DIR', root)[0], 'projects')
159
+ for await (const path of walkFiles(projectsDir, isJsonl)) {
160
+ const text = await readUtf8(path)
161
+ for (const [ev] of parseJsonl(text, path)) {
162
+ if (!ev || !ev.usageMetadata) continue
163
+ const u = ev.usageMetadata
164
+ const rawInput = Number(u.promptTokenCount || 0)
165
+ const rawOutput = Number(u.candidatesTokenCount || 0)
166
+ const thoughts = Number(u.thoughtsTokenCount || 0) // reasoning → output
167
+ const cacheRead = Number(u.cachedContentTokenCount || 0)
168
+ // promptTokenCount is inclusive of cached; subtract to get fresh input
169
+ const input = Math.max(0, rawInput - cacheRead)
170
+ const output = rawOutput + thoughts
171
+ if (input + output + cacheRead === 0) continue
172
+ yield { id: ev.id || null, sid: null, ts: ev.timestamp || null, input, output, cacheCreate: 0, cacheRead, file: path }
173
+ }
174
+ }
175
+ },
176
+ }
177
+
178
+ // ── 4. pi-agent ───────────────────────────────────────────────────────────────
179
+ // ~/.pi/agent/sessions/**/*.jsonl
180
+ // Fields: inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens
181
+ export const piAdapter = {
182
+ platform: 'pi',
183
+ defaultRoot: () => join(homedir(), '.pi', 'agent', 'sessions'),
184
+ async *messages(root) {
185
+ for (const r of roots('PI_AGENT_DIR', root)) {
186
+ for await (const path of walkFiles(r, isJsonl)) {
187
+ const text = await readUtf8(path)
188
+ for (const [ev] of parseJsonl(text, path)) {
189
+ if (!ev) continue
190
+ // pi-agent stores usage in assistant messages or usage events
191
+ const u = ev.usage || ev
192
+ const input = Number(u.inputTokens || u.input_tokens || 0)
193
+ const output = Number(u.outputTokens || u.output_tokens || 0)
194
+ const cacheCreate = Number(u.cacheCreationTokens || u.cache_creation_tokens || 0)
195
+ const cacheRead = Number(u.cacheReadTokens || u.cache_read_tokens || 0)
196
+ if (input + output + cacheCreate + cacheRead === 0) continue
197
+ yield { id: ev.id || null, sid: ev.sessionId || null, ts: ev.timestamp || null, input, output, cacheCreate, cacheRead, file: path }
198
+ }
199
+ }
200
+ }
201
+ },
202
+ }
203
+
204
+ // ── 5. OpenClaw ───────────────────────────────────────────────────────────────
205
+ // ~/.openclaw/ (also ~/.clawdbot/, ~/.moltbot/, ~/.moldbot/)
206
+ // agents/<agentId>/sessions/<uuid>.jsonl (+ .deleted.<ts> and .reset.<ts> variants)
207
+ // Per-message: input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens
208
+ export const openclawAdapter = {
209
+ platform: 'openclaw',
210
+ defaultRoot: () => {
211
+ const env = process.env.OPENCLAW_DIR
212
+ if (env) return env.split(',')[0].trim()
213
+ for (const d of ['.openclaw', '.clawdbot', '.moltbot', '.moldbot']) {
214
+ const p = join(homedir(), d)
215
+ return p // return first; walkFiles silently skips missing
216
+ }
217
+ return join(homedir(), '.openclaw')
218
+ },
219
+ async *messages(root) {
220
+ const dirs = process.env.OPENCLAW_DIR
221
+ ? process.env.OPENCLAW_DIR.split(',').map((s) => s.trim())
222
+ : [join(homedir(), '.openclaw'), join(homedir(), '.clawdbot'), join(homedir(), '.moltbot'), join(homedir(), '.moldbot')]
223
+ const pred = (n) => isJsonl(n) || n.endsWith('.json') // covers archived variants
224
+ for (const dir of dirs) {
225
+ for await (const path of walkFiles(dir, pred)) {
226
+ const text = await readUtf8(path)
227
+ for (const [ev] of parseJsonl(text, path)) {
228
+ if (!ev || ev.role !== 'assistant') continue
229
+ const u = ev.usage || ev.tokens || {}
230
+ const input = Number(u.input_tokens || u.inputTokens || 0)
231
+ const output = Number(u.output_tokens || u.outputTokens || 0)
232
+ const cacheCreate = Number(u.cache_creation_tokens || u.cacheCreationTokens || 0)
233
+ const cacheRead = Number(u.cache_read_tokens || u.cacheReadTokens || 0)
234
+ if (input + output + cacheCreate + cacheRead === 0) continue
235
+ yield { id: ev.id || null, sid: null, ts: ev.timestamp || null, input, output, cacheCreate, cacheRead, file: path }
236
+ }
237
+ }
238
+ }
239
+ },
240
+ }
241
+
242
+ // ── 6. Droid ──────────────────────────────────────────────────────────────────
243
+ // ~/.factory/sessions/**/*.settings.json
244
+ // Fields: input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, thinking_tokens
245
+ // Thinking tokens → output. Per-settings-file (session-level granularity).
246
+ export const droidAdapter = {
247
+ platform: 'droid',
248
+ defaultRoot: () => join(homedir(), '.factory', 'sessions'),
249
+ async *messages(root) {
250
+ for (const r of roots('DROID_SESSIONS_DIR', root)) {
251
+ for await (const path of walkFiles(r, (n) => n.endsWith('.settings.json'))) {
252
+ const text = await readUtf8(path)
253
+ if (!text) continue
254
+ let s
255
+ try { s = JSON.parse(text) } catch { continue }
256
+ const input = Number(s.input_tokens || 0)
257
+ const output = Number(s.output_tokens || 0) + Number(s.thinking_tokens || 0)
258
+ const cacheCreate = Number(s.cache_creation_tokens || 0)
259
+ const cacheRead = Number(s.cache_read_tokens || 0)
260
+ if (input + output + cacheCreate + cacheRead === 0) continue
261
+ yield { id: null, sid: s.session_id || s.id || null, ts: s.updated_at || s.created_at || null, input, output, cacheCreate, cacheRead, file: path }
262
+ }
263
+ }
264
+ },
265
+ }
266
+
267
+ // ── 7. Codebuff ───────────────────────────────────────────────────────────────
268
+ // ~/.config/manicode/projects/<project>/chats/<chat-id>/chat-messages.json
269
+ // assistant messages: metadata.usage or metadata.codebuff.usage
270
+ export const codebuffAdapter = {
271
+ platform: 'codebuff',
272
+ defaultRoot: () => join(homedir(), '.config', 'manicode'),
273
+ async *messages(root) {
274
+ const dirs = process.env.CODEBUFF_DATA_DIR
275
+ ? process.env.CODEBUFF_DATA_DIR.split(',').map((s) => s.trim())
276
+ : [join(homedir(), '.config', 'manicode'), join(homedir(), '.config', 'manicode-dev'), join(homedir(), '.config', 'manicode-staging')]
277
+ for (const dir of dirs) {
278
+ for await (const path of walkFiles(dir, (n) => n === 'chat-messages.json')) {
279
+ const text = await readUtf8(path)
280
+ if (!text) continue
281
+ let msgs
282
+ try { msgs = JSON.parse(text) } catch { continue }
283
+ if (!Array.isArray(msgs)) continue
284
+ for (const msg of msgs) {
285
+ if (!msg || msg.role !== 'assistant') continue
286
+ const u = (msg.metadata && (msg.metadata.usage || msg.metadata.codebuff?.usage)) || {}
287
+ const input = Number(u.input_tokens || u.inputTokens || 0)
288
+ const output = Number(u.output_tokens || u.outputTokens || 0)
289
+ const cacheCreate = Number(u.cache_creation_tokens || u.cacheCreationTokens || 0)
290
+ const cacheRead = Number(u.cache_read_tokens || u.cacheReadTokens || 0)
291
+ if (input + output + cacheCreate + cacheRead === 0) continue
292
+ yield { id: msg.id || null, sid: null, ts: msg.timestamp || msg.created_at || null, input, output, cacheCreate, cacheRead, file: path }
293
+ }
294
+ }
295
+ }
296
+ },
297
+ }
298
+
299
+ // ── 8. Gemini CLI ─────────────────────────────────────────────────────────────
300
+ // ~/.gemini/tmp/*/chats/*.json and *.jsonl
301
+ // Fields: input, output, cached, thought (reasoning), tool, total
302
+ // SigRank mapping: input = input−cached (fresh), cacheRead = cached, cacheCreate = 0 (not exposed),
303
+ // output = output + thought (reasoning→output)
304
+ export const geminiAdapter = {
305
+ platform: 'gemini',
306
+ estimated: true, // no cacheCreate field in Gemini logs
307
+ defaultRoot: () => join(homedir(), '.gemini', 'tmp'),
308
+ async *messages(root) {
309
+ for (const r of roots('GEMINI_DATA_DIR', root)) {
310
+ for await (const path of walkFiles(r, (n) => n.endsWith('.json') || n.endsWith('.jsonl'))) {
311
+ const text = await readUtf8(path)
312
+ if (!text) continue
313
+ // Try JSONL first, then single JSON
314
+ let parsed = []
315
+ try {
316
+ parsed = text.trim().split('\n').filter(Boolean).map((l) => JSON.parse(l))
317
+ } catch {
318
+ try { parsed = [JSON.parse(text)] } catch { continue }
319
+ }
320
+ for (const ev of parsed) {
321
+ if (!ev) continue
322
+ // Gemini usage may be at top level or nested in usageMetadata
323
+ const u = ev.usageMetadata || ev.usage || ev
324
+ const rawInput = Number(u.input || u.promptTokenCount || 0)
325
+ const rawOutput = Number(u.output || u.candidatesTokenCount || 0)
326
+ const cached = Number(u.cached || u.cachedContentTokenCount || 0)
327
+ const thought = Number(u.thought || u.thoughtsTokenCount || 0)
328
+ if (rawInput + rawOutput + cached + thought === 0) continue
329
+ const input = Math.max(0, rawInput - cached) // strip cached from input
330
+ const output = rawOutput + thought // reasoning → output
331
+ yield { id: ev.id || null, sid: null, ts: ev.timestamp || null, input, output, cacheCreate: 0, cacheRead: cached, file: path }
332
+ }
333
+ }
334
+ }
335
+ },
336
+ }
337
+
338
+ // ── 9. GitHub Copilot CLI ─────────────────────────────────────────────────────
339
+ // ~/.copilot/otel/*.jsonl (requires COPILOT_OTEL_ENABLED=true before session start)
340
+ // OpenTelemetry spans; looks for llm.token_count.prompt / completion / cached attributes
341
+ export const copilotAdapter = {
342
+ platform: 'copilot',
343
+ defaultRoot: () => join(homedir(), '.copilot', 'otel'),
344
+ async *messages(root) {
345
+ const dir = process.env.COPILOT_OTEL_FILE_EXPORTER_PATH
346
+ ? require('node:path').dirname(process.env.COPILOT_OTEL_FILE_EXPORTER_PATH)
347
+ : root
348
+ for await (const path of walkFiles(dir, isJsonl)) {
349
+ const text = await readUtf8(path)
350
+ for (const [ev] of parseJsonl(text, path)) {
351
+ if (!ev) continue
352
+ // OTel span: attributes may be an object or array of {key,value} pairs
353
+ const attrs = ev.attributes || ev.resource?.attributes || {}
354
+ const get = (k) => {
355
+ if (typeof attrs === 'object' && !Array.isArray(attrs)) return attrs[k]
356
+ if (Array.isArray(attrs)) { const a = attrs.find((x) => x.key === k); return a?.value?.intValue ?? a?.value?.stringValue ?? null }
357
+ return null
358
+ }
359
+ const input = Number(get('llm.token_count.prompt') || get('gen_ai.usage.input_tokens') || 0)
360
+ const output = Number(get('llm.token_count.completion') || get('gen_ai.usage.output_tokens') || 0)
361
+ const cacheCreate = Number(get('llm.token_count.cache_creation') || 0)
362
+ const cacheRead = Number(get('llm.token_count.cache_read') || get('gen_ai.usage.cache_read_input_tokens') || 0)
363
+ if (input + output + cacheCreate + cacheRead === 0) continue
364
+ 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 }
365
+ }
366
+ }
367
+ },
368
+ 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.',
369
+ }
370
+
371
+ // ── 10. OpenCode ──────────────────────────────────────────────────────────────
372
+ // ~/.local/share/opencode — JSON message files, but costs stored as 0 and token fields
373
+ // are calculated via LiteLLM pricing (not stored in the log). No raw token fields.
374
+ // SigRank cannot derive pillars from OpenCode logs with current log format.
375
+ export const opencodeAdapter = {
376
+ platform: 'opencode',
377
+ defaultRoot: () => join(homedir(), '.local', 'share', 'opencode'),
378
+ estimated: true,
379
+ 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.',
380
+ // eslint-disable-next-line require-yield
381
+ async *messages() { /* no data available */ },
382
+ }
383
+
384
+ // ── 11. Goose ─────────────────────────────────────────────────────────────────
385
+ // SQLite: sessions.db at standard Goose data roots or $GOOSE_PATH_ROOT/data/sessions/sessions.db
386
+ // Columns: accumulated_input_tokens (or input_tokens), accumulated_output_tokens (or output_tokens),
387
+ // accumulated_total_tokens (or total_tokens). NO cache fields. Reasoning = total-input-output.
388
+ export const gooseAdapter = {
389
+ platform: 'goose',
390
+ estimated: true, // no cacheCreate or cacheRead
391
+ defaultRoot: () => {
392
+ const env = process.env.GOOSE_PATH_ROOT
393
+ if (env) return env
394
+ // Standard locations (macOS first, then XDG)
395
+ return join(homedir(), 'Library', 'Application Support', 'goose')
396
+ },
397
+ async *messages(root) {
398
+ const dbCandidates = process.env.GOOSE_PATH_ROOT
399
+ ? [join(process.env.GOOSE_PATH_ROOT, 'data', 'sessions', 'sessions.db')]
400
+ : [
401
+ join(homedir(), 'Library', 'Application Support', 'goose', 'sessions', 'sessions.db'),
402
+ join(homedir(), '.local', 'share', 'goose', 'sessions', 'sessions.db'),
403
+ join(homedir(), '.local', 'share', 'Block', 'goose', 'sessions', 'sessions.db'),
404
+ ]
405
+ for (const db of dbCandidates) {
406
+ const rows = await sqliteJson(db, 'SELECT * FROM sessions')
407
+ for (const row of rows) {
408
+ const input = Number(row.accumulated_input_tokens || row.input_tokens || 0)
409
+ const output = Number(row.accumulated_output_tokens || row.output_tokens || 0)
410
+ const total = Number(row.accumulated_total_tokens || row.total_tokens || 0)
411
+ const reasoning = Math.max(0, total - input - output) // folded into output
412
+ if (input + output === 0) continue
413
+ 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 }
414
+ }
415
+ }
416
+ },
417
+ }
418
+
419
+ // ── 12. Kilo ──────────────────────────────────────────────────────────────────
420
+ // SQLite: ~/.local/share/kilo/kilo.db
421
+ // Per-message rows with model, input/output/cache token columns.
422
+ export const kiloAdapter = {
423
+ platform: 'kilo',
424
+ defaultRoot: () => join(homedir(), '.local', 'share', 'kilo'),
425
+ async *messages(root) {
426
+ const dbPath = join(roots('KILO_DATA_DIR', root)[0], 'kilo.db')
427
+ const rows = await sqliteJson(dbPath, 'SELECT * FROM messages WHERE role="assistant"')
428
+ for (const row of rows) {
429
+ const input = Number(row.input_tokens || row.inputTokens || 0)
430
+ const output = Number(row.output_tokens || row.outputTokens || 0)
431
+ const cacheCreate = Number(row.cache_creation_tokens || row.cacheCreationTokens || 0)
432
+ const cacheRead = Number(row.cache_read_tokens || row.cacheReadTokens || 0)
433
+ if (input + output + cacheCreate + cacheRead === 0) continue
434
+ 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 }
435
+ }
436
+ },
437
+ }
438
+
439
+ // ── 13. Hermes Agent ─────────────────────────────────────────────────────────
440
+ // SQLite: ~/.hermes/state.db
441
+ // Per-session rows: input, output, cache_read, cache_write (=cacheCreate), reasoning_tokens → output
442
+ export const hermesAdapter = {
443
+ platform: 'hermes',
444
+ defaultRoot: () => join(homedir(), '.hermes'),
445
+ async *messages(root) {
446
+ for (const r of roots('HERMES_HOME', root)) {
447
+ const dbPath = join(r, 'state.db')
448
+ const rows = await sqliteJson(dbPath, 'SELECT * FROM sessions')
449
+ for (const row of rows) {
450
+ const input = Number(row.input || 0)
451
+ const reasoning = Number(row.reasoning_tokens || 0)
452
+ const output = Number(row.output || 0) + reasoning
453
+ const cacheCreate = Number(row.cache_write || row.cache_creation || 0)
454
+ const cacheRead = Number(row.cache_read || 0)
455
+ if (input + output + cacheCreate + cacheRead === 0) continue
456
+ yield { id: String(row.id || ''), sid: null, ts: row.created_at || row.updated_at || null, input, output, cacheCreate, cacheRead, file: dbPath }
457
+ }
458
+ }
459
+ },
460
+ }
461
+
462
+ // ── Registry ──────────────────────────────────────────────────────────────────
463
+ /** All non-Claude, non-Codex adapters keyed by platform ID. */
464
+ export const ADAPTERS = {
465
+ amp: ampAdapter,
466
+ kimi: kimiAdapter,
467
+ qwen: qwenAdapter,
468
+ pi: piAdapter,
469
+ openclaw: openclawAdapter,
470
+ droid: droidAdapter,
471
+ codebuff: codebuffAdapter,
472
+ gemini: geminiAdapter,
473
+ copilot: copilotAdapter,
474
+ opencode: opencodeAdapter,
475
+ goose: gooseAdapter,
476
+ kilo: kiloAdapter,
477
+ hermes: hermesAdapter,
478
+ }
479
+
480
+ export const ALL_PLATFORMS = Object.keys(ADAPTERS).concat(['claude', 'codex'])