@cruxy/cli 1.0.0 → 1.0.2
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 +1 -1
- package/dist/cli/commands/usage.js +8 -1
- package/dist/config/schema.d.ts +38 -8
- package/dist/config/schema.js +19 -4
- package/dist/errors/constructors.d.ts +16 -0
- package/dist/errors/constructors.js +28 -0
- package/dist/errors/types.d.ts +11 -0
- package/dist/errors/types.js +12 -0
- package/dist/indexing/embedder.js +24 -12
- package/dist/tools/search-codebase.js +51 -2
- package/package.json +1 -1
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
|
|
|
@@ -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(
|
|
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
|
}
|
package/dist/config/schema.d.ts
CHANGED
|
@@ -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,
|
|
189
|
-
*
|
|
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,
|
|
1188
|
-
*
|
|
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. */
|
package/dist/config/schema.js
CHANGED
|
@@ -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,
|
|
150
|
-
*
|
|
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,
|
package/dist/errors/types.d.ts
CHANGED
|
@@ -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";
|
package/dist/errors/types.js
CHANGED
|
@@ -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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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;
|
|
@@ -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
|
-
|
|
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, {
|
|
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";
|