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.
- package/CHANGELOG.md +938 -0
- package/DESIGN.md +1423 -0
- package/LICENSE +21 -0
- package/QUICKSTART.md +48 -0
- package/README.md +336 -0
- package/SETUP.md +1027 -0
- package/SHIPPED.md +70 -0
- package/adapters/bob/README.md +100 -0
- package/adapters/claude-code/README.md +101 -0
- package/adapters/claude-code/settings.json +47 -0
- package/adapters/claude-code/skills/geneseed/SKILL.md +72 -0
- package/adapters/copilot/README.md +76 -0
- package/adapters/opencode/GLOBAL-HARNESS-SPEC.md +434 -0
- package/adapters/opencode/HOW-OPENCODE-LOADS.md +158 -0
- package/adapters/opencode/README.md +582 -0
- package/adapters/opencode/agents/orchestrator.md +25 -0
- package/adapters/opencode/opencode.json +44 -0
- package/adapters/opencode/plugins/geneseed-activity.js +430 -0
- package/adapters/opencode/plugins/geneseed-context.js +876 -0
- package/adapters/opencode/plugins/geneseed-guard.js +290 -0
- package/adapters/opencode/plugins/geneseed-learn.js +396 -0
- package/adapters/opencode/plugins/geneseed-notify.js +142 -0
- package/adapters/opencode/plugins/geneseed-ponytail.js +148 -0
- package/adapters/opencode/plugins/geneseed-workflow.js +183 -0
- package/adapters/opencode/workflows/_runtime.js +277 -0
- package/adapters/opencode/workflows/council.js +64 -0
- package/adapters/opencode/workflows/dispatch.js +89 -0
- package/adapters/opencode/workflows/research-plan-implement.js +43 -0
- package/adapters/opencode/workflows/review.js +57 -0
- package/bin/geneseed-cli.mjs +412 -0
- package/bin/geneseed-hook.mjs +129 -0
- package/bin/geneseed.mjs +1086 -0
- package/docs/declined.md +149 -0
- package/docs/limits.md +270 -0
- package/docs/opencode-plugin-setup.md +64 -0
- package/docs/token-footprint.md +68 -0
- package/docs/web/_groups.json +45 -0
- package/docs/web/about.md +6 -0
- package/docs/web/adapters-claude-code.md +8 -0
- package/docs/web/adapters-opencode-loads.md +8 -0
- package/docs/web/adapters-opencode-spec.md +8 -0
- package/docs/web/adapters-opencode.md +8 -0
- package/docs/web/agents.md +8 -0
- package/docs/web/authoring.md +11 -0
- package/docs/web/autostart.md +9 -0
- package/docs/web/claude-hooks.md +36 -0
- package/docs/web/cli.md +6 -0
- package/docs/web/collaboration.md +27 -0
- package/docs/web/configure-wiki.md +9 -0
- package/docs/web/design.md +7 -0
- package/docs/web/env-knobs.md +9 -0
- package/docs/web/exclusions.md +26 -0
- package/docs/web/first-session.md +28 -0
- package/docs/web/footprint.md +31 -0
- package/docs/web/foreman-mode.md +33 -0
- package/docs/web/glossary.md +6 -0
- package/docs/web/harnesses.md +28 -0
- package/docs/web/headless.md +9 -0
- package/docs/web/ingest-docs.md +9 -0
- package/docs/web/install-paths.md +9 -0
- package/docs/web/install-quick.md +35 -0
- package/docs/web/lsp-overview.md +37 -0
- package/docs/web/mcp-claude-code.md +10 -0
- package/docs/web/mcp-filesystem.md +9 -0
- package/docs/web/mcp-gitlab.md +9 -0
- package/docs/web/mcp-markitdown.md +9 -0
- package/docs/web/mcp-overview.md +34 -0
- package/docs/web/mcp-trouble.md +9 -0
- package/docs/web/mcp-verify.md +9 -0
- package/docs/web/memory.md +7 -0
- package/docs/web/migrate.md +39 -0
- package/docs/web/model.md +20 -0
- package/docs/web/notebook.md +7 -0
- package/docs/web/pipelines.md +24 -0
- package/docs/web/plugin-activity.md +23 -0
- package/docs/web/plugin-context.md +26 -0
- package/docs/web/plugin-guard.md +24 -0
- package/docs/web/plugin-learn.md +27 -0
- package/docs/web/plugin-notify.md +24 -0
- package/docs/web/plugin-ponytail.md +23 -0
- package/docs/web/plugin-setup.md +7 -0
- package/docs/web/plugin-workflow.md +24 -0
- package/docs/web/plugins.md +17 -0
- package/docs/web/project-context.md +9 -0
- package/docs/web/rules.md +8 -0
- package/docs/web/run-anywhere.md +9 -0
- package/docs/web/self-improve.md +8 -0
- package/docs/web/setup-choices.md +39 -0
- package/docs/web/skills.md +8 -0
- package/docs/web/themes-author.md +8 -0
- package/docs/web/themes.md +9 -0
- package/docs/web/token-footprint.md +7 -0
- package/docs/web/trouble.md +38 -0
- package/docs/web/upgrade.md +9 -0
- package/docs/web/verify.md +30 -0
- package/docs/web/wiki.md +7 -0
- package/docs/web/worktree.md +10 -0
- package/docs/web-ui.md +144 -0
- package/docs/wiki.md +43 -0
- package/geneseed +39 -0
- package/geneseed.cmd +34 -0
- package/harness.config.json +6 -0
- package/js/anim.mjs +326 -0
- package/js/catalog.mjs +112 -0
- package/js/checkout.mjs +88 -0
- package/js/cli-table.json +1034 -0
- package/js/cli.mjs +498 -0
- package/js/diff.mjs +384 -0
- package/js/doctor.mjs +1364 -0
- package/js/emit.mjs +1373 -0
- package/js/excludes.mjs +271 -0
- package/js/generate.mjs +379 -0
- package/js/hooks.mjs +1026 -0
- package/js/hosts.mjs +253 -0
- package/js/installs.mjs +378 -0
- package/js/inventory.mjs +175 -0
- package/js/lib/fs.mjs +913 -0
- package/js/lib/proc.mjs +28 -0
- package/js/lib/udiff.mjs +275 -0
- package/js/link.mjs +286 -0
- package/js/mcp.mjs +335 -0
- package/js/memory.mjs +108 -0
- package/js/menu.mjs +118 -0
- package/js/migrate.mjs +251 -0
- package/js/native.mjs +426 -0
- package/js/opencode.mjs +325 -0
- package/js/registry.mjs +110 -0
- package/js/render.mjs +282 -0
- package/js/settings.mjs +924 -0
- package/js/setup.mjs +492 -0
- package/js/status.mjs +281 -0
- package/js/themes.mjs +195 -0
- package/js/tui.mjs +444 -0
- package/js/uninstall.mjs +1137 -0
- package/js/update.mjs +910 -0
- package/js/web/actions.mjs +868 -0
- package/js/web/activity.mjs +284 -0
- package/js/web/api.mjs +873 -0
- package/js/web/docs.mjs +531 -0
- package/js/web/graph.mjs +0 -0
- package/js/web/jobs.mjs +480 -0
- package/js/web/server.mjs +1135 -0
- package/package.json +54 -0
- package/registry.json +474 -0
- package/src/AGENT.md.tmpl +390 -0
- package/src/agents/_template.md +76 -0
- package/src/agents/advocate.md +36 -0
- package/src/agents/architect.md +55 -0
- package/src/agents/developer.md +66 -0
- package/src/agents/docs.md +54 -0
- package/src/agents/empiricist.md +39 -0
- package/src/agents/explorer.md +54 -0
- package/src/agents/framer.md +36 -0
- package/src/agents/historian.md +51 -0
- package/src/agents/operator.md +37 -0
- package/src/agents/pragmatist.md +37 -0
- package/src/agents/reviewer.md +52 -0
- package/src/agents/security.md +57 -0
- package/src/agents/skeptic.md +48 -0
- package/src/agents/steward.md +36 -0
- package/src/agents/tester.md +54 -0
- package/src/agents/user-advocate.md +36 -0
- package/src/agents/visionary.md +36 -0
- package/src/laws/universal.md +387 -0
- package/src/memory/README.md +109 -0
- package/src/memory/gitignore +6 -0
- package/src/modes/README.md +26 -0
- package/src/modes/direct.md +2 -0
- package/src/modes/foreman.md +19 -0
- package/src/notebook/README.md +39 -0
- package/src/notebook/gitignore +7 -0
- package/src/postures/README.md +31 -0
- package/src/postures/artisan.md +14 -0
- package/src/postures/assistant.md +12 -0
- package/src/postures/expert.md +11 -0
- package/src/postures/mentor.md +11 -0
- package/src/postures/peer.md +12 -0
- package/src/skills/_template.md +47 -0
- package/src/skills/brainstorm.md +28 -0
- package/src/skills/clarify.md +28 -0
- package/src/skills/codebase-design.md +44 -0
- package/src/skills/commit.md +48 -0
- package/src/skills/council.md +34 -0
- package/src/skills/crash-course.md +28 -0
- package/src/skills/daydream/.claude-plugin/plugin.json +9 -0
- package/src/skills/daydream/SKILL.md +74 -0
- package/src/skills/daydream/VENDOR.md +25 -0
- package/src/skills/daydream/critic-prompt.md +38 -0
- package/src/skills/daydream/instructions.md +422 -0
- package/src/skills/daydream/synthesizer-prompt.md +41 -0
- package/src/skills/debug.md +37 -0
- package/src/skills/decode.md +28 -0
- package/src/skills/develop.md +29 -0
- package/src/skills/document-project.md +79 -0
- package/src/skills/domain-modeling.md +38 -0
- package/src/skills/drill.md +28 -0
- package/src/skills/feynman.md +26 -0
- package/src/skills/forge-mcp.md +48 -0
- package/src/skills/fresh-eyes.md +29 -0
- package/src/skills/frontend-design.md +58 -0
- package/src/skills/gap-detector.md +27 -0
- package/src/skills/geneseed-code-review.md +33 -0
- package/src/skills/geneseed.md +29 -0
- package/src/skills/git-archaeology.md +47 -0
- package/src/skills/git-rescue.md +68 -0
- package/src/skills/handoff.md +29 -0
- package/src/skills/herdr.md +164 -0
- package/src/skills/ingest.md +49 -0
- package/src/skills/learning-path.md +26 -0
- package/src/skills/migrate.md +45 -0
- package/src/skills/opencode-theme.md +52 -0
- package/src/skills/parallel-agents.md +28 -0
- package/src/skills/pipeline.md +52 -0
- package/src/skills/plan.md +45 -0
- package/src/skills/ponytail.md +29 -0
- package/src/skills/profile.md +58 -0
- package/src/skills/prose.md +42 -0
- package/src/skills/prototype.md +44 -0
- package/src/skills/react-view-transitions/AGENTS.md +955 -0
- package/src/skills/react-view-transitions/README.md +42 -0
- package/src/skills/react-view-transitions/SKILL.md +320 -0
- package/src/skills/react-view-transitions/VENDOR.md +16 -0
- package/src/skills/react-view-transitions/metadata.json +12 -0
- package/src/skills/react-view-transitions/references/css-recipes.md +242 -0
- package/src/skills/react-view-transitions/references/implementation.md +182 -0
- package/src/skills/react-view-transitions/references/nextjs.md +176 -0
- package/src/skills/react-view-transitions/references/patterns.md +262 -0
- package/src/skills/refactor.md +32 -0
- package/src/skills/release.md +43 -0
- package/src/skills/repo-map.md +36 -0
- package/src/skills/research.md +42 -0
- package/src/skills/review-response.md +36 -0
- package/src/skills/roast-me.md +28 -0
- package/src/skills/rule.md +100 -0
- package/src/skills/ship.md +49 -0
- package/src/skills/tdd.md +31 -0
- package/src/skills/tickets.md +44 -0
- package/src/skills/token-report/SKILL.md +76 -0
- package/src/skills/token-report/VENDOR.md +23 -0
- package/src/skills/token-report/scripts/token_report.mjs +1301 -0
- package/src/skills/wayfinder.md +49 -0
- package/src/skills/wiki.md +68 -0
- package/src/skills/workflow.md +38 -0
- package/themes/README.md +69 -0
- package/themes/_TEMPLATE.json +147 -0
- package/themes/biker.json +147 -0
- package/themes/commentator.json +147 -0
- package/themes/cyberpunk.json +147 -0
- package/themes/gamer.json +147 -0
- package/themes/imperial.json +147 -0
- package/themes/joker.json +147 -0
- package/themes/marvin.json +147 -0
- package/themes/mean.json +147 -0
- package/themes/military.json +147 -0
- package/themes/neutral.json +147 -0
- package/themes/opencode/README.md +58 -0
- package/themes/opencode/catppuccin.json +27 -0
- package/themes/opencode/dracula.json +27 -0
- package/themes/opencode/everforest.json +27 -0
- package/themes/opencode/gruvbox.json +27 -0
- package/themes/opencode/kanagawa.json +27 -0
- package/themes/opencode/neon.json +27 -0
- package/themes/opencode/nord.json +27 -0
- package/themes/opencode/rosepine.json +27 -0
- package/themes/opencode/tokyonight.json +27 -0
- package/themes/pirate.json +147 -0
- package/themes/sports.json +147 -0
- package/themes/verstappen.json +147 -0
- package/themes/wizard.json +147 -0
- package/web/dist/assets/About-DX1-VTBP.js +1 -0
- package/web/dist/assets/Activity-Dp9NaXOn.js +1 -0
- package/web/dist/assets/ActivityDetail-HpOBtrXS.js +1 -0
- package/web/dist/assets/Diff-Ct4G3Se8.js +5 -0
- package/web/dist/assets/Doctor-DGjkZFuA.js +1 -0
- package/web/dist/assets/Graph-b8o-S6kv.js +1 -0
- package/web/dist/assets/Harnesses-gr3-aHBF.js +1 -0
- package/web/dist/assets/Laws-DTwF5tTC.js +1 -0
- package/web/dist/assets/Library-BEZscyaQ.js +1 -0
- package/web/dist/assets/Markdown-k_LSLBGq.js +1 -0
- package/web/dist/assets/Profile-DydukTxP.js +1 -0
- package/web/dist/assets/Rules-CIRG4Ldt.js +1 -0
- package/web/dist/assets/Skills-Buoi1IBr.js +1 -0
- package/web/dist/assets/StatusBadge-CE_AJttl.js +1 -0
- package/web/dist/assets/Themes-CI4o3gJC.js +2 -0
- package/web/dist/assets/activity-BCW7e6Cy.js +1 -0
- package/web/dist/assets/audiowide-latin-400-normal-6GFCX7ni.woff2 +0 -0
- package/web/dist/assets/geist-mono-latin-400-normal-DKaoCDn5.woff2 +0 -0
- package/web/dist/assets/geist-mono-latin-500-normal-C3sF8Y1B.woff2 +0 -0
- package/web/dist/assets/geist-mono-latin-600-normal-BHzjB6_C.woff2 +0 -0
- package/web/dist/assets/hanken-grotesk-latin-400-italic-Bserb8X5.woff2 +0 -0
- package/web/dist/assets/hanken-grotesk-latin-400-normal-BG6hkEXj.woff2 +0 -0
- package/web/dist/assets/hanken-grotesk-latin-500-italic-BGfwwhek.woff2 +0 -0
- package/web/dist/assets/hanken-grotesk-latin-500-normal-DrDcrrxK.woff2 +0 -0
- package/web/dist/assets/hanken-grotesk-latin-600-normal-CIXX6EOa.woff2 +0 -0
- package/web/dist/assets/hanken-grotesk-latin-700-normal-CeQ8H3UY.woff2 +0 -0
- package/web/dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- package/web/dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
- package/web/dist/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
- package/web/dist/assets/ibm-plex-serif-latin-400-normal-BIGslYFI.woff2 +0 -0
- package/web/dist/assets/ibm-plex-serif-latin-500-normal-REKAfBVG.woff2 +0 -0
- package/web/dist/assets/ibm-plex-serif-latin-600-normal-DAxitmSu.woff2 +0 -0
- package/web/dist/assets/index-B1PIx7RM.js +1 -0
- package/web/dist/assets/index-BnTQUSUz.js +12 -0
- package/web/dist/assets/index-Ck2iCdaC.js +2 -0
- package/web/dist/assets/index-DUCsDv7z.css +1 -0
- package/web/dist/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
- package/web/dist/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
- package/web/dist/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
- package/web/dist/assets/inter-latin-700-normal-Yt3aPRUw.woff2 +0 -0
- package/web/dist/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
- package/web/dist/assets/jetbrains-mono-latin-500-normal-BWZEU5yA.woff2 +0 -0
- package/web/dist/assets/jetbrains-mono-latin-600-normal-C8RAYTDA.woff2 +0 -0
- package/web/dist/assets/marked.esm-JKvyS0Pp.js +46 -0
- package/web/dist/assets/oswald-latin-400-normal-BAFo8sP8.woff2 +0 -0
- package/web/dist/assets/oswald-latin-500-normal-B-eyT0Dw.woff2 +0 -0
- package/web/dist/assets/oswald-latin-600-normal-plFmSr5g.woff2 +0 -0
- package/web/dist/assets/oswald-latin-700-normal-vy6hmKSr.woff2 +0 -0
- package/web/dist/assets/poppins-latin-400-normal-cpxAROuN.woff2 +0 -0
- package/web/dist/assets/poppins-latin-500-normal-C8OXljZJ.woff2 +0 -0
- package/web/dist/assets/poppins-latin-600-normal-zEkxB9Mr.woff2 +0 -0
- package/web/dist/assets/poppins-latin-700-normal-Qrb0O0WB.woff2 +0 -0
- package/web/dist/assets/react-DOpiTn5v.js +32 -0
- package/web/dist/assets/roman-DkYN0LX9.js +1 -0
- package/web/dist/assets/space-grotesk-latin-400-normal-CJ-V5oYT.woff2 +0 -0
- package/web/dist/assets/space-grotesk-latin-500-normal-lFbtlQH6.woff2 +0 -0
- package/web/dist/assets/space-grotesk-latin-600-normal-DjKNqYRj.woff2 +0 -0
- package/web/dist/assets/space-grotesk-latin-700-normal-RjhwGPKo.woff2 +0 -0
- package/web/dist/assets/space-mono-latin-400-italic-YylcN9Ay.woff2 +0 -0
- package/web/dist/assets/space-mono-latin-400-normal-Rg4St2Dn.woff2 +0 -0
- package/web/dist/assets/space-mono-latin-700-italic-vNvENeTh.woff2 +0 -0
- package/web/dist/assets/space-mono-latin-700-normal-mWgeinG7.woff2 +0 -0
- package/web/dist/icon-maskable.svg +10 -0
- package/web/dist/icon.svg +8 -0
- package/web/dist/index.html +21 -0
- package/web/dist/manifest.webmanifest +14 -0
- package/web/dist/sw.js +40 -0
- 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
|
+
}
|