@cruxy/cli 1.0.0 → 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.
package/README.md CHANGED
@@ -161,7 +161,7 @@ branch on them:
161
161
  | `5` | network | `CRUXY_E_GATEWAY_UNREACHABLE`, `CRUXY_E_GIT_PUSH_FAILED` |
162
162
  | `6` | api | `CRUXY_E_API`, `CRUXY_E_API_RATE_LIMIT`, `CRUXY_E_API_OVERLOADED`, `CRUXY_E_BUDGET_EXHAUSTED`, `CRUXY_E_FORGE_API` |
163
163
  | `7` | filesystem | `CRUXY_E_FILE_NOT_FOUND`, `CRUXY_E_PERMISSION_DENIED`, `CRUXY_E_PATH_ESCAPE`, `CRUXY_E_CHECKPOINT_FAILED` |
164
- | `8` | index | `CRUXY_E_INDEX_EMBEDDER_UNAVAILABLE`, `CRUXY_E_INDEX_STORE_UNAVAILABLE`, `CRUXY_E_INDEX_FAILED` |
164
+ | `8` | index | `CRUXY_E_INDEX_EMBEDDER_UNAVAILABLE`, `CRUXY_E_INDEX_EMBEDDER_DOWNLOAD_FAILED`, `CRUXY_E_INDEX_STORE_UNAVAILABLE`, `CRUXY_E_INDEX_FAILED` |
165
165
  | `9` | skill | `CRUXY_E_SKILL_INVALID`, `CRUXY_E_SKILL_NOT_FOUND` |
166
166
  | `10` | approval | `CRUXY_E_APPROVAL_REQUIRED`, `CRUXY_E_PLAN_APPROVAL_REQUIRED`, `CRUXY_E_ROLLBACK_APPROVAL_REQUIRED` |
167
167
 
@@ -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":
@@ -44,8 +44,15 @@ export function usageCommand() {
44
44
  logger.print(t.heading(`usage — ${scopeLabel}`));
45
45
  logger.print(renderSummary(summary, t));
46
46
  // State when NO price is configured, so an absent cost never reads as $0.
47
+ // Self-contained: set a number, see cost — copy-pasteable commands, no
48
+ // docs lookup and no jargon needed.
47
49
  if (!summary.priced) {
48
- logger.print(t.muted("cost omitted — no prices configured (set usage.prices.<tier>.{input,output}, per million tokens)"));
50
+ logger.print(t.muted([
51
+ "cost omitted — no prices set. To see cost, set your per-million-token",
52
+ "rates for each tier (kavi, vaani, mira):",
53
+ " cruxy config set usage.prices.kavi.input 0.5",
54
+ " cruxy config set usage.prices.kavi.output 1.5",
55
+ ].join("\n")));
49
56
  }
50
57
  });
51
58
  }
@@ -185,15 +185,30 @@ export declare const IndexConfigSchema: z.ZodObject<{
185
185
  /**
186
186
  * Embedding backend. Only `fastembed` (bge-small-en-v1.5, local ONNX; the
187
187
  * model downloads and caches on first use) is selectable — if it cannot be
188
- * loaded, indexing fails loudly rather than degrading. (The deterministic
189
- * hashing embedder exists for tests and is injected directly, never chosen
190
- * here.)
188
+ * loaded, or the model cannot be downloaded/initialized, indexing fails
189
+ * loudly rather than degrading. (The deterministic hashing embedder exists
190
+ * for tests and is injected directly, never chosen here.)
191
+ *
192
+ * THE STORE-VS-EMBEDDER ASYMMETRY (deliberate — see `store` below). The
193
+ * embedder has NO degrade path while the store's `auto` does, and that is
194
+ * correct, not an inconsistency: a store fallback (sqlite → in-memory) loses
195
+ * only PERSISTENCE — search results are byte-identical, you just re-index
196
+ * each session, a quality-NEUTRAL trade. The embedder's only cheaper
197
+ * substitute is the lexical hashing backend, whose degrade is a SILENT
198
+ * QUALITY LOSS — semantic search quietly stops being semantic. That is the
199
+ * "reads like success, isn't" trap C.17 forbids, so the embedder fails loud
200
+ * (module-load → CRUXY_E_INDEX_EMBEDDER_UNAVAILABLE; first-run download/init
201
+ * → CRUXY_E_INDEX_EMBEDDER_DOWNLOAD_FAILED) and `search_codebase` routes the
202
+ * model to `grep_files` rather than substituting a weaker index.
191
203
  */
192
204
  embedder: z.ZodDefault<z.ZodEnum<["fastembed"]>>;
193
205
  /**
194
206
  * Vector store backend. `sqlite` persists to `.cruxy/index.db` via
195
207
  * better-sqlite3; `memory` is ephemeral (tests). `auto` prefers sqlite and
196
- * falls back to memory when the native dependency cannot be loaded.
208
+ * falls back to memory when the native dependency cannot be loaded — a
209
+ * quality-NEUTRAL degrade (only persistence is lost; results are identical).
210
+ * This is why `auto` may degrade while `embedder` may not — see the
211
+ * asymmetry note above.
197
212
  */
198
213
  store: z.ZodDefault<z.ZodEnum<["auto", "sqlite", "memory"]>>;
199
214
  /** Hard per-file size cap, in bytes; larger files are skipped entirely. */
@@ -1184,15 +1199,30 @@ export declare const CruxyConfigSchema: z.ZodObject<{
1184
1199
  /**
1185
1200
  * Embedding backend. Only `fastembed` (bge-small-en-v1.5, local ONNX; the
1186
1201
  * model downloads and caches on first use) is selectable — if it cannot be
1187
- * loaded, indexing fails loudly rather than degrading. (The deterministic
1188
- * hashing embedder exists for tests and is injected directly, never chosen
1189
- * here.)
1202
+ * loaded, or the model cannot be downloaded/initialized, indexing fails
1203
+ * loudly rather than degrading. (The deterministic hashing embedder exists
1204
+ * for tests and is injected directly, never chosen here.)
1205
+ *
1206
+ * THE STORE-VS-EMBEDDER ASYMMETRY (deliberate — see `store` below). The
1207
+ * embedder has NO degrade path while the store's `auto` does, and that is
1208
+ * correct, not an inconsistency: a store fallback (sqlite → in-memory) loses
1209
+ * only PERSISTENCE — search results are byte-identical, you just re-index
1210
+ * each session, a quality-NEUTRAL trade. The embedder's only cheaper
1211
+ * substitute is the lexical hashing backend, whose degrade is a SILENT
1212
+ * QUALITY LOSS — semantic search quietly stops being semantic. That is the
1213
+ * "reads like success, isn't" trap C.17 forbids, so the embedder fails loud
1214
+ * (module-load → CRUXY_E_INDEX_EMBEDDER_UNAVAILABLE; first-run download/init
1215
+ * → CRUXY_E_INDEX_EMBEDDER_DOWNLOAD_FAILED) and `search_codebase` routes the
1216
+ * model to `grep_files` rather than substituting a weaker index.
1190
1217
  */
1191
1218
  embedder: z.ZodDefault<z.ZodEnum<["fastembed"]>>;
1192
1219
  /**
1193
1220
  * Vector store backend. `sqlite` persists to `.cruxy/index.db` via
1194
1221
  * better-sqlite3; `memory` is ephemeral (tests). `auto` prefers sqlite and
1195
- * falls back to memory when the native dependency cannot be loaded.
1222
+ * falls back to memory when the native dependency cannot be loaded — a
1223
+ * quality-NEUTRAL degrade (only persistence is lost; results are identical).
1224
+ * This is why `auto` may degrade while `embedder` may not — see the
1225
+ * asymmetry note above.
1196
1226
  */
1197
1227
  store: z.ZodDefault<z.ZodEnum<["auto", "sqlite", "memory"]>>;
1198
1228
  /** Hard per-file size cap, in bytes; larger files are skipped entirely. */
@@ -146,15 +146,30 @@ export const IndexConfigSchema = z
146
146
  /**
147
147
  * Embedding backend. Only `fastembed` (bge-small-en-v1.5, local ONNX; the
148
148
  * model downloads and caches on first use) is selectable — if it cannot be
149
- * loaded, indexing fails loudly rather than degrading. (The deterministic
150
- * hashing embedder exists for tests and is injected directly, never chosen
151
- * here.)
149
+ * loaded, or the model cannot be downloaded/initialized, indexing fails
150
+ * loudly rather than degrading. (The deterministic hashing embedder exists
151
+ * for tests and is injected directly, never chosen here.)
152
+ *
153
+ * THE STORE-VS-EMBEDDER ASYMMETRY (deliberate — see `store` below). The
154
+ * embedder has NO degrade path while the store's `auto` does, and that is
155
+ * correct, not an inconsistency: a store fallback (sqlite → in-memory) loses
156
+ * only PERSISTENCE — search results are byte-identical, you just re-index
157
+ * each session, a quality-NEUTRAL trade. The embedder's only cheaper
158
+ * substitute is the lexical hashing backend, whose degrade is a SILENT
159
+ * QUALITY LOSS — semantic search quietly stops being semantic. That is the
160
+ * "reads like success, isn't" trap C.17 forbids, so the embedder fails loud
161
+ * (module-load → CRUXY_E_INDEX_EMBEDDER_UNAVAILABLE; first-run download/init
162
+ * → CRUXY_E_INDEX_EMBEDDER_DOWNLOAD_FAILED) and `search_codebase` routes the
163
+ * model to `grep_files` rather than substituting a weaker index.
152
164
  */
153
165
  embedder: z.enum(["fastembed"]).default("fastembed"),
154
166
  /**
155
167
  * Vector store backend. `sqlite` persists to `.cruxy/index.db` via
156
168
  * better-sqlite3; `memory` is ephemeral (tests). `auto` prefers sqlite and
157
- * falls back to memory when the native dependency cannot be loaded.
169
+ * falls back to memory when the native dependency cannot be loaded — a
170
+ * quality-NEUTRAL degrade (only persistence is lost; results are identical).
171
+ * This is why `auto` may degrade while `embedder` may not — see the
172
+ * asymmetry note above.
158
173
  */
159
174
  store: z.enum(["auto", "sqlite", "memory"]).default("auto"),
160
175
  /** Hard per-file size cap, in bytes; larger files are skipped entirely. */
@@ -41,6 +41,22 @@ export declare function budgetExhausted(underlying?: unknown): CruxyError;
41
41
  export declare function fileNotFound(path: string, underlying?: unknown): CruxyError;
42
42
  export declare function permissionDenied(path: string, underlying?: unknown): CruxyError;
43
43
  export declare function indexEmbedderUnavailable(underlying?: unknown): CruxyError;
44
+ /**
45
+ * The fastembed module loaded, but the model could not be brought up at runtime
46
+ * — the first-run download/decompress of bge-small-en-v1.5, or the ONNX-runtime
47
+ * init, failed. This is the COMMON first-run failure (offline, the model bucket
48
+ * is unreachable, a corporate proxy blocks it), and it is deliberately kept
49
+ * distinct from both {@link indexEmbedderUnavailable} (a module *load* failure)
50
+ * and the generic {@link indexFailed} ("re-run --verbose") so the cause is
51
+ * actionable rather than a shrug.
52
+ *
53
+ * Fail-loud, not degrade: there is no fallback to the lexical hashing embedder
54
+ * (a silent quality loss). `cruxy index` treats this as fatal — the user
55
+ * explicitly asked to build the index, so a silent no-op index would be the
56
+ * exact "reads like success, isn't" trap C.17 forbids. The `search_codebase`
57
+ * tool instead surfaces it as a tool error and points the model at `grep_files`.
58
+ */
59
+ export declare function indexEmbedderDownloadFailed(underlying?: unknown): CruxyError;
44
60
  export declare function indexStoreUnavailable(underlying?: unknown): CruxyError;
45
61
  export declare function indexFailed(underlying?: unknown): CruxyError;
46
62
  /**
@@ -266,6 +266,34 @@ export function indexEmbedderUnavailable(underlying) {
266
266
  underlying,
267
267
  });
268
268
  }
269
+ /**
270
+ * The fastembed module loaded, but the model could not be brought up at runtime
271
+ * — the first-run download/decompress of bge-small-en-v1.5, or the ONNX-runtime
272
+ * init, failed. This is the COMMON first-run failure (offline, the model bucket
273
+ * is unreachable, a corporate proxy blocks it), and it is deliberately kept
274
+ * distinct from both {@link indexEmbedderUnavailable} (a module *load* failure)
275
+ * and the generic {@link indexFailed} ("re-run --verbose") so the cause is
276
+ * actionable rather than a shrug.
277
+ *
278
+ * Fail-loud, not degrade: there is no fallback to the lexical hashing embedder
279
+ * (a silent quality loss). `cruxy index` treats this as fatal — the user
280
+ * explicitly asked to build the index, so a silent no-op index would be the
281
+ * exact "reads like success, isn't" trap C.17 forbids. The `search_codebase`
282
+ * tool instead surfaces it as a tool error and points the model at `grep_files`.
283
+ */
284
+ export function indexEmbedderDownloadFailed(underlying) {
285
+ return new CruxyError({
286
+ code: ErrorCode.IndexEmbedderDownloadFailed,
287
+ title: "the local embedding model could not be downloaded or initialized",
288
+ cause: messageOf(underlying),
289
+ nextSteps: [
290
+ "check your internet connection — the model (bge-small-en-v1.5) downloads once on first use",
291
+ "if you are behind a proxy or firewall, allow access to the model host (Hugging Face) and set HTTPS_PROXY",
292
+ "once the download succeeds it is cached under ~/.cruxy/models and never re-fetched",
293
+ ],
294
+ underlying,
295
+ });
296
+ }
269
297
  export function indexStoreUnavailable(underlying) {
270
298
  return new CruxyError({
271
299
  code: ErrorCode.IndexStoreUnavailable,
@@ -46,7 +46,18 @@ export declare const ErrorCode: {
46
46
  readonly PermissionDenied: "CRUXY_E_PERMISSION_DENIED";
47
47
  readonly PathEscape: "CRUXY_E_PATH_ESCAPE";
48
48
  readonly CheckpointFailed: "CRUXY_E_CHECKPOINT_FAILED";
49
+ /** The fastembed native module could not be LOADED (missing/broken install,
50
+ * un-built onnxruntime-node addon). Fail-loud by design — the embedder never
51
+ * silently degrades to the lexical hashing backend (that would be a silent
52
+ * quality loss). See the store-vs-embedder asymmetry note in `schema.ts`. */
49
53
  readonly IndexEmbedderUnavailable: "CRUXY_E_INDEX_EMBEDDER_UNAVAILABLE";
54
+ /** The fastembed module loaded, but the model could not be brought up at
55
+ * RUNTIME — the first-run download/decompress, or the ONNX-runtime init,
56
+ * failed (offline, the model bucket is unreachable, a proxy blocks it). Kept
57
+ * DISTINCT from `IndexEmbedderUnavailable` (a load failure) and from the
58
+ * generic `IndexFailed` ("re-run --verbose") so the actual, common first-run
59
+ * failure carries an actionable cause instead of a shrug. */
60
+ readonly IndexEmbedderDownloadFailed: "CRUXY_E_INDEX_EMBEDDER_DOWNLOAD_FAILED";
50
61
  readonly IndexStoreUnavailable: "CRUXY_E_INDEX_STORE_UNAVAILABLE";
51
62
  readonly IndexFailed: "CRUXY_E_INDEX_FAILED";
52
63
  readonly SkillInvalid: "CRUXY_E_SKILL_INVALID";
@@ -54,7 +54,18 @@ export const ErrorCode = {
54
54
  PathEscape: "CRUXY_E_PATH_ESCAPE",
55
55
  CheckpointFailed: "CRUXY_E_CHECKPOINT_FAILED",
56
56
  // index (exit 8)
57
+ /** The fastembed native module could not be LOADED (missing/broken install,
58
+ * un-built onnxruntime-node addon). Fail-loud by design — the embedder never
59
+ * silently degrades to the lexical hashing backend (that would be a silent
60
+ * quality loss). See the store-vs-embedder asymmetry note in `schema.ts`. */
57
61
  IndexEmbedderUnavailable: "CRUXY_E_INDEX_EMBEDDER_UNAVAILABLE",
62
+ /** The fastembed module loaded, but the model could not be brought up at
63
+ * RUNTIME — the first-run download/decompress, or the ONNX-runtime init,
64
+ * failed (offline, the model bucket is unreachable, a proxy blocks it). Kept
65
+ * DISTINCT from `IndexEmbedderUnavailable` (a load failure) and from the
66
+ * generic `IndexFailed` ("re-run --verbose") so the actual, common first-run
67
+ * failure carries an actionable cause instead of a shrug. */
68
+ IndexEmbedderDownloadFailed: "CRUXY_E_INDEX_EMBEDDER_DOWNLOAD_FAILED",
58
69
  IndexStoreUnavailable: "CRUXY_E_INDEX_STORE_UNAVAILABLE",
59
70
  IndexFailed: "CRUXY_E_INDEX_FAILED",
60
71
  // skill (exit 9)
@@ -275,6 +286,7 @@ const EXIT_CODES = {
275
286
  [ErrorCode.PathEscape]: 7,
276
287
  [ErrorCode.CheckpointFailed]: 7,
277
288
  [ErrorCode.IndexEmbedderUnavailable]: 8,
289
+ [ErrorCode.IndexEmbedderDownloadFailed]: 8,
278
290
  [ErrorCode.IndexStoreUnavailable]: 8,
279
291
  [ErrorCode.IndexFailed]: 8,
280
292
  [ErrorCode.SkillInvalid]: 9,
@@ -1,5 +1,5 @@
1
1
  import { promises as fs } from "node:fs";
2
- import { indexEmbedderUnavailable } from "../errors/index.js";
2
+ import { indexEmbedderDownloadFailed, indexEmbedderUnavailable, } from "../errors/index.js";
3
3
  import { l2normalize } from "./util.js";
4
4
  /**
5
5
  * Output dimensionality of bge-small-en-v1.5, and the default size of the
@@ -84,18 +84,30 @@ export class FastEmbedEmbedder {
84
84
  getModel() {
85
85
  if (!this.model) {
86
86
  this.model = (async () => {
87
- const mod = await import("fastembed");
88
- // fastembed's init does a non-recursive mkdir of the cache dir, so it
89
- // fails if an ancestor (e.g. ~/.cruxy) doesn't exist yet. Create it first.
90
- if (this.opts.cacheDir) {
91
- await fs.mkdir(this.opts.cacheDir, { recursive: true });
87
+ try {
88
+ const mod = await import("fastembed");
89
+ // fastembed's init does a non-recursive mkdir of the cache dir, so it
90
+ // fails if an ancestor (e.g. ~/.cruxy) doesn't exist yet. Create it first.
91
+ if (this.opts.cacheDir) {
92
+ await fs.mkdir(this.opts.cacheDir, { recursive: true });
93
+ }
94
+ return (await mod.FlagEmbedding.init({
95
+ model: mod.EmbeddingModel.BGESmallENV15,
96
+ maxLength: this.opts.maxLength ?? 512,
97
+ cacheDir: this.opts.cacheDir,
98
+ showDownloadProgress: this.opts.showDownloadProgress ?? false,
99
+ }));
100
+ }
101
+ catch (err) {
102
+ // The heavy path: first-run model download/decompress or ONNX-runtime
103
+ // init failed (offline, unreachable model bucket, proxy). Surface a
104
+ // typed, actionable error instead of letting it collapse into the
105
+ // generic CRUXY_E_INDEX_FAILED ("re-run --verbose"). Still fail-loud —
106
+ // this never degrades to the lexical backend. (Module-*load* failure is
107
+ // caught earlier and eagerly in `createEmbedder`, so what reaches here is
108
+ // a runtime bring-up failure, not a missing install.)
109
+ throw indexEmbedderDownloadFailed(err);
92
110
  }
93
- return (await mod.FlagEmbedding.init({
94
- model: mod.EmbeddingModel.BGESmallENV15,
95
- maxLength: this.opts.maxLength ?? 512,
96
- cacheDir: this.opts.cacheDir,
97
- showDownloadProgress: this.opts.showDownloadProgress ?? false,
98
- }));
99
111
  })();
100
112
  }
101
113
  return this.model;
@@ -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: "-",
@@ -1,8 +1,38 @@
1
1
  import { z } from "zod";
2
+ import { CruxyError, ErrorCode } from "../errors/index.js";
2
3
  import { getIndexService, mergeRankedHits } from "../indexing/index.js";
3
4
  import { contextWorkspace, labelName, resolveReadRoots } from "./file/paths.js";
4
5
  /** Hard cap on `k`, mirroring the retriever. */
5
6
  const MAX_K = 50;
7
+ /**
8
+ * The embedder is unavailable — it failed to load, or the model failed to
9
+ * download/init. Fail-loud is preserved (the embedder never degrades to a
10
+ * lexical backend), so semantic search genuinely cannot run right now. This
11
+ * ROUTES THE MODEL to a working alternative: it is a hint in the error text, NOT
12
+ * an embedder fallback — `grep_files` is a different tool with different
13
+ * semantics (exact strings / symbols), and choosing it is the model's call.
14
+ */
15
+ const GREP_ROUTE_HINT = "semantic search is unavailable right now — use grep_files to find code by exact string or symbol name instead";
16
+ /** True when `err` is a semantic-search-blocking embedder unavailability. */
17
+ function isEmbedderUnavailable(err) {
18
+ return (err instanceof CruxyError &&
19
+ (err.code === ErrorCode.IndexEmbedderUnavailable ||
20
+ err.code === ErrorCode.IndexEmbedderDownloadFailed));
21
+ }
22
+ /**
23
+ * The model-facing message for a failed search. For an embedder unavailability
24
+ * it appends the grep_files routing hint (and the actionable cause, if any) so
25
+ * the model has both the reason and a productive next move; every other failure
26
+ * passes through unchanged.
27
+ */
28
+ function searchErrorMessage(err) {
29
+ const base = err.message;
30
+ if (isEmbedderUnavailable(err)) {
31
+ const cause = err instanceof CruxyError && err.cause ? ` (${err.cause})` : "";
32
+ return `${base}${cause}. ${GREP_ROUTE_HINT}.`;
33
+ }
34
+ return base;
35
+ }
6
36
  const parameters = z.object({
7
37
  query: z
8
38
  .string()
@@ -76,7 +106,9 @@ export const searchCodebaseTool = {
76
106
  return { ok: true, output: formatHits(hits, false) };
77
107
  }
78
108
  catch (err) {
79
- return { ok: false, error: err.message };
109
+ // Embedder-unavailable errors carry a grep_files routing hint (a hint,
110
+ // NOT a fallback — the embedder still failed loud).
111
+ return { ok: false, error: searchErrorMessage(err) };
80
112
  }
81
113
  }
82
114
  // Multi-root fan: one independent search per root, per-root failures isolated
@@ -85,6 +117,9 @@ export const searchCodebaseTool = {
85
117
  const rawByRoot = new Map();
86
118
  const searched = [];
87
119
  const failed = [];
120
+ // Set if ANY root's failure was an embedder unavailability, so the fanned
121
+ // footer routes the model to grep_files ONCE rather than per failing root.
122
+ let embedderUnavailable = false;
88
123
  for (const root of roots) {
89
124
  try {
90
125
  const service = await getIndexService(root.absPath, ctx.config, ctx.logger);
@@ -101,7 +136,11 @@ export const searchCodebaseTool = {
101
136
  searched.push(root.name);
102
137
  }
103
138
  catch (err) {
139
+ // The root is NAMED (never conflated with "no matches"); the grep_files
140
+ // hint is added once, in the footer, not repeated per root.
104
141
  failed.push({ name: root.name, reason: err.message });
142
+ if (isEmbedderUnavailable(err))
143
+ embedderUnavailable = true;
105
144
  }
106
145
  }
107
146
  // Global rank + budget + cap AFTER the merge (⚖︎JC-I): a strong hit in one root
@@ -113,7 +152,12 @@ export const searchCodebaseTool = {
113
152
  });
114
153
  return {
115
154
  ok: true,
116
- output: renderFanned(merged, { searched, failed, rawByRoot }),
155
+ output: renderFanned(merged, {
156
+ searched,
157
+ failed,
158
+ rawByRoot,
159
+ embedderUnavailable,
160
+ }),
117
161
  };
118
162
  },
119
163
  };
@@ -156,6 +200,11 @@ function renderFanned(merged, ctx) {
156
200
  for (const f of ctx.failed) {
157
201
  notes.push(`${f.name}: index unavailable — ${f.reason}`);
158
202
  }
203
+ // One routing hint for the whole fan when the embedder is the blocker (a hint,
204
+ // NOT a fallback — the embedder still failed loud on every affected root).
205
+ if (ctx.embedderUnavailable) {
206
+ notes.push(GREP_ROUTE_HINT);
207
+ }
159
208
  const scope = ctx.searched.length
160
209
  ? `searched ${ctx.searched.length} root${ctx.searched.length === 1 ? "" : "s"}: ${ctx.searched.join(", ")}`
161
210
  : "no roots could be searched";
@@ -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.0",
3
+ "version": "1.0.3",
4
4
  "description": "an agentic coding CLI",
5
5
  "type": "module",
6
6
  "bin": {