opencode-token-norm 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 salitaba
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,201 @@
1
+ # opencode-token-norm
2
+
3
+ **Your token rules are advice. This makes them mechanical.**
4
+
5
+ You wrote a token budget into `AGENTS.md`. It loads into every session. The agent
6
+ reads it, agrees with it, and then runs 184 tool calls and 3.0M effective tokens
7
+ on a task that should have been three sessions.
8
+
9
+ That is not a prompting failure. Rules that survive are the ones that do not
10
+ depend on the agent choosing to follow them. This plugin does not add advice —
11
+ it **counts**, and at thresholds it staples an instruction onto tool output the
12
+ agent is already reading.
13
+
14
+ Two halves:
15
+
16
+ - **`TokenNormBudget`** — counts tool calls, fires un-skippable reminders at
17
+ thresholds, and runs the usage audit *for* the agent so there is no step to defer.
18
+ - **`TokenNormHandoff`** — a `handoff` tool that makes the session split a single
19
+ tool call instead of three manual steps.
20
+
21
+ ---
22
+
23
+ ## Install
24
+
25
+ ```json
26
+ // ~/.config/opencode/opencode.json
27
+ {
28
+ "$schema": "https://opencode.ai/config.json",
29
+ "plugin": ["opencode-token-norm"]
30
+ }
31
+ ```
32
+
33
+ Restart OpenCode. Pin a version if you want stability:
34
+
35
+ ```json
36
+ { "plugin": ["opencode-token-norm@0.1.0"] }
37
+ ```
38
+
39
+ Requires OpenCode with plugin support (`@opencode-ai/plugin` ≥ 1.15.12).
40
+ The audit checkpoint additionally needs `python3` — without it, the reminder
41
+ still fires and tells the agent to run the audit manually.
42
+
43
+ ---
44
+
45
+ ## What it actually does
46
+
47
+ ### 1. Task boundary detection (the one nobody has)
48
+
49
+ The expensive failure is not a long task. It is a **new** task inheriting an old
50
+ task's context and an old task's permission.
51
+
52
+ Real session: 195k tokens deep, the agent had the norm in context, ran the audit,
53
+ reported *"77x cache, bloat HIGH"* — and continued anyway. Why? A second request
54
+ (*"now do all of them"*) was treated as a continuation of the `do everything`
55
+ override granted for the first. Overrides are per-task. Nothing enforced that.
56
+
57
+ When a new user message arrives in a session already past `BOUNDARY_AT` calls
58
+ (default 40), the next tool call carries:
59
+
60
+ ```
61
+ TOKEN NORM -- new request arrived 47 tool calls deep. This is a TASK BOUNDARY.
62
+ A prior "do everything" / "don't ask" was scoped to the PREVIOUS task. It does not carry.
63
+ ...
64
+ Do NOT justify continuing with "finishing here beats reloading cold". That reasoning
65
+ is always available, feels free only because this context is already warm, and is the
66
+ exact rationalization the norm exists to block.
67
+ ```
68
+
69
+ That last line matters. Every agent talks itself past a split with *"reloading
70
+ cold costs more than finishing here."* It is always available and almost always
71
+ wrong — the marginal call feels free precisely because the context is warm.
72
+
73
+ ### 2. Cost statement at 25 calls
74
+
75
+ The norm wants a cost statement **before** a big task. In practice the agent only
76
+ learns the true size once it is underway. So this fires at the first moment the
77
+ task is provably big and forces the statement then: remaining calls, caps in
78
+ effect, and which part could ship now behind a handoff.
79
+
80
+ ### 3. Audit checkpoint every 60 calls — **already run**
81
+
82
+ "Run this command and report the number" is advice, and advice at a checkpoint
83
+ loses to the task in flight every time. One session was told to audit at call 79,
84
+ kept working, and produced the number only when the user asked afterwards.
85
+
86
+ So the plugin runs it. The command is cheap, deterministic, and opens OpenCode's
87
+ sqlite DB **read-only**. The agent gets the result stapled to its tool output:
88
+
89
+ ```
90
+ TOKEN NORM -- 60 tool calls. Audit checkpoint (ran for you):
91
+
92
+ totals : input 23k output 15k cache_read 891k cache_write 87k
93
+ effective fresh tokens: 220k (input + 0.1·cache_read + 1.25·cache_write — the money number)
94
+ calls : 22 context/call min 23k med 49k max 61k
95
+ cache : 23x fresh tokens — bloat driver ok
96
+
97
+ In your NEXT message, before continuing the task: report the effective-token
98
+ number and the cache multiplier to the user, and say whether you are splitting.
99
+ ```
100
+
101
+ No step to defer. Only a fact to report.
102
+
103
+ ### 4. Compaction context
104
+
105
+ Compaction is the one moment the agent provably re-reads its own rules. The
106
+ plugin injects the call count there, because *"you are 180 calls deep"* changes
107
+ behavior and *"be frugal"* does not.
108
+
109
+ ### 5. The `handoff` tool
110
+
111
+ The norm says split at phase boundaries. It does not happen, because splitting
112
+ means leaving the TUI, opening a new session, and re-typing context by hand —
113
+ three manual steps at exactly the moment the warm session feels cheapest to continue.
114
+
115
+ One tool call instead:
116
+
117
+ ```
118
+ handoff({
119
+ task: "Fix token expiry off-by-one in auth middleware",
120
+ done: "Diagnosed: TokenValidator.isExpired() at src/auth/token.ts:88 uses < not <=",
121
+ next: "Change the comparison, add a boundary test at exactly expiresAt",
122
+ files: ["src/auth/token.ts:88", "src/auth/__tests__/token.test.ts"],
123
+ })
124
+ ```
125
+
126
+ The plugin writes the note to disk, opens a new TUI session, and pre-fills the
127
+ prompt. You press enter.
128
+
129
+ **Design decisions worth knowing:**
130
+
131
+ - **Not auto-submitted by default.** An auto-submitted handoff starts burning
132
+ tokens on a task you may have wanted to redirect — and the beat before enter
133
+ is the entire point of splitting. Pass `submit: true` for unattended work.
134
+ - **Persisted before the TUI switch.** If the switch fails, the note still exists
135
+ on disk. The reverse ordering loses it on exactly the failure that matters.
136
+ - **Refused for subagents.** Plugin tools register for every agent. A subagent
137
+ calling `handoff` would hijack your screen mid-task. Subagent status is resolved
138
+ from the live agent list, so agents you add later classify correctly.
139
+ - **Structured args, not a freeform summary.** `task`/`done`/`next`/`files`/`notes`
140
+ forces the agent to name real paths and real identifiers it verified — the next
141
+ session cannot see the scrollback.
142
+
143
+ ---
144
+
145
+ ## Safety
146
+
147
+ - Never blocks a tool call, never edits args, never throws. A wrong threshold
148
+ guess costs a few lines of text, not a broken session.
149
+ - The audit opens `~/.local/share/opencode/opencode.db` **read-only** and never writes.
150
+ - No network calls. No telemetry. Nothing leaves your machine.
151
+ - Handoff notes are written to `~/.local/share/opencode/handoff/`.
152
+
153
+ ---
154
+
155
+ ## Configuration
156
+
157
+ All optional, all environment variables.
158
+
159
+ | Variable | Default | Meaning |
160
+ |---|---|---|
161
+ | `TOKEN_NORM_ANNOUNCE_AT` | `25` | Calls before the cost-statement reminder |
162
+ | `TOKEN_NORM_AUDIT_EVERY` | `60` | Calls between audit checkpoints |
163
+ | `TOKEN_NORM_BOUNDARY_AT` | `40` | Session size above which a new user message is a task boundary |
164
+ | `TOKEN_NORM_CHEAP_TOOLS` | `todowrite,question,skill` | Tools that do not count toward the budget |
165
+ | `TOKEN_NORM_HANDOFF_DIR` | `~/.local/share/opencode/handoff` | Where handoff notes are written |
166
+ | `TOKEN_NORM_LOG` | `~/.local/share/opencode/token-norm.log` | Threshold event log |
167
+ | `TOKEN_NORM_PYTHON` | `python3` | Interpreter for the audit script |
168
+ | `TOKEN_NORM_AUDIT_SCRIPT` | bundled | Override the audit script path |
169
+ | `TOKEN_NORM_SETTLE_MS` | `350` | Wait after `session_new` before pre-filling the prompt |
170
+ | `TOKEN_NORM_BUDGET` | `1` | Set `0` to disable the budget half |
171
+ | `TOKEN_NORM_HANDOFF` | `1` | Set `0` to disable the handoff tool |
172
+
173
+ Cheap tools do not count because reads and greps are how you *avoid* waste —
174
+ scaring the agent off them makes sessions more expensive, not less.
175
+
176
+ ---
177
+
178
+ ## Run the audit yourself
179
+
180
+ ```bash
181
+ python3 node_modules/opencode-token-norm/scripts/usage-audit.py --last
182
+ python3 node_modules/opencode-token-norm/scripts/usage-audit.py --top 5
183
+ python3 node_modules/opencode-token-norm/scripts/usage-audit.py --top 5 --json
184
+ ```
185
+
186
+ `effective fresh tokens = input + 0.1·cache_read + 1.25·cache_write` — the number
187
+ that maps to money. Raw `cache_read` does not.
188
+
189
+ ---
190
+
191
+ ## Pairs with
192
+
193
+ A rules file the reminders can point at. The plugin enforces; your `AGENTS.md`
194
+ still supplies the specifics (read windows, smallest test target, subagent
195
+ delegation, output caps).
196
+
197
+ ---
198
+
199
+ ## License
200
+
201
+ MIT
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Run the usage audit and return a compact result. Must never throw and must
3
+ * never hang: a checkpoint that breaks the session is worse than no checkpoint.
4
+ */
5
+ export declare function runAudit(): string;
package/dist/audit.js ADDED
@@ -0,0 +1,32 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { AUDIT_SCRIPT, PYTHON } from "./config.js";
3
+ const KEEP_LINE = /^(totals|effective|calls|cacheR?|cache)\b/;
4
+ /**
5
+ * Run the usage audit and return a compact result. Must never throw and must
6
+ * never hang: a checkpoint that breaks the session is worse than no checkpoint.
7
+ */
8
+ export function runAudit() {
9
+ try {
10
+ const out = execFileSync(PYTHON, [AUDIT_SCRIPT, "--last"], {
11
+ encoding: "utf8",
12
+ timeout: 20_000,
13
+ stdio: ["ignore", "pipe", "ignore"],
14
+ maxBuffer: 1024 * 1024,
15
+ });
16
+ // Keep only the lines that change a decision; the full table is noise here.
17
+ const keep = out
18
+ .split("\n")
19
+ .filter((l) => KEEP_LINE.test(l.trim()))
20
+ .join("\n");
21
+ return keep.trim() || out.trim().slice(0, 800);
22
+ }
23
+ catch (err) {
24
+ const reason = String(err instanceof Error ? err.message : err).slice(0, 160);
25
+ return [
26
+ `(usage-audit did not run: ${reason})`,
27
+ `It needs python3 and opencode's local db. Run it manually:`,
28
+ ` ${PYTHON} ${AUDIT_SCRIPT} --last`,
29
+ ].join("\n");
30
+ }
31
+ }
32
+ //# sourceMappingURL=audit.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"audit.js","sourceRoot":"","sources":["../src/audit.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAA;AACjD,OAAO,EAAE,YAAY,EAAE,MAAM,EAAE,MAAM,aAAa,CAAA;AAElD,MAAM,SAAS,GAAG,2CAA2C,CAAA;AAE7D;;;GAGG;AACH,MAAM,UAAU,QAAQ;IACtB,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,YAAY,CAAC,MAAM,EAAE,CAAC,YAAY,EAAE,QAAQ,CAAC,EAAE;YACzD,QAAQ,EAAE,MAAM;YAChB,OAAO,EAAE,MAAM;YACf,KAAK,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,CAAC;YACnC,SAAS,EAAE,IAAI,GAAG,IAAI;SACvB,CAAC,CAAA;QACF,4EAA4E;QAC5E,MAAM,IAAI,GAAG,GAAG;aACb,KAAK,CAAC,IAAI,CAAC;aACX,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC;aACvC,IAAI,CAAC,IAAI,CAAC,CAAA;QACb,OAAO,IAAI,CAAC,IAAI,EAAE,IAAI,GAAG,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,CAAA;IAChD,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,MAAM,MAAM,GAAG,MAAM,CAAC,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,CAAA;QAC7E,OAAO;YACL,6BAA6B,MAAM,GAAG;YACtC,4DAA4D;YAC5D,KAAK,MAAM,IAAI,YAAY,SAAS;SACrC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IACd,CAAC;AACH,CAAC"}
@@ -0,0 +1,21 @@
1
+ /** Norm: ">~30 tool calls" needs an up-front cost statement. Warn slightly
2
+ * early so the announcement can still change the plan instead of narrating it. */
3
+ export declare const ANNOUNCE_AT: number;
4
+ /** Norm: "once midway through long tasks". Then keep reminding, because one
5
+ * notice 120 calls ago is not a live constraint. */
6
+ export declare const AUDIT_EVERY: number;
7
+ /** A session that is already large is where a NEW user request should start a
8
+ * fresh session instead of inheriting the old context. Below this, the handoff
9
+ * costs more than it saves. */
10
+ export declare const BOUNDARY_AT: number;
11
+ /** Cheap reads/greps are how you AVOID waste; do not scare the agent off them. */
12
+ export declare const CHEAP_TOOLS: Set<string>;
13
+ export declare const HANDOFF_DIR: string;
14
+ export declare const LOG_PATH: string;
15
+ /** Resolved relative to the installed package, not to the user's config dir,
16
+ * so the bundled script is found wherever npm/bun placed the package. */
17
+ export declare const AUDIT_SCRIPT: string;
18
+ export declare const PYTHON: string;
19
+ /** Opt out of one half without uninstalling the package. */
20
+ export declare const BUDGET_ENABLED: boolean;
21
+ export declare const HANDOFF_ENABLED: boolean;
package/dist/config.js ADDED
@@ -0,0 +1,37 @@
1
+ import path from "node:path";
2
+ import os from "node:os";
3
+ import { fileURLToPath } from "node:url";
4
+ function num(name, fallback) {
5
+ const raw = process.env[name];
6
+ if (!raw)
7
+ return fallback;
8
+ const parsed = Number.parseInt(raw, 10);
9
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
10
+ }
11
+ /** Norm: ">~30 tool calls" needs an up-front cost statement. Warn slightly
12
+ * early so the announcement can still change the plan instead of narrating it. */
13
+ export const ANNOUNCE_AT = num("TOKEN_NORM_ANNOUNCE_AT", 25);
14
+ /** Norm: "once midway through long tasks". Then keep reminding, because one
15
+ * notice 120 calls ago is not a live constraint. */
16
+ export const AUDIT_EVERY = num("TOKEN_NORM_AUDIT_EVERY", 60);
17
+ /** A session that is already large is where a NEW user request should start a
18
+ * fresh session instead of inheriting the old context. Below this, the handoff
19
+ * costs more than it saves. */
20
+ export const BOUNDARY_AT = num("TOKEN_NORM_BOUNDARY_AT", 40);
21
+ /** Cheap reads/greps are how you AVOID waste; do not scare the agent off them. */
22
+ export const CHEAP_TOOLS = new Set((process.env.TOKEN_NORM_CHEAP_TOOLS ?? "todowrite,question,skill")
23
+ .split(",")
24
+ .map((t) => t.trim())
25
+ .filter(Boolean));
26
+ const DATA_HOME = process.env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share");
27
+ export const HANDOFF_DIR = process.env.TOKEN_NORM_HANDOFF_DIR || path.join(DATA_HOME, "opencode", "handoff");
28
+ export const LOG_PATH = process.env.TOKEN_NORM_LOG || path.join(DATA_HOME, "opencode", "token-norm.log");
29
+ /** Resolved relative to the installed package, not to the user's config dir,
30
+ * so the bundled script is found wherever npm/bun placed the package. */
31
+ export const AUDIT_SCRIPT = process.env.TOKEN_NORM_AUDIT_SCRIPT ||
32
+ path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "scripts", "usage-audit.py");
33
+ export const PYTHON = process.env.TOKEN_NORM_PYTHON || "python3";
34
+ /** Opt out of one half without uninstalling the package. */
35
+ export const BUDGET_ENABLED = process.env.TOKEN_NORM_BUDGET !== "0";
36
+ export const HANDOFF_ENABLED = process.env.TOKEN_NORM_HANDOFF !== "0";
37
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA,OAAO,IAAI,MAAM,WAAW,CAAA;AAC5B,OAAO,EAAE,MAAM,SAAS,CAAA;AACxB,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAA;AAExC,SAAS,GAAG,CAAC,IAAY,EAAE,QAAgB;IACzC,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,CAAA;IAC7B,IAAI,CAAC,GAAG;QAAE,OAAO,QAAQ,CAAA;IACzB,MAAM,MAAM,GAAG,MAAM,CAAC,QAAQ,CAAC,GAAG,EAAE,EAAE,CAAC,CAAA;IACvC,OAAO,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,IAAI,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,QAAQ,CAAA;AAClE,CAAC;AAED;kFACkF;AAClF,MAAM,CAAC,MAAM,WAAW,GAAG,GAAG,CAAC,wBAAwB,EAAE,EAAE,CAAC,CAAA;AAE5D;oDACoD;AACpD,MAAM,CAAC,MAAM,WAAW,GAAG,GAAG,CAAC,wBAAwB,EAAE,EAAE,CAAC,CAAA;AAE5D;;+BAE+B;AAC/B,MAAM,CAAC,MAAM,WAAW,GAAG,GAAG,CAAC,wBAAwB,EAAE,EAAE,CAAC,CAAA;AAE5D,kFAAkF;AAClF,MAAM,CAAC,MAAM,WAAW,GAAG,IAAI,GAAG,CAChC,CAAC,OAAO,CAAC,GAAG,CAAC,sBAAsB,IAAI,0BAA0B,CAAC;KAC/D,KAAK,CAAC,GAAG,CAAC;KACV,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;KACpB,MAAM,CAAC,OAAO,CAAC,CACnB,CAAA;AAED,MAAM,SAAS,GAAG,OAAO,CAAC,GAAG,CAAC,aAAa,IAAI,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,EAAE,QAAQ,EAAE,OAAO,CAAC,CAAA;AAEzF,MAAM,CAAC,MAAM,WAAW,GAAG,OAAO,CAAC,GAAG,CAAC,sBAAsB,IAAI,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,UAAU,EAAE,SAAS,CAAC,CAAA;AAE5G,MAAM,CAAC,MAAM,QAAQ,GAAG,OAAO,CAAC,GAAG,CAAC,cAAc,IAAI,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,UAAU,EAAE,gBAAgB,CAAC,CAAA;AAExG;yEACyE;AACzE,MAAM,CAAC,MAAM,YAAY,GACvB,OAAO,CAAC,GAAG,CAAC,uBAAuB;IACnC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,gBAAgB,CAAC,CAAA;AAE5F,MAAM,CAAC,MAAM,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,iBAAiB,IAAI,SAAS,CAAA;AAEhE,4DAA4D;AAC5D,MAAM,CAAC,MAAM,cAAc,GAAG,OAAO,CAAC,GAAG,CAAC,iBAAiB,KAAK,GAAG,CAAA;AACnE,MAAM,CAAC,MAAM,eAAe,GAAG,OAAO,CAAC,GAAG,CAAC,kBAAkB,KAAK,GAAG,CAAA"}
@@ -0,0 +1,2 @@
1
+ import { type Plugin } from "@opencode-ai/plugin";
2
+ export declare const HandoffPlugin: Plugin;
@@ -0,0 +1,150 @@
1
+ // Session handoff ("new session per task with a 3-line handoff").
2
+ //
3
+ // The norm says split at phase boundaries. In practice the split does not
4
+ // happen, because splitting means the user leaving the TUI, opening a new
5
+ // session, and re-typing context by hand -- three manual steps at exactly the
6
+ // moment the warm session feels cheapest to continue. So the rule loses to
7
+ // friction every time.
8
+ //
9
+ // This makes the split one tool call. The agent writes the handoff, the plugin
10
+ // persists it to disk, opens a NEW TUI session, and pre-fills that session's
11
+ // prompt with the handoff text. The user lands in a cold session with the
12
+ // context already typed, and presses enter.
13
+ //
14
+ // The prompt is pre-filled but NOT submitted by default. An auto-submitted
15
+ // handoff would start burning tokens on a task the user may have wanted to
16
+ // redirect, and the whole point of the split is to give them that beat. Pass
17
+ // submit: true when the continuation is genuinely unattended.
18
+ import { tool } from "@opencode-ai/plugin";
19
+ import fs from "node:fs/promises";
20
+ import path from "node:path";
21
+ import { HANDOFF_DIR } from "./config.js";
22
+ import { log } from "./log.js";
23
+ // The TUI processes /tui/execute-command asynchronously: the request returns
24
+ // once the command is dispatched, not once the new session is mounted.
25
+ // Appending the prompt too early lands the text in the OLD session's editor,
26
+ // which is worse than not splitting at all -- the user sees nothing and the
27
+ // handoff is lost.
28
+ const SWITCH_SETTLE_MS = Number.parseInt(process.env.TOKEN_NORM_SETTLE_MS ?? "", 10) || 350;
29
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
30
+ // Plugin tools register for EVERY agent, subagents included. A subagent calling
31
+ // handoff would open a new TUI session in the middle of the parent's task --
32
+ // hijacking the user's screen for work they did not ask to split. Only a
33
+ // primary agent owns the session, so only a primary agent may end it.
34
+ //
35
+ // Resolved from the live agent list instead of a hardcoded name list, so agents
36
+ // the user adds later are classified correctly without touching this file.
37
+ async function isSubagent(client, agentName) {
38
+ if (!agentName)
39
+ return false;
40
+ try {
41
+ const res = await client.app.agents();
42
+ const agents = res?.data ?? res;
43
+ const found = Array.isArray(agents) ? agents.find((a) => a.name === agentName) : undefined;
44
+ return found?.mode === "subagent";
45
+ }
46
+ catch {
47
+ // If the lookup fails, allow. A false block would strand the primary agent
48
+ // with no way to split, which is the failure this plugin exists to prevent.
49
+ return false;
50
+ }
51
+ }
52
+ function stamp() {
53
+ return new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
54
+ }
55
+ function renderHandoff({ task, done, next, files, notes }) {
56
+ const lines = [`## Handoff`, ``, `**Task:** ${task}`, `**Done:** ${done}`, `**Next:** ${next}`];
57
+ if (files?.length) {
58
+ lines.push(``, `**Files in play:**`);
59
+ for (const f of files)
60
+ lines.push(`- ${f}`);
61
+ }
62
+ if (notes)
63
+ lines.push(``, `**Notes:**`, notes);
64
+ return lines.join("\n");
65
+ }
66
+ export const HandoffPlugin = async ({ client, directory }) => {
67
+ return {
68
+ tool: {
69
+ handoff: tool({
70
+ description: [
71
+ "End the current session at a phase boundary and continue in a FRESH session.",
72
+ "Persists a handoff note to disk, opens a new TUI session, and pre-fills its prompt",
73
+ "with that note so the user only has to press enter.",
74
+ "",
75
+ "Use when: diagnosis is done and implementation has not started; the user asks for a",
76
+ "new/clean session; context is large and the remaining work does not need the",
77
+ "accumulated tool output; or the session-budget audit says to split.",
78
+ "",
79
+ "Write the handoff for a reader with ZERO context. Name real file paths and real",
80
+ "identifiers you verified this session -- the new session cannot see your scrollback.",
81
+ ].join("\n"),
82
+ args: {
83
+ task: tool.schema.string().describe("The one-line task the next session must accomplish."),
84
+ done: tool.schema
85
+ .string()
86
+ .describe("What is already finished and verified. Be concrete; include findings worth keeping."),
87
+ next: tool.schema.string().describe("The exact next action the fresh session should take first."),
88
+ files: tool.schema
89
+ .array(tool.schema.string())
90
+ .optional()
91
+ .describe("Absolute paths (file:line where useful) the next session will need to open."),
92
+ notes: tool.schema
93
+ .string()
94
+ .optional()
95
+ .describe("Decisions, constraints, dead ends already ruled out, verified identifiers/commands."),
96
+ submit: tool.schema
97
+ .boolean()
98
+ .optional()
99
+ .describe("Auto-submit the handoff in the new session. Default false: the user presses enter."),
100
+ },
101
+ async execute(args, ctx) {
102
+ if (await isSubagent(client, ctx.agent)) {
103
+ return {
104
+ title: "Handoff refused",
105
+ output: [
106
+ `handoff is not available to subagents (you are "${ctx.agent}", mode: subagent).`,
107
+ `Return your findings to the parent agent and let it decide whether to split.`,
108
+ ].join("\n"),
109
+ metadata: { refused: "subagent", agent: ctx.agent },
110
+ };
111
+ }
112
+ const body = renderHandoff(args);
113
+ await fs.mkdir(HANDOFF_DIR, { recursive: true });
114
+ const notePath = path.join(HANDOFF_DIR, `${stamp()}-${ctx.sessionID}.md`);
115
+ // Persist BEFORE switching. If the TUI call fails, the handoff still
116
+ // exists on disk and the user can recover it manually; the reverse
117
+ // ordering would lose the note on exactly the failure that matters.
118
+ await fs.writeFile(notePath, `${body}\n\n_from session ${ctx.sessionID} in ${directory}_\n`, "utf8");
119
+ log(`${ctx.sessionID} handoff written to ${notePath}`);
120
+ const prompt = `${body}\n\n_Handoff note: ${notePath}_\n`;
121
+ await client.tui.executeCommand({ body: { command: "session_new" } });
122
+ await sleep(SWITCH_SETTLE_MS);
123
+ await client.tui.appendPrompt({ body: { text: prompt } });
124
+ if (args.submit)
125
+ await client.tui.submitPrompt();
126
+ await client.tui.showToast({
127
+ body: {
128
+ title: "Handoff",
129
+ message: args.submit ? "New session started" : "New session ready — press enter",
130
+ variant: "success",
131
+ },
132
+ });
133
+ return {
134
+ title: "Handed off to new session",
135
+ output: [
136
+ `Handoff written to ${notePath}`,
137
+ `New session opened; prompt ${args.submit ? "submitted" : "pre-filled (awaiting enter)"}.`,
138
+ ``,
139
+ `STOP HERE. This session is over. Do not continue the task, do not make further`,
140
+ `tool calls, and do not summarize beyond one line — the work now belongs to the`,
141
+ `new session. Continuing here spends the context the handoff exists to discard.`,
142
+ ].join("\n"),
143
+ metadata: { notePath, submitted: !!args.submit },
144
+ };
145
+ },
146
+ }),
147
+ },
148
+ };
149
+ };
150
+ //# sourceMappingURL=handoff.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"handoff.js","sourceRoot":"","sources":["../src/handoff.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,3 @@
1
+ import type { Plugin } from "@opencode-ai/plugin";
2
+ export declare const TokenNormBudget: Plugin;
3
+ export declare const TokenNormHandoff: Plugin;
package/dist/index.js ADDED
@@ -0,0 +1,9 @@
1
+ import { BUDGET_ENABLED, HANDOFF_ENABLED } from "./config.js";
2
+ import { SessionBudgetPlugin } from "./session-budget.js";
3
+ import { HandoffPlugin } from "./handoff.js";
4
+ const noop = async () => ({});
5
+ // Both halves are exported so a user can load one without the other, and both
6
+ // respect an env kill switch so opting out never requires uninstalling.
7
+ export const TokenNormBudget = BUDGET_ENABLED ? SessionBudgetPlugin : noop;
8
+ export const TokenNormHandoff = HANDOFF_ENABLED ? HandoffPlugin : noop;
9
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,aAAa,CAAA;AAC7D,OAAO,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAA;AACzD,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAA;AAE5C,MAAM,IAAI,GAAW,KAAK,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC,CAAA;AAErC,8EAA8E;AAC9E,wEAAwE;AACxE,MAAM,CAAC,MAAM,eAAe,GAAW,cAAc,CAAC,CAAC,CAAC,mBAAmB,CAAC,CAAC,CAAC,IAAI,CAAA;AAClF,MAAM,CAAC,MAAM,gBAAgB,GAAW,eAAe,CAAC,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC,IAAI,CAAA"}
package/dist/log.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare function log(line: string): void;
package/dist/log.js ADDED
@@ -0,0 +1,13 @@
1
+ import { appendFileSync, mkdirSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { LOG_PATH } from "./config.js";
4
+ export function log(line) {
5
+ try {
6
+ mkdirSync(dirname(LOG_PATH), { recursive: true });
7
+ appendFileSync(LOG_PATH, `${new Date().toISOString()} ${line}\n`);
8
+ }
9
+ catch {
10
+ /* logging must never break a session */
11
+ }
12
+ }
13
+ //# sourceMappingURL=log.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"log.js","sourceRoot":"","sources":["../src/log.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,SAAS,CAAA;AACnD,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAA;AACnC,OAAO,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAA;AAEtC,MAAM,UAAU,GAAG,CAAC,IAAY;IAC9B,IAAI,CAAC;QACH,SAAS,CAAC,OAAO,CAAC,QAAQ,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAA;QACjD,cAAc,CAAC,QAAQ,EAAE,GAAG,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE,IAAI,IAAI,IAAI,CAAC,CAAA;IACnE,CAAC;IAAC,MAAM,CAAC;QACP,wCAAwC;IAC1C,CAAC;AACH,CAAC"}
@@ -0,0 +1,2 @@
1
+ import type { Plugin } from "@opencode-ai/plugin";
2
+ export declare const SessionBudgetPlugin: Plugin;
@@ -0,0 +1,175 @@
1
+ // Session budget enforcement (always-on, all projects).
2
+ //
3
+ // WHY THIS EXISTS
4
+ // A token norm written into AGENTS.md loads into every session and is still
5
+ // broken, because the rules that survive are the ones that do not depend on the
6
+ // agent choosing to follow them. Capping bash output works every time because a
7
+ // plugin rewrites the command and nobody has to remember it. The two rules with
8
+ // no mechanism -- "announce the cost before a big task" and "run the audit
9
+ // midway" -- were the exact two that failed in a 184-call, 3.0M effective-token
10
+ // session where the norm was in context the whole time.
11
+ //
12
+ // So this plugin does not add advice. It counts, and at thresholds it staples
13
+ // a notice onto tool output the agent is already reading. An instruction the
14
+ // agent cannot skip past beats an instruction it merely has.
15
+ //
16
+ // It never blocks, never edits args, never fails a tool call: a wrong guess
17
+ // here must cost a few lines of text, not a broken session.
18
+ import { runAudit } from "./audit.js";
19
+ import { log } from "./log.js";
20
+ import { ANNOUNCE_AT, AUDIT_EVERY, BOUNDARY_AT, CHEAP_TOOLS } from "./config.js";
21
+ const state = new Map();
22
+ function track(sessionID, tool) {
23
+ let s = state.get(sessionID);
24
+ if (!s) {
25
+ s = { calls: 0, announced: false, lastAudit: 0, tools: new Map(), seenMessages: new Set(), pendingBoundary: false };
26
+ state.set(sessionID, s);
27
+ }
28
+ if (!CHEAP_TOOLS.has(tool))
29
+ s.calls++;
30
+ s.tools.set(tool, (s.tools.get(tool) ?? 0) + 1);
31
+ return s;
32
+ }
33
+ function topTools(s) {
34
+ return [...s.tools.entries()]
35
+ .sort((a, b) => b[1] - a[1])
36
+ .slice(0, 3)
37
+ .map(([t, n]) => `${t} ${n}`)
38
+ .join(", ");
39
+ }
40
+ function note(lines) {
41
+ return `\n\n<system-reminder>\n${lines.join("\n")}\n</system-reminder>`;
42
+ }
43
+ export const SessionBudgetPlugin = async () => {
44
+ return {
45
+ // A new user message in an already-large session is the task boundary the
46
+ // norm cares about most, and the one with no mechanism until now. In a
47
+ // 195k-token session the agent had the rule in context, ran the audit,
48
+ // reported "77x cache, bloat HIGH" -- and then continued anyway, because a
49
+ // second task ("do all of them") was treated as a continuation of the
50
+ // "do everything" override granted for the first. Overrides are per-task;
51
+ // nothing enforced that. This fires on the NEXT tool call after such a
52
+ // message, which is the earliest point the agent cannot skip past.
53
+ event: async ({ event }) => {
54
+ try {
55
+ if (event?.type !== "message.updated")
56
+ return;
57
+ const info = event.properties?.info;
58
+ if (info?.role !== "user")
59
+ return;
60
+ const s = state.get(info.sessionID);
61
+ if (!s || s.calls < BOUNDARY_AT)
62
+ return;
63
+ // Dedupe on MESSAGE IDENTITY, not on call count.
64
+ //
65
+ // `message.updated` fires many times for the SAME user message (it is
66
+ // an update event: streaming, metadata, revisions). An earlier guard
67
+ // compared `boundaryAt === s.calls`, but `s.calls` increments on every
68
+ // tool call, so it went stale after one tool call and re-armed. One
69
+ // live session fired this reminder 61 times for a single user message
70
+ // -- on nearly every tool call for the rest of the session.
71
+ //
72
+ // That is worse than not firing at all. A warning that repeats on
73
+ // every tool call becomes wallpaper, and the agent learns to skip ALL
74
+ // system-reminders -- including the audit checkpoint, which in that
75
+ // same session was ignored precisely because it arrived buried in the
76
+ // 61st copy of this one. Cry wolf once per wolf.
77
+ if (!info.id || s.seenMessages.has(info.id))
78
+ return;
79
+ s.seenMessages.add(info.id);
80
+ s.pendingBoundary = true;
81
+ log(`${info.sessionID} task-boundary at ${s.calls} calls (msg ${info.id})`);
82
+ }
83
+ catch {
84
+ /* a missed boundary must never break the session */
85
+ }
86
+ },
87
+ "tool.execute.after": async (input, output) => {
88
+ let s;
89
+ try {
90
+ s = track(input.sessionID, input.tool);
91
+ }
92
+ catch {
93
+ return;
94
+ }
95
+ // Threshold 0 -- task boundary. Highest priority: acting on it avoids
96
+ // the spend the other two thresholds only measure after the fact.
97
+ if (s.pendingBoundary) {
98
+ s.pendingBoundary = false;
99
+ output.output += note([
100
+ `TOKEN NORM -- new request arrived ${s.calls} tool calls deep. This is a TASK BOUNDARY.`,
101
+ `A prior "do everything" / "don't ask" was scoped to the PREVIOUS task. It does not carry.`,
102
+ `Before continuing, in your next message:`,
103
+ ` 1. Say whether this request names new files/subsystems (-> new task, not a continuation).`,
104
+ ` 2. If new: propose finishing here with a 3-line handoff (done / state / next),`,
105
+ ` and let the user start it fresh. Cold start = system floor + a few targeted reads.`,
106
+ ` 3. If the user re-grants the override, proceed -- but state the cost first.`,
107
+ `Do NOT justify continuing with "finishing here beats reloading cold". That reasoning`,
108
+ `is always available, feels free only because this context is already warm, and is the`,
109
+ `exact rationalization the norm exists to block.`,
110
+ ]);
111
+ return;
112
+ }
113
+ // Threshold 1 -- the startup reflex, fired late but before the bulk of
114
+ // the spend. The norm wants this BEFORE the work; in practice the agent
115
+ // only discovers the true size once it is underway, so catch it at the
116
+ // first moment the task is provably "big" and force the statement then.
117
+ if (!s.announced && s.calls >= ANNOUNCE_AT) {
118
+ s.announced = true;
119
+ log(`${input.sessionID} announce-threshold at ${s.calls} calls (${topTools(s)})`);
120
+ output.output += note([
121
+ `TOKEN NORM -- ${s.calls} tool calls in this session (${topTools(s)}).`,
122
+ `This is now a "big task" under the norm, which required a cost statement BEFORE starting.`,
123
+ `Do this now, in your next message to the user, before more tool calls:`,
124
+ ` 1. State remaining expected tool calls and what will drive them.`,
125
+ ` 2. State your caps (read windows, smallest test target, no whole-file reads).`,
126
+ ` 3. Offer a session split: which part could ship now with a 3-line handoff?`,
127
+ `If the user already said "do everything", the split is overridden -- the cost`,
128
+ `statement and the audit are NOT. Say so explicitly rather than staying silent.`,
129
+ ]);
130
+ return;
131
+ }
132
+ // Threshold 2 -- the midway audit. Recurring, because the norm's real
133
+ // failure mode is a session that quietly runs 3x past where a split
134
+ // should have happened.
135
+ if (s.calls > 0 && s.calls - s.lastAudit >= AUDIT_EVERY) {
136
+ s.lastAudit = s.calls;
137
+ log(`${input.sessionID} audit-threshold at ${s.calls} calls (${topTools(s)})`);
138
+ // Run the audit HERE rather than asking the agent to run it.
139
+ //
140
+ // "Run this command and report the number" is advice, and advice at a
141
+ // checkpoint loses to the task in flight every time: one session was
142
+ // told to audit at call 79, kept working, and produced the number only
143
+ // when the user asked afterwards. The command is cheap, deterministic
144
+ // and read-only, so the plugin runs it and staples the RESULT on. The
145
+ // agent then has the number in hand and no step to defer -- only a
146
+ // fact to report.
147
+ const audit = runAudit();
148
+ output.output += note([
149
+ `TOKEN NORM -- ${s.calls} tool calls. Audit checkpoint (ran for you):`,
150
+ ``,
151
+ audit,
152
+ ``,
153
+ `In your NEXT message, before continuing the task: report the effective-token`,
154
+ `number and the cache multiplier to the user, and say whether you are splitting.`,
155
+ `If work remains: state/done/next in a NOTES file, then propose a fresh session.`,
156
+ ]);
157
+ }
158
+ },
159
+ // Compaction is the one moment the agent provably re-reads its own rules.
160
+ // Adding the number matters, because "you are 180 calls deep" is a fact
161
+ // that changes behavior and "be frugal" is not.
162
+ "experimental.session.compacting": async (input, output) => {
163
+ const s = state.get(input.sessionID);
164
+ if (!s)
165
+ return;
166
+ output.context.push(`## Session budget at compaction
167
+ This session has made ${s.calls} tool calls (${topTools(s)}).
168
+ Compaction is itself evidence the session ran too long for one task.
169
+ - Run the usage audit and report the effective-token number to the user.
170
+ - Put state/done/next in a NOTES file, not in the rebuilt context.
171
+ - Propose finishing here with a 3-line handoff and starting the next task fresh.`);
172
+ },
173
+ };
174
+ };
175
+ //# sourceMappingURL=session-budget.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session-budget.js","sourceRoot":"","sources":["../src/session-budget.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "opencode-token-norm",
3
+ "version": "0.1.0",
4
+ "description": "Enforces a token budget in OpenCode: counts tool calls, staples un-skippable reminders at thresholds, runs the usage audit for you, and makes the session split a single tool call",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "salitaba",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/salitaba/opencode-token-norm.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/salitaba/opencode-token-norm/issues"
14
+ },
15
+ "homepage": "https://github.com/salitaba/opencode-token-norm#readme",
16
+ "keywords": [
17
+ "opencode",
18
+ "opencode-plugin",
19
+ "tokens",
20
+ "cost",
21
+ "context",
22
+ "budget",
23
+ "handoff",
24
+ "session"
25
+ ],
26
+ "exports": {
27
+ ".": {
28
+ "import": "./dist/index.js",
29
+ "types": "./dist/index.d.ts"
30
+ }
31
+ },
32
+ "files": [
33
+ "dist",
34
+ "scripts",
35
+ "README.md",
36
+ "LICENSE"
37
+ ],
38
+ "scripts": {
39
+ "build": "tsc",
40
+ "typecheck": "tsc --noEmit",
41
+ "prepublishOnly": "npm run build"
42
+ },
43
+ "dependencies": {
44
+ "@opencode-ai/plugin": "^1.15.12"
45
+ },
46
+ "devDependencies": {
47
+ "@types/node": "^24.12.2",
48
+ "typescript": "^5.8.2"
49
+ },
50
+ "engines": {
51
+ "node": ">=22"
52
+ }
53
+ }
@@ -0,0 +1,292 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ usage-audit — read-only token-usage audit for opencode sessions.
4
+
5
+ Reports the four numbers that matter (calls, context/call, cache reads, read
6
+ hogs) plus a cause breakdown, straight from opencode's local sqlite DB.
7
+ Safe to run anytime: opens the DB read-only, never writes.
8
+
9
+ Usage:
10
+ python3 usage-audit.py --last # most recently updated session
11
+ python3 usage-audit.py --session <id> # a specific session
12
+ python3 usage-audit.py --top 5 # rank recent sessions by cache reads
13
+ python3 usage-audit.py --top 5 --json # machine-readable
14
+
15
+ DB path: ~/.local/share/opencode/opencode.db (override with OPENCODE_DB).
16
+ """
17
+ import argparse
18
+ import json
19
+ import os
20
+ import sqlite3
21
+ import sys
22
+ from collections import Counter, defaultdict
23
+ from pathlib import Path
24
+
25
+ DB = Path(os.environ.get("OPENCODE_DB", Path.home() / ".local/share/opencode/opencode.db"))
26
+
27
+
28
+ def connect() -> sqlite3.Connection:
29
+ if not DB.exists():
30
+ sys.exit(f"no opencode db at {DB}")
31
+ c = sqlite3.connect(f"file:{DB}?mode=ro", uri=True)
32
+ c.row_factory = sqlite3.Row
33
+ return c
34
+
35
+
36
+ def fmt(n: float) -> str:
37
+ if n >= 1e6:
38
+ return f"{n/1e6:.1f}M"
39
+ if n >= 1e3:
40
+ return f"{n/1e3:.0f}k"
41
+ return f"{n:.0f}"
42
+
43
+
44
+ def effective_fresh(input_tok: int, cache_read: int, cache_write: int) -> float:
45
+ """Cost-normalized input: cache reads ~0.1x, writes ~1.25x (5-min TTL,
46
+ Anthropic list; other providers similar). This is the number that maps to
47
+ money — raw cache_read does not."""
48
+ return input_tok + 0.1 * cache_read + 1.25 * cache_write
49
+
50
+
51
+ def session_rows(c: sqlite3.Connection, limit: int = 20):
52
+ return c.execute(
53
+ """select id, title, model, cost, tokens_input, tokens_output,
54
+ tokens_cache_read, tokens_cache_write, time_updated
55
+ from session
56
+ where tokens_cache_read > 0 or tokens_input > 0
57
+ order by time_updated desc
58
+ limit ?""",
59
+ (limit,),
60
+ ).fetchall()
61
+
62
+
63
+ def part_series(c: sqlite3.Connection, sid: str):
64
+ """Per-call token series + part-level byte hogs, from the parts table."""
65
+ try:
66
+ rows = c.execute(
67
+ """select p.data from part p
68
+ join message m on m.id = p.message_id
69
+ where p.session_id = ? and p.data like '%step-finish%'
70
+ order by p.time_created""",
71
+ (sid,),
72
+ ).fetchall()
73
+ except sqlite3.Error:
74
+ return None, None
75
+ calls = []
76
+ for (data,) in rows:
77
+ try:
78
+ p = json.loads(data)
79
+ except Exception:
80
+ continue
81
+ if p.get("type") != "step-finish" or not p.get("tokens"):
82
+ continue
83
+ t = p["tokens"]
84
+ calls.append(
85
+ {
86
+ "input": t.get("input", 0),
87
+ "output": t.get("output", 0),
88
+ "cache_read": t.get("cache", {}).get("read", 0),
89
+ "total": t.get("total", 0),
90
+ }
91
+ )
92
+ hogs = defaultdict(int)
93
+ cnt = Counter()
94
+ images = []
95
+ try:
96
+ rows2 = c.execute(
97
+ """select p.data from part p
98
+ join message m on m.id = p.message_id
99
+ where p.session_id = ?""",
100
+ (sid,),
101
+ ).fetchall()
102
+ except sqlite3.Error:
103
+ rows2 = []
104
+ for (data,) in rows2:
105
+ try:
106
+ p = json.loads(data)
107
+ except Exception:
108
+ continue
109
+ if p.get("type") != "tool":
110
+ continue
111
+ tool = p.get("tool", "?")
112
+ size = len(data)
113
+ hogs[tool] += size
114
+ cnt[tool] += 1
115
+ if tool == "read":
116
+ fp = p.get("state", {}).get("input", {}).get("filePath", "?")
117
+ if fp.lower().endswith((".png", ".jpg", ".jpeg", ".webp", ".gif")):
118
+ images.append((size, fp))
119
+ top_files = Counter()
120
+ try:
121
+ rows3 = c.execute(
122
+ """select p.data from part p
123
+ join message m on m.id = p.message_id
124
+ where p.session_id = ? and p.data like '%"tool":"read"%'""",
125
+ (sid,),
126
+ ).fetchall()
127
+ except sqlite3.Error:
128
+ rows3 = []
129
+ for (data,) in rows3:
130
+ try:
131
+ p = json.loads(data)
132
+ except Exception:
133
+ continue
134
+ if p.get("type") != "tool" or p.get("tool") != "read":
135
+ continue
136
+ fp = p.get("state", {}).get("input", {}).get("filePath", "?")
137
+ top_files[fp] += len(data)
138
+ return calls, {"tools": hogs, "counts": cnt, "top_files": top_files, "images": sorted(images, reverse=True)}
139
+
140
+
141
+ def med(xs):
142
+ if not xs:
143
+ return 0
144
+ s = sorted(xs)
145
+ return s[len(s) // 2]
146
+
147
+
148
+ def audit(sid: str) -> dict:
149
+ c = connect()
150
+ r = c.execute(
151
+ """select id, title, model, cost, tokens_input, tokens_output,
152
+ tokens_cache_read, tokens_cache_write, time_updated
153
+ from session where id = ?""",
154
+ (sid,),
155
+ ).fetchone()
156
+ if not r:
157
+ sys.exit(f"no session {sid}")
158
+ calls, hogs = part_series(c, sid)
159
+ out = {
160
+ "session": r["id"],
161
+ "title": r["title"],
162
+ "model": json.loads(r["model"]).get("id") if r["model"] else None,
163
+ "totals": {
164
+ "input": r["tokens_input"] or 0,
165
+ "output": r["tokens_output"] or 0,
166
+ "cache_read": r["tokens_cache_read"] or 0,
167
+ "cache_write": r["tokens_cache_write"] or 0,
168
+ "effective_fresh": effective_fresh(
169
+ r["tokens_input"] or 0, r["tokens_cache_read"] or 0, r["tokens_cache_write"] or 0
170
+ ),
171
+ "cost_usd": r["cost"] or 0,
172
+ },
173
+ }
174
+ if calls:
175
+ out["calls"] = len(calls)
176
+ for k in ("input", "cache_read", "total"):
177
+ xs = [x[k] for x in calls]
178
+ out[f"per_call_{k}"] = {
179
+ "min": min(xs),
180
+ "median": med(xs),
181
+ "max": max(xs),
182
+ }
183
+ # first-call floor ~= system prompt + tools + skills tax
184
+ out["first_call_total"] = calls[0]["total"]
185
+ if hogs:
186
+ out["tool_bytes"] = {k: v for k, v in sorted(hogs["tools"].items(), key=lambda kv: -kv[1])}
187
+ out["top_file_reads"] = [
188
+ (fp, b) for fp, b in hogs["top_files"].most_common(10)
189
+ ]
190
+ out["image_attachments"] = [
191
+ (fp, b) for b, fp in hogs["images"][:6]
192
+ ]
193
+ c.close()
194
+ return out
195
+
196
+
197
+ def rank(top: int) -> list:
198
+ c = connect()
199
+ rows = session_rows(c, top)
200
+ out = []
201
+ for r in rows:
202
+ model = None
203
+ try:
204
+ model = json.loads(r["model"]).get("id") if r["model"] else None
205
+ except Exception:
206
+ pass
207
+ out.append(
208
+ {
209
+ "id": r["id"],
210
+ "title": (r["title"] or "")[:60],
211
+ "model": model,
212
+ "input": r["tokens_input"] or 0,
213
+ "output": r["tokens_output"] or 0,
214
+ "cache_read": r["tokens_cache_read"] or 0,
215
+ "effective_fresh": effective_fresh(
216
+ r["tokens_input"] or 0, r["tokens_cache_read"] or 0, r["tokens_cache_write"] or 0
217
+ ),
218
+ }
219
+ )
220
+ c.close()
221
+ return out
222
+
223
+
224
+ def main():
225
+ ap = argparse.ArgumentParser(description="audit opencode session token usage")
226
+ g = ap.add_mutually_exclusive_group(required=True)
227
+ g.add_argument("--last", action="store_true", help="most recently updated session")
228
+ g.add_argument("--session", metavar="ID", help="session id")
229
+ g.add_argument("--top", type=int, metavar="N", help="rank the N most recent sessions")
230
+ ap.add_argument("--json", action="store_true", help="raw json output")
231
+ args = ap.parse_args()
232
+
233
+ if args.top:
234
+ rows = rank(args.top)
235
+ if args.json:
236
+ print(json.dumps(rows, indent=2))
237
+ return
238
+ rows.sort(key=lambda r: -r["effective_fresh"])
239
+ print(f"{'effective':>12} {'cache_read':>10} {'input':>8} title")
240
+ for r in rows:
241
+ print(
242
+ f"{fmt(r['effective_fresh']):>12} {fmt(r['cache_read']):>10} {fmt(r['input']):>8} "
243
+ f"{r['title']} ({r['id'][4:16]}…) {r['model']}"
244
+ )
245
+ return
246
+
247
+ sid = None
248
+ if args.session:
249
+ sid = args.session
250
+ elif args.last:
251
+ c = connect()
252
+ r = c.execute("select id from session order by time_updated desc limit 1").fetchone()
253
+ c.close()
254
+ sid = r["id"] if r else None
255
+ if not sid:
256
+ sys.exit("no session found")
257
+ a = audit(sid)
258
+ if args.json:
259
+ print(json.dumps(a, indent=2))
260
+ return
261
+
262
+ t = a["totals"]
263
+ eff = t["effective_fresh"]
264
+ print(f"session : {a['session']} «{a['title']}»")
265
+ print(f"model : {a['model']}")
266
+ print(f"totals : input {fmt(t['input'])} output {fmt(t['output'])} "
267
+ f"cache_read {fmt(t['cache_read'])} cache_write {fmt(t['cache_write'])}")
268
+ print(f"effective fresh tokens: {fmt(eff)} (input + 0.1·cache_read + 1.25·cache_write — the money number) cost ${t['cost_usd']:.4f}")
269
+ if "calls" in a:
270
+ p = a["per_call_total"]
271
+ c = a["per_call_cache_read"]
272
+ print(f"calls : {a['calls']} context/call min {fmt(p['min'])} med {fmt(p['median'])} max {fmt(p['max'])}")
273
+ print(f"cacheR : per-call med {fmt(c['median'])} (first-call total {fmt(a['first_call_total'])} = system floor)")
274
+ ratio = t["cache_read"] / max(1, t["input"] + t["output"])
275
+ print(f"cache : {ratio:.0f}x fresh tokens — bloat driver "
276
+ + ("HIGH" if p["median"] > 120000 else "ok" if p["median"] <= 60000 else "watch"))
277
+ print("tool bytes (payload that re-enters the conversation):")
278
+ for tool, b in a.get("tool_bytes", {}).items():
279
+ print(f" {tool:10s} {fmt(b):>8} B")
280
+ print("biggest file reads (bytes re-entering context):")
281
+ for fp, b in a.get("top_file_reads", [])[:8]:
282
+ print(f" {fmt(b):>8} B {fp}")
283
+ imgs = a.get("image_attachments", [])
284
+ if imgs:
285
+ print("image attachments — each is ~1.3k-1.6k vision tokens per call "
286
+ "it stays in history (28x28-px tiles), NOT priced by file bytes:")
287
+ for fp, b in imgs:
288
+ print(f" {fmt(b):>8} B {fp}")
289
+
290
+
291
+ if __name__ == "__main__":
292
+ main()