@cruxy/cli 1.0.2 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,6 @@
1
1
  import { CruxyError, providerUnsupported } from "../errors/index.js";
2
2
  import { resolveTaskModel, } from "../routing/index.js";
3
+ import { accumulateCacheTokens } from "../usage/collect.js";
3
4
  import { buildSystemPrompt } from "./prompts.js";
4
5
  import { resolveShell } from "../tools/shell/resolve-shell.js";
5
6
  /** Tools whose successful call is a file change (drives the `on-file-change`
@@ -155,12 +156,14 @@ async function driveLoop(args, renderer, routed) {
155
156
  case "usage":
156
157
  usage.input_tokens = ev.usage.input_tokens || usage.input_tokens;
157
158
  usage.output_tokens += ev.usage.output_tokens;
159
+ accumulateCacheTokens(usage, ev.usage);
158
160
  // Mirror the accumulation into the per-request figure the telemetry
159
161
  // callback reports (same last-non-zero-in / summed-out semantics).
160
162
  sawUsage = true;
161
163
  reqUsage.input_tokens =
162
164
  ev.usage.input_tokens || reqUsage.input_tokens;
163
165
  reqUsage.output_tokens += ev.usage.output_tokens;
166
+ accumulateCacheTokens(reqUsage, ev.usage);
164
167
  break;
165
168
  case "message_stop":
166
169
  // Turn complete; the stream ends after this.
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { loadProjectInstructions } from "../config/index.js";
3
3
  import { resolveTaskModel } from "../routing/index.js";
4
- import { UsageCollector, } from "../usage/index.js";
4
+ import { UsageCollector, accumulateCacheTokens, } from "../usage/index.js";
5
5
  import { Budget } from "./budget.js";
6
6
  import { runAgent, } from "./loop.js";
7
7
  import { SUMMARY_SYSTEM, COMPACTION_MARKER } from "./prompts.js";
@@ -345,6 +345,7 @@ export class Session {
345
345
  case "usage":
346
346
  usage.input_tokens = ev.usage.input_tokens || usage.input_tokens;
347
347
  usage.output_tokens += ev.usage.output_tokens;
348
+ accumulateCacheTokens(usage, ev.usage);
348
349
  sawUsage = true;
349
350
  break;
350
351
  case "error":
@@ -43,6 +43,12 @@ export interface ThemeGlyphs {
43
43
  arrow: string;
44
44
  caretUp: string;
45
45
  caretDown: string;
46
+ /**
47
+ * Cache-hit marker for the usage line (`cached ↻N`). Decorative — the word
48
+ * "cached" carries the meaning, so it degrades to empty on ASCII / screen
49
+ * reader rather than a cryptic symbol.
50
+ */
51
+ cached: string;
46
52
  /** Text-cursor bar in the fuzzy query line. */
47
53
  cursorBar: string;
48
54
  bullet: string;
@@ -20,6 +20,7 @@ export const UNICODE_GLYPHS = {
20
20
  arrow: "→",
21
21
  caretUp: "↑",
22
22
  caretDown: "↓",
23
+ cached: "↻",
23
24
  cursorBar: "▏",
24
25
  bullet: "•",
25
26
  sep: "·",
@@ -42,6 +43,7 @@ export const ASCII_GLYPHS = {
42
43
  arrow: "->",
43
44
  caretUp: "^",
44
45
  caretDown: "v",
46
+ cached: "",
45
47
  cursorBar: "|",
46
48
  bullet: "*",
47
49
  sep: "-",
@@ -68,6 +70,7 @@ export const SCREEN_READER_GLYPHS = {
68
70
  arrow: "->",
69
71
  caretUp: "up",
70
72
  caretDown: "down",
73
+ cached: "",
71
74
  cursorBar: "",
72
75
  bullet: "-",
73
76
  sep: "-",
@@ -11,6 +11,19 @@ import type { UsageRecord } from "./types.js";
11
11
  * `Usage` and is stored as `0`. Nothing is estimated, re-tokenized, or
12
12
  * zero-filled, and this module makes ZERO network calls.
13
13
  */
14
+ /**
15
+ * Fold a streamed {@link Usage} event's cache counters into an accumulator.
16
+ *
17
+ * Phase-A prompt caching (Anthropic dev path only) reports `cache_read_*` /
18
+ * `cache_creation_*` ONCE per request but echoes the same value on both the
19
+ * `message_start` and terminal `message_delta` usage events — so we ASSIGN
20
+ * (never `+=`) to avoid double-counting within a request. `undefined` is left
21
+ * untouched: a provider that doesn't cache (the cruxy gateway, OpenAI-compat)
22
+ * never sets these, so the accumulator's fields stay `undefined` — the honest
23
+ * "unknown", never a fabricated `0`. A real reported `0` (cache in play, no read
24
+ * this request) is captured as `0`.
25
+ */
26
+ export declare function accumulateCacheTokens(acc: Usage, ev: Usage): void;
14
27
  /** What the loop hands over for one completed request. */
15
28
  export interface RequestUsage {
16
29
  /** The routing tier (C.30) the request ran on, if routing was active. */
@@ -1,3 +1,32 @@
1
+ /**
2
+ * Usage collection (C.22). Accumulates per-request usage exactly as the agent
3
+ * loop reports it — one {@link UsageEntry} per completed model request — and
4
+ * emits a {@link UsageRecord} for the run.
5
+ *
6
+ * The one honesty invariant: `usage: undefined` (the loop's signal that the
7
+ * provider returned NO usage event for a request) is recorded as `undefined`
8
+ * token counts — the honest "unknown". A provider-reported `0` arrives as a real
9
+ * `Usage` and is stored as `0`. Nothing is estimated, re-tokenized, or
10
+ * zero-filled, and this module makes ZERO network calls.
11
+ */
12
+ /**
13
+ * Fold a streamed {@link Usage} event's cache counters into an accumulator.
14
+ *
15
+ * Phase-A prompt caching (Anthropic dev path only) reports `cache_read_*` /
16
+ * `cache_creation_*` ONCE per request but echoes the same value on both the
17
+ * `message_start` and terminal `message_delta` usage events — so we ASSIGN
18
+ * (never `+=`) to avoid double-counting within a request. `undefined` is left
19
+ * untouched: a provider that doesn't cache (the cruxy gateway, OpenAI-compat)
20
+ * never sets these, so the accumulator's fields stay `undefined` — the honest
21
+ * "unknown", never a fabricated `0`. A real reported `0` (cache in play, no read
22
+ * this request) is captured as `0`.
23
+ */
24
+ export function accumulateCacheTokens(acc, ev) {
25
+ if (ev.cache_read_input_tokens !== undefined)
26
+ acc.cache_read_input_tokens = ev.cache_read_input_tokens;
27
+ if (ev.cache_creation_input_tokens !== undefined)
28
+ acc.cache_creation_input_tokens = ev.cache_creation_input_tokens;
29
+ }
1
30
  const systemClock = () => new Date().toISOString();
2
31
  export class UsageCollector {
3
32
  now;
@@ -11,10 +40,20 @@ export class UsageCollector {
11
40
  * nothing. A real reported `0` is preserved as `0`.
12
41
  */
13
42
  record(req) {
43
+ const u = req.usage;
14
44
  this.entries.push({
15
45
  tier: req.tier,
16
- inputTokens: req.usage?.input_tokens,
17
- outputTokens: req.usage?.output_tokens,
46
+ inputTokens: u?.input_tokens,
47
+ outputTokens: u?.output_tokens,
48
+ // Cache counters are conditionally spread so an unreported field stays
49
+ // absent from the persisted entry (undefined ≠ 0) — only the Anthropic
50
+ // dev path ever populates them.
51
+ ...(u?.cache_read_input_tokens !== undefined
52
+ ? { cacheReadTokens: u.cache_read_input_tokens }
53
+ : {}),
54
+ ...(u?.cache_creation_input_tokens !== undefined
55
+ ? { cacheCreationTokens: u.cache_creation_input_tokens }
56
+ : {}),
18
57
  at: this.now(),
19
58
  });
20
59
  }
@@ -9,7 +9,7 @@
9
9
  * ships nothing that sends. Asserted by the runtime + static no-phone-home tests.
10
10
  */
11
11
  export * from "./types.js";
12
- export { UsageCollector, type RequestUsage, type Clock } from "./collect.js";
12
+ export { UsageCollector, accumulateCacheTokens, type RequestUsage, type Clock, } from "./collect.js";
13
13
  export { costFor, priceForTier } from "./cost.js";
14
14
  export { loadUsage, appendRun, usageStorePath } from "./store.js";
15
15
  export { summarizeRuns, renderSummary, formatCost, type SummarizeOptions, } from "./summary.js";
@@ -9,7 +9,7 @@
9
9
  * ships nothing that sends. Asserted by the runtime + static no-phone-home tests.
10
10
  */
11
11
  export * from "./types.js";
12
- export { UsageCollector } from "./collect.js";
12
+ export { UsageCollector, accumulateCacheTokens, } from "./collect.js";
13
13
  export { costFor, priceForTier } from "./cost.js";
14
14
  export { loadUsage, appendRun, usageStorePath } from "./store.js";
15
15
  export { summarizeRuns, renderSummary, formatCost, } from "./summary.js";
@@ -5,6 +5,8 @@ export function summarizeRuns(runs, opts) {
5
5
  const byTier = new Map();
6
6
  let totalInputTokens;
7
7
  let totalOutputTokens;
8
+ let totalCacheReadTokens;
9
+ let totalCacheCreationTokens;
8
10
  let requests = 0;
9
11
  let requestsWithoutUsage = 0;
10
12
  const addKnown = (acc, v) => (v === undefined ? acc : (acc ?? 0) + v);
@@ -16,6 +18,8 @@ export function summarizeRuns(runs, opts) {
16
18
  requestsWithoutUsage++;
17
19
  totalInputTokens = addKnown(totalInputTokens, e.inputTokens);
18
20
  totalOutputTokens = addKnown(totalOutputTokens, e.outputTokens);
21
+ totalCacheReadTokens = addKnown(totalCacheReadTokens, e.cacheReadTokens);
22
+ totalCacheCreationTokens = addKnown(totalCacheCreationTokens, e.cacheCreationTokens);
19
23
  // Per-tier attribution only for entries that carry a tier. Untiered
20
24
  // requests (routing inert) still count toward totals — the total stays
21
25
  // honest — but there is no tier label to bucket them under.
@@ -29,6 +33,8 @@ export function summarizeRuns(runs, opts) {
29
33
  b.requestsWithoutUsage++;
30
34
  b.inputTokens = addKnown(b.inputTokens, e.inputTokens);
31
35
  b.outputTokens = addKnown(b.outputTokens, e.outputTokens);
36
+ b.cacheReadTokens = addKnown(b.cacheReadTokens, e.cacheReadTokens);
37
+ b.cacheCreationTokens = addKnown(b.cacheCreationTokens, e.cacheCreationTokens);
32
38
  byTier.set(e.tier, b);
33
39
  }
34
40
  }
@@ -37,6 +43,8 @@ export function summarizeRuns(runs, opts) {
37
43
  tier,
38
44
  inputTokens: b.inputTokens,
39
45
  outputTokens: b.outputTokens,
46
+ cacheReadTokens: b.cacheReadTokens,
47
+ cacheCreationTokens: b.cacheCreationTokens,
40
48
  requests: b.requests,
41
49
  requestsWithoutUsage: b.requestsWithoutUsage,
42
50
  cost: costFor(tier, b.inputTokens, b.outputTokens, opts.prices),
@@ -50,6 +58,8 @@ export function summarizeRuns(runs, opts) {
50
58
  perTier,
51
59
  totalInputTokens,
52
60
  totalOutputTokens,
61
+ totalCacheReadTokens,
62
+ totalCacheCreationTokens,
53
63
  totalCost,
54
64
  priced,
55
65
  currency: opts.currency,
@@ -106,6 +116,15 @@ export function renderSummary(summary, t) {
106
116
  parts.push(totalKnown
107
117
  ? `${t.strong("total")} ${tokenText(summary.totalInputTokens, summary.totalOutputTokens, t)}${t.muted(totalCost)}`
108
118
  : `${t.strong("total")} —`);
119
+ // Cache reads (phase-A caching, Anthropic dev path): shown ONLY when the
120
+ // provider actually reported cache usage — `undefined` on the cruxy gateway
121
+ // and every OpenAI-compat path, so normal users never see this segment. A
122
+ // reported 0 IS shown (`cached ↻0`): honest proof the counter is flowing,
123
+ // e.g. the first request of a run that writes the cache but reads nothing.
124
+ if (summary.totalCacheReadTokens !== undefined) {
125
+ const glyph = t.glyph.cached;
126
+ parts.push(t.muted(`cached ${glyph}${formatTokens(summary.totalCacheReadTokens)}`));
127
+ }
109
128
  // The honesty guard on display: a visible note whenever any request went
110
129
  // unreported, so the total above is never read as the whole story.
111
130
  if (summary.requestsWithoutUsage > 0) {
@@ -22,6 +22,15 @@ export declare const UsageEntrySchema: z.ZodObject<{
22
22
  inputTokens: z.ZodOptional<z.ZodNumber>;
23
23
  /** Provider-reported completion tokens; `undefined` ⇔ no usage was reported. */
24
24
  outputTokens: z.ZodOptional<z.ZodNumber>;
25
+ /**
26
+ * Prompt-cache tokens READ from a warm cache (phase-A caching, Anthropic dev
27
+ * path). `undefined` ⇔ the provider doesn't cache / reported nothing (the
28
+ * cruxy gateway today) — never zero-filled. Optional, so older on-disk
29
+ * entries without the field still parse.
30
+ */
31
+ cacheReadTokens: z.ZodOptional<z.ZodNumber>;
32
+ /** Prompt-cache tokens WRITTEN this request (the ~1.25× write premium); same `undefined`-≠-0 rule. */
33
+ cacheCreationTokens: z.ZodOptional<z.ZodNumber>;
25
34
  /** ISO-8601 timestamp the request completed. */
26
35
  at: z.ZodString;
27
36
  }, "strict", z.ZodTypeAny, {
@@ -29,11 +38,15 @@ export declare const UsageEntrySchema: z.ZodObject<{
29
38
  tier?: string | undefined;
30
39
  inputTokens?: number | undefined;
31
40
  outputTokens?: number | undefined;
41
+ cacheReadTokens?: number | undefined;
42
+ cacheCreationTokens?: number | undefined;
32
43
  }, {
33
44
  at: string;
34
45
  tier?: string | undefined;
35
46
  inputTokens?: number | undefined;
36
47
  outputTokens?: number | undefined;
48
+ cacheReadTokens?: number | undefined;
49
+ cacheCreationTokens?: number | undefined;
37
50
  }>;
38
51
  export type UsageEntry = z.infer<typeof UsageEntrySchema>;
39
52
  /** One run's usage: an ordered list of per-request entries. */
@@ -51,6 +64,15 @@ export declare const UsageRecordSchema: z.ZodObject<{
51
64
  inputTokens: z.ZodOptional<z.ZodNumber>;
52
65
  /** Provider-reported completion tokens; `undefined` ⇔ no usage was reported. */
53
66
  outputTokens: z.ZodOptional<z.ZodNumber>;
67
+ /**
68
+ * Prompt-cache tokens READ from a warm cache (phase-A caching, Anthropic dev
69
+ * path). `undefined` ⇔ the provider doesn't cache / reported nothing (the
70
+ * cruxy gateway today) — never zero-filled. Optional, so older on-disk
71
+ * entries without the field still parse.
72
+ */
73
+ cacheReadTokens: z.ZodOptional<z.ZodNumber>;
74
+ /** Prompt-cache tokens WRITTEN this request (the ~1.25× write premium); same `undefined`-≠-0 rule. */
75
+ cacheCreationTokens: z.ZodOptional<z.ZodNumber>;
54
76
  /** ISO-8601 timestamp the request completed. */
55
77
  at: z.ZodString;
56
78
  }, "strict", z.ZodTypeAny, {
@@ -58,11 +80,15 @@ export declare const UsageRecordSchema: z.ZodObject<{
58
80
  tier?: string | undefined;
59
81
  inputTokens?: number | undefined;
60
82
  outputTokens?: number | undefined;
83
+ cacheReadTokens?: number | undefined;
84
+ cacheCreationTokens?: number | undefined;
61
85
  }, {
62
86
  at: string;
63
87
  tier?: string | undefined;
64
88
  inputTokens?: number | undefined;
65
89
  outputTokens?: number | undefined;
90
+ cacheReadTokens?: number | undefined;
91
+ cacheCreationTokens?: number | undefined;
66
92
  }>, "many">;
67
93
  }, "strict", z.ZodTypeAny, {
68
94
  entries: {
@@ -70,6 +96,8 @@ export declare const UsageRecordSchema: z.ZodObject<{
70
96
  tier?: string | undefined;
71
97
  inputTokens?: number | undefined;
72
98
  outputTokens?: number | undefined;
99
+ cacheReadTokens?: number | undefined;
100
+ cacheCreationTokens?: number | undefined;
73
101
  }[];
74
102
  runId: string;
75
103
  startedAt: string;
@@ -80,6 +108,8 @@ export declare const UsageRecordSchema: z.ZodObject<{
80
108
  tier?: string | undefined;
81
109
  inputTokens?: number | undefined;
82
110
  outputTokens?: number | undefined;
111
+ cacheReadTokens?: number | undefined;
112
+ cacheCreationTokens?: number | undefined;
83
113
  }[];
84
114
  runId: string;
85
115
  startedAt: string;
@@ -103,6 +133,15 @@ export declare const UsageFileSchema: z.ZodObject<{
103
133
  inputTokens: z.ZodOptional<z.ZodNumber>;
104
134
  /** Provider-reported completion tokens; `undefined` ⇔ no usage was reported. */
105
135
  outputTokens: z.ZodOptional<z.ZodNumber>;
136
+ /**
137
+ * Prompt-cache tokens READ from a warm cache (phase-A caching, Anthropic dev
138
+ * path). `undefined` ⇔ the provider doesn't cache / reported nothing (the
139
+ * cruxy gateway today) — never zero-filled. Optional, so older on-disk
140
+ * entries without the field still parse.
141
+ */
142
+ cacheReadTokens: z.ZodOptional<z.ZodNumber>;
143
+ /** Prompt-cache tokens WRITTEN this request (the ~1.25× write premium); same `undefined`-≠-0 rule. */
144
+ cacheCreationTokens: z.ZodOptional<z.ZodNumber>;
106
145
  /** ISO-8601 timestamp the request completed. */
107
146
  at: z.ZodString;
108
147
  }, "strict", z.ZodTypeAny, {
@@ -110,11 +149,15 @@ export declare const UsageFileSchema: z.ZodObject<{
110
149
  tier?: string | undefined;
111
150
  inputTokens?: number | undefined;
112
151
  outputTokens?: number | undefined;
152
+ cacheReadTokens?: number | undefined;
153
+ cacheCreationTokens?: number | undefined;
113
154
  }, {
114
155
  at: string;
115
156
  tier?: string | undefined;
116
157
  inputTokens?: number | undefined;
117
158
  outputTokens?: number | undefined;
159
+ cacheReadTokens?: number | undefined;
160
+ cacheCreationTokens?: number | undefined;
118
161
  }>, "many">;
119
162
  }, "strict", z.ZodTypeAny, {
120
163
  entries: {
@@ -122,6 +165,8 @@ export declare const UsageFileSchema: z.ZodObject<{
122
165
  tier?: string | undefined;
123
166
  inputTokens?: number | undefined;
124
167
  outputTokens?: number | undefined;
168
+ cacheReadTokens?: number | undefined;
169
+ cacheCreationTokens?: number | undefined;
125
170
  }[];
126
171
  runId: string;
127
172
  startedAt: string;
@@ -132,6 +177,8 @@ export declare const UsageFileSchema: z.ZodObject<{
132
177
  tier?: string | undefined;
133
178
  inputTokens?: number | undefined;
134
179
  outputTokens?: number | undefined;
180
+ cacheReadTokens?: number | undefined;
181
+ cacheCreationTokens?: number | undefined;
135
182
  }[];
136
183
  runId: string;
137
184
  startedAt: string;
@@ -145,6 +192,8 @@ export declare const UsageFileSchema: z.ZodObject<{
145
192
  tier?: string | undefined;
146
193
  inputTokens?: number | undefined;
147
194
  outputTokens?: number | undefined;
195
+ cacheReadTokens?: number | undefined;
196
+ cacheCreationTokens?: number | undefined;
148
197
  }[];
149
198
  runId: string;
150
199
  startedAt: string;
@@ -158,6 +207,8 @@ export declare const UsageFileSchema: z.ZodObject<{
158
207
  tier?: string | undefined;
159
208
  inputTokens?: number | undefined;
160
209
  outputTokens?: number | undefined;
210
+ cacheReadTokens?: number | undefined;
211
+ cacheCreationTokens?: number | undefined;
161
212
  }[];
162
213
  runId: string;
163
214
  startedAt: string;
@@ -191,6 +242,10 @@ export interface TierUsage {
191
242
  inputTokens?: number;
192
243
  /** Sum of KNOWN output tokens; `undefined` if none reported. */
193
244
  outputTokens?: number;
245
+ /** Sum of KNOWN cache-read tokens on this tier; `undefined` if none reported. */
246
+ cacheReadTokens?: number;
247
+ /** Sum of KNOWN cache-creation tokens on this tier; `undefined` if none reported. */
248
+ cacheCreationTokens?: number;
194
249
  /** Requests attributed to this tier. */
195
250
  requests: number;
196
251
  /** How many of those reported no usage (surfaced, never silently dropped). */
@@ -205,6 +260,14 @@ export interface UsageSummary {
205
260
  totalInputTokens?: number;
206
261
  /** Sum of KNOWN output tokens across all runs; `undefined` if none known. */
207
262
  totalOutputTokens?: number;
263
+ /**
264
+ * Sum of KNOWN cache-read tokens across all runs; `undefined` if none known
265
+ * (i.e. every request ran on a non-caching provider). Drives the `cached ↻N`
266
+ * figure, which is the observable proof that phase-A caching is working.
267
+ */
268
+ totalCacheReadTokens?: number;
269
+ /** Sum of KNOWN cache-creation tokens across all runs; `undefined` if none known. */
270
+ totalCacheCreationTokens?: number;
208
271
  /** Sum of per-tier costs; `undefined` unless ≥1 tier was priced. */
209
272
  totalCost?: number;
210
273
  /** True iff at least one tier had a configured price (cost is shown). */
@@ -22,6 +22,15 @@ export const UsageEntrySchema = z
22
22
  inputTokens: z.number().int().nonnegative().optional(),
23
23
  /** Provider-reported completion tokens; `undefined` ⇔ no usage was reported. */
24
24
  outputTokens: z.number().int().nonnegative().optional(),
25
+ /**
26
+ * Prompt-cache tokens READ from a warm cache (phase-A caching, Anthropic dev
27
+ * path). `undefined` ⇔ the provider doesn't cache / reported nothing (the
28
+ * cruxy gateway today) — never zero-filled. Optional, so older on-disk
29
+ * entries without the field still parse.
30
+ */
31
+ cacheReadTokens: z.number().int().nonnegative().optional(),
32
+ /** Prompt-cache tokens WRITTEN this request (the ~1.25× write premium); same `undefined`-≠-0 rule. */
33
+ cacheCreationTokens: z.number().int().nonnegative().optional(),
25
34
  /** ISO-8601 timestamp the request completed. */
26
35
  at: z.string(),
27
36
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cruxy/cli",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "an agentic coding CLI",
5
5
  "type": "module",
6
6
  "bin": {