@cruxy/cli 1.11.0 → 1.11.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 +12 -0
- package/dist/agent/context.js +6 -0
- package/dist/checkpoint/service.js +44 -3
- package/dist/cli/commands/sessions.js +8 -0
- package/dist/cli/session-commands.js +4 -0
- package/dist/components/input.js +18 -1
- package/dist/components/keys.js +66 -3
- package/dist/config/schema.js +8 -1
- package/dist/errors/constructors.js +15 -6
- package/dist/errors/types.js +7 -0
- package/dist/indexing/embedder.js +34 -11
- package/dist/indexing/model-cache.js +399 -0
- package/dist/render/context-view.js +12 -3
- package/dist/render/index.js +2 -1
- package/dist/session/index.js +1 -0
- package/dist/session/log.js +66 -0
- package/dist/session/owner.js +123 -0
- package/dist/session/prune.js +11 -0
- package/dist/session/resume.js +22 -3
- package/dist/subagent/orchestrator.js +2 -2
- package/dist/subagent/registry-scope.js +28 -5
- package/dist/tools/file/apply-patch.js +49 -23
- package/dist/tools/file/edit-file.js +15 -1
- package/dist/tools/file/snapshot.js +63 -0
- package/dist/tools/file/write-file.js +26 -5
- package/dist/tui/app.js +30 -7
- package/dist/tui/approval-overlay.js +4 -1
- package/dist/tui/layout.js +25 -1
- package/dist/tui/panels.js +44 -6
- package/dist/tui/renderer.js +187 -14
- package/dist/tui/supports.js +15 -0
- package/dist/utils/logger.js +52 -6
- package/dist/utils/process-owner.js +107 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -219,6 +219,7 @@ once. Reduced motion is also implied by screen-reader mode.
|
|
|
219
219
|
| Variable | Effect |
|
|
220
220
|
| --------------------- | ------------------------------------------------------------------------------ |
|
|
221
221
|
| `CRUXY_NO_ALT_SCREEN` | Keep the full-screen TUI in the normal buffer instead of the alternate screen. |
|
|
222
|
+
| `CRUXY_NO_MOUSE` | Leave the mouse to the terminal — the wheel scrolls the window, not the pane. |
|
|
222
223
|
|
|
223
224
|
By default the TUI runs on the terminal's alternate screen — the second buffer
|
|
224
225
|
`less` and `vim` use. Leaving it restores the normal buffer byte for byte, so
|
|
@@ -237,6 +238,17 @@ prefer your shell to keep the frame. It changes nothing else about the TUI, and
|
|
|
237
238
|
it is ignored where the TUI does not run at all (a pipe, a screen reader,
|
|
238
239
|
`TERM=dumb`).
|
|
239
240
|
|
|
241
|
+
### The mouse
|
|
242
|
+
|
|
243
|
+
While the TUI is active it turns mouse reporting on, so the wheel scrolls the
|
|
244
|
+
conversation (or the selected view) the way Page Up and Page Down do, instead
|
|
245
|
+
of scrolling the terminal window over a buffer that has no scrollback. The
|
|
246
|
+
trade is the one `less` and `vim` make: **native text selection needs Shift
|
|
247
|
+
(Option in Terminal.app) while the TUI is active.** If you select text
|
|
248
|
+
constantly, set `CRUXY_NO_MOUSE=1` — the wheel goes back to the terminal, and
|
|
249
|
+
nothing else about the TUI changes. It is implied off wherever the alternate
|
|
250
|
+
screen is off, because the two are turned on and released as one.
|
|
251
|
+
|
|
240
252
|
However cruxy exits — quit, `kill -TERM`, a hangup when the window closes, an
|
|
241
253
|
uncaught error — the terminal is handed back: the frame erased, the cursor
|
|
242
254
|
shown, the alternate screen left.
|
package/dist/agent/context.js
CHANGED
|
@@ -41,6 +41,11 @@ export function readContext(messages, budget) {
|
|
|
41
41
|
// reserve covers the system prompt and tool schemas that `estimateTokens`
|
|
42
42
|
// never sees, and omitting it here would under-report by ~4.5k tokens and let
|
|
43
43
|
// the panel read "comfortable" while the seam was about to compact.
|
|
44
|
+
//
|
|
45
|
+
// It is an ALLOWANCE, not a measurement, and every renderer of `used` has
|
|
46
|
+
// to be able to say so — hence `reserve` travels with the reading. It is
|
|
47
|
+
// fixed by config: it does not grow when CRUXY.md, recalled memory, LSP,
|
|
48
|
+
// web or MCP tool schemas grow the actual request.
|
|
44
49
|
const used = estimateTokens(messages) + budget.reserveTokens;
|
|
45
50
|
const total = budget.maxTokens;
|
|
46
51
|
return {
|
|
@@ -48,6 +53,7 @@ export function readContext(messages, budget) {
|
|
|
48
53
|
total,
|
|
49
54
|
fraction: total <= 0 ? 1 : Math.min(1, Math.max(0, used / total)),
|
|
50
55
|
compactAt: Math.round(budget.compactThreshold * total),
|
|
56
|
+
reserve: budget.reserveTokens,
|
|
51
57
|
};
|
|
52
58
|
}
|
|
53
59
|
/**
|
|
@@ -8,6 +8,7 @@ import { captureFiles } from "./capture.js";
|
|
|
8
8
|
import { GitCheckpointStore } from "./git-store.js";
|
|
9
9
|
import { ShadowCheckpointStore } from "./shadow-store.js";
|
|
10
10
|
import { applyRollback, buildRollbackPreview, computeRollbackPlan, } from "./restore.js";
|
|
11
|
+
import { describeOwner, selfStamp, } from "../utils/process-owner.js";
|
|
11
12
|
/** Is `root` inside a git working tree? (Decides the checkpoint substrate.) */
|
|
12
13
|
export function isGitWorkTree(root) {
|
|
13
14
|
const res = runGitCapture(["rev-parse", "--is-inside-work-tree"], root);
|
|
@@ -112,6 +113,7 @@ export class CheckpointService {
|
|
|
112
113
|
files: entries,
|
|
113
114
|
touchedPaths: [],
|
|
114
115
|
hasShellMutations: false,
|
|
116
|
+
owner: selfStamp(),
|
|
115
117
|
};
|
|
116
118
|
await this.writeManifest(checkpoint);
|
|
117
119
|
await this.prune();
|
|
@@ -203,10 +205,27 @@ export class CheckpointService {
|
|
|
203
205
|
const applied = await applyRollback(this.root, plan, store);
|
|
204
206
|
return { kind: "applied", checkpoint, applied };
|
|
205
207
|
}
|
|
206
|
-
/**
|
|
208
|
+
/**
|
|
209
|
+
* Enforce `checkpoint.retention`: drop oldest manifests, then GC content.
|
|
210
|
+
*
|
|
211
|
+
* ACROSS PROCESSES (P1). `.cruxy/checkpoints/` is per root, not per process,
|
|
212
|
+
* and every cruxy in the project prunes it by count — so a second session
|
|
213
|
+
* creating its own checkpoints pushed the first session's out of the
|
|
214
|
+
* window and swept their objects, and the first session's `cruxy rollback`
|
|
215
|
+
* then failed or restored the wrong thing. A checkpoint whose recorded
|
|
216
|
+
* owner is another LIVE process is therefore not this process's to prune:
|
|
217
|
+
* it is set aside, its objects stay referenced, and the retention count is
|
|
218
|
+
* applied to everything else. The bound is exceeded by at most the live
|
|
219
|
+
* foreign checkpoints, and only while their owners run: a crashed owner's
|
|
220
|
+
* checkpoints are ordinary candidates the next time anyone prunes, because
|
|
221
|
+
* liveness is pid + start-time asked of the OS, never a file that must be
|
|
222
|
+
* cleaned up.
|
|
223
|
+
*/
|
|
207
224
|
async prune() {
|
|
208
225
|
const all = await this.list();
|
|
209
|
-
const
|
|
226
|
+
const protectedIds = foreignLive(all);
|
|
227
|
+
const candidates = all.filter((c) => !protectedIds.has(c.id));
|
|
228
|
+
const doomed = candidates.slice(this.config.checkpoint.retention);
|
|
210
229
|
if (doomed.length === 0)
|
|
211
230
|
return;
|
|
212
231
|
for (const checkpoint of doomed) {
|
|
@@ -214,7 +233,7 @@ export class CheckpointService {
|
|
|
214
233
|
force: true,
|
|
215
234
|
});
|
|
216
235
|
}
|
|
217
|
-
const survivors = all.
|
|
236
|
+
const survivors = all.filter((c) => !doomed.includes(c));
|
|
218
237
|
const referenced = new Set(survivors.flatMap((c) => c.files.map((f) => f.oid)));
|
|
219
238
|
// The shadow pool is ours to sweep; git's dangling objects belong to git gc.
|
|
220
239
|
await new ShadowCheckpointStore(this.root).collect(referenced);
|
|
@@ -266,6 +285,28 @@ export class CheckpointService {
|
|
|
266
285
|
return parsed;
|
|
267
286
|
}
|
|
268
287
|
}
|
|
288
|
+
/**
|
|
289
|
+
* Ids of the checkpoints another LIVE process owns (P1 — see {@link prune}).
|
|
290
|
+
* One liveness lookup per distinct owner, not per manifest: the darwin lookup
|
|
291
|
+
* is a `ps` call, and a project can hold many checkpoints from one session.
|
|
292
|
+
*/
|
|
293
|
+
function foreignLive(all) {
|
|
294
|
+
const ids = new Set();
|
|
295
|
+
const status = new Map();
|
|
296
|
+
for (const c of all) {
|
|
297
|
+
if (!c.owner)
|
|
298
|
+
continue;
|
|
299
|
+
const key = `${c.owner.pid}:${c.owner.token}`;
|
|
300
|
+
let s = status.get(key);
|
|
301
|
+
if (s === undefined) {
|
|
302
|
+
s = describeOwner(c.owner);
|
|
303
|
+
status.set(key, s);
|
|
304
|
+
}
|
|
305
|
+
if (s === "live")
|
|
306
|
+
ids.add(c.id);
|
|
307
|
+
}
|
|
308
|
+
return ids;
|
|
309
|
+
}
|
|
269
310
|
/** `ck-<utc-stamp>-<rand>` — sortable, collision-safe enough for a local CLI. */
|
|
270
311
|
function newCheckpointId() {
|
|
271
312
|
const stamp = new Date()
|
|
@@ -7,6 +7,7 @@ import { SESSION_FILE_EXT, matchSessionRefs, listSessionRefs, pruneSessions, ses
|
|
|
7
7
|
import { themeForColor } from "../../theme/index.js";
|
|
8
8
|
import { formatBytes } from "../../utils/disk.js";
|
|
9
9
|
import { logger } from "../../utils/logger.js";
|
|
10
|
+
import { describeHolder, removeOwnerFile, sessionHeldBy, } from "../../session/owner.js";
|
|
10
11
|
/**
|
|
11
12
|
* `cruxy sessions` (#257) — see and bound what `~/.cruxy/projects/<project>/`
|
|
12
13
|
* is holding.
|
|
@@ -149,8 +150,15 @@ export function sessionsCommand() {
|
|
|
149
150
|
"sessions are per-directory; check you are in the right one",
|
|
150
151
|
]);
|
|
151
152
|
}
|
|
153
|
+
// Not while another cruxy is writing it (P1): its next append would
|
|
154
|
+
// recreate the file headless and leave a session nothing can load.
|
|
155
|
+
const holder = sessionHeldBy(matches[0].file);
|
|
156
|
+
if (holder) {
|
|
157
|
+
throw usageError(`session ${shortId(matches[0].sessionId)} is open in another cruxy (${describeHolder(holder)})`, ["quit that cruxy first, then delete it"]);
|
|
158
|
+
}
|
|
152
159
|
try {
|
|
153
160
|
unlinkSync(matches[0].file);
|
|
161
|
+
removeOwnerFile(matches[0].file);
|
|
154
162
|
}
|
|
155
163
|
catch (err) {
|
|
156
164
|
throw usageError(`could not delete session ${shortId(matches[0].sessionId)}`, [`${matches[0].file}: ${err.message}`]);
|
|
@@ -63,6 +63,10 @@ export async function dispatchCommand(input, ctx) {
|
|
|
63
63
|
return { kind: "exit" };
|
|
64
64
|
if (trimmed === "/clear") {
|
|
65
65
|
session.clear();
|
|
66
|
+
// The screen too, where the shell owns one. A history reset that left the
|
|
67
|
+
// old transcript on screen above its own confirmation read as a reset that
|
|
68
|
+
// had not happened — the context WAS empty; only the screen said otherwise.
|
|
69
|
+
out.clear?.();
|
|
66
70
|
out.print(t.muted("history cleared"));
|
|
67
71
|
return { kind: "handled" };
|
|
68
72
|
}
|
package/dist/components/input.js
CHANGED
|
@@ -75,7 +75,7 @@ export async function readSingleKey(stdin = process.stdin) {
|
|
|
75
75
|
const keys = createKeyReader(stdin);
|
|
76
76
|
keys.begin();
|
|
77
77
|
try {
|
|
78
|
-
const key = await keys
|
|
78
|
+
const key = await readAnswerKey(keys);
|
|
79
79
|
switch (key.kind) {
|
|
80
80
|
case "char":
|
|
81
81
|
return key.char;
|
|
@@ -89,6 +89,23 @@ export async function readSingleKey(stdin = process.stdin) {
|
|
|
89
89
|
keys.restore();
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* The next key that can be an ANSWER — a wheel notch is not one.
|
|
94
|
+
*
|
|
95
|
+
* Every single-key prompt maps "anything unmapped" to the safe default (deny,
|
|
96
|
+
* cancel, no). That is right for an arrow or a function key, and wrong for the
|
|
97
|
+
* mouse wheel: with mouse reporting on (the TUI turns it on), a reader who
|
|
98
|
+
* scrolls back to check what a prompt is about would answer it "no" by doing
|
|
99
|
+
* so. The wheel is a scroll, never a reply, so it is skipped here and the
|
|
100
|
+
* prompt keeps waiting.
|
|
101
|
+
*/
|
|
102
|
+
export async function readAnswerKey(keys) {
|
|
103
|
+
for (;;) {
|
|
104
|
+
const key = await keys.read();
|
|
105
|
+
if (key.kind !== "wheel-up" && key.kind !== "wheel-down")
|
|
106
|
+
return key;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
92
109
|
/** The real environment: frames to stderr, keys from stdin, caps from stderr. */
|
|
93
110
|
export function defaultComponentIO() {
|
|
94
111
|
// Detection resolves both axes (stderr for output, stdin for input) and their
|
package/dist/components/keys.js
CHANGED
|
@@ -26,6 +26,22 @@ const CBT = 0x5a;
|
|
|
26
26
|
* not rejected, so these are matched on the leading number instead.
|
|
27
27
|
*/
|
|
28
28
|
const TILDE = 0x7e;
|
|
29
|
+
/**
|
|
30
|
+
* The two mouse encodings a terminal can answer `?1000h` with.
|
|
31
|
+
*
|
|
32
|
+
* SGR (`?1006h`, the one the TUI asks for) sends `ESC [ < Cb ; Cx ; Cy M` for
|
|
33
|
+
* a press and `… m` for a release, all in printable ASCII. A terminal that does
|
|
34
|
+
* not know SGR falls back to X10: `ESC [ M` followed by THREE RAW BYTES
|
|
35
|
+
* (button+32, column+32, row+32). The X10 form has to be recognised even though
|
|
36
|
+
* it is never requested, because the alternative is worse than an unmapped key:
|
|
37
|
+
* the generic CSI rule below would stop at the `M`, and the three bytes after
|
|
38
|
+
* it — printable, by construction — would land in the input line as text.
|
|
39
|
+
*/
|
|
40
|
+
const SGR_MOUSE_INTRO = 0x3c; // <
|
|
41
|
+
const X10_MOUSE_FINAL = 0x4d; // M
|
|
42
|
+
/** Bit 6 of the button code marks a wheel event; bit 0 says which way. */
|
|
43
|
+
const MOUSE_WHEEL_FLAG = 64;
|
|
44
|
+
const X10_MOUSE_PAYLOAD = 3;
|
|
29
45
|
/** Leading `~`-sequence parameter → key. `ESC [ 5 ~` / `ESC [ 6 ~`. */
|
|
30
46
|
const TILDES = {
|
|
31
47
|
5: "page-up",
|
|
@@ -43,9 +59,10 @@ const ARROWS = {
|
|
|
43
59
|
* keys per chunk; all are returned in order.
|
|
44
60
|
*
|
|
45
61
|
* Escape handling is deliberately simple: `ESC [ A..D` decodes to an arrow,
|
|
46
|
-
* `ESC [ … Z` decodes to Shift+Tab,
|
|
47
|
-
*
|
|
48
|
-
*
|
|
62
|
+
* `ESC [ … Z` decodes to Shift+Tab, a mouse report (SGR `ESC [ < … M`, or X10
|
|
63
|
+
* `ESC [ M` + 3 bytes) decodes to a wheel notch or to nothing, any other CSI
|
|
64
|
+
* sequence (`ESC [ …final`) is swallowed whole (unmapped keys must not leak
|
|
65
|
+
* garbage chars into a query), and a lone ESC decodes to `escape`. Terminals send arrow sequences atomically in practice; a sequence
|
|
49
66
|
* split across chunks degrades to `escape` + literal chars, which is safe
|
|
50
67
|
* (escape cancels).
|
|
51
68
|
*/
|
|
@@ -56,12 +73,44 @@ export function decodeKeys(chunk) {
|
|
|
56
73
|
const byte = buf[i];
|
|
57
74
|
if (byte === ESC) {
|
|
58
75
|
if (buf[i + 1] === 0x5b /* [ */) {
|
|
76
|
+
// X10 mouse: `ESC [ M` plus three raw payload bytes. Consumed as one
|
|
77
|
+
// unit BEFORE the generic CSI walk, which would otherwise treat `M` as
|
|
78
|
+
// the final byte and hand the payload to the printable branch.
|
|
79
|
+
if (buf[i + 2] === X10_MOUSE_FINAL) {
|
|
80
|
+
const end = i + 3 + X10_MOUSE_PAYLOAD;
|
|
81
|
+
if (end <= buf.length) {
|
|
82
|
+
const wheel = mouseWheel(buf[i + 3] - 32);
|
|
83
|
+
if (wheel)
|
|
84
|
+
keys.push({ kind: wheel });
|
|
85
|
+
i = end;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
// Truncated at the chunk boundary: the same degradation as any other
|
|
89
|
+
// partial CSI — escape, and drop the tail.
|
|
90
|
+
keys.push({ kind: "escape" });
|
|
91
|
+
i = buf.length;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
59
94
|
// CSI: consume parameter/intermediate bytes (0x20–0x3f) up to the
|
|
60
95
|
// final byte (0x40–0x7e); map arrows, swallow everything else.
|
|
61
96
|
let j = i + 2;
|
|
62
97
|
while (j < buf.length && buf[j] >= 0x20 && buf[j] <= 0x3f)
|
|
63
98
|
j++;
|
|
64
99
|
if (j < buf.length) {
|
|
100
|
+
if (buf[i + 2] === SGR_MOUSE_INTRO) {
|
|
101
|
+
// SGR mouse: `ESC [ < Cb ; Cx ; Cy M|m`. Only the button code is
|
|
102
|
+
// read — the wheel has no position — and a press is the only edge
|
|
103
|
+
// a wheel has, so `m` (release) never maps to anything.
|
|
104
|
+
let cb = 0;
|
|
105
|
+
for (let k = i + 3; k < j && buf[k] >= 0x30 && buf[k] <= 0x39; k++) {
|
|
106
|
+
cb = cb * 10 + (buf[k] - 0x30);
|
|
107
|
+
}
|
|
108
|
+
const wheel = buf[j] === X10_MOUSE_FINAL ? mouseWheel(cb) : null;
|
|
109
|
+
if (wheel)
|
|
110
|
+
keys.push({ kind: wheel });
|
|
111
|
+
i = j + 1;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
65
114
|
if (buf[j] === CBT) {
|
|
66
115
|
// Shift+Tab, in BOTH its forms: bare `ESC [ Z`, and the
|
|
67
116
|
// parameterized `ESC [ 1 ; 2 Z` some terminals send when a modifier
|
|
@@ -165,3 +214,17 @@ export function decodeKeys(chunk) {
|
|
|
165
214
|
}
|
|
166
215
|
return keys;
|
|
167
216
|
}
|
|
217
|
+
/**
|
|
218
|
+
* A mouse button code → the wheel direction it encodes, or null for any other
|
|
219
|
+
* mouse event. Modifier bits (shift 4, meta 8, ctrl 16) are masked off so a
|
|
220
|
+
* wheel with a modifier held still scrolls; the low two bits pick the direction.
|
|
221
|
+
*/
|
|
222
|
+
function mouseWheel(button) {
|
|
223
|
+
if ((button & MOUSE_WHEEL_FLAG) === 0)
|
|
224
|
+
return null;
|
|
225
|
+
return (button & 3) === 0
|
|
226
|
+
? "wheel-up"
|
|
227
|
+
: (button & 3) === 1
|
|
228
|
+
? "wheel-down"
|
|
229
|
+
: null;
|
|
230
|
+
}
|
package/dist/config/schema.js
CHANGED
|
@@ -133,7 +133,14 @@ export const ContextConfigSchema = z
|
|
|
133
133
|
* sees — the system prompt and every tool's JSON schema — added to the
|
|
134
134
|
* measured history before the threshold test so the trigger reflects the
|
|
135
135
|
* real request size, not just the visible messages. Roughly the size of
|
|
136
|
-
* the built system prompt plus the default tool catalogue today
|
|
136
|
+
* the built system prompt plus the default tool catalogue today (about
|
|
137
|
+
* 3.7k measured for the prompt and 14 tool schemas).
|
|
138
|
+
*
|
|
139
|
+
* An ALLOWANCE, not a measurement — the context panel and `/context` say
|
|
140
|
+
* so. It is the same number on every turn, so it does not track the parts
|
|
141
|
+
* of a request that vary: CRUXY.md, recalled memory, LSP context, web
|
|
142
|
+
* results and MCP tool schemas all make the real request larger without
|
|
143
|
+
* moving it. Raise it if a project carries a lot of those.
|
|
137
144
|
*/
|
|
138
145
|
reserveTokens: z.number().int().nonnegative().default(4500),
|
|
139
146
|
/** Most-recent messages always kept verbatim (a floor; the cut rounds up to
|
|
@@ -545,16 +545,25 @@ export function indexEmbedderUnavailable(underlying) {
|
|
|
545
545
|
* exact "reads like success, isn't" trap C.17 forbids. The `search_codebase`
|
|
546
546
|
* tool instead surfaces it as a tool error and points the model at `grep_files`.
|
|
547
547
|
*/
|
|
548
|
-
export function indexEmbedderDownloadFailed(underlying) {
|
|
548
|
+
export function indexEmbedderDownloadFailed(underlying, opts = {}) {
|
|
549
|
+
// A refused archive is not a connectivity problem: the download succeeded
|
|
550
|
+
// and cruxy's extractor rejected an entry (path traversal, a link, an
|
|
551
|
+
// unexpected layout). Retrying will refuse it again, so say so.
|
|
552
|
+
const nextSteps = opts.archiveRefused
|
|
553
|
+
? [
|
|
554
|
+
"the model archive was refused by cruxy's extraction guard and nothing was written; this is not a connectivity problem",
|
|
555
|
+
"do not retry blindly — if it persists, report it with the message above (the upstream archive may have changed shape)",
|
|
556
|
+
]
|
|
557
|
+
: [
|
|
558
|
+
"check your internet connection — the model (bge-small-en-v1.5, ~77 MB) downloads once on first use",
|
|
559
|
+
"if you are behind a proxy or firewall, allow access to storage.googleapis.com (the qdrant-fastembed bucket); HTTPS_PROXY / NO_PROXY are honored",
|
|
560
|
+
"once the download succeeds it is cached under ~/.cruxy/models and never re-fetched",
|
|
561
|
+
];
|
|
549
562
|
return new CruxyError({
|
|
550
563
|
code: ErrorCode.IndexEmbedderDownloadFailed,
|
|
551
564
|
title: "the local embedding model could not be downloaded or initialized",
|
|
552
565
|
cause: messageOf(underlying),
|
|
553
|
-
nextSteps
|
|
554
|
-
"check your internet connection — the model (bge-small-en-v1.5) downloads once on first use",
|
|
555
|
-
"if you are behind a proxy or firewall, allow access to the model host (Hugging Face) and set HTTPS_PROXY",
|
|
556
|
-
"once the download succeeds it is cached under ~/.cruxy/models and never re-fetched",
|
|
557
|
-
],
|
|
566
|
+
nextSteps,
|
|
558
567
|
underlying,
|
|
559
568
|
});
|
|
560
569
|
}
|
package/dist/errors/types.js
CHANGED
|
@@ -99,6 +99,12 @@ export const ErrorCode = {
|
|
|
99
99
|
PermissionDenied: "CRUXY_E_PERMISSION_DENIED",
|
|
100
100
|
PathEscape: "CRUXY_E_PATH_ESCAPE",
|
|
101
101
|
CheckpointFailed: "CRUXY_E_CHECKPOINT_FAILED",
|
|
102
|
+
/** A mutating file tool re-read its target immediately before the approved
|
|
103
|
+
* write and found it was not the file the approval was granted against (P1):
|
|
104
|
+
* changed, deleted, or created by something else during the approval wait.
|
|
105
|
+
* Refused with nothing written — the approval covered a diff against ONE
|
|
106
|
+
* specific state, and that state moved. See `tools/file/snapshot.ts`. */
|
|
107
|
+
FileChangedSinceRead: "CRUXY_E_FILE_CHANGED_SINCE_READ",
|
|
102
108
|
// index (exit 8)
|
|
103
109
|
/** The fastembed native module could not be LOADED (missing/broken install,
|
|
104
110
|
* un-built onnxruntime-node addon). Fail-loud by design — the embedder never
|
|
@@ -341,6 +347,7 @@ const EXIT_CODES = {
|
|
|
341
347
|
[ErrorCode.PermissionDenied]: 7,
|
|
342
348
|
[ErrorCode.PathEscape]: 7,
|
|
343
349
|
[ErrorCode.CheckpointFailed]: 7,
|
|
350
|
+
[ErrorCode.FileChangedSinceRead]: 7,
|
|
344
351
|
[ErrorCode.IndexEmbedderUnavailable]: 8,
|
|
345
352
|
[ErrorCode.IndexEmbedderDownloadFailed]: 8,
|
|
346
353
|
[ErrorCode.IndexStoreUnavailable]: 8,
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { globalDir } from "../config/paths.js";
|
|
2
3
|
import { indexEmbedderDownloadFailed, indexEmbedderUnavailable, } from "../errors/index.js";
|
|
4
|
+
import { ensureModelDir, MODEL_ONNX_FILE, } from "./model-cache.js";
|
|
3
5
|
import { l2normalize } from "./util.js";
|
|
4
6
|
/**
|
|
5
7
|
* Output dimensionality of bge-small-en-v1.5, and the default size of the
|
|
@@ -69,6 +71,12 @@ export class HashingEmbedder {
|
|
|
69
71
|
* registering the `search_codebase` tool stays cheap and the heavy ONNX runtime
|
|
70
72
|
* only loads when an index is actually built or queried.
|
|
71
73
|
*
|
|
74
|
+
* The model files are provisioned by cruxy ({@link ensureModelDir}), NOT by
|
|
75
|
+
* fastembed: fastembed is initialised with `model: CUSTOM` and an absolute
|
|
76
|
+
* directory, which in its `init` makes its own `retrieveModel` (tar@6-based
|
|
77
|
+
* download + extract, #306) unreachable. `CUSTOM` changes nothing else for this
|
|
78
|
+
* model — fastembed's only model-specific branch is for multilingual-e5.
|
|
79
|
+
*
|
|
72
80
|
* Embedding is CPU-bound and single-threaded inside ONNX, so throughput is
|
|
73
81
|
* bounded by `batchSize` (fed sequentially through fastembed's batching
|
|
74
82
|
* generator) rather than by JS-level concurrency.
|
|
@@ -84,23 +92,29 @@ export class FastEmbedEmbedder {
|
|
|
84
92
|
getModel() {
|
|
85
93
|
if (!this.model) {
|
|
86
94
|
this.model = (async () => {
|
|
95
|
+
const cacheDir = this.opts.cacheDir ?? path.join(globalDir(), "models");
|
|
96
|
+
const provision = this.opts.provision ?? ((dir) => ensureModelDir({ cacheDir: dir }));
|
|
97
|
+
let modelDir;
|
|
98
|
+
try {
|
|
99
|
+
modelDir = await provision(cacheDir);
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
throw indexEmbedderDownloadFailed(err, {
|
|
103
|
+
archiveRefused: isRefusedArchive(err),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
87
106
|
try {
|
|
88
107
|
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
108
|
return (await mod.FlagEmbedding.init({
|
|
95
|
-
model: mod.EmbeddingModel.
|
|
109
|
+
model: mod.EmbeddingModel.CUSTOM,
|
|
110
|
+
modelAbsoluteDirPath: modelDir,
|
|
111
|
+
modelName: MODEL_ONNX_FILE,
|
|
96
112
|
maxLength: this.opts.maxLength ?? 512,
|
|
97
|
-
|
|
98
|
-
showDownloadProgress: this.opts.showDownloadProgress ?? false,
|
|
113
|
+
showDownloadProgress: false,
|
|
99
114
|
}));
|
|
100
115
|
}
|
|
101
116
|
catch (err) {
|
|
102
|
-
//
|
|
103
|
-
// init failed (offline, unreachable model bucket, proxy). Surface a
|
|
117
|
+
// ONNX-runtime init failed on a verified model directory. Surface a
|
|
104
118
|
// typed, actionable error instead of letting it collapse into the
|
|
105
119
|
// generic CRUXY_E_INDEX_FAILED ("re-run --verbose"). Still fail-loud —
|
|
106
120
|
// this never degrades to the lexical backend. (Module-*load* failure is
|
|
@@ -128,6 +142,15 @@ export class FastEmbedEmbedder {
|
|
|
128
142
|
return l2normalize(Float32Array.from(await model.queryEmbed(text)));
|
|
129
143
|
}
|
|
130
144
|
}
|
|
145
|
+
/**
|
|
146
|
+
* Duck-typed on purpose: tests re-import this module across
|
|
147
|
+
* `vi.resetModules()`, which would defeat an `instanceof ModelCacheError`.
|
|
148
|
+
*/
|
|
149
|
+
function isRefusedArchive(err) {
|
|
150
|
+
return (err instanceof Error &&
|
|
151
|
+
err.name === "ModelCacheError" &&
|
|
152
|
+
err.kind === "refused");
|
|
153
|
+
}
|
|
131
154
|
/**
|
|
132
155
|
* Build the **production** embedder: fastembed / bge-small-en-v1.5.
|
|
133
156
|
*
|