tokenjam 0.5.7 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +6 -14
  2. package/bin/tj.js +198 -70
  3. package/package.json +4 -4
package/README.md CHANGED
@@ -11,17 +11,15 @@
11
11
 
12
12
  </div>
13
13
 
14
- TokenJam ingests telemetry data about your agents from a multitude of sources and provides you a quick and easy way to visualize and optimize cost so that you get the most out of the tokens you pay for. This package is the zero-install launcher: one command, no pip environment, no config.
14
+ TokenJam ingests telemetry data about your agents from a multitude of sources and provides you a quick and easy way to visualize and optimize cost so that you get the most out of the tokens you pay for. This package is the zero-install launcher: no pip environment, no manual config.
15
15
 
16
16
  ```bash
17
- npx tokenjam
17
+ npx tokenjam onboard # or: pipx install tokenjam && tj onboard
18
18
  ```
19
19
 
20
20
  ## What you get
21
21
 
22
- Bare `npx tokenjam` reads the session logs you already have (Claude Code today; more sources land in the full CLI) and prints a 15-second, read-only report: quota composition (what share of your tokens went to re-reading history and context vs. net-new work) plus a session timeline. Nothing is installed, nothing is kept.
23
-
24
- For the full setup, run `npx tokenjam onboard`: it wires up live capture, all six analyzers, the Lens dashboard, and the zero-token statusline in one command.
22
+ `tj onboard` is guided setup: it writes a config, generates an ingest secret, and asks how you use AI agents (Claude Code, Codex, or your own SDK/API agents) to wire the right path. For Claude Code and Codex that means backfilling recent history and installing a statusline and hooks for live capture; restart and you're live. Onboarding unlocks all six analyzers, the Lens dashboard, and the zero-token statusline in one command.
25
23
 
26
24
  ## Commands
27
25
 
@@ -29,20 +27,14 @@ All arguments pass straight through to the Python CLI, so any `tj` subcommand an
29
27
 
30
28
  | Command | What it does |
31
29
  |---|---|
32
- | `npx tokenjam` | Zero-install first run: quota composition and a session timeline from your existing session logs. |
30
+ | `npx tokenjam onboard` | Guided setup: writes a config, generates an ingest secret, and optionally installs the background daemon for live capture. |
33
31
  | `npx tokenjam context` | Where your quota goes: re-read vs. net-new share, recurring inclusions, `/compact` candidates. |
34
32
  | `npx tokenjam optimize` | Cost-saving candidates: model downsizing, cache opportunities, prompt trimming, workflow reuse, subagent right-sizing. |
35
- | `npx tokenjam onboard` | Guided setup: writes a config, generates an ingest secret, and optionally installs the background daemon for live capture. |
33
+ | `npx tokenjam` | Bare run: still works, still zero-install, still a reference passthrough to the Python CLI. |
36
34
 
37
35
  ## Go deeper
38
36
 
39
- `npx tokenjam` is the no-setup front door. One command sets up live capture, the local Lens dashboard, and the zero-token statusline:
40
-
41
- ```bash
42
- npx tokenjam onboard # or: pipx install tokenjam && tj onboard
43
- ```
44
-
45
- `tj onboard` asks how you use AI agents (Claude Code, Codex, or your own SDK/API agents) and wires the right path. For Claude Code and Codex that means backfilling recent history plus a statusline and hooks; restart and you're live. From there:
37
+ `tj onboard` sets up live capture, the local Lens dashboard, and the zero-token statusline in one command. From there:
46
38
 
47
39
  ```bash
48
40
  tj optimize # cost-saving candidates from your actual usage
package/bin/tj.js CHANGED
@@ -7,12 +7,13 @@
7
7
  * `npx <tool>` first. `npx tokenjam` here resolves a Python launcher with NO pip env,
8
8
  * NO daemon, NO onboarding — it shells out to the Python CLI via the first
9
9
  * available runner and hands every argument straight through. Bare `npx tokenjam`
10
- * (no subcommand) routes to `tj quickstart`: where your Claude Code quota goes,
11
- * from the same ~/.claude/projects/*.jsonl files ccusage reads, in one command.
10
+ * (no subcommand) prints the zero-install report: where your Claude Code quota
11
+ * goes, from the same ~/.claude/projects/*.jsonl files ccusage reads, in one
12
+ * command.
12
13
  *
13
14
  * Runner preference (first that exists wins):
14
- * 1. `uvx --from tokenjam tj …` — fully ephemeral, downloads nothing global
15
- * 2. `pipx run --spec tokenjam tj …`
15
+ * 1. `uvx --from tokenjam==<own version> tj …` — fully ephemeral, downloads nothing global
16
+ * 2. `pipx run --spec tokenjam==<own version> tj …`
16
17
  * 3. `tj …` — an already-installed CLI on PATH
17
18
  *
18
19
  * If none are present we print actionable install guidance and exit non-zero.
@@ -22,20 +23,27 @@
22
23
  * wrapper keeps the explicit `--from tokenjam tj` / `--spec tokenjam tj` form
23
24
  * below for back-compat with the 0.5.3 and earlier releases it also targets.
24
25
  *
25
- * Freshness (issue #111): `uv` reuses its cached tool environment and never
26
- * re-resolves on its own, so a machine that first ran this wrapper on an old
27
- * release keeps getting that release forever, even after newer ones hit
28
- * PyPI. To avoid pinning stale versions indefinitely, the `uvx` branch passes
29
- * `--refresh` at most once per 24h (tracked via a timestamp file — see
30
- * `shouldRefresh`/`markRefreshed` below). `pipx run` isn't touched: its own
31
- * cache already expires after ~14 days on its own. The installed-`tj` branch
32
- * has no cache to go stale.
26
+ * Version pinning: `uv`/`pipx` cache a resolved tool environment and reuse it
27
+ * forever unless the requested spec changes — an unpinned `--from tokenjam`
28
+ * silently keeps reusing whatever was resolved first (e.g. a prior `uv tool
29
+ * install tokenjam` at an old version), never re-resolving on its own, no
30
+ * matter how many newer releases hit PyPI since. Pinning `--from
31
+ * tokenjam==<version>` / `--spec tokenjam==<version>` to this wrapper's OWN
32
+ * version (kept in sync with the release tag by publish-npm.yml's `npm
33
+ * version ${GITHUB_REF_NAME#v}` step) forces the resolver past that shortcut,
34
+ * so `npx tokenjam` always runs the release it shipped with. If the pinned
35
+ * spec can't be resolved yet (this wrapper published slightly ahead of PyPI
36
+ * propagation), we fall back to the unpinned form rather than fail outright.
37
+ *
38
+ * Staleness note: pinning only fixes what THIS wrapper runs. A bare `tj`
39
+ * invoked directly (no `npx`) still runs whatever was separately installed
40
+ * via `uv tool install` / `pipx install` / `pip install` / Homebrew, which can
41
+ * sit on an old version indefinitely. See `warnIfShadowedByStaleInstall`
42
+ * below: detect-and-tell only, never mutates, never auto-upgrades.
33
43
  */
34
44
  "use strict";
35
45
 
36
46
  const { spawnSync } = require("child_process");
37
- const fs = require("fs");
38
- const os = require("os");
39
47
  const path = require("path");
40
48
 
41
49
  // PyPI package name vs. command name differ (`tokenjam` ships the `tj` script),
@@ -49,87 +57,207 @@ function has(bin) {
49
57
  return probe.status === 0 || probe.status === 1; // 1 = exists but no --version
50
58
  }
51
59
 
52
- function runners() {
60
+ // This wrapper's own version. `publish-npm.yml`'s wrapper-publish job runs
61
+ // `npm version ${GITHUB_REF_NAME#v}` against npm-wrapper/package.json before
62
+ // `npm publish`, so whatever version this file ships inside always matches
63
+ // the tokenjam release it was cut alongside — safe to read at runtime as the
64
+ // version to pin the Python side to.
65
+ function ownVersion() {
66
+ try {
67
+ return require(path.join(__dirname, "..", "package.json")).version;
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ function runners(version) {
74
+ const pinnedSpec = version ? `${PACKAGE}==${version}` : null;
53
75
  return [
54
- { bin: "uvx", prefix: ["--from", PACKAGE, COMMAND] },
55
- { bin: "pipx", prefix: ["run", "--spec", PACKAGE, COMMAND] },
56
- { bin: COMMAND, prefix: [] }, // already installed on PATH
76
+ {
77
+ bin: "uvx",
78
+ pinnedPrefix: pinnedSpec ? ["--from", pinnedSpec, COMMAND] : null,
79
+ prefix: ["--from", PACKAGE, COMMAND],
80
+ },
81
+ {
82
+ bin: "pipx",
83
+ pinnedPrefix: pinnedSpec ? ["run", "--spec", pinnedSpec, COMMAND] : null,
84
+ prefix: ["run", "--spec", PACKAGE, COMMAND],
85
+ },
86
+ { bin: COMMAND, pinnedPrefix: null, prefix: [] }, // already installed on PATH, nothing to pin
57
87
  ];
58
88
  }
59
89
 
60
- // --- uvx cache freshness (issue #111) -------------------------------------
61
- //
62
- // Only the `uvx` runner needs this: `uv` caches a resolved tool environment
63
- // and reuses it forever unless told to `--refresh`, so a returning user
64
- // silently keeps whatever version they first resolved. Tracked with a plain
65
- // timestamp file rather than anything fancier — this wrapper is intentionally
66
- // dependency-free (stdlib `fs`/`os`/`path` only).
67
-
68
- const REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24h
69
-
70
- function refreshCacheDir() {
71
- const xdgCacheHome = process.env.XDG_CACHE_HOME;
72
- const base =
73
- xdgCacheHome && xdgCacheHome.trim()
74
- ? xdgCacheHome
75
- : path.join(os.homedir(), ".cache");
76
- return path.join(base, "tokenjam-npx");
90
+ // Cheap, side-effect-free resolution probe: does `<bin> <args> --version`
91
+ // succeed? Used to decide, before the real invocation, whether the pinned
92
+ // package spec actually resolves on this runner — falls back to the
93
+ // unpinned prefix when it doesn't (wrapper published ahead of PyPI
94
+ // propagation). Unlike `has()`, this requires an exact status 0: `uv`
95
+ // exits 1 both for "resolution failed" AND for "binary ran fine but
96
+ // doesn't understand --version", so treating 1 as success here would
97
+ // silently paper over real resolution failures (verified: `uvx --from
98
+ // tokenjam==<bogus> tj --version` also exits 1, indistinguishable from the
99
+ // unsupported-flag case `has()` is built around).
100
+ function resolves(bin, args) {
101
+ const probe = spawnSync(bin, [...args, "--version"], { stdio: "ignore" });
102
+ return probe.status === 0;
77
103
  }
78
104
 
79
- function refreshTimestampPath() {
80
- return path.join(refreshCacheDir(), "last-refresh");
81
- }
105
+ // --- stale shadowing install detection ------------------------------------
106
+ //
107
+ // Detect-and-tell only, per design: this never mutates anything and never
108
+ // upgrades on the user's behalf, in interactive or non-interactive/CI
109
+ // contexts alike. It's a best-effort nudge — any detection failure (missing
110
+ // binary, unexpected output, timeout) is swallowed and skipped silently; it
111
+ // must never break or slow down the primary command above by much.
82
112
 
83
- // True if it's been >24h (or we've never refreshed / can't tell). Fails
84
- // open on any fs error — an unwritable/unreadable cache dir must never
85
- // break the wrapper, it just means we skip the freshness nudge this run.
86
- function shouldRefresh() {
113
+ const DETECT_TIMEOUT_MS = 2000;
114
+
115
+ function safeSpawn(bin, args) {
87
116
  try {
88
- const stat = fs.statSync(refreshTimestampPath());
89
- return Date.now() - stat.mtimeMs > REFRESH_INTERVAL_MS;
117
+ return spawnSync(bin, args, {
118
+ stdio: ["ignore", "pipe", "pipe"],
119
+ timeout: DETECT_TIMEOUT_MS,
120
+ encoding: "utf8",
121
+ });
90
122
  } catch {
91
- return true; // no timestamp yet (or unreadable) => treat as stale
123
+ return null;
124
+ }
125
+ }
126
+
127
+ function versionParts(v) {
128
+ return String(v)
129
+ .trim()
130
+ .split(".")
131
+ .map((n) => parseInt(n, 10) || 0);
132
+ }
133
+
134
+ function isOlder(a, b) {
135
+ const pa = versionParts(a);
136
+ const pb = versionParts(b);
137
+ const len = Math.max(pa.length, pb.length);
138
+ for (let i = 0; i < len; i++) {
139
+ const x = pa[i] || 0;
140
+ const y = pb[i] || 0;
141
+ if (x !== y) return x < y;
92
142
  }
143
+ return false;
93
144
  }
94
145
 
95
- // Called only AFTER `uvx --refresh` has actually returned with a zero exit
96
- // status (not before we spawn it, and not on failure). If the refresh's
97
- // download is interrupted partway through (network drop, Ctrl-C, OOM kill),
98
- // spawnSync never returns normally, this never runs. If it returns but uv
99
- // exits non-zero (PyPI unreachable, partial download), the caller also
100
- // skips this call. Either way the *next* invocation still sees a
101
- // stale/missing timestamp and retries `--refresh`. Writing the timestamp up
102
- // front, or unconditionally on return, would mark the cache "fresh" even
103
- // though that refresh never completed, silently pinning a broken/partial
104
- // environment for a full 24h. Best-effort/fail-open: swallow fs errors.
105
- function markRefreshed() {
146
+ // Each detector below is skipped up front via `has()` when its own binary
147
+ // isn't even on PATH, so a machine without e.g. Homebrew never pays for a
148
+ // `brew list` spawn.
149
+
150
+ function detectUvTool() {
151
+ if (!has("uv")) return null;
152
+ const result = safeSpawn("uv", ["tool", "list"]);
153
+ if (!result || result.status !== 0 || !result.stdout) return null;
154
+ const match = result.stdout.match(/^tokenjam\s+v?(\S+)/m);
155
+ if (!match) return null;
156
+ return {
157
+ method: "uv tool",
158
+ version: match[1],
159
+ upgradeCmd: "uv tool upgrade tokenjam",
160
+ };
161
+ }
162
+
163
+ function detectPipx() {
164
+ if (!has("pipx")) return null;
165
+ const result = safeSpawn("pipx", ["list", "--json"]);
166
+ if (!result || result.status !== 0 || !result.stdout) return null;
106
167
  try {
107
- fs.mkdirSync(refreshCacheDir(), { recursive: true });
108
- fs.writeFileSync(refreshTimestampPath(), String(Date.now()));
168
+ const data = JSON.parse(result.stdout);
169
+ const venv = data.venvs && data.venvs[PACKAGE];
170
+ const version =
171
+ venv &&
172
+ venv.metadata &&
173
+ venv.metadata.main_package &&
174
+ venv.metadata.main_package.package_version;
175
+ if (!version) return null;
176
+ return { method: "pipx", version, upgradeCmd: "pipx upgrade tokenjam" };
109
177
  } catch {
110
- // fail open — worst case we just try to refresh again next run
178
+ return null;
179
+ }
180
+ }
181
+
182
+ function detectPip() {
183
+ for (const pipBin of ["pip3", "pip"]) {
184
+ if (!has(pipBin)) continue;
185
+ const result = safeSpawn(pipBin, ["show", PACKAGE]);
186
+ if (!result || result.status !== 0 || !result.stdout) continue;
187
+ const match = result.stdout.match(/^Version:\s*(\S+)/m);
188
+ if (!match) continue;
189
+ // Covers both a plain `pip install` and `pip install --user` — pip
190
+ // doesn't distinguish the two in `pip show` output, and either way the
191
+ // fix command is the same.
192
+ return {
193
+ method: "pip",
194
+ version: match[1],
195
+ upgradeCmd: `${pipBin} install --upgrade ${PACKAGE}`,
196
+ };
197
+ }
198
+ return null;
199
+ }
200
+
201
+ function detectHomebrew() {
202
+ if (!has("brew")) return null;
203
+ const result = safeSpawn("brew", ["list", "--versions", PACKAGE]);
204
+ if (!result || result.status !== 0 || !result.stdout) return null;
205
+ const match = result.stdout.trim().match(/^tokenjam\s+(\S+)/);
206
+ if (!match) return null;
207
+ return {
208
+ method: "Homebrew",
209
+ version: match[1],
210
+ upgradeCmd: "brew upgrade tokenjam",
211
+ };
212
+ }
213
+
214
+ function warnIfShadowedByStaleInstall(wrapperVersion) {
215
+ if (!wrapperVersion) return;
216
+ const detectors = [detectUvTool, detectPipx, detectPip, detectHomebrew];
217
+ for (const detect of detectors) {
218
+ let found = null;
219
+ try {
220
+ found = detect();
221
+ } catch {
222
+ found = null;
223
+ }
224
+ if (!found || !isOlder(found.version, wrapperVersion)) continue;
225
+ process.stderr.write(
226
+ "\n" +
227
+ `Note: a ${found.method} install of tokenjam is at v${found.version}, older than v${wrapperVersion} run here.\n` +
228
+ `Upgrade it with: ${found.upgradeCmd}\n`
229
+ );
230
+ return; // one line is enough — first stale install found wins
111
231
  }
112
232
  }
113
233
 
114
234
  function main() {
115
- // Bare `npx tokenjam` IS the zero-install first run — route it to
116
- // `tj quickstart` (the quota report the docs promise). The branded home
117
- // screen that bare LOCAL `tj` prints assumes an installed CLI and would
118
- // dead-end an npx user ("You're set up", suggesting commands they don't
119
- // have). Any explicit args pass through untouched.
235
+ // Bare `npx tokenjam` IS the zero-install first run — the quota report the
236
+ // docs promise. The branded home screen that bare LOCAL `tj` prints assumes
237
+ // an installed CLI and would dead-end an npx user ("You're set up",
238
+ // suggesting commands they don't have). Any explicit args pass through
239
+ // untouched; a bare invocation stays bare (no synthetic subcommand — there
240
+ // is no public/typeable command for this) and instead sets an env var that
241
+ // the Python CLI's own no-subcommand branch reads to pick the report over
242
+ // the home screen.
120
243
  const argv = process.argv.slice(2);
121
- const passthrough = argv.length ? argv : ["quickstart"];
244
+ const passthrough = argv;
245
+ const childEnv = argv.length
246
+ ? process.env
247
+ : { ...process.env, TJ_NPX_ZERO_INSTALL_REPORT: "1" };
248
+
249
+ const version = ownVersion();
122
250
 
123
- for (const { bin, prefix } of runners()) {
251
+ for (const { bin, pinnedPrefix, prefix } of runners(version)) {
124
252
  if (!has(bin)) continue;
125
- const isUvx = bin === "uvx";
126
- const doRefresh = isUvx && shouldRefresh();
127
- const args = doRefresh ? ["--refresh", ...prefix] : prefix;
253
+ const args =
254
+ pinnedPrefix && resolves(bin, pinnedPrefix) ? pinnedPrefix : prefix;
128
255
  const result = spawnSync(bin, [...args, ...passthrough], {
129
256
  stdio: "inherit",
257
+ env: childEnv,
130
258
  });
131
259
  if (result.error) continue; // try the next runner on spawn failure
132
- if (doRefresh && result.status === 0) markRefreshed();
260
+ warnIfShadowedByStaleInstall(version);
133
261
  process.exit(result.status === null ? 1 : result.status);
134
262
  }
135
263
 
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "tokenjam",
3
- "version": "0.5.7",
4
- "description": "Zero-install launcher for TokenJam (tj): npx tokenjam runs the Python CLI via uvx/pipx and prints where your AI agent's token quota actually goes, no setup required.",
3
+ "version": "0.6.1",
4
+ "description": "Zero-install launcher for TokenJam (tj): npx tokenjam runs the Python CLI via uvx/pipx and reports the recurring mistakes your AI agent keeps repeating, no setup required.",
5
5
  "keywords": [
6
6
  "claude-code",
7
7
  "ccusage",
8
8
  "tokens",
9
- "cost",
9
+ "cost-optimization",
10
10
  "llm",
11
11
  "agents",
12
12
  "observability",
@@ -14,7 +14,7 @@
14
14
  "anthropic",
15
15
  "claude",
16
16
  "token-usage",
17
- "cost-tracking",
17
+ "agent-behavior",
18
18
  "cli",
19
19
  "statusline",
20
20
  "opentelemetry",