geneseed 2.0.0

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 (337) hide show
  1. package/CHANGELOG.md +938 -0
  2. package/DESIGN.md +1423 -0
  3. package/LICENSE +21 -0
  4. package/QUICKSTART.md +48 -0
  5. package/README.md +336 -0
  6. package/SETUP.md +1027 -0
  7. package/SHIPPED.md +70 -0
  8. package/adapters/bob/README.md +100 -0
  9. package/adapters/claude-code/README.md +101 -0
  10. package/adapters/claude-code/settings.json +47 -0
  11. package/adapters/claude-code/skills/geneseed/SKILL.md +72 -0
  12. package/adapters/copilot/README.md +76 -0
  13. package/adapters/opencode/GLOBAL-HARNESS-SPEC.md +434 -0
  14. package/adapters/opencode/HOW-OPENCODE-LOADS.md +158 -0
  15. package/adapters/opencode/README.md +582 -0
  16. package/adapters/opencode/agents/orchestrator.md +25 -0
  17. package/adapters/opencode/opencode.json +44 -0
  18. package/adapters/opencode/plugins/geneseed-activity.js +430 -0
  19. package/adapters/opencode/plugins/geneseed-context.js +876 -0
  20. package/adapters/opencode/plugins/geneseed-guard.js +290 -0
  21. package/adapters/opencode/plugins/geneseed-learn.js +396 -0
  22. package/adapters/opencode/plugins/geneseed-notify.js +142 -0
  23. package/adapters/opencode/plugins/geneseed-ponytail.js +148 -0
  24. package/adapters/opencode/plugins/geneseed-workflow.js +183 -0
  25. package/adapters/opencode/workflows/_runtime.js +277 -0
  26. package/adapters/opencode/workflows/council.js +64 -0
  27. package/adapters/opencode/workflows/dispatch.js +89 -0
  28. package/adapters/opencode/workflows/research-plan-implement.js +43 -0
  29. package/adapters/opencode/workflows/review.js +57 -0
  30. package/bin/geneseed-cli.mjs +412 -0
  31. package/bin/geneseed-hook.mjs +129 -0
  32. package/bin/geneseed.mjs +1086 -0
  33. package/docs/declined.md +149 -0
  34. package/docs/limits.md +270 -0
  35. package/docs/opencode-plugin-setup.md +64 -0
  36. package/docs/token-footprint.md +68 -0
  37. package/docs/web/_groups.json +45 -0
  38. package/docs/web/about.md +6 -0
  39. package/docs/web/adapters-claude-code.md +8 -0
  40. package/docs/web/adapters-opencode-loads.md +8 -0
  41. package/docs/web/adapters-opencode-spec.md +8 -0
  42. package/docs/web/adapters-opencode.md +8 -0
  43. package/docs/web/agents.md +8 -0
  44. package/docs/web/authoring.md +11 -0
  45. package/docs/web/autostart.md +9 -0
  46. package/docs/web/claude-hooks.md +36 -0
  47. package/docs/web/cli.md +6 -0
  48. package/docs/web/collaboration.md +27 -0
  49. package/docs/web/configure-wiki.md +9 -0
  50. package/docs/web/design.md +7 -0
  51. package/docs/web/env-knobs.md +9 -0
  52. package/docs/web/exclusions.md +26 -0
  53. package/docs/web/first-session.md +28 -0
  54. package/docs/web/footprint.md +31 -0
  55. package/docs/web/foreman-mode.md +33 -0
  56. package/docs/web/glossary.md +6 -0
  57. package/docs/web/harnesses.md +28 -0
  58. package/docs/web/headless.md +9 -0
  59. package/docs/web/ingest-docs.md +9 -0
  60. package/docs/web/install-paths.md +9 -0
  61. package/docs/web/install-quick.md +35 -0
  62. package/docs/web/lsp-overview.md +37 -0
  63. package/docs/web/mcp-claude-code.md +10 -0
  64. package/docs/web/mcp-filesystem.md +9 -0
  65. package/docs/web/mcp-gitlab.md +9 -0
  66. package/docs/web/mcp-markitdown.md +9 -0
  67. package/docs/web/mcp-overview.md +34 -0
  68. package/docs/web/mcp-trouble.md +9 -0
  69. package/docs/web/mcp-verify.md +9 -0
  70. package/docs/web/memory.md +7 -0
  71. package/docs/web/migrate.md +39 -0
  72. package/docs/web/model.md +20 -0
  73. package/docs/web/notebook.md +7 -0
  74. package/docs/web/pipelines.md +24 -0
  75. package/docs/web/plugin-activity.md +23 -0
  76. package/docs/web/plugin-context.md +26 -0
  77. package/docs/web/plugin-guard.md +24 -0
  78. package/docs/web/plugin-learn.md +27 -0
  79. package/docs/web/plugin-notify.md +24 -0
  80. package/docs/web/plugin-ponytail.md +23 -0
  81. package/docs/web/plugin-setup.md +7 -0
  82. package/docs/web/plugin-workflow.md +24 -0
  83. package/docs/web/plugins.md +17 -0
  84. package/docs/web/project-context.md +9 -0
  85. package/docs/web/rules.md +8 -0
  86. package/docs/web/run-anywhere.md +9 -0
  87. package/docs/web/self-improve.md +8 -0
  88. package/docs/web/setup-choices.md +39 -0
  89. package/docs/web/skills.md +8 -0
  90. package/docs/web/themes-author.md +8 -0
  91. package/docs/web/themes.md +9 -0
  92. package/docs/web/token-footprint.md +7 -0
  93. package/docs/web/trouble.md +38 -0
  94. package/docs/web/upgrade.md +9 -0
  95. package/docs/web/verify.md +30 -0
  96. package/docs/web/wiki.md +7 -0
  97. package/docs/web/worktree.md +10 -0
  98. package/docs/web-ui.md +144 -0
  99. package/docs/wiki.md +43 -0
  100. package/geneseed +39 -0
  101. package/geneseed.cmd +34 -0
  102. package/harness.config.json +6 -0
  103. package/js/anim.mjs +326 -0
  104. package/js/catalog.mjs +112 -0
  105. package/js/checkout.mjs +88 -0
  106. package/js/cli-table.json +1034 -0
  107. package/js/cli.mjs +498 -0
  108. package/js/diff.mjs +384 -0
  109. package/js/doctor.mjs +1364 -0
  110. package/js/emit.mjs +1373 -0
  111. package/js/excludes.mjs +271 -0
  112. package/js/generate.mjs +379 -0
  113. package/js/hooks.mjs +1026 -0
  114. package/js/hosts.mjs +253 -0
  115. package/js/installs.mjs +378 -0
  116. package/js/inventory.mjs +175 -0
  117. package/js/lib/fs.mjs +913 -0
  118. package/js/lib/proc.mjs +28 -0
  119. package/js/lib/udiff.mjs +275 -0
  120. package/js/link.mjs +286 -0
  121. package/js/mcp.mjs +335 -0
  122. package/js/memory.mjs +108 -0
  123. package/js/menu.mjs +118 -0
  124. package/js/migrate.mjs +251 -0
  125. package/js/native.mjs +426 -0
  126. package/js/opencode.mjs +325 -0
  127. package/js/registry.mjs +110 -0
  128. package/js/render.mjs +282 -0
  129. package/js/settings.mjs +924 -0
  130. package/js/setup.mjs +492 -0
  131. package/js/status.mjs +281 -0
  132. package/js/themes.mjs +195 -0
  133. package/js/tui.mjs +444 -0
  134. package/js/uninstall.mjs +1137 -0
  135. package/js/update.mjs +910 -0
  136. package/js/web/actions.mjs +868 -0
  137. package/js/web/activity.mjs +284 -0
  138. package/js/web/api.mjs +873 -0
  139. package/js/web/docs.mjs +531 -0
  140. package/js/web/graph.mjs +0 -0
  141. package/js/web/jobs.mjs +480 -0
  142. package/js/web/server.mjs +1135 -0
  143. package/package.json +54 -0
  144. package/registry.json +474 -0
  145. package/src/AGENT.md.tmpl +390 -0
  146. package/src/agents/_template.md +76 -0
  147. package/src/agents/advocate.md +36 -0
  148. package/src/agents/architect.md +55 -0
  149. package/src/agents/developer.md +66 -0
  150. package/src/agents/docs.md +54 -0
  151. package/src/agents/empiricist.md +39 -0
  152. package/src/agents/explorer.md +54 -0
  153. package/src/agents/framer.md +36 -0
  154. package/src/agents/historian.md +51 -0
  155. package/src/agents/operator.md +37 -0
  156. package/src/agents/pragmatist.md +37 -0
  157. package/src/agents/reviewer.md +52 -0
  158. package/src/agents/security.md +57 -0
  159. package/src/agents/skeptic.md +48 -0
  160. package/src/agents/steward.md +36 -0
  161. package/src/agents/tester.md +54 -0
  162. package/src/agents/user-advocate.md +36 -0
  163. package/src/agents/visionary.md +36 -0
  164. package/src/laws/universal.md +387 -0
  165. package/src/memory/README.md +109 -0
  166. package/src/memory/gitignore +6 -0
  167. package/src/modes/README.md +26 -0
  168. package/src/modes/direct.md +2 -0
  169. package/src/modes/foreman.md +19 -0
  170. package/src/notebook/README.md +39 -0
  171. package/src/notebook/gitignore +7 -0
  172. package/src/postures/README.md +31 -0
  173. package/src/postures/artisan.md +14 -0
  174. package/src/postures/assistant.md +12 -0
  175. package/src/postures/expert.md +11 -0
  176. package/src/postures/mentor.md +11 -0
  177. package/src/postures/peer.md +12 -0
  178. package/src/skills/_template.md +47 -0
  179. package/src/skills/brainstorm.md +28 -0
  180. package/src/skills/clarify.md +28 -0
  181. package/src/skills/codebase-design.md +44 -0
  182. package/src/skills/commit.md +48 -0
  183. package/src/skills/council.md +34 -0
  184. package/src/skills/crash-course.md +28 -0
  185. package/src/skills/daydream/.claude-plugin/plugin.json +9 -0
  186. package/src/skills/daydream/SKILL.md +74 -0
  187. package/src/skills/daydream/VENDOR.md +25 -0
  188. package/src/skills/daydream/critic-prompt.md +38 -0
  189. package/src/skills/daydream/instructions.md +422 -0
  190. package/src/skills/daydream/synthesizer-prompt.md +41 -0
  191. package/src/skills/debug.md +37 -0
  192. package/src/skills/decode.md +28 -0
  193. package/src/skills/develop.md +29 -0
  194. package/src/skills/document-project.md +79 -0
  195. package/src/skills/domain-modeling.md +38 -0
  196. package/src/skills/drill.md +28 -0
  197. package/src/skills/feynman.md +26 -0
  198. package/src/skills/forge-mcp.md +48 -0
  199. package/src/skills/fresh-eyes.md +29 -0
  200. package/src/skills/frontend-design.md +58 -0
  201. package/src/skills/gap-detector.md +27 -0
  202. package/src/skills/geneseed-code-review.md +33 -0
  203. package/src/skills/geneseed.md +29 -0
  204. package/src/skills/git-archaeology.md +47 -0
  205. package/src/skills/git-rescue.md +68 -0
  206. package/src/skills/handoff.md +29 -0
  207. package/src/skills/herdr.md +164 -0
  208. package/src/skills/ingest.md +49 -0
  209. package/src/skills/learning-path.md +26 -0
  210. package/src/skills/migrate.md +45 -0
  211. package/src/skills/opencode-theme.md +52 -0
  212. package/src/skills/parallel-agents.md +28 -0
  213. package/src/skills/pipeline.md +52 -0
  214. package/src/skills/plan.md +45 -0
  215. package/src/skills/ponytail.md +29 -0
  216. package/src/skills/profile.md +58 -0
  217. package/src/skills/prose.md +42 -0
  218. package/src/skills/prototype.md +44 -0
  219. package/src/skills/react-view-transitions/AGENTS.md +955 -0
  220. package/src/skills/react-view-transitions/README.md +42 -0
  221. package/src/skills/react-view-transitions/SKILL.md +320 -0
  222. package/src/skills/react-view-transitions/VENDOR.md +16 -0
  223. package/src/skills/react-view-transitions/metadata.json +12 -0
  224. package/src/skills/react-view-transitions/references/css-recipes.md +242 -0
  225. package/src/skills/react-view-transitions/references/implementation.md +182 -0
  226. package/src/skills/react-view-transitions/references/nextjs.md +176 -0
  227. package/src/skills/react-view-transitions/references/patterns.md +262 -0
  228. package/src/skills/refactor.md +32 -0
  229. package/src/skills/release.md +43 -0
  230. package/src/skills/repo-map.md +36 -0
  231. package/src/skills/research.md +42 -0
  232. package/src/skills/review-response.md +36 -0
  233. package/src/skills/roast-me.md +28 -0
  234. package/src/skills/rule.md +100 -0
  235. package/src/skills/ship.md +49 -0
  236. package/src/skills/tdd.md +31 -0
  237. package/src/skills/tickets.md +44 -0
  238. package/src/skills/token-report/SKILL.md +76 -0
  239. package/src/skills/token-report/VENDOR.md +23 -0
  240. package/src/skills/token-report/scripts/token_report.mjs +1301 -0
  241. package/src/skills/wayfinder.md +49 -0
  242. package/src/skills/wiki.md +68 -0
  243. package/src/skills/workflow.md +38 -0
  244. package/themes/README.md +69 -0
  245. package/themes/_TEMPLATE.json +147 -0
  246. package/themes/biker.json +147 -0
  247. package/themes/commentator.json +147 -0
  248. package/themes/cyberpunk.json +147 -0
  249. package/themes/gamer.json +147 -0
  250. package/themes/imperial.json +147 -0
  251. package/themes/joker.json +147 -0
  252. package/themes/marvin.json +147 -0
  253. package/themes/mean.json +147 -0
  254. package/themes/military.json +147 -0
  255. package/themes/neutral.json +147 -0
  256. package/themes/opencode/README.md +58 -0
  257. package/themes/opencode/catppuccin.json +27 -0
  258. package/themes/opencode/dracula.json +27 -0
  259. package/themes/opencode/everforest.json +27 -0
  260. package/themes/opencode/gruvbox.json +27 -0
  261. package/themes/opencode/kanagawa.json +27 -0
  262. package/themes/opencode/neon.json +27 -0
  263. package/themes/opencode/nord.json +27 -0
  264. package/themes/opencode/rosepine.json +27 -0
  265. package/themes/opencode/tokyonight.json +27 -0
  266. package/themes/pirate.json +147 -0
  267. package/themes/sports.json +147 -0
  268. package/themes/verstappen.json +147 -0
  269. package/themes/wizard.json +147 -0
  270. package/web/dist/assets/About-DX1-VTBP.js +1 -0
  271. package/web/dist/assets/Activity-Dp9NaXOn.js +1 -0
  272. package/web/dist/assets/ActivityDetail-HpOBtrXS.js +1 -0
  273. package/web/dist/assets/Diff-Ct4G3Se8.js +5 -0
  274. package/web/dist/assets/Doctor-DGjkZFuA.js +1 -0
  275. package/web/dist/assets/Graph-b8o-S6kv.js +1 -0
  276. package/web/dist/assets/Harnesses-gr3-aHBF.js +1 -0
  277. package/web/dist/assets/Laws-DTwF5tTC.js +1 -0
  278. package/web/dist/assets/Library-BEZscyaQ.js +1 -0
  279. package/web/dist/assets/Markdown-k_LSLBGq.js +1 -0
  280. package/web/dist/assets/Profile-DydukTxP.js +1 -0
  281. package/web/dist/assets/Rules-CIRG4Ldt.js +1 -0
  282. package/web/dist/assets/Skills-Buoi1IBr.js +1 -0
  283. package/web/dist/assets/StatusBadge-CE_AJttl.js +1 -0
  284. package/web/dist/assets/Themes-CI4o3gJC.js +2 -0
  285. package/web/dist/assets/activity-BCW7e6Cy.js +1 -0
  286. package/web/dist/assets/audiowide-latin-400-normal-6GFCX7ni.woff2 +0 -0
  287. package/web/dist/assets/geist-mono-latin-400-normal-DKaoCDn5.woff2 +0 -0
  288. package/web/dist/assets/geist-mono-latin-500-normal-C3sF8Y1B.woff2 +0 -0
  289. package/web/dist/assets/geist-mono-latin-600-normal-BHzjB6_C.woff2 +0 -0
  290. package/web/dist/assets/hanken-grotesk-latin-400-italic-Bserb8X5.woff2 +0 -0
  291. package/web/dist/assets/hanken-grotesk-latin-400-normal-BG6hkEXj.woff2 +0 -0
  292. package/web/dist/assets/hanken-grotesk-latin-500-italic-BGfwwhek.woff2 +0 -0
  293. package/web/dist/assets/hanken-grotesk-latin-500-normal-DrDcrrxK.woff2 +0 -0
  294. package/web/dist/assets/hanken-grotesk-latin-600-normal-CIXX6EOa.woff2 +0 -0
  295. package/web/dist/assets/hanken-grotesk-latin-700-normal-CeQ8H3UY.woff2 +0 -0
  296. package/web/dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  297. package/web/dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
  298. package/web/dist/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
  299. package/web/dist/assets/ibm-plex-serif-latin-400-normal-BIGslYFI.woff2 +0 -0
  300. package/web/dist/assets/ibm-plex-serif-latin-500-normal-REKAfBVG.woff2 +0 -0
  301. package/web/dist/assets/ibm-plex-serif-latin-600-normal-DAxitmSu.woff2 +0 -0
  302. package/web/dist/assets/index-B1PIx7RM.js +1 -0
  303. package/web/dist/assets/index-BnTQUSUz.js +12 -0
  304. package/web/dist/assets/index-Ck2iCdaC.js +2 -0
  305. package/web/dist/assets/index-DUCsDv7z.css +1 -0
  306. package/web/dist/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
  307. package/web/dist/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
  308. package/web/dist/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
  309. package/web/dist/assets/inter-latin-700-normal-Yt3aPRUw.woff2 +0 -0
  310. package/web/dist/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
  311. package/web/dist/assets/jetbrains-mono-latin-500-normal-BWZEU5yA.woff2 +0 -0
  312. package/web/dist/assets/jetbrains-mono-latin-600-normal-C8RAYTDA.woff2 +0 -0
  313. package/web/dist/assets/marked.esm-JKvyS0Pp.js +46 -0
  314. package/web/dist/assets/oswald-latin-400-normal-BAFo8sP8.woff2 +0 -0
  315. package/web/dist/assets/oswald-latin-500-normal-B-eyT0Dw.woff2 +0 -0
  316. package/web/dist/assets/oswald-latin-600-normal-plFmSr5g.woff2 +0 -0
  317. package/web/dist/assets/oswald-latin-700-normal-vy6hmKSr.woff2 +0 -0
  318. package/web/dist/assets/poppins-latin-400-normal-cpxAROuN.woff2 +0 -0
  319. package/web/dist/assets/poppins-latin-500-normal-C8OXljZJ.woff2 +0 -0
  320. package/web/dist/assets/poppins-latin-600-normal-zEkxB9Mr.woff2 +0 -0
  321. package/web/dist/assets/poppins-latin-700-normal-Qrb0O0WB.woff2 +0 -0
  322. package/web/dist/assets/react-DOpiTn5v.js +32 -0
  323. package/web/dist/assets/roman-DkYN0LX9.js +1 -0
  324. package/web/dist/assets/space-grotesk-latin-400-normal-CJ-V5oYT.woff2 +0 -0
  325. package/web/dist/assets/space-grotesk-latin-500-normal-lFbtlQH6.woff2 +0 -0
  326. package/web/dist/assets/space-grotesk-latin-600-normal-DjKNqYRj.woff2 +0 -0
  327. package/web/dist/assets/space-grotesk-latin-700-normal-RjhwGPKo.woff2 +0 -0
  328. package/web/dist/assets/space-mono-latin-400-italic-YylcN9Ay.woff2 +0 -0
  329. package/web/dist/assets/space-mono-latin-400-normal-Rg4St2Dn.woff2 +0 -0
  330. package/web/dist/assets/space-mono-latin-700-italic-vNvENeTh.woff2 +0 -0
  331. package/web/dist/assets/space-mono-latin-700-normal-mWgeinG7.woff2 +0 -0
  332. package/web/dist/icon-maskable.svg +10 -0
  333. package/web/dist/icon.svg +8 -0
  334. package/web/dist/index.html +21 -0
  335. package/web/dist/manifest.webmanifest +14 -0
  336. package/web/dist/sw.js +40 -0
  337. package/web/src/pages/Laws.jsx +230 -0
@@ -0,0 +1,1301 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Generate a token usage report for the current agent session — multi-host.
4
+ *
5
+ * Supported hosts (auto-detected by most recent session activity):
6
+ *
7
+ * claude Claude Code ~/.claude/projects/<slug>/<session>.jsonl exact usage
8
+ * bob IBM Bob ~/.bob/projects/<slug>/<session>.jsonl exact usage
9
+ * (Claude-Code-shaped; $BOB_CONFIG_DIR honoured)
10
+ * opencode OpenCode ~/.local/share/opencode/opencode.db exact usage
11
+ * (SQLite, v1.2+: session/message/part tables,
12
+ * JSON `data` columns; falls back to the pre-1.2
13
+ * storage/ JSON files when no DB exists)
14
+ * copilot GitHub Copilot ~/.copilot/history-session-state/ best effort
15
+ * (schema undocumented; token fields harvested
16
+ * generically, estimates otherwise)
17
+ *
18
+ * The report distinguishes two kinds of numbers:
19
+ *
20
+ * * exact — recorded by the host per request (API usage fields)
21
+ * * estimated — attribution of content by character count (~4 chars/token)
22
+ *
23
+ * Estimates are labeled as such and never presented as exact.
24
+ *
25
+ * ---------------------------------------------------------------------------------------
26
+ * WHY THIS FILE CARRIES ITS OWN PYTHON PRIMITIVES
27
+ *
28
+ * It is the port of a Python script, and it is the one piece of that port which ships
29
+ * INSIDE a user's bundle: a rendered install contains
30
+ * `<skills>/token-report/scripts/token_report.mjs` and no `js/` directory whatsoever
31
+ * (measured — an emit snapshot's path list has 102 entries and not one begins `js/`). So
32
+ * it cannot import the generator's shared primitives module, where this repository
33
+ * settles Python-vs-JS disagreements once. The primitives below are therefore deliberate
34
+ * COPIES, each naming its origin.
35
+ *
36
+ * They are pinned by a RECORDED CORPUS: the repository's suite drives every primitive
37
+ * exported at the foot of this file over a corpus of hand-picked values whose answers
38
+ * were taken from the Python reference while it still existed, and asserts the corpus
39
+ * reaches every export. Once the reference is gone that recording IS the specification
40
+ * for these semantics — there is nothing left to ask.
41
+ */
42
+
43
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
44
+ import { EOL, homedir } from 'node:os';
45
+ import path from 'node:path';
46
+ import process from 'node:process';
47
+ import { pathToFileURL } from 'node:url';
48
+
49
+ const CHARS_PER_TOKEN = 4.0;
50
+ const USAGE_FIELDS = ['input_tokens', 'output_tokens',
51
+ 'cache_read_input_tokens', 'cache_creation_input_tokens'];
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Python primitives (copies — see the file docblock)
55
+ // ---------------------------------------------------------------------------
56
+
57
+ /** `print()` — `sys.stdout` translates `\n` to `os.linesep`; `process.stdout` does not. */
58
+ const xlate = (s) => (EOL === '\n' ? s : s.replaceAll('\n', EOL));
59
+ const pyPrint = (s) => process.stdout.write(xlate(`${s}\n`));
60
+
61
+ /** `sys.exit(msg)` — the message goes to stderr and the status is 1. */
62
+ function sysExit(msg) {
63
+ process.stderr.write(xlate(`${msg}\n`));
64
+ process.exit(1);
65
+ }
66
+
67
+ /**
68
+ * `len(str)` — Python counts CODE POINTS, `String.length` counts UTF-16 units.
69
+ *
70
+ * Every character count in this file feeds an estimate, and a transcript full of emoji
71
+ * or astral CJK would inflate every one of them by the number of surrogate pairs. The
72
+ * loop is written out rather than `[...s].length` (which is what the generator's own
73
+ * `pyLen` uses) because the strings here are whole tool results and can be megabytes:
74
+ * the spread allocates an array of one string per character, and this does not
75
+ * allocate at all.
76
+ */
77
+ function pyLen(s) {
78
+ let n = s.length;
79
+ for (let i = 0; i < s.length; i += 1) {
80
+ const c = s.charCodeAt(i);
81
+ if (c >= 0xd800 && c < 0xdc00 && (s.charCodeAt(i + 1) & 0xfc00) === 0xdc00) {
82
+ n -= 1;
83
+ i += 1;
84
+ }
85
+ }
86
+ return n;
87
+ }
88
+
89
+ /**
90
+ * `round()` — Python rounds HALF TO EVEN and `Math.round` rounds half UP.
91
+ *
92
+ * Copy of the generator's `pyRound`. This is not an exotic corner here: `est_tokens` is
93
+ * `round(chars / 4.0)`, so EVERY character count congruent to 2 mod 4 lands exactly on
94
+ * `.5` — a quarter of all possible inputs. `Math.round` would move a quarter of the rows
95
+ * in every report.
96
+ */
97
+ function pyRound(x) {
98
+ const f = Math.floor(x);
99
+ const d = x - f;
100
+ if (d > 0.5) return f + 1;
101
+ if (d < 0.5) return f;
102
+ return f % 2 === 0 ? f : f + 1;
103
+ }
104
+
105
+ /**
106
+ * `f"{x:.Nf}"` — the same half-to-even rule, in the FORMATTER rather than in `round()`.
107
+ *
108
+ * `Number.prototype.toFixed` is specified to break ties toward the LARGER n, so
109
+ * `(0.5).toFixed(0)` is "1" where `f"{0.5:.0f}"` is "0", and `(6.25).toFixed(1)` is
110
+ * "6.3" where `f"{6.25:.1f}"` is "6.2".
111
+ *
112
+ * THE FIRST DRAFT OF THIS FILE USED `toFixed(1)` FOR `pct`, ON A WRITTEN PROOF THAT NO
113
+ * TIE COULD EXIST: a tie needs a double exactly equal to (2k+1)/20, and 20 is not a
114
+ * power of two. The proof is WRONG — the fraction reduces whenever (2k+1) is a multiple
115
+ * of five, so every odd multiple of 0.25 is an exact tie and every one of them is
116
+ * dyadic. The parity suite against the reference produced 6.25 from a four-character row
117
+ * in a sixty-four-character total on the first run that reached it. The argument was
118
+ * confident, self-consistent and false; the corpus was neither.
119
+ *
120
+ * AND THE SECOND DRAFT WAS WRONG TOO, in the other direction: it scaled by `10 **
121
+ * digits` and reused `pyRound`. Scaling is not information-preserving. 5100/200000 is
122
+ * 2.55%, which is NOT dyadic, so the double sits a hair BELOW the tie and Python
123
+ * correctly answers "2.5" — but multiplying that double by ten lands exactly on the
124
+ * midpoint between 25.5 and its neighbour, rounds to 25.5, and manufactures a tie that
125
+ * the real value never had. Answer: "2.6". The corpus caught this one as well.
126
+ *
127
+ * So the rounding is done on the EXACT DECIMAL. `toFixed` is specified to pick the n
128
+ * minimising |n/10^f - x| — correct rounding of the double's true value — and the kept
129
+ * digits are then rounded half-to-even as DECIMAL text, where a tie is a literal "5
130
+ * followed by nothing".
131
+ *
132
+ * TWENTY places, not `digits + 15`. The third draft used the latter and the primitive
133
+ * corpus refuted it on its first run: 0.05, 0.15 and 0.35 are all NON-ties (their
134
+ * doubles miss the midpoint at the 17th decimal), but sixteen places cannot see that
135
+ * far, so each looked like an exact tie and two of the three rounded the wrong way. A
136
+ * double of magnitude >= 0.01 has an ULP no smaller than ~1.7e-18, so twenty places
137
+ * resolve every value this script can form, and anything smaller rounds to zero at one
138
+ * decimal regardless.
139
+ */
140
+ function pyFixed(x, digits) {
141
+ const neg = x < 0 || Object.is(x, -0);
142
+ // `toFixed` switches to exponential notation at 1e21 and the digit surgery below
143
+ // needs positional text. Nothing here reaches it; the guard is so that it degrades
144
+ // rather than returns nonsense if something ever does.
145
+ if (!Number.isFinite(x) || Math.abs(x) >= 1e21) return Math.abs(x).toFixed(digits);
146
+ const exact = Math.abs(x).toFixed(20);
147
+ const dot = exact.indexOf('.');
148
+ const frac = exact.slice(dot + 1);
149
+ const rest = frac.slice(digits);
150
+ let n = BigInt(exact.slice(0, dot) + frac.slice(0, digits));
151
+ const first = rest.charCodeAt(0) - 48;
152
+ const restNonZero = /[1-9]/.test(rest.slice(1));
153
+ if (first > 5 || (first === 5 && restNonZero)) n += 1n;
154
+ else if (first === 5 && !restNonZero && n % 2n === 1n) n += 1n; // half to EVEN
155
+ const s = n.toString().padStart(digits + 1, '0');
156
+ const body = digits === 0 ? s : `${s.slice(0, -digits)}.${s.slice(-digits)}`;
157
+ return (neg ? '-' : '') + body;
158
+ }
159
+
160
+ /** `f"{n:,}"` — round half to even, then group the integer part in threes. */
161
+ function fmt(n) {
162
+ return String(pyRound(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
163
+ }
164
+
165
+ /** `json.dumps`'s `ensure_ascii=True`: everything from U+007F up, per UTF-16 unit. */
166
+ function escapeNonAscii(text) {
167
+ // Built from escapes, never literals: the low end of this range is U+007F (DEL), and
168
+ // a raw control character sitting in a shipped source file is invisible in review and
169
+ // the first thing a well-meaning sanitiser strips.
170
+ return text.replace(new RegExp('[\\u007f-\\uffff]', 'g'),
171
+ (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
172
+ }
173
+
174
+ /** `repr(float)` — the generator's `pyFloat`, for the one branch `jsonDumps` can reach. */
175
+ function pyFloat(n) {
176
+ if (Number.isNaN(n)) return 'NaN';
177
+ if (!Number.isFinite(n)) return n > 0 ? 'Infinity' : '-Infinity';
178
+ const a = Math.abs(n);
179
+ if (a !== 0 && (a >= 1e16 || a < 1e-4)) {
180
+ const [mantissa, exp] = n.toExponential().split('e');
181
+ return `${mantissa}e${exp[0] === '-' ? '-' : '+'}${exp.slice(1).padStart(2, '0')}`;
182
+ }
183
+ const s = Object.is(n, -0) ? '-0' : String(n);
184
+ return s.includes('.') || s.includes('e') ? s : `${s}.0`;
185
+ }
186
+
187
+ /**
188
+ * `json.dumps(obj)` — the COMPACT form, which is the only one this script uses.
189
+ *
190
+ * Copy of the generator's `jsonDumpsCompact` walk, narrowed to values that came out of
191
+ * `JSON.parse`. `JSON.stringify` cannot stand in: without an indent Python's separators
192
+ * are `(', ', ': ')`, so `{"a": 1, "b": [1, 2]}` where `JSON.stringify` writes
193
+ * `{"a":1,"b":[1,2]}` — SIX characters fewer on that tiny object, and every use of this
194
+ * function here is `len(json.dumps(x))` feeding a token estimate. Patching separators
195
+ * back into serialised text would corrupt any string containing `,` or `:`.
196
+ *
197
+ * DECLARED RESIDUAL — an INTEGRAL FLOAT. `json.loads` keeps `1.0` a Python float and
198
+ * `json.dumps` writes it back as `1.0`; `JSON.parse` yields the double `1` and nothing
199
+ * downstream can tell it from an int, so this writes `1`. A tool input carrying `{"a":
200
+ * 1.0}` is therefore counted 2 characters shorter here than by the reference. Closing it
201
+ * needs a JSON parser that preserves the int/float distinction — a parser inside a
202
+ * shipped skill script, to move a character count that then gets divided by four. It is
203
+ * measured instead: the recorded primitive corpus keeps this class of input among its
204
+ * declared KNOWN LIMITS and asserts the two implementations still differ there, so the
205
+ * residual cannot be forgotten and cannot be closed without the check noticing.
206
+ */
207
+ function jsonDumps(value) {
208
+ return escapeNonAscii(dumpsImpl(value));
209
+ }
210
+
211
+ function dumpsImpl(v) {
212
+ if (v === null || v === undefined) return 'null';
213
+ if (v === true) return 'true';
214
+ if (v === false) return 'false';
215
+ if (typeof v === 'string') return JSON.stringify(v);
216
+ if (typeof v === 'number') return Number.isInteger(v) ? String(v) : pyFloat(v);
217
+ if (Array.isArray(v)) return `[${v.map(dumpsImpl).join(', ')}]`;
218
+ if (typeof v === 'object') {
219
+ return `{${Object.entries(v).map(([k, x]) => `${JSON.stringify(k)}: ${dumpsImpl(x)}`)
220
+ .join(', ')}}`;
221
+ }
222
+ return 'null';
223
+ }
224
+
225
+ /** `repr(value)` for a `json.loads` value — the generator's `pyReprImpl(v, false)`. */
226
+ function pyRepr(v) {
227
+ if (typeof v === 'string') {
228
+ let body = '';
229
+ for (const ch of v) {
230
+ const cp = ch.codePointAt(0);
231
+ if (ch === '\\') body += '\\\\';
232
+ else if (ch === '\n') body += '\\n';
233
+ else if (ch === '\r') body += '\\r';
234
+ else if (ch === '\t') body += '\\t';
235
+ else if (cp < 0x20 || cp === 0x7f) body += `\\x${cp.toString(16).padStart(2, '0')}`;
236
+ else body += ch;
237
+ }
238
+ return body.includes("'") && !body.includes('"')
239
+ ? `"${body}"`
240
+ : `'${body.replaceAll("'", "\\'")}'`;
241
+ }
242
+ if (v === null || v === undefined) return 'None';
243
+ if (v === true) return 'True';
244
+ if (v === false) return 'False';
245
+ if (typeof v === 'number') return Number.isInteger(v) ? String(v) : pyFloat(v);
246
+ if (Array.isArray(v)) return `[${v.map(pyRepr).join(', ')}]`;
247
+ if (typeof v === 'object') {
248
+ return `{${Object.entries(v).map(([k, x]) => `${pyRepr(k)}: ${pyRepr(x)}`).join(', ')}}`;
249
+ }
250
+ return String(v);
251
+ }
252
+
253
+ /** `str(value)` — `repr` for everything except a string, which is itself. */
254
+ const pyStrOfJson = (v) => (typeof v === 'string' ? v : pyRepr(v));
255
+
256
+ /** `int(s)` — the generator's `pyInt`; accepts a sign, `_` between digits, any Nd digit. */
257
+ function pyInt(s) {
258
+ if (typeof s !== 'string') return null;
259
+ const trimmed = s.trim();
260
+ if (trimmed === '') return null;
261
+ let body = trimmed;
262
+ let sign = 1;
263
+ if (body[0] === '+' || body[0] === '-') {
264
+ if (body[0] === '-') sign = -1;
265
+ body = body.slice(1);
266
+ }
267
+ const chars = [...body];
268
+ if (chars.length === 0) return null;
269
+ let value = 0n;
270
+ let prevWasDigit = false;
271
+ for (const ch of chars) {
272
+ if (ch === '_') {
273
+ if (!prevWasDigit) return null;
274
+ prevWasDigit = false;
275
+ continue;
276
+ }
277
+ if (!/^\p{Nd}$/u.test(ch)) return null;
278
+ const cp = ch.codePointAt(0);
279
+ let zero = cp;
280
+ while (zero > 0 && /^\p{Nd}$/u.test(String.fromCodePoint(zero - 1))) zero -= 1;
281
+ value = value * 10n + BigInt(cp - zero);
282
+ prevWasDigit = true;
283
+ }
284
+ if (!prevWasDigit) return null;
285
+ return Number(sign === -1 ? -value : value);
286
+ }
287
+
288
+ /** `sorted()` on strings — Python compares CODE POINTS, JS `<` compares UTF-16 units. */
289
+ function pyStrCmp(a, b) {
290
+ const A = [...a];
291
+ const B = [...b];
292
+ for (let i = 0; i < Math.min(A.length, B.length); i += 1) {
293
+ const x = A[i].codePointAt(0);
294
+ const y = B[i].codePointAt(0);
295
+ if (x !== y) return x < y ? -1 : 1;
296
+ }
297
+ return A.length - B.length;
298
+ }
299
+
300
+ /** `sorted()` on Path objects — the generator's `comparePaths`: COMPONENTS, not text. */
301
+ function comparePaths(a, b) {
302
+ const norm = (s) => (process.platform === 'win32' ? s.toLowerCase() : s);
303
+ const A = norm(a).split(/[\\/]/);
304
+ const B = norm(b).split(/[\\/]/);
305
+ for (let i = 0; i < Math.min(A.length, B.length); i += 1) {
306
+ const c = pyStrCmp(A[i], B[i]);
307
+ if (c !== 0) return c;
308
+ }
309
+ return A.length - B.length;
310
+ }
311
+
312
+ // --- small filesystem helpers (`pathlib` shapes this file actually uses) ---
313
+
314
+ const isDict = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
315
+ /** `d.get(k, default)` — a present-but-null value is the VALUE, not the default. */
316
+ const getOr = (o, k, d) => (isDict(o) && Object.hasOwn(o, k) ? o[k] : d);
317
+
318
+ function isDir(p) {
319
+ try { return statSync(p).isDirectory(); } catch { return false; }
320
+ }
321
+
322
+ function isFile(p) {
323
+ try { return statSync(p).isFile(); } catch { return false; }
324
+ }
325
+
326
+ function mtime(p) {
327
+ try { return statSync(p).mtimeMs / 1000; } catch { return 0; }
328
+ }
329
+
330
+ function listDir(p) {
331
+ try { return readdirSync(p).map((n) => path.join(p, n)); } catch { return []; }
332
+ }
333
+
334
+ /** Every file at any depth under `root` whose basename matches `pred`. */
335
+ function walkFiles(root, pred, out = []) {
336
+ let entries;
337
+ try { entries = readdirSync(root, { withFileTypes: true }); } catch { return out; }
338
+ for (const e of entries) {
339
+ const full = path.join(root, e.name);
340
+ if (e.isDirectory()) walkFiles(full, pred, out);
341
+ else if (pred(e.name)) out.push(full);
342
+ }
343
+ return out;
344
+ }
345
+
346
+ /** Every DIRECTORY at any depth under `root` named `name` — `Path.rglob("<name>")`. */
347
+ function walkDirsNamed(root, name, out = []) {
348
+ let entries;
349
+ try { entries = readdirSync(root, { withFileTypes: true }); } catch { return out; }
350
+ for (const e of entries) {
351
+ if (!e.isDirectory()) continue;
352
+ const full = path.join(root, e.name);
353
+ if (e.name === name) out.push(full);
354
+ walkDirsNamed(full, name, out);
355
+ }
356
+ return out;
357
+ }
358
+
359
+ function readTextMaybe(p) {
360
+ try { return readFileSync(p, 'utf8'); } catch { return null; }
361
+ }
362
+
363
+ function loadJsonMaybe(text) {
364
+ if (text === null) return undefined;
365
+ try { return JSON.parse(text); } catch { return undefined; }
366
+ }
367
+
368
+ // ---------------------------------------------------------------------------
369
+ // Common helpers
370
+ // ---------------------------------------------------------------------------
371
+
372
+ /** Total characters of text in a message content field (str or block list). */
373
+ function textLen(content) {
374
+ if (content === null || content === undefined) return 0;
375
+ if (typeof content === 'string') return pyLen(content);
376
+ if (Array.isArray(content)) {
377
+ let total = 0;
378
+ for (const block of content) {
379
+ if (!isDict(block)) {
380
+ total += pyLen(pyStrOfJson(block));
381
+ continue;
382
+ }
383
+ const btype = getOr(block, 'type', undefined);
384
+ if (btype === 'text') total += pyLen(pyStrOfJson(getOr(block, 'text', '')));
385
+ else if (btype === 'thinking') {
386
+ total += pyLen(pyStrOfJson(getOr(block, 'thinking', '')));
387
+ } else if (btype === 'tool_use') total += jsonDumps(getOr(block, 'input', {})).length;
388
+ else if (btype === 'tool_result') total += textLen(getOr(block, 'content', undefined));
389
+ else total += jsonDumps(block).length;
390
+ }
391
+ return total;
392
+ }
393
+ return pyLen(pyStrOfJson(content));
394
+ }
395
+
396
+ const estTokens = (chars) => pyRound(chars / CHARS_PER_TOKEN);
397
+
398
+ const pct = (part, whole) => (whole ? `${pyFixed(100 * part / whole, 1)}%` : '–');
399
+
400
+ function bar(part, whole, width = 10) {
401
+ const filled = whole ? pyRound(width * part / whole) : 0;
402
+ return '█'.repeat(Math.min(filled, width)) + '░'.repeat(Math.max(width - filled, 0));
403
+ }
404
+
405
+ /** Input-side tokens of one request = context size at that moment. */
406
+ const contextSize = (usage) => (getOr(usage, 'input_tokens', 0) || 0)
407
+ + (getOr(usage, 'cache_read_input_tokens', 0) || 0)
408
+ + (getOr(usage, 'cache_creation_input_tokens', 0) || 0);
409
+
410
+ function newest(paths) {
411
+ const live = paths.filter((p) => existsSync(p));
412
+ if (live.length === 0) return null;
413
+ // `max()` keeps the FIRST maximum; `reduce` with `>` does the same.
414
+ return live.reduce((best, p) => (mtime(p) > mtime(best) ? p : best));
415
+ }
416
+
417
+ /** `defaultdict` — a Map that reads missing keys as 0 and keeps insertion order. */
418
+ class Counter {
419
+ constructor() { this.m = new Map(); }
420
+
421
+ get(k) { return this.m.get(k) ?? 0; }
422
+
423
+ add(k, n) { this.m.set(k, this.get(k) + n); }
424
+
425
+ set(k, n) { this.m.set(k, n); }
426
+
427
+ entries() { return [...this.m.entries()]; }
428
+
429
+ get size() { return this.m.size; }
430
+ }
431
+
432
+ /** Host-independent intermediate model the renderer consumes. */
433
+ class SessionData {
434
+ constructor(host, label) {
435
+ this.host = host;
436
+ this.label = label; // transcript file / session id
437
+ this.main = new Counter(); // aggregated exact usage, main thread
438
+ this.side = new Counter(); // aggregated exact usage, subagents
439
+ this.models = new Counter(); // output tokens per model
440
+ this.requestsMain = 0;
441
+ this.requestsSide = 0;
442
+ this.firstContext = null; // exact context size, first request
443
+ this.lastContext = null; // exact context size, last request
444
+ this.peakContext = 0;
445
+ this.cats = new Counter(); // estimated chars per category
446
+ this.top = []; // [chars, label] heaviest single items
447
+ this.firstUserChars = null;
448
+ this.duration = null; // seconds
449
+ this.notes = []; // host-specific caveats for the report
450
+ }
451
+
452
+ get hasExact() { return this.requestsMain > 0 || this.requestsSide > 0; }
453
+
454
+ addUsage(usage, model = null, side = false) {
455
+ const bucket = side ? this.side : this.main;
456
+ for (const f of USAGE_FIELDS) bucket.add(f, getOr(usage, f, 0) || 0);
457
+ if (side) {
458
+ this.requestsSide += 1;
459
+ } else {
460
+ this.requestsMain += 1;
461
+ const size = contextSize(usage);
462
+ if (this.firstContext === null) this.firstContext = size;
463
+ this.lastContext = size;
464
+ this.peakContext = Math.max(this.peakContext, size);
465
+ }
466
+ if (model) this.models.add(model, getOr(usage, 'output_tokens', 0) || 0);
467
+ }
468
+
469
+ span(firstTs, lastTs) {
470
+ if (!firstTs || !lastTs || firstTs === lastTs) return;
471
+ const parse = (t) => {
472
+ if (typeof t === 'number') { // epoch millis or seconds
473
+ return t > 1e12 ? t : t * 1000;
474
+ }
475
+ const ms = Date.parse(String(t).replace('Z', '+00:00'));
476
+ return Number.isNaN(ms) ? null : ms;
477
+ };
478
+ const a = parse(firstTs);
479
+ const b = parse(lastTs);
480
+ // `except (ValueError, OSError, OverflowError): pass` — an unparseable stamp
481
+ // leaves `duration` None and the header simply omits it.
482
+ if (a === null || b === null) return;
483
+ this.duration = (b - a) / 1000;
484
+ }
485
+ }
486
+
487
+ // ---------------------------------------------------------------------------
488
+ // Host: Claude Code / IBM Bob (same transcript shape, different config root)
489
+ // ---------------------------------------------------------------------------
490
+
491
+ const slugify = (p) => p.replace(/[^A-Za-z0-9]/g, '-');
492
+
493
+ function claudeShapedRoot(host) {
494
+ const env = { claude: 'CLAUDE_CONFIG_DIR', bob: 'BOB_CONFIG_DIR' }[host];
495
+ const root = process.env[env] || path.join(homedir(), `.${host}`);
496
+ return isDir(path.join(root, 'projects')) ? root : null;
497
+ }
498
+
499
+ function claudeShapedFind(host, explicit = null) {
500
+ if (explicit) return explicit;
501
+ const root = claudeShapedRoot(host);
502
+ if (!root) return null;
503
+ const projects = path.join(root, 'projects');
504
+ const candidates = [];
505
+ const exact = path.join(projects, slugify(process.cwd()));
506
+ const dirs = isDir(exact) ? [exact] : listDir(projects)
507
+ .filter(isDir)
508
+ .sort((a, b) => mtime(b) - mtime(a));
509
+ for (const d of dirs) {
510
+ candidates.push(...walkTop(d, (n) => n.endsWith('.jsonl')));
511
+ if (candidates.length) break;
512
+ }
513
+ return newest(candidates);
514
+ }
515
+
516
+ /** `Path.glob("*.jsonl")` — one level, unlike `rglob`. */
517
+ function walkTop(dir, pred) {
518
+ return listDir(dir).filter((p) => pred(path.basename(p)) && isFile(p));
519
+ }
520
+
521
+ function claudeShapedParse(host, file) {
522
+ const data = new SessionData(host, path.basename(file));
523
+ const toolNameById = new Map();
524
+ let firstTs = null;
525
+ let lastTs = null;
526
+
527
+ const text = readTextMaybe(file);
528
+ if (text === null) sysExit(`error: cannot read ${file}`);
529
+
530
+ for (const rawLine of text.split('\n')) {
531
+ const line = rawLine.trim();
532
+ if (!line) continue;
533
+ const rec = loadJsonMaybe(line);
534
+ if (rec === undefined) continue;
535
+ const rtype = getOr(rec, 'type', undefined);
536
+ const side = Boolean(getOr(rec, 'isSidechain', false));
537
+ const ts = getOr(rec, 'timestamp', undefined);
538
+ if (ts) {
539
+ firstTs = firstTs || ts;
540
+ lastTs = ts;
541
+ }
542
+
543
+ if (rtype === 'assistant') {
544
+ const msg = getOr(rec, 'message', {});
545
+ const usage = getOr(msg, 'usage', null) || {};
546
+ // Python's `if usage:` — an empty dict is falsy.
547
+ if (isDict(usage) && Object.keys(usage).length) {
548
+ data.addUsage(usage, getOr(msg, 'model', undefined), side);
549
+ }
550
+ if (side) continue; // sidechain content never enters this context
551
+ for (const block of getOr(msg, 'content', null) || []) {
552
+ if (!isDict(block)) continue;
553
+ const btype = getOr(block, 'type', undefined);
554
+ if (btype === 'text') {
555
+ data.cats.add('Assistant replies', pyLen(pyStrOfJson(getOr(block, 'text', ''))));
556
+ } else if (btype === 'thinking') {
557
+ data.cats.add('Assistant thinking',
558
+ pyLen(pyStrOfJson(getOr(block, 'thinking', ''))));
559
+ } else if (btype === 'tool_use') {
560
+ // `pyStrOfJson`, not bare interpolation: an f-string calls `str()`, and a
561
+ // `"name": null` in the record is `None` to Python and `null` to JS. The
562
+ // RAW value is what goes in the id map, because Python stores it raw and
563
+ // calls `str()` again at each use.
564
+ const name = getOr(block, 'name', 'unknown');
565
+ toolNameById.set(getOr(block, 'id', undefined), name);
566
+ data.cats.add(`Tool call · ${pyStrOfJson(name)}`,
567
+ jsonDumps(getOr(block, 'input', {})).length);
568
+ }
569
+ }
570
+ } else if (rtype === 'user' && !side) {
571
+ // isMeta marks injected content (loaded skill bodies, slash-command
572
+ // expansions) arriving as a user turn the user didn't type.
573
+ const userCat = getOr(rec, 'isMeta', false)
574
+ ? 'Injected skill/command content' : 'User messages';
575
+ const content = getOr(getOr(rec, 'message', {}), 'content', undefined);
576
+ const blocks = Array.isArray(content) ? content : [content];
577
+ for (const block of blocks) {
578
+ if (isDict(block) && getOr(block, 'type', undefined) === 'tool_result') {
579
+ // `.get(id, "unknown")` — `has`, not `??`: a name STORED as null is None,
580
+ // and only an ABSENT id falls back to "unknown".
581
+ const id = getOr(block, 'tool_use_id', undefined);
582
+ const name = toolNameById.has(id) ? toolNameById.get(id) : 'unknown';
583
+ const chars = textLen(getOr(block, 'content', undefined));
584
+ data.cats.add(`Tool result · ${pyStrOfJson(name)}`, chars);
585
+ data.top.push([chars, `${pyStrOfJson(name)} result`]);
586
+ } else {
587
+ const chars = textLen(typeof block === 'string' ? block : [block]);
588
+ data.cats.add(userCat, chars);
589
+ if (userCat === 'User messages' && data.firstUserChars === null) {
590
+ data.firstUserChars = chars;
591
+ }
592
+ }
593
+ }
594
+ } else if (rtype === 'attachment' && !side) {
595
+ data.cats.add('System attachments (reminders, listings)',
596
+ jsonDumps(getOr(rec, 'attachment', {})).length);
597
+ }
598
+ }
599
+
600
+ data.span(firstTs, lastTs);
601
+ return data;
602
+ }
603
+
604
+ // ---------------------------------------------------------------------------
605
+ // Host: OpenCode
606
+ // ---------------------------------------------------------------------------
607
+
608
+ /**
609
+ * Set when `node:sqlite` is present but unusable, or absent. See `loadSqlite`.
610
+ *
611
+ * THE ONE DELIBERATE DIVERGENCE FROM THE REFERENCE, and it is a degradation rather than
612
+ * a behaviour change: Python's `sqlite3` is always there, `node:sqlite` is not. The
613
+ * decision taken for this port is a guarded import plus a declared note — NOT an
614
+ * `engines` bump, because the agent runs this file as a bare `node <skill-dir>/scripts/…`
615
+ * per SKILL.md, so `package.json`'s `engines` gates `npm i -g geneseed` and nothing at
616
+ * all about this script.
617
+ */
618
+ let SQLITE_NOTE = null;
619
+
620
+ async function loadSqlite() {
621
+ try {
622
+ const mod = await import('node:sqlite');
623
+ if (typeof mod.DatabaseSync !== 'function') throw new Error('no DatabaseSync');
624
+ return mod.DatabaseSync;
625
+ } catch {
626
+ SQLITE_NOTE = 'OpenCode v1.2+ keeps its sessions in SQLite and this Node runtime '
627
+ + 'cannot open one: `node:sqlite` is unavailable. It needs Node 22.5+ started with '
628
+ + '--experimental-sqlite, or Node 22.13+, where it is on by default.';
629
+ return null;
630
+ }
631
+ }
632
+
633
+ function opencodeDataDir() {
634
+ const xdg = process.env.XDG_DATA_HOME;
635
+ const candidates = (xdg ? [path.join(xdg, 'opencode')] : [])
636
+ .concat([path.join(homedir(), '.local', 'share', 'opencode'),
637
+ path.join(homedir(), '.opencode')]);
638
+ return candidates.find(isDir) ?? null;
639
+ }
640
+
641
+ function opencodeRoot() {
642
+ const d = opencodeDataDir();
643
+ return d && isDir(path.join(d, 'storage')) ? path.join(d, 'storage') : null;
644
+ }
645
+
646
+ /**
647
+ * Locate the current OpenCode session.
648
+ *
649
+ * v1.2+ stores everything in SQLite (<data-dir>/opencode.db) — prefer that.
650
+ * Older installs keep JSON files under storage/; fall back to walking them.
651
+ * Returns ["db", [dbPath, sessionIdOrNull]] or ["files", [sid, files]] — and, where the
652
+ * reference cannot go, ["nosqlite", [dbPath]] when the DB is the only source and this
653
+ * runtime cannot read it.
654
+ */
655
+ async function opencodeFind(explicit = null) {
656
+ const d = opencodeDataDir();
657
+ if (d) {
658
+ const db = path.join(d, 'opencode.db');
659
+ if (isFile(db)) {
660
+ const Database = await loadSqlite();
661
+ if (Database) return ['db', [db, explicit]];
662
+ const fallback = opencodeFindFiles(explicit);
663
+ if (fallback) {
664
+ SQLITE_NOTE += ' Falling back to the pre-v1.2 storage/ JSON files, which are '
665
+ + 'older than the SQLite store and may be missing this session entirely.';
666
+ return fallback;
667
+ }
668
+ return ['nosqlite', [db]];
669
+ }
670
+ }
671
+ return opencodeFindFiles(explicit);
672
+ }
673
+
674
+ /**
675
+ * Pre-SQLite fallback: [session_id, [message files]] for the most recently active
676
+ * session. Message files live under a `message/<sessionID>/` folder somewhere below
677
+ * storage/ (the nesting has moved between versions, so walk).
678
+ */
679
+ function opencodeFindFiles(explicit = null) {
680
+ const storage = opencodeRoot();
681
+ if (!storage) return null;
682
+ const sessions = new Map();
683
+ for (const msgdir of walkDirsNamed(storage, 'message')) {
684
+ for (const sdir of listDir(msgdir)) {
685
+ if (!isDir(sdir)) continue;
686
+ const files = walkTop(sdir, (n) => n.endsWith('.json'));
687
+ if (files.length) {
688
+ sessions.set(path.basename(sdir),
689
+ (sessions.get(path.basename(sdir)) ?? []).concat(files));
690
+ }
691
+ }
692
+ }
693
+ if (sessions.size === 0) return null;
694
+ if (explicit && sessions.has(explicit)) return ['files', [explicit, sessions.get(explicit)]];
695
+ let sid = null;
696
+ let best = -Infinity;
697
+ for (const [s, files] of sessions) {
698
+ const m = Math.max(...files.map(mtime));
699
+ if (m > best) { best = m; sid = s; } // `max()` keeps the first maximum
700
+ }
701
+ return ['files', [sid, sessions.get(sid)]];
702
+ }
703
+
704
+ /** Map OpenCode's tokens shape onto the common usage fields. */
705
+ function ocUsageFromTokens(tok) {
706
+ const cache = getOr(tok, 'cache', null) || {};
707
+ return {
708
+ input_tokens: getOr(tok, 'input', 0),
709
+ output_tokens: (getOr(tok, 'output', 0) || 0) + (getOr(tok, 'reasoning', 0) || 0),
710
+ cache_read_input_tokens: getOr(cache, 'read', 0),
711
+ cache_creation_input_tokens: getOr(cache, 'write', 0),
712
+ };
713
+ }
714
+
715
+ /**
716
+ * Fold one OpenCode message record (the MessageV2 Info shape — identical in the old JSON
717
+ * files and the SQLite `message.data` column) into the model.
718
+ * Returns [message_id, role, had_tokens].
719
+ */
720
+ function ocTakeMessage(data, msg, side = false) {
721
+ const role = getOr(msg, 'role', undefined);
722
+ const tok = getOr(msg, 'tokens', null) || {};
723
+ const hasTok = isDict(tok) && Object.keys(tok).length > 0;
724
+ if (role === 'assistant' && hasTok) {
725
+ data.addUsage(ocUsageFromTokens(tok), getOr(msg, 'modelID', undefined), side);
726
+ }
727
+ return [getOr(msg, 'id', undefined), role, role === 'assistant' && hasTok];
728
+ }
729
+
730
+ /**
731
+ * Categorise one OpenCode content part (same shape in files and DB).
732
+ * Returns the part's `tokens` dict for step-finish parts, else null.
733
+ */
734
+ function ocTakePart(data, role, part) {
735
+ const ptype = getOr(part, 'type', undefined);
736
+ if (ptype === 'text') {
737
+ const cat = role === 'assistant' ? 'Assistant replies' : 'User messages';
738
+ const chars = pyLen(pyStrOfJson(getOr(part, 'text', '')));
739
+ data.cats.add(cat, chars);
740
+ if (cat === 'User messages' && data.firstUserChars === null) {
741
+ data.firstUserChars = chars;
742
+ }
743
+ } else if (ptype === 'reasoning') {
744
+ data.cats.add('Assistant thinking', pyLen(pyStrOfJson(getOr(part, 'text', ''))));
745
+ } else if (ptype === 'tool') {
746
+ const name = pyStrOfJson(getOr(part, 'tool', 'unknown'));
747
+ const state = getOr(part, 'state', null) || {};
748
+ data.cats.add(`Tool call · ${name}`, jsonDumps(getOr(state, 'input', {})).length);
749
+ const outChars = textLen(getOr(state, 'output', undefined));
750
+ data.cats.add(`Tool result · ${name}`, outChars);
751
+ data.top.push([outChars, `${name} result`]);
752
+ } else if (ptype === 'step-finish') {
753
+ return getOr(part, 'tokens', undefined);
754
+ }
755
+ return null;
756
+ }
757
+
758
+ /**
759
+ * Parse OpenCode's SQLite store (v1.2+): `session`, `message`, `part` tables with the
760
+ * structured payloads in JSON `data` columns. Read-only; defensive about columns, since
761
+ * the schema is young and may evolve.
762
+ */
763
+ async function opencodeDbParse(dbPath, explicit = null) {
764
+ const Database = await loadSqlite();
765
+ if (!Database) return nosqliteData(dbPath);
766
+ let con;
767
+ try {
768
+ con = new Database(dbPath, { readOnly: true });
769
+ } catch {
770
+ con = new Database(dbPath);
771
+ }
772
+ try {
773
+ const rows = (sql, args = []) => {
774
+ try { return con.prepare(sql).all(...args); } catch { return []; }
775
+ };
776
+
777
+ let sid = explicit;
778
+ if (!sid) {
779
+ // Prefer top-level sessions (parent_id NULL = not a subagent child),
780
+ // in the current project dir when recorded, newest activity first.
781
+ const cwd = process.cwd();
782
+ let cands = rows('SELECT id, directory, time_updated FROM session '
783
+ + 'WHERE parent_id IS NULL ORDER BY time_updated DESC');
784
+ if (!cands.length) {
785
+ cands = rows('SELECT id, NULL AS directory, time_updated FROM session '
786
+ + 'ORDER BY time_updated DESC');
787
+ }
788
+ if (!cands.length) sysExit('error: opencode.db contains no sessions');
789
+ sid = (cands.find((r) => r.directory === cwd) ?? cands[0]).id;
790
+ }
791
+
792
+ const data = new SessionData('opencode', sid);
793
+ let firstTs = null;
794
+ let lastTs = null;
795
+ const roleByMsg = new Map();
796
+ const untokened = new Map(); // assistant messages with no tokens in message.data
797
+
798
+ for (const r of rows('SELECT id, time_created, data FROM message '
799
+ + 'WHERE session_id=? ORDER BY time_created, id', [sid])) {
800
+ const msg = typeof r.data === 'string' ? loadJsonMaybe(r.data) : (r.data || {});
801
+ if (msg === undefined) continue;
802
+ if (r.time_created) {
803
+ firstTs = firstTs || r.time_created;
804
+ lastTs = r.time_created;
805
+ }
806
+ const [, role, had] = ocTakeMessage(data, msg);
807
+ roleByMsg.set(r.id, role);
808
+ if (role === 'assistant' && !had) untokened.set(r.id, getOr(msg, 'modelID', undefined));
809
+ }
810
+
811
+ const stepTokens = new Map(); // message_id -> [tokens dicts]
812
+ for (const r of rows('SELECT p.message_id, p.data FROM part p '
813
+ + 'JOIN message m ON p.message_id = m.id '
814
+ + 'WHERE m.session_id=? ORDER BY p.time_created, p.id', [sid])) {
815
+ const part = typeof r.data === 'string' ? loadJsonMaybe(r.data) : (r.data || {});
816
+ if (part === undefined) continue;
817
+ const tok = ocTakePart(data, roleByMsg.get(r.message_id), part);
818
+ if (tok) {
819
+ if (!stepTokens.has(r.message_id)) stepTokens.set(r.message_id, []);
820
+ stepTokens.get(r.message_id).push(tok);
821
+ }
822
+ }
823
+ // step-finish parts also carry usage — use them only for assistant
824
+ // messages whose own record had none, so nothing is double-counted.
825
+ for (const [mid, model] of untokened) {
826
+ for (const tok of stepTokens.get(mid) ?? []) {
827
+ data.addUsage(ocUsageFromTokens(tok), model);
828
+ }
829
+ }
830
+
831
+ // Child sessions are subagent runs: aggregate their usage separately —
832
+ // their content never enters this session's context window.
833
+ for (const child of rows('SELECT id FROM session WHERE parent_id=?', [sid])) {
834
+ for (const r of rows('SELECT data FROM message WHERE session_id=?', [child.id])) {
835
+ const msg = typeof r.data === 'string' ? loadJsonMaybe(r.data) : (r.data || {});
836
+ if (msg === undefined) continue;
837
+ ocTakeMessage(data, msg, true);
838
+ }
839
+ }
840
+
841
+ data.span(firstTs, lastTs);
842
+ data.notes.push('Source: opencode.db (SQLite, OpenCode v1.2+). Subagent '
843
+ + "totals aggregate this session's child sessions.");
844
+ return data;
845
+ } finally {
846
+ try { con.close(); } catch { /* already closed */ }
847
+ }
848
+ }
849
+
850
+ /** The DB is the only source and this runtime cannot read it — report that, honestly. */
851
+ function nosqliteData(dbPath) {
852
+ const data = new SessionData('opencode', path.basename(dbPath));
853
+ data.notes.push(SQLITE_NOTE ?? 'node:sqlite is unavailable.');
854
+ data.notes.push('No pre-v1.2 storage/ JSON files were found either, so this report '
855
+ + 'has no session data to attribute at all.');
856
+ return data;
857
+ }
858
+
859
+ /** Pre-SQLite fallback: parse the per-message JSON files under storage/. */
860
+ function opencodeParse(sid, files) {
861
+ const data = new SessionData('opencode', sid);
862
+ const storage = opencodeRoot();
863
+ const roleByMsg = new Map();
864
+ let firstTs = null;
865
+ let lastTs = null;
866
+
867
+ const ordered = [...files].sort(
868
+ (a, b) => pyStrCmp(path.basename(a), path.basename(b)));
869
+ for (const f of ordered) {
870
+ const msg = loadJsonMaybe(readTextMaybe(f));
871
+ if (msg === undefined) continue;
872
+ const [mid, role] = ocTakeMessage(data, msg);
873
+ roleByMsg.set(mid, role);
874
+ const t = getOr(msg, 'time', null) || {};
875
+ const ts = getOr(t, 'created', undefined) || getOr(t, 'completed', undefined);
876
+ if (ts) {
877
+ firstTs = firstTs || ts;
878
+ lastTs = ts;
879
+ }
880
+ }
881
+
882
+ // Content parts live under part/<messageID>/ folders (walk for the same
883
+ // version-drift reason as messages).
884
+ if (storage) {
885
+ for (const partdir of walkDirsNamed(storage, 'part')) {
886
+ for (const mdir of listDir(partdir)) {
887
+ const role = roleByMsg.get(path.basename(mdir));
888
+ if (role === undefined || !isDir(mdir)) continue;
889
+ for (const pf of walkTop(mdir, (n) => n.endsWith('.json'))) {
890
+ const part = loadJsonMaybe(readTextMaybe(pf));
891
+ if (part === undefined) continue;
892
+ ocTakePart(data, role, part);
893
+ }
894
+ }
895
+ }
896
+ }
897
+
898
+ data.span(firstTs, lastTs);
899
+ data.notes.push('Source: OpenCode JSON storage (pre-v1.2). Exact per-request '
900
+ + 'tokens from message records.');
901
+ if (SQLITE_NOTE) data.notes.push(SQLITE_NOTE);
902
+ return data;
903
+ }
904
+
905
+ // ---------------------------------------------------------------------------
906
+ // Host: GitHub Copilot CLI (best effort — schema undocumented)
907
+ // ---------------------------------------------------------------------------
908
+
909
+ function copilotRoot() {
910
+ const root = process.env.COPILOT_CONFIG_DIR || path.join(homedir(), '.copilot');
911
+ for (const sub of ['history-session-state', 'session-state', 'sessions']) {
912
+ if (isDir(path.join(root, sub))) return path.join(root, sub);
913
+ }
914
+ return null;
915
+ }
916
+
917
+ /** Recursively collect numeric fields whose key mentions tokens. */
918
+ function harvestTokens(obj, sink) {
919
+ if (isDict(obj)) {
920
+ for (const [k, v] of Object.entries(obj)) {
921
+ const lk = k.toLowerCase();
922
+ // `isinstance(v, (int, float))` — and in Python a BOOL *is* an int, so a
923
+ // `"hasTokens": true` adds 1. Reproduced rather than tidied away.
924
+ if ((typeof v === 'number' || typeof v === 'boolean') && lk.includes('token')) {
925
+ sink.add(lk, typeof v === 'boolean' ? (v ? 1 : 0) : v);
926
+ } else {
927
+ harvestTokens(v, sink);
928
+ }
929
+ }
930
+ } else if (Array.isArray(obj)) {
931
+ for (const v of obj) harvestTokens(v, sink);
932
+ }
933
+ }
934
+
935
+ /** Recursively estimate message-ish content by role. */
936
+ function harvestText(obj, sink, depth = 0) {
937
+ if (depth > 12) return;
938
+ if (isDict(obj)) {
939
+ const roleRaw = getOr(obj, 'role', undefined);
940
+ const role = typeof roleRaw === 'string' ? roleRaw : null;
941
+ const body = role ? getOr(obj, 'content', undefined) : null;
942
+ if (role && body !== null && body !== undefined) {
943
+ const cat = { user: 'User messages', assistant: 'Assistant replies',
944
+ tool: 'Tool result · (unattributed)' }[role] ?? `${role} messages`;
945
+ sink.add(cat, textLen(body));
946
+ return;
947
+ }
948
+ for (const v of Object.values(obj)) harvestText(v, sink, depth + 1);
949
+ } else if (Array.isArray(obj)) {
950
+ for (const v of obj) harvestText(v, sink, depth + 1);
951
+ }
952
+ }
953
+
954
+ function copilotFind(explicit = null) {
955
+ const root = copilotRoot();
956
+ if (!root) return null;
957
+ if (explicit) return existsSync(explicit) ? explicit : null;
958
+ return newest(listDir(root));
959
+ }
960
+
961
+ function copilotParse(target) {
962
+ const data = new SessionData('copilot', path.basename(target));
963
+ // `rglob("*.json*")` — the basename must CONTAIN ".json", so .json and .jsonl both
964
+ // match and so does anything after them.
965
+ const files = isDir(target)
966
+ ? walkFiles(target, (n) => n.includes('.json')).sort(comparePaths)
967
+ : [target];
968
+ const tokens = new Counter();
969
+ for (const f of files.slice(0, 500)) {
970
+ const text = readTextMaybe(f);
971
+ if (text === null) continue;
972
+ const docs = [];
973
+ if (path.extname(f) === '.jsonl') {
974
+ for (const line of pySplitLines(text)) {
975
+ const d = loadJsonMaybe(line);
976
+ if (d !== undefined) docs.push(d);
977
+ }
978
+ } else {
979
+ const d = loadJsonMaybe(text);
980
+ if (d !== undefined) docs.push(d);
981
+ }
982
+ for (const doc of docs) {
983
+ harvestTokens(doc, tokens);
984
+ harvestText(doc, data.cats);
985
+ }
986
+ }
987
+ if (tokens.size) {
988
+ data.notes.push('Copilot session state is undocumented; token-like fields '
989
+ + 'found (summed, field names as stored): '
990
+ + tokens.entries().sort((a, b) => pyStrCmp(a[0], b[0]))
991
+ .map(([k, v]) => `${k}=${fmt(v)}`).join(', '));
992
+ } else {
993
+ data.notes.push('Copilot records no recognisable token counts in its session '
994
+ + 'state — the report below is character-count estimates only.');
995
+ }
996
+ return data;
997
+ }
998
+
999
+ /**
1000
+ * `str.splitlines()` — copy of the rule the generator settles once: Python splits
1001
+ * on a dozen characters JS's `\n` split does not, and drops a trailing empty field.
1002
+ */
1003
+ function pySplitLines(s) {
1004
+ if (s === '') return [];
1005
+ const parts = s.split(
1006
+ new RegExp('\\r\\n|[\\n\\r\\v\\f\\x1c\\x1d\\x1e\\x85\\u2028\\u2029]'));
1007
+ if (parts.length && parts[parts.length - 1] === '') parts.pop();
1008
+ return parts;
1009
+ }
1010
+
1011
+ // ---------------------------------------------------------------------------
1012
+ // Detection and rendering
1013
+ // ---------------------------------------------------------------------------
1014
+
1015
+ /** Pick the host: explicit --host wins, else the most recently active. */
1016
+ async function detect(hostArg, transcript, session) {
1017
+ const finders = {
1018
+ claude: () => claudeShapedFind('claude', transcript),
1019
+ bob: () => claudeShapedFind('bob', transcript),
1020
+ opencode: () => opencodeFind(session),
1021
+ copilot: () => copilotFind(transcript),
1022
+ };
1023
+ if (hostArg) {
1024
+ const found = await finders[hostArg]();
1025
+ if (!found) sysExit(`error: no session data found for host '${hostArg}'`);
1026
+ return [hostArg, found];
1027
+ }
1028
+
1029
+ let best = null;
1030
+ for (const [host, finder] of Object.entries(finders)) {
1031
+ const found = await finder();
1032
+ if (!found) continue;
1033
+ let probe;
1034
+ if (host === 'opencode') { // ["db", [dbPath, sid]] or ["files", [sid, files]]
1035
+ const [kind, payload] = found;
1036
+ probe = kind === 'files' ? payload[1][0] : payload[0];
1037
+ } else {
1038
+ probe = found;
1039
+ }
1040
+ const m = existsSync(probe) ? mtime(probe) : 0;
1041
+ if (best === null || m > best[2]) best = [host, found, m];
1042
+ }
1043
+ if (!best) {
1044
+ sysExit('error: no session data found for any supported host '
1045
+ + '(claude, bob, opencode, copilot)');
1046
+ }
1047
+ return [best[0], best[1]];
1048
+ }
1049
+
1050
+ async function load(host, found) {
1051
+ if (host === 'claude' || host === 'bob') return claudeShapedParse(host, found);
1052
+ if (host === 'opencode') {
1053
+ const [kind, payload] = found;
1054
+ if (kind === 'db') return opencodeDbParse(...payload);
1055
+ if (kind === 'nosqlite') return nosqliteData(...payload);
1056
+ return opencodeParse(...payload);
1057
+ }
1058
+ return copilotParse(found);
1059
+ }
1060
+
1061
+ const HOST_NAMES = { claude: 'Claude Code', bob: 'IBM Bob', opencode: 'OpenCode', copilot: 'GitHub Copilot' };
1062
+
1063
+ function render(d, limit, topN) {
1064
+ const out = [`# Token usage report — ${HOST_NAMES[d.host] ?? d.host}`, ''];
1065
+ // Every interpolation of a value that came out of a transcript goes through
1066
+ // `pyStrOfJson`: an f-string is `str()`, and `str(None)` is `None`, not `null`.
1067
+ out.push(`Session: \`${pyStrOfJson(d.label)}\``
1068
+ + (d.duration ? ` · duration ${pyFixed(d.duration / 60, 0)} min` : ''));
1069
+ out.push('');
1070
+
1071
+ if (d.hasExact) {
1072
+ out.push('## Session totals (exact, from recorded usage)');
1073
+ out.push('');
1074
+ out.push('| Metric | Main thread | Subagents | Total |');
1075
+ out.push('|---|---:|---:|---:|');
1076
+ const rows = [['Requests', d.requestsMain, d.requestsSide]].concat(
1077
+ [['Output tokens', 'output_tokens'],
1078
+ ['Fresh input tokens', 'input_tokens'],
1079
+ ['Cache writes', 'cache_creation_input_tokens'],
1080
+ ['Cache reads', 'cache_read_input_tokens']]
1081
+ .map(([label, f]) => [label, d.main.get(f), d.side.get(f)]));
1082
+ for (const [label, m, s] of rows) {
1083
+ out.push(`| ${label} | ${fmt(m)} | ${fmt(s)} | ${fmt(m + s)} |`);
1084
+ }
1085
+ out.push('');
1086
+ if (d.models.size) {
1087
+ out.push(`Models used: ${d.models.entries()
1088
+ .sort((a, b) => b[1] - a[1])
1089
+ .map(([m, t]) => `\`${pyStrOfJson(m)}\` (${fmt(t)} out)`).join(', ')}`);
1090
+ out.push('');
1091
+ }
1092
+
1093
+ const first = d.firstContext || 0;
1094
+ const last = d.lastContext || 0;
1095
+ out.push('## Context window');
1096
+ out.push('');
1097
+ out.push('| | Tokens | % of limit | |');
1098
+ out.push('|---|---:|---:|---|');
1099
+ out.push(`| Session start (harness + context) | ${fmt(first)} | ${pct(first, limit)} | ${bar(first, limit)} |`);
1100
+ out.push(`| Current context size | ${fmt(last)} | ${pct(last, limit)} | ${bar(last, limit)} |`);
1101
+ if (d.peakContext > last) {
1102
+ out.push(`| Peak context size | ${fmt(d.peakContext)} | ${pct(d.peakContext, limit)} | ${bar(d.peakContext, limit)} |`);
1103
+ }
1104
+ out.push(`| Context limit | ${fmt(limit)} | 100% | ${bar(limit, limit)} |`);
1105
+ out.push('');
1106
+ if (first) {
1107
+ const firstPrompt = estTokens(d.firstUserChars || 0);
1108
+ const harness = Math.max(first - firstPrompt, 0);
1109
+ out.push(`The session-start baseline is exact: **${fmt(first)}** tokens were `
1110
+ + `in context before any work happened — roughly ${fmt(harness)} of `
1111
+ + 'harness (system prompt, tool schemas, skill and agent listings, '
1112
+ + `memory) plus ~${fmt(firstPrompt)} for the first user message.`);
1113
+ out.push('');
1114
+ const grown = Math.max(last - first, 0);
1115
+ out.push(`Since then the persistent context grew by **${fmt(grown)}** tokens, `
1116
+ + `and the session generated **${fmt(d.main.get('output_tokens'))}** output `
1117
+ + 'tokens. The breakdown below attributes everything the session '
1118
+ + 'processed — including ephemeral thinking, which is generated and '
1119
+ + 'paid for but dropped from context after each turn.');
1120
+ out.push('');
1121
+ }
1122
+ } else {
1123
+ out.push('*(This host recorded no exact per-request usage — everything below '
1124
+ + 'is a character-count estimate.)*');
1125
+ out.push('');
1126
+ }
1127
+
1128
+ // Thinking text is often not persisted (signature only) even though it IS
1129
+ // counted in the exact output totals. When absent, derive it: exact output
1130
+ // minus the visible output we can measure.
1131
+ if (d.cats.get('Assistant thinking') === 0 && d.main.get('output_tokens')) {
1132
+ let visible = 0;
1133
+ for (const [name, chars] of d.cats.entries()) {
1134
+ if (name === 'Assistant replies' || name.startsWith('Tool call')) visible += chars;
1135
+ }
1136
+ const derived = d.main.get('output_tokens') - estTokens(visible);
1137
+ if (derived > 0) {
1138
+ d.cats.set('Assistant thinking (derived)', pyRound(derived * CHARS_PER_TOKEN));
1139
+ }
1140
+ }
1141
+
1142
+ let totalChars = 0;
1143
+ for (const [, chars] of d.cats.entries()) totalChars += chars;
1144
+ if (totalChars) {
1145
+ out.push('## Content breakdown (estimated, ~4 chars/token)');
1146
+ out.push('');
1147
+ out.push('| Category | Est. tokens | Share | |');
1148
+ out.push('|---|---:|---:|---|');
1149
+ // `sorted(..., key=lambda kv: -kv[1])` is STABLE, and a Map preserves insertion
1150
+ // order, so a comparator that returns 0 for equal counts reproduces Python exactly.
1151
+ for (const [name, chars] of d.cats.entries().sort((a, b) => b[1] - a[1])) {
1152
+ const t = estTokens(chars);
1153
+ if (t === 0) continue;
1154
+ out.push(`| ${name} | ${fmt(t)} | ${pct(chars, totalChars)} | ${bar(chars, totalChars)} |`);
1155
+ }
1156
+ out.push(`| **Total conversation content** | **${fmt(estTokens(totalChars))}** | 100% | |`);
1157
+ out.push('');
1158
+ out.push('*Estimates cover conversation content only — the harness baseline '
1159
+ + '(system prompt, tool schemas) is not re-counted here. Subagent '
1160
+ + 'content is excluded: it never enters this context window. Thinking '
1161
+ + 'is ephemeral — dropped from context after each turn — so it counts '
1162
+ + 'as processed output, not persistent context growth.*');
1163
+ out.push('');
1164
+ }
1165
+
1166
+ // `sorted(d.top, reverse=True)` compares TUPLES: chars first, then the label.
1167
+ const heavy = [...d.top]
1168
+ .sort((a, b) => (b[0] - a[0]) || pyStrCmp(b[1], a[1]))
1169
+ .slice(0, topN);
1170
+ if (heavy.length && heavy[0][0] > 0) {
1171
+ out.push(`## Top ${heavy.length} heaviest single items`);
1172
+ out.push('');
1173
+ out.push('| Item | Est. tokens |');
1174
+ out.push('|---|---:|');
1175
+ for (const [chars, label] of heavy) out.push(`| ${label} | ${fmt(estTokens(chars))} |`);
1176
+ out.push('');
1177
+ }
1178
+
1179
+ for (const note of d.notes) {
1180
+ out.push(`> ${note}`);
1181
+ out.push('');
1182
+ }
1183
+ return out.join('\n');
1184
+ }
1185
+
1186
+ // ---------------------------------------------------------------------------
1187
+ // The argument parser
1188
+ // ---------------------------------------------------------------------------
1189
+
1190
+ const PROG = path.basename(process.argv[1] ?? 'token_report.mjs');
1191
+ const HOST_CHOICES = ['claude', 'bob', 'opencode', 'copilot'];
1192
+ const OPTIONS = [
1193
+ ['-h, --help', 'show this help message and exit'],
1194
+ [`--host {${HOST_CHOICES.join(',')}}`, 'force a host instead of auto-detecting by recency'],
1195
+ ['--transcript TRANSCRIPT', 'explicit transcript path (claude/bob/copilot)'],
1196
+ ['--session SESSION', 'explicit session id (opencode)'],
1197
+ ['--limit LIMIT', 'context window limit for % columns (default 200000)'],
1198
+ ['--top TOP', 'how many heaviest single items to list (default 5)'],
1199
+ ];
1200
+
1201
+ /**
1202
+ * `argparse`'s usage block, wrapped the way argparse wraps it at 80 columns.
1203
+ *
1204
+ * The continuation indent is `len("usage: ") + len(prog) + 1`, so it is one space wider
1205
+ * here than it was under the reference's shorter program name — the only difference the
1206
+ * two implementations' help text ever had, and the reason the parity cells over `--help`
1207
+ * normalised leading whitespace. The WRAP POINTS are identical for both names (measured:
1208
+ * every line has at least a character of slack at both widths).
1209
+ */
1210
+ function usage() {
1211
+ const head = `usage: ${PROG} `;
1212
+ const pad = ' '.repeat(head.length);
1213
+ return [`${head}[-h] [--host {${HOST_CHOICES.join(',')}}]`,
1214
+ `${pad}[--transcript TRANSCRIPT] [--session SESSION]`,
1215
+ `${pad}[--limit LIMIT] [--top TOP]`].join('\n');
1216
+ }
1217
+
1218
+ function helpText() {
1219
+ const lines = [usage(), '', 'Token usage report for the current agent session', '',
1220
+ 'options:'];
1221
+ for (const [invocation, help] of OPTIONS) {
1222
+ const left = ` ${invocation}`;
1223
+ // argparse puts help in column 24, or on the next line when the invocation
1224
+ // reaches it.
1225
+ if (left.length + 2 <= 24) lines.push(left.padEnd(24) + help);
1226
+ else lines.push(left, ' '.repeat(24) + help);
1227
+ }
1228
+ return lines.join('\n');
1229
+ }
1230
+
1231
+ function argError(msg) {
1232
+ process.stderr.write(xlate(`${usage()}\n${PROG}: error: ${msg}\n`));
1233
+ process.exit(2);
1234
+ }
1235
+
1236
+ function parseArgs(argv) {
1237
+ const args = { host: null, transcript: null, session: null, limit: 200000, top: 5 };
1238
+ const takesValue = new Set(['--host', '--transcript', '--session', '--limit', '--top']);
1239
+ for (let i = 0; i < argv.length; i += 1) {
1240
+ let name = argv[i];
1241
+ let value = null;
1242
+ if (name === '-h' || name === '--help') {
1243
+ pyPrint(helpText());
1244
+ process.exit(0);
1245
+ }
1246
+ const eq = name.indexOf('=');
1247
+ if (eq > 0 && name.startsWith('--')) {
1248
+ value = name.slice(eq + 1);
1249
+ name = name.slice(0, eq);
1250
+ }
1251
+ if (!takesValue.has(name)) {
1252
+ argError(`unrecognized arguments: ${argv[i]}`);
1253
+ }
1254
+ if (value === null) {
1255
+ i += 1;
1256
+ if (i >= argv.length) argError(`argument ${name}: expected one argument`);
1257
+ value = argv[i];
1258
+ }
1259
+ if (name === '--host') {
1260
+ if (!HOST_CHOICES.includes(value)) {
1261
+ argError(`argument --host: invalid choice: '${value}' `
1262
+ + `(choose from ${HOST_CHOICES.join(', ')})`);
1263
+ }
1264
+ args.host = value;
1265
+ } else if (name === '--transcript') args.transcript = value;
1266
+ else if (name === '--session') args.session = value;
1267
+ else {
1268
+ const n = pyInt(value);
1269
+ if (n === null) {
1270
+ argError(`argument ${name}: invalid int value: '${value}'`);
1271
+ }
1272
+ if (name === '--limit') args.limit = n;
1273
+ else args.top = n;
1274
+ }
1275
+ }
1276
+ return args;
1277
+ }
1278
+
1279
+ async function main() {
1280
+ const args = parseArgs(process.argv.slice(2));
1281
+ const [host, found] = await detect(args.host, args.transcript, args.session);
1282
+ pyPrint(render(await load(host, found), args.limit, args.top));
1283
+ }
1284
+
1285
+ /**
1286
+ * The primitives above are exported so the suite's probe can IMPORT this file and drive
1287
+ * each one over a corpus of hand-picked values. Two `pyFixed` defects reached a passing
1288
+ * behavioural suite before that corpus existed, because a transcript only produces the
1289
+ * numbers it happens to produce.
1290
+ *
1291
+ * `main()` therefore runs only when this file IS the entry point — a plain `node
1292
+ * <skill-dir>/scripts/token_report.mjs`, which is how SKILL.md tells the agent to run
1293
+ * it. If this guard were ever wrong, importing the file for its exports would run the
1294
+ * whole report as a side effect and the probe's stdout would stop parsing on the next run.
1295
+ */
1296
+ export { pyLen, pyRound, pyFixed, fmt, jsonDumps, pyRepr, pyStrOfJson, pyInt, pyStrCmp,
1297
+ comparePaths, pct, bar, estTokens, textLen, escapeNonAscii, pySplitLines };
1298
+
1299
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
1300
+ await main();
1301
+ }