@cruxy/cli 1.11.1 → 1.11.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/dist/agent/instruction-loss.js +204 -0
- package/dist/agent/prompts.js +25 -4
- package/dist/agent/session.js +165 -33
- package/dist/agent/status.js +18 -0
- package/dist/checkpoint/service.js +44 -3
- package/dist/cli/commands/pr.js +14 -0
- package/dist/cli/commands/run.js +35 -0
- package/dist/cli/commands/sessions.js +8 -0
- package/dist/cli/session-commands.js +3 -1
- package/dist/cli/session-factory.js +54 -6
- package/dist/config/schema.js +9 -0
- 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/mcp/bounds.js +8 -1
- package/dist/plan/execute.js +4 -1
- package/dist/plan/service.js +42 -5
- package/dist/plan/step-message.js +49 -0
- package/dist/render/context-view.js +44 -1
- package/dist/render/status-view.js +13 -0
- package/dist/session/index.js +7 -3
- package/dist/session/log.js +163 -2
- package/dist/session/owner.js +123 -0
- package/dist/session/prune.js +11 -0
- package/dist/session/recorded-runs.js +56 -0
- package/dist/session/replay.js +75 -1
- package/dist/session/resume.js +110 -3
- package/dist/session/types.js +158 -0
- package/dist/subagent/orchestrator.js +2 -2
- package/dist/subagent/registry-scope.js +28 -5
- package/dist/testing/run-tests-tool.js +3 -1
- package/dist/tools/create-pull-request.js +8 -1
- package/dist/tools/file/apply-patch.js +53 -23
- package/dist/tools/file/edit-file.js +19 -1
- package/dist/tools/file/snapshot.js +68 -0
- package/dist/tools/file/write-file.js +31 -5
- package/dist/tools/registry.js +39 -8
- package/dist/tools/schema-depth.js +79 -6
- package/dist/tools/shell/exec.js +7 -0
- package/dist/tools/shell/run-command.js +45 -21
- package/dist/utils/process-owner.js +107 -0
- package/dist/vcs/generate.js +48 -6
- package/dist/verification/index.js +15 -0
- package/dist/verification/ledger.js +99 -0
- package/dist/verification/types.js +26 -0
- package/dist/verification/view.js +87 -0
- package/package.json +3 -2
package/dist/cli/commands/pr.js
CHANGED
|
@@ -6,6 +6,7 @@ import { authMissingKey, shouldUseColor } from "../../errors/index.js";
|
|
|
6
6
|
import { themeForColor } from "../../theme/index.js";
|
|
7
7
|
import { ApprovalService, defaultPromptIO, InteractivePolicy, SessionAllowlist, } from "../../approval/index.js";
|
|
8
8
|
import { resolveTaskModel, routerForConfig } from "../../routing/index.js";
|
|
9
|
+
import { latestRecordedRuns, shortId } from "../../session/index.js";
|
|
9
10
|
import { createForgeProvider, createPrService, generateWithLlm, loadCommitGuidance, resolveForgeToken, } from "../../vcs/index.js";
|
|
10
11
|
/**
|
|
11
12
|
* `cruxy pr` — turn the current changes into a pull request (C.15). Generates the
|
|
@@ -48,6 +49,18 @@ export function prCommand() {
|
|
|
48
49
|
// resolveForgeToken throws CRUXY_E_FORGE_AUTH (exit 4) if none is found.
|
|
49
50
|
const forge = createForgeProvider(resolveForgeToken());
|
|
50
51
|
const guidance = await loadCommitGuidance(cwd);
|
|
52
|
+
// The verification record (P2): this command runs outside any
|
|
53
|
+
// session, so the evidence is the project's latest session log — its
|
|
54
|
+
// runs, each dated, under a header naming that session rather than
|
|
55
|
+
// "this session". The model never writes the section (generate.ts);
|
|
56
|
+
// no session here, or one that ran nothing → no section at all.
|
|
57
|
+
const recorded = latestRecordedRuns(cwd);
|
|
58
|
+
const verification = recorded
|
|
59
|
+
? {
|
|
60
|
+
runs: recorded.runs,
|
|
61
|
+
from: `in the latest session for this project (${shortId(recorded.sessionId)})`,
|
|
62
|
+
}
|
|
63
|
+
: undefined;
|
|
51
64
|
// The allowlist here is provably inert, and stated rather than implied
|
|
52
65
|
// (cli#251). `cruxy pr` submits exactly one kind of action — `vcs` — and
|
|
53
66
|
// `vcsRequest` gives every one of them `scope: {kind: "none"}` and an
|
|
@@ -70,6 +83,7 @@ export function prCommand() {
|
|
|
70
83
|
...i,
|
|
71
84
|
scopes: guidance.scopes,
|
|
72
85
|
skillBody: guidance.skillBody,
|
|
86
|
+
...(verification ? { verification } : {}),
|
|
73
87
|
}, { model: genModel }),
|
|
74
88
|
});
|
|
75
89
|
logger.info(t.muted("generating pull request content…"));
|
package/dist/cli/commands/run.js
CHANGED
|
@@ -16,6 +16,8 @@ import { apiKeyEnvVar, classifyCredentialLifetime, configSourceFile, globalDir,
|
|
|
16
16
|
import { agentIncomplete, authMissingKey, shouldUseColor, usageError, } from "../../errors/index.js";
|
|
17
17
|
import { createRenderer } from "../../render/index.js";
|
|
18
18
|
import { themeForColor } from "../../theme/index.js";
|
|
19
|
+
import { verificationTurnLines } from "../../verification/index.js";
|
|
20
|
+
import { compactionTallyLines } from "../../render/context-view.js";
|
|
19
21
|
import { summarizeRuns, renderSummary, } from "../../usage/index.js";
|
|
20
22
|
import { CheckpointGate } from "../../checkpoint/index.js";
|
|
21
23
|
import { SandboxService } from "../../sandbox/index.js";
|
|
@@ -383,6 +385,19 @@ export async function executeRun(promptParts, opts) {
|
|
|
383
385
|
usage: restore.usage,
|
|
384
386
|
sessionId: restore.meta.sessionId,
|
|
385
387
|
mode: restore.mode,
|
|
388
|
+
// The last run the log recorded, so `/status` after a resume
|
|
389
|
+
// says what last ran instead of "none". Not re-derived.
|
|
390
|
+
...(restore.lastVerification
|
|
391
|
+
? { lastVerification: restore.lastVerification }
|
|
392
|
+
: {}),
|
|
393
|
+
// And what compaction cost it so far (P3), so `/context`
|
|
394
|
+
// continues the tally instead of restarting at zero.
|
|
395
|
+
compactions: restore.compactions,
|
|
396
|
+
// `restore.plan` is deliberately NOT passed (plan-durability).
|
|
397
|
+
// The resume notice has already described it. Handing it to
|
|
398
|
+
// the session would invite exactly the thing the record must
|
|
399
|
+
// not do: treat a past `approve-grant` as consent in this
|
|
400
|
+
// process. The allowlist starts empty; every action asks.
|
|
386
401
|
},
|
|
387
402
|
}
|
|
388
403
|
: {}),
|
|
@@ -543,6 +558,12 @@ export async function executeRun(promptParts, opts) {
|
|
|
543
558
|
if (config.usage.enabled && session.lastRun) {
|
|
544
559
|
printRunUsage(session.lastRun);
|
|
545
560
|
}
|
|
561
|
+
// The verification record (P2 verification), next to the exit code CI
|
|
562
|
+
// already trusts: what ran this turn and how it exited — or that nothing
|
|
563
|
+
// did. Printed for BOTH a completed run and one that gave up below, and
|
|
564
|
+
// whether or not usage is on, so "completed" is never read on its own.
|
|
565
|
+
// The record is evidence; the exit code below is still decided by `stop`.
|
|
566
|
+
printTurnVerification(session);
|
|
546
567
|
// Fail loud on a non-completed stop (#3/#5): a one-shot run that hit the
|
|
547
568
|
// iteration cap or a token budget (or was cancelled) MUST exit non-zero —
|
|
548
569
|
// otherwise CI reads a gave-up run as success. The partial history already
|
|
@@ -561,6 +582,20 @@ export async function executeRun(promptParts, opts) {
|
|
|
561
582
|
}
|
|
562
583
|
}
|
|
563
584
|
/** Render the just-finished run's usage as a single themed line (C.22). */
|
|
585
|
+
/** The one-shot summary's verification block — see `verification/view.ts`. */
|
|
586
|
+
function printTurnVerification(session) {
|
|
587
|
+
const t = themeForColor(shouldUseColor(process.stdout));
|
|
588
|
+
for (const line of verificationTurnLines(session.turnVerification(), t)) {
|
|
589
|
+
logger.print(line);
|
|
590
|
+
}
|
|
591
|
+
// What compaction cost this run (P3 context quality), next to the
|
|
592
|
+
// verification block and for the same reason: a one-shot run that
|
|
593
|
+
// compacted three times and may have dropped an instruction is a fact CI
|
|
594
|
+
// reads nowhere else. Silent when nothing compacted.
|
|
595
|
+
for (const line of compactionTallyLines(session.compactions(), t)) {
|
|
596
|
+
logger.print(line);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
564
599
|
function printRunUsage(record) {
|
|
565
600
|
if (record.entries.length === 0)
|
|
566
601
|
return;
|
|
@@ -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}`]);
|
|
@@ -680,7 +680,9 @@ function handleExport(input, ctx) {
|
|
|
680
680
|
function handleContext(ctx) {
|
|
681
681
|
const { out, session } = ctx;
|
|
682
682
|
const report = contextReport(session.messages, session.toolContext.config.context);
|
|
683
|
-
|
|
683
|
+
// Plus what compaction has already cost (P3) — the one figure the
|
|
684
|
+
// preview above cannot give, because it is a record, not an estimate.
|
|
685
|
+
for (const line of contextReportLines(report, out.theme, Infinity, session.compactions())) {
|
|
684
686
|
out.print(out.fit(line));
|
|
685
687
|
}
|
|
686
688
|
}
|
|
@@ -25,6 +25,7 @@ import { MemoryService, buildMultiRootRecallBlock, rememberTool, } from "../memo
|
|
|
25
25
|
import { findDefinitionTool, findReferencesTool, getDiagnosticsTool, hoverTool, } from "../lsp/index.js";
|
|
26
26
|
import { createWebSearchTool, createWebFetchTool } from "../web/index.js";
|
|
27
27
|
import { appendRun } from "../usage/index.js";
|
|
28
|
+
import { VerificationLedger } from "../verification/index.js";
|
|
28
29
|
import { Semaphore, SubagentOrchestrator, makeSpawnSubagentTool, makeSpawnSubagentsTool, } from "../subagent/index.js";
|
|
29
30
|
import { ApprovalQueue, JobManager, makeRunInBackgroundTool, } from "../jobs/index.js";
|
|
30
31
|
/**
|
|
@@ -253,13 +254,26 @@ opts = {}) {
|
|
|
253
254
|
logger.warn(`${error.code}: ${error.title} — ${error.cause}`);
|
|
254
255
|
}
|
|
255
256
|
: undefined;
|
|
257
|
+
// The verification record (P2 verification): ONE ledger for the session,
|
|
258
|
+
// fed by the exec tools' side channels below and by the file tools through
|
|
259
|
+
// `ctx.verification`, and written through to the session log as its own
|
|
260
|
+
// event kinds. The in-memory side is what `/status` and the one-shot
|
|
261
|
+
// summary read; the log is what a resume and the transcript keep.
|
|
262
|
+
const verification = new VerificationLedger({
|
|
263
|
+
sink: opts.recorder ? (obs) => opts.recorder.observe(obs) : undefined,
|
|
264
|
+
});
|
|
256
265
|
// The `run_tests` side channel (P3): the structured outcome the tool already
|
|
257
266
|
// built, handed to the renderer to draw. Every field it needs is copied
|
|
258
267
|
// across as-is — nothing is derived here, so an absent `total` stays absent
|
|
259
|
-
// rather than becoming a number the parsers refused to claim.
|
|
268
|
+
// rather than becoming a number the parsers refused to claim. The record
|
|
269
|
+
// takes the same object, from the same call — never re-parsed from the
|
|
270
|
+
// string the model gets.
|
|
260
271
|
const execRegistry = buildDefaultRegistry({
|
|
261
|
-
|
|
262
|
-
|
|
272
|
+
// `tools.fileEdit` / `tools.shell` (P4): wired here, the one place the
|
|
273
|
+
// default registry is built for a session.
|
|
274
|
+
tools: config.tools,
|
|
275
|
+
onTestResult: (result, command, run) => {
|
|
276
|
+
renderer?.testResult({
|
|
263
277
|
passed: result.passed,
|
|
264
278
|
command: command.command,
|
|
265
279
|
durationMs: result.durationMs,
|
|
@@ -270,8 +284,38 @@ opts = {}) {
|
|
|
270
284
|
...(f.line !== undefined ? { line: f.line } : {}),
|
|
271
285
|
})),
|
|
272
286
|
outputTruncated: result.outputTruncated,
|
|
273
|
-
})
|
|
274
|
-
|
|
287
|
+
});
|
|
288
|
+
verification.record({
|
|
289
|
+
kind: "verification",
|
|
290
|
+
tool: "run_tests",
|
|
291
|
+
command: command.command,
|
|
292
|
+
source: command.source,
|
|
293
|
+
passed: result.passed,
|
|
294
|
+
exitCode: result.exitCode,
|
|
295
|
+
durationMs: result.durationMs,
|
|
296
|
+
...(result.total !== undefined ? { total: result.total } : {}),
|
|
297
|
+
failureCount: result.failures.length,
|
|
298
|
+
failureNames: result.failures.map((f) => f.name),
|
|
299
|
+
outputTruncated: result.outputTruncated,
|
|
300
|
+
substrate: run.substrate,
|
|
301
|
+
});
|
|
302
|
+
},
|
|
303
|
+
// `run_command` records what ran and how it exited — and NOTHING about
|
|
304
|
+
// what it was for. Whether "pnpm typecheck" was a typecheck is the
|
|
305
|
+
// reader's call; classifying it here from its text is the inference the
|
|
306
|
+
// record refuses to make.
|
|
307
|
+
onCommandResult: (result) => verification.record({
|
|
308
|
+
kind: "verification",
|
|
309
|
+
tool: "run_command",
|
|
310
|
+
command: result.command,
|
|
311
|
+
passed: result.exitCode === 0,
|
|
312
|
+
exitCode: result.exitCode,
|
|
313
|
+
durationMs: result.durationMs,
|
|
314
|
+
failureCount: 0,
|
|
315
|
+
failureNames: [],
|
|
316
|
+
outputTruncated: result.outputTruncated,
|
|
317
|
+
substrate: result.substrate,
|
|
318
|
+
}),
|
|
275
319
|
});
|
|
276
320
|
const git = getGitInfo(cwd);
|
|
277
321
|
const projectInstructions = loadProjectInstructions(cwd);
|
|
@@ -508,8 +552,9 @@ opts = {}) {
|
|
|
508
552
|
requestApproval: gate(approval),
|
|
509
553
|
checkpointsActive,
|
|
510
554
|
sandbox,
|
|
555
|
+
verification,
|
|
511
556
|
};
|
|
512
|
-
const planRunner = ({ messages, projectInstructions, recalledMemory: turnMemory, renderer: turnRenderer, onRequestUsage, }) => runPlanSession({
|
|
557
|
+
const planRunner = ({ messages, projectInstructions, recalledMemory: turnMemory, renderer: turnRenderer, onRequestUsage, compact, record, }) => runPlanSession({
|
|
513
558
|
provider,
|
|
514
559
|
config,
|
|
515
560
|
ctx,
|
|
@@ -524,6 +569,8 @@ opts = {}) {
|
|
|
524
569
|
renderer: turnRenderer,
|
|
525
570
|
router,
|
|
526
571
|
onRequestUsage,
|
|
572
|
+
compact,
|
|
573
|
+
record,
|
|
527
574
|
});
|
|
528
575
|
holder.session = new Session({
|
|
529
576
|
provider,
|
|
@@ -548,6 +595,7 @@ opts = {}) {
|
|
|
548
595
|
// not a second list that agrees with them only by luck.
|
|
549
596
|
allowlist,
|
|
550
597
|
recorder: opts.recorder,
|
|
598
|
+
verification,
|
|
551
599
|
restore: opts.restore,
|
|
552
600
|
});
|
|
553
601
|
return holder.session;
|
package/dist/config/schema.js
CHANGED
|
@@ -78,9 +78,18 @@ export const AgentConfigSchema = z
|
|
|
78
78
|
planMode: z.boolean().default(false),
|
|
79
79
|
})
|
|
80
80
|
.strict();
|
|
81
|
+
/**
|
|
82
|
+
* Which built-in tool families the model is handed (P4). Declared in the C.0
|
|
83
|
+
* scaffold and consumed by nothing until P4 — see `buildDefaultRegistry` for
|
|
84
|
+
* why they were wired rather than removed (`initConfig` writes them into every
|
|
85
|
+
* generated config, and the schema is `.strict()`).
|
|
86
|
+
*/
|
|
81
87
|
export const ToolsConfigSchema = z
|
|
82
88
|
.object({
|
|
89
|
+
/** `false` withholds `write_file`, `edit_file` and `apply_patch`. Reads stay. */
|
|
83
90
|
fileEdit: z.boolean().default(true),
|
|
91
|
+
/** `false` withholds `run_command` and `run_tests` — no command execution
|
|
92
|
+
* by the model at all. `create_pull_request` (git, own approval) stays. */
|
|
84
93
|
shell: z.boolean().default(true),
|
|
85
94
|
})
|
|
86
95
|
.strict();
|
|
@@ -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
|
*
|