akm-cli 0.10.261014-beta → 0.10.261015-beta

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/CHANGELOG.md CHANGED
@@ -6,6 +6,51 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.10.261015] - 2026-10-27
10
+
11
+ ### Changed
12
+
13
+ - `akm setup` downloads what the configuration it saves needs (#1254). Choosing the
14
+ built-in embedder (`--embedding local`, or the wizard's choice) downloads ONNX
15
+ Runtime 1.24.3 for this platform once from the npm registry (about 86 MB,
16
+ checked against the registry's sha512 integrity before anything is extracted)
17
+ into `<cache dir>/onnxruntime/<version>-<platform>-<arch>/`, and the model
18
+ (about 127 MB), then loads both. The wizard asks first, as before; `--yes`
19
+ and `--from` do it once the config is saved and report `semanticAssets`
20
+ (`ready`, `blocked: <reason>`, or `skipped`). `--no-probe` now means nothing
21
+ is contacted or downloaded; the first `akm index` downloads what setup did
22
+ not, which is also how an install upgraded from an earlier release gets the
23
+ runtime: its next `akm index` downloads it once. The vectors are unchanged,
24
+ so no re-index is needed.
25
+ - Local embeddings are offered on Linux x64 (glibc) and Apple-silicon
26
+ macOS (#1256). Every other platform uses keyword search or an embedding endpoint:
27
+ setup offers only those, with one sentence saying why, `--embedding local`
28
+ warns and saves keyword search, `--from` keeps the file's values, the index
29
+ reports semantic search unavailable for the platform, and `akm health` warns
30
+ with the setup arguments for keyword search or an endpoint.
31
+ - When agent work runs on akm's own runtime, the setup wizard offers to download
32
+ its OpenCode binary now rather than on the first agent run. `--yes` and
33
+ `--from` leave it to the first run.
34
+ - The npm package installs with no install script anywhere in its tree (#1255), so it
35
+ works as installed where npm runs none (npm 12 blocks dependency scripts by
36
+ default). On Node, the SQLite driver is `better-sqlite3` 13.0.3, which ships
37
+ prebuilt N-API bindings for Linux (glibc and musl) and macOS on x64 and arm64
38
+ and for Windows on x64 and arm64: a Node upgrade no longer needs
39
+ `npm rebuild`. 32-bit ARM Linux has no SQLite binding on Node.
40
+ - Standalone binaries carry no native code and are up to 36 MB smaller (Linux
41
+ x64: 123.6 MB to 88.0 MB). A `noexec` temp directory no longer affects
42
+ semantic search (#1257); akm's cache directory must still allow executing files.
43
+
44
+ ### Removed
45
+
46
+ - The `onnxruntime-node` dependency, and with it the install script that
47
+ downloaded about 317 MB of CUDA providers on every Linux x64 `npm install`
48
+ (akm uses the CPU only; #1254). akm loads that package's binding from its cache
49
+ instead.
50
+ - The `preinstall` Node.js version check (#1255). Both launchers and the CLI still
51
+ refuse Node.js older than 22 when akm starts, and npm warns from
52
+ `engines.node` at install.
53
+
9
54
  ## [0.10.261014] - 2026-10-27
10
55
 
11
56
  ### Added
package/README.md CHANGED
@@ -40,14 +40,13 @@ prefers Bun for execution; old, unusable, or absent Bun installations fall
40
40
  back to Node.js. Node.js remains required for the npm package. The standalone
41
41
  binaries are runtime-free.
42
42
 
43
- Local semantic search runs its embedding model on ONNX Runtime
44
- (`onnxruntime-node`). A standalone binary carries the library for its platform
45
- and copies it once into akm's cache (18 to 34 MB); a `noexec` temp directory
46
- blocks a standalone binary's semantic search (keyword search still works), so
47
- set `TMPDIR` to a writable, executable one.
48
- Alpine/musl and Intel macOS have no native ONNX Runtime, so semantic search is
49
- unavailable there and keyword search works. See
50
- [Getting Started](https://akm.fwdslsh.dev/docs/guides/getting-started/#standalone-binary-and-semantic-search).
43
+ Local semantic search runs its embedding model on ONNX Runtime and is offered on
44
+ Linux x64 (glibc) and Apple-silicon macOS; every other platform uses
45
+ keyword search or an embedding endpoint. Choosing it in `akm setup`
46
+ (`--embedding local`) downloads the runtime (about 86 MB) and the model (about
47
+ 127 MB) once into akm's cache: installing the package runs no install script and
48
+ downloads nothing native. See
49
+ [Getting Started](https://akm.fwdslsh.dev/docs/guides/getting-started/#local-semantic-search).
51
50
 
52
51
  With a connection and no harness, agent work runs on akm's own runtime, a pinned
53
52
  OpenCode 2 binary. The first time it runs, akm downloads that binary from the npm
package/SECURITY.md CHANGED
@@ -104,6 +104,6 @@ for assets containing secrets or private notes.
104
104
  runtime-free. This is a compatibility limitation, not a security risk.
105
105
  - **Workflows can read any file the akm process can read.** This is not a
106
106
  bug — see "Threat model" above.
107
- - **Installing `akm-cli` runs the preinstall hook.** The hook only validates
108
- the runtime version and exits non-zero when it is unsupported; it does not
109
- phone home or write outside the install directory.
107
+ - **Installing `akm-cli` runs no install script.** Neither `akm-cli` nor any
108
+ package it depends on declares one. The Node.js version is checked when akm
109
+ starts, not when it installs.
package/dist/cli.js CHANGED
@@ -178,7 +178,7 @@ const setupCommand = defineCommand({
178
178
  },
179
179
  embedding: {
180
180
  type: "string",
181
- description: "Embeddings: a model the connection serves, local (the built-in embedder), or off",
181
+ description: "Embeddings: a model the connection serves, local (the built-in embedder, on the platforms that have it; its runtime and model are downloaded now), or off",
182
182
  },
183
183
  "embedding-endpoint": {
184
184
  type: "string",
@@ -218,7 +218,7 @@ const setupCommand = defineCommand({
218
218
  // declaring `no-probe` is what puts the flag in `--help` and keeps the unknown-flag check from refusing it.
219
219
  "no-probe": {
220
220
  type: "boolean",
221
- description: "Offline run: the endpoint is not contacted and its models are not listed. A connection added this way has no model until one is named with `akm models set`; the picks it already has are kept.",
221
+ description: "Offline run: nothing is contacted or downloaded. The endpoint's models are not listed, so a connection added this way has no model until one is named with `akm models set` (the picks it already has are kept), and the local embedder's runtime and model are fetched by the first `akm index`.",
222
222
  },
223
223
  },
224
224
  async run({ args }) {
@@ -62,10 +62,10 @@ export function semanticSearchCheck(state) {
62
62
  const asOf = builtAt ? ` at the last index (${builtAt})` : " at the last index";
63
63
  switch (record.status) {
64
64
  case "unavailable":
65
- // No native library for this platform is not something to fix: keyword search is the healthy state there.
65
+ // No local embeddings on this platform is not something to fix: keyword search or a remote endpoint is the default there.
66
66
  return record.unavailable === "platform"
67
67
  ? result("warn", `Semantic search is unavailable on this platform: ${record.reason} ` +
68
- 'Set semanticSearchMode to "off", or embedding.endpoint to a remote embedding service.', evidence)
68
+ "For keyword search run `akm setup --embedding off`; for a remote endpoint, `akm setup --embedding <model> --embedding-endpoint <url>`.", evidence)
69
69
  : result("fail", `Semantic search was unavailable on this machine${asOf}: ${record.reason}`, evidence);
70
70
  case "blocked":
71
71
  return result("fail", `Semantic search was blocked${asOf}: ${record.reason}`, evidence);
@@ -0,0 +1,89 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * Files out of a package on the npm registry, fetched without installing the
6
+ * package. akm's own agent runtime (an OpenCode binary) and the local
7
+ * embedder's runtime (onnxruntime-node's binding and library) come this way:
8
+ * installing either package would bring what akm does not use, every platform's
9
+ * binary in one case and an install script that downloads CUDA providers in the
10
+ * other.
11
+ */
12
+ import { createHash } from "node:crypto";
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+ import { writeResponseToFileCapped } from "../runtime.js";
16
+ import { fetchWithRetry, isRecord } from "./common.js";
17
+ import { extractTarGzMembers } from "./tar.js";
18
+ export const NPM_REGISTRY = "https://registry.npmjs.org";
19
+ /** The `sha512-<base64>` integrity npm records for a tarball. */
20
+ function sha512Integrity(file) {
21
+ return new Promise((resolve, reject) => {
22
+ const hash = createHash("sha512");
23
+ fs.createReadStream(file)
24
+ .on("data", (chunk) => hash.update(chunk))
25
+ .on("error", reject)
26
+ .on("end", () => resolve(`sha512-${hash.digest("base64")}`));
27
+ });
28
+ }
29
+ /** Throws unless the tarball at `file` has the integrity the registry lists for it. */
30
+ export async function verifyIntegrity(file, expected) {
31
+ const listed = expected.split(/\s+/).filter((integrity) => integrity.startsWith("sha512-"));
32
+ if (listed.length === 0)
33
+ throw new Error(`the registry lists no sha512 integrity for it (${expected || "none"})`);
34
+ const actual = await sha512Integrity(file);
35
+ if (!listed.includes(actual))
36
+ throw new Error(`its sha512 integrity is ${actual}, not the ${listed.join(" or ")} the registry lists`);
37
+ }
38
+ async function registryDocument(url) {
39
+ const response = await fetchWithRetry(url, { headers: { accept: "application/json" } }, { timeout: 30_000 });
40
+ if (!response.ok) {
41
+ await response.body?.cancel().catch(() => undefined);
42
+ throw new Error(`the registry answered HTTP ${response.status}`);
43
+ }
44
+ const dist = (await response.json()).dist;
45
+ if (!isRecord(dist) || typeof dist.tarball !== "string" || typeof dist.integrity !== "string") {
46
+ throw new Error("the registry document has no dist.tarball and dist.integrity");
47
+ }
48
+ return { tarball: dist.tarball, integrity: dist.integrity };
49
+ }
50
+ /**
51
+ * Keep members of `name@version`'s tarball in `directory`, each as `file`: the registry document of that one version,
52
+ * its tarball, the check against the sha512 integrity the registry lists before anything is extracted, then each
53
+ * member by temp file and rename (mode 0755), in the order given. Two akm processes fetching at once both finish: a
54
+ * rename that loses to the other leaves the file the other made.
55
+ */
56
+ export async function fetchNpmPackageFiles(name, version, directory, files) {
57
+ fs.mkdirSync(directory, { recursive: true });
58
+ const download = path.join(directory, `.download-${process.pid}.tgz`);
59
+ const partial = (file) => path.join(directory, `.${file}-${process.pid}.tmp`);
60
+ try {
61
+ const { tarball, integrity } = await registryDocument(`${NPM_REGISTRY}/${name}/${version}`);
62
+ const response = await fetchWithRetry(tarball, undefined, { timeout: 60_000 });
63
+ if (!response.ok) {
64
+ await response.body?.cancel().catch(() => undefined);
65
+ throw new Error(`${tarball} answered HTTP ${response.status}`);
66
+ }
67
+ // No overall deadline for a large tarball on a slow link: only 60 s without bytes fails it.
68
+ await writeResponseToFileCapped(download, response, { stallTimeoutMs: 60_000 });
69
+ await verifyIntegrity(download, integrity);
70
+ const sizes = await extractTarGzMembers(download, new Map(files.map(({ member, file }) => [member, partial(file)])));
71
+ for (const { member, file } of files) {
72
+ const target = path.join(directory, file);
73
+ fs.chmodSync(partial(file), 0o755);
74
+ try {
75
+ fs.renameSync(partial(file), target);
76
+ }
77
+ catch (error) {
78
+ // Where a file in use cannot be replaced, the other process's file stands.
79
+ if (!fs.existsSync(target) || fs.statSync(target).size !== sizes.get(member))
80
+ throw error;
81
+ }
82
+ }
83
+ }
84
+ finally {
85
+ fs.rmSync(download, { force: true });
86
+ for (const { file } of files)
87
+ fs.rmSync(partial(file), { force: true });
88
+ }
89
+ }
@@ -2,10 +2,10 @@
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  /**
5
- * One member of a gzip-compressed tar archive, streamed to a file. This is all
6
- * akm needs of tar: the OpenCode binary is a single regular file of about 240 MB
7
- * inside its package tarball, so it is never held in memory and no tar library
8
- * is a dependency.
5
+ * Named members of a gzip-compressed tar archive, streamed to files. This is all
6
+ * akm needs of tar: a few regular files out of an npm package tarball (the
7
+ * OpenCode binary, about 240 MB; onnxruntime-node's binding and library), so
8
+ * nothing is held in memory and no tar library is a dependency.
9
9
  */
10
10
  import fs from "node:fs";
11
11
  import { pipeline } from "node:stream";
@@ -57,35 +57,41 @@ const field = (header, start, end) => {
57
57
  return bytes.subarray(0, stop < 0 ? bytes.length : stop).toString("utf8");
58
58
  };
59
59
  /**
60
- * Write the regular file `member` of the `.tgz` at `archive` to `destination` (mode 0755) and return its size. Entries
61
- * are matched by the header's name field alone, which holds up to 100 bytes; a longer name, which tar keeps in the
62
- * ustar prefix field or a PAX `path` record, is not read, so such a member is never found. The one member akm reads,
63
- * `package/bin/opencode`, is short. Extended headers are skipped like any other entry that is not the member.
60
+ * Write each regular file of the `.tgz` at `archive` that `members` names (archive path → destination, mode 0755) and
61
+ * return each one's size, in one pass that stops once the last is written. Entries are matched by the header's name
62
+ * field alone, which holds up to 100 bytes; a longer name, which tar keeps in the ustar prefix field or a PAX `path`
63
+ * record, is not read, so such a member is never found. The members akm reads (`package/bin/opencode`,
64
+ * `package/bin/napi-v6/<platform>/<arch>/<file>`) are short. Extended headers are skipped like any other entry that is
65
+ * not a member.
64
66
  */
65
- export async function extractTarGzMember(archive, member, destination) {
67
+ export async function extractTarGzMembers(archive, members) {
66
68
  const gunzip = zlib.createGunzip();
67
69
  pipeline(fs.createReadStream(archive), gunzip, () => { });
68
70
  const reader = new ByteReader(gunzip);
69
71
  try {
70
- return await extractFrom(reader, archive, member, destination);
72
+ return await extractFrom(reader, archive, members);
71
73
  }
72
74
  finally {
73
75
  // The rest of the archive is not read: close it now rather than when the stream is collected.
74
76
  gunzip.destroy();
75
77
  }
76
78
  }
77
- async function extractFrom(reader, archive, member, destination) {
79
+ async function extractFrom(reader, archive, members) {
80
+ const sizes = new Map();
78
81
  for (;;) {
79
82
  const header = await reader.exact(BLOCK_BYTES);
80
- if (header.every((byte) => byte === 0))
81
- throw new Error(`${archive} has no ${member}`);
83
+ if (header.every((byte) => byte === 0)) {
84
+ const missing = [...members.keys()].filter((member) => !sizes.has(member));
85
+ throw new Error(`${archive} has no ${missing.join(", ")}`);
86
+ }
82
87
  const name = field(header, 0, 100).replace(/^\.\//, "");
83
88
  const typeflag = String.fromCharCode(header[156] ?? 0);
84
89
  const size = Number.parseInt(field(header, 124, 136).trim() || "0", 8);
85
90
  if (!Number.isSafeInteger(size))
86
91
  throw new Error(`${archive} is not a tar archive this reader understands`);
87
92
  const padding = (BLOCK_BYTES - (size % BLOCK_BYTES)) % BLOCK_BYTES;
88
- if (name !== member || (typeflag !== "0" && typeflag !== "\0")) {
93
+ const destination = members.get(name);
94
+ if (destination === undefined || sizes.has(name) || (typeflag !== "0" && typeflag !== "\0")) {
89
95
  await reader.skip(size + padding);
90
96
  continue;
91
97
  }
@@ -94,7 +100,7 @@ async function extractFrom(reader, archive, member, destination) {
94
100
  for (let left = size; left > 0;) {
95
101
  const part = await reader.read(left);
96
102
  if (!part)
97
- throw new Error(`${archive} ends inside ${member}`);
103
+ throw new Error(`${archive} ends inside ${name}`);
98
104
  await out.writeFile(part);
99
105
  left -= part.length;
100
106
  }
@@ -102,6 +108,9 @@ async function extractFrom(reader, archive, member, destination) {
102
108
  finally {
103
109
  await out.close();
104
110
  }
105
- return size;
111
+ sizes.set(name, size);
112
+ if (sizes.size === members.size)
113
+ return sizes;
114
+ await reader.skip(padding);
106
115
  }
107
116
  }
@@ -15,14 +15,12 @@
15
15
  * `postinstall.mjs` of `@opencode/cli`.
16
16
  */
17
17
  import { spawnSync } from "node:child_process";
18
- import { createHash } from "node:crypto";
19
18
  import fs from "node:fs";
20
19
  import path from "node:path";
21
- import { fetchWithRetry, isRecord, toErrorMessage } from "../../../../core/common.js";
20
+ import { toErrorMessage } from "../../../../core/common.js";
21
+ import { fetchNpmPackageFiles, NPM_REGISTRY } from "../../../../core/npm-package.js";
22
22
  import { getCacheDir } from "../../../../core/paths.js";
23
23
  import { info } from "../../../../core/warn.js";
24
- import { writeResponseToFileCapped } from "../../../../runtime.js";
25
- import { extractTarGzMember } from "./tar.js";
26
24
  /**
27
25
  * The OpenCode release akm's own runtime runs. It is pinned because the runtime depends on things no OpenCode release
28
26
  * promises: the inline config's shape, the `--standalone` flag, the plugin API, the text `run` prints, and the
@@ -31,7 +29,6 @@ import { extractTarGzMember } from "./tar.js";
31
29
  * binary (it fetches it) and read `docs/reference/configuration.md` ("Without a harness") for what the version changes.
32
30
  */
33
31
  export const OPENCODE_RUNTIME_VERSION = "2.1.4";
34
- const REGISTRY = "https://registry.npmjs.org";
35
32
  /** The platform packages of `@opencode/cli` at {@link OPENCODE_RUNTIME_VERSION}. */
36
33
  const PACKAGES = new Set([
37
34
  "@opencode/cli-linux-x64",
@@ -109,84 +106,20 @@ export function detectHost(env = process.env, operatingSystem = process.platform
109
106
  export function runtimeBinaryPath(platform = process.platform) {
110
107
  return path.join(getCacheDir(), "opencode", OPENCODE_RUNTIME_VERSION, platform === "win32" ? "opencode.exe" : "opencode");
111
108
  }
112
- /** The `sha512-<base64>` integrity npm records for a tarball. */
113
- function sha512Integrity(file) {
114
- return new Promise((resolve, reject) => {
115
- const hash = createHash("sha512");
116
- fs.createReadStream(file)
117
- .on("data", (chunk) => hash.update(chunk))
118
- .on("error", reject)
119
- .on("end", () => resolve(`sha512-${hash.digest("base64")}`));
120
- });
121
- }
122
- /** Throws unless the tarball at `file` has the integrity the registry lists for it. */
123
- export async function verifyIntegrity(file, expected) {
124
- const listed = expected.split(/\s+/).filter((integrity) => integrity.startsWith("sha512-"));
125
- if (listed.length === 0)
126
- throw new Error(`the registry lists no sha512 integrity for it (${expected || "none"})`);
127
- const actual = await sha512Integrity(file);
128
- if (!listed.includes(actual))
129
- throw new Error(`its sha512 integrity is ${actual}, not the ${listed.join(" or ")} the registry lists`);
130
- }
131
- async function registryDocument(url) {
132
- const response = await fetchWithRetry(url, { headers: { accept: "application/json" } }, { timeout: 30_000 });
133
- if (!response.ok) {
134
- await response.body?.cancel().catch(() => undefined);
135
- throw new Error(`the registry answered HTTP ${response.status}`);
136
- }
137
- const dist = (await response.json()).dist;
138
- if (!isRecord(dist) || typeof dist.tarball !== "string" || typeof dist.integrity !== "string") {
139
- throw new Error("the registry document has no dist.tarball and dist.integrity");
140
- }
141
- return { tarball: dist.tarball, integrity: dist.integrity };
142
- }
143
- /**
144
- * Fetch the binary of the package `name` into `binary`: the registry document of that one package, its tarball, the
145
- * integrity check before anything is extracted, then the executable by temp file and rename. Two akm processes fetching
146
- * at once both finish: a rename that loses to the other leaves the file the other made.
147
- */
148
- async function fetchBinary(name, binary) {
149
- const directory = path.dirname(binary);
150
- fs.mkdirSync(directory, { recursive: true });
151
- const download = path.join(directory, `.download-${process.pid}.tgz`);
152
- const partial = path.join(directory, `.opencode-${process.pid}.tmp`);
153
- try {
154
- const { tarball, integrity } = await registryDocument(`${REGISTRY}/${name}/${OPENCODE_RUNTIME_VERSION}`);
155
- info(`Fetching OpenCode ${OPENCODE_RUNTIME_VERSION} for akm's own agent runtime (about 114 MB, once) into ${directory}.`);
156
- const response = await fetchWithRetry(tarball, undefined, { timeout: 60_000 });
157
- if (!response.ok) {
158
- await response.body?.cancel().catch(() => undefined);
159
- throw new Error(`${tarball} answered HTTP ${response.status}`);
160
- }
161
- // No overall deadline for a 114 MB file on a slow link: only 60 s without bytes fails it.
162
- await writeResponseToFileCapped(download, response, { stallTimeoutMs: 60_000 });
163
- await verifyIntegrity(download, integrity);
164
- const size = await extractTarGzMember(download, `package/bin/${path.basename(binary)}`, partial);
165
- fs.chmodSync(partial, 0o755);
166
- try {
167
- fs.renameSync(partial, binary);
168
- }
169
- catch (error) {
170
- // Where an open executable cannot be replaced, the other process's file stands.
171
- if (!fs.existsSync(binary) || fs.statSync(binary).size !== size)
172
- throw error;
173
- }
174
- }
175
- finally {
176
- fs.rmSync(download, { force: true });
177
- fs.rmSync(partial, { force: true });
178
- }
179
- }
180
109
  /** Fetch the binary for this machine, naming the package, its URL and the cache path when it cannot. */
181
110
  async function fetchForThisMachine(binary) {
182
111
  let name = "an @opencode/cli platform package";
183
112
  try {
184
113
  name = opencodePackage(detectHost());
185
- await fetchBinary(name, binary);
114
+ info(`Fetching OpenCode ${OPENCODE_RUNTIME_VERSION} for akm's own agent runtime (about 114 MB, once) into ${path.dirname(binary)}.`);
115
+ const file = path.basename(binary);
116
+ await fetchNpmPackageFiles(name, OPENCODE_RUNTIME_VERSION, path.dirname(binary), [
117
+ { member: `package/bin/${file}`, file },
118
+ ]);
186
119
  return binary;
187
120
  }
188
121
  catch (error) {
189
- throw new Error(`Could not fetch OpenCode ${OPENCODE_RUNTIME_VERSION}, which akm's own agent runtime runs: package ${name} from ${REGISTRY}/${name}/${OPENCODE_RUNTIME_VERSION} into ${path.dirname(binary)}: ${toErrorMessage(error)}. ` +
122
+ throw new Error(`Could not fetch OpenCode ${OPENCODE_RUNTIME_VERSION}, which akm's own agent runtime runs: package ${name} from ${NPM_REGISTRY}/${name}/${OPENCODE_RUNTIME_VERSION} into ${path.dirname(binary)}: ${toErrorMessage(error)}. ` +
190
123
  "Without network access for this step, enable the opencode harness with an installed OpenCode 2 instead (see docs/reference/configuration.md).");
191
124
  }
192
125
  }
@@ -24,7 +24,7 @@ import { resolveAkmInvocation } from "../../../../tasks/resolve-akm-bin.js";
24
24
  import { buildChildEnv, runBuiltCommand, } from "../../../agent/spawn.js";
25
25
  import { akmOpencodeDatabasePath, akmRuntimeDir } from "../roots.js";
26
26
  import { v2SessionTokens } from "../session-log-v2.js";
27
- import { ensureRuntimeBinary, OPENCODE_RUNTIME_VERSION } from "./binary.js";
27
+ import { ensureRuntimeBinary, OPENCODE_RUNTIME_VERSION, runtimeBinaryPath } from "./binary.js";
28
28
  import { runtimeArgv, runtimeConfig, runtimeEnv, runtimePrepareArgv } from "./command.js";
29
29
  /**
30
30
  * What a request is lowered for when it runs here. The persona goes in the prompt, as for the opencode harness
@@ -126,6 +126,10 @@ function sessionRecord(sessionId) {
126
126
  }
127
127
  export const opencodeRuntime = {
128
128
  lowering: runtimeLowerer,
129
+ pendingFetch: () => fs.existsSync(runtimeBinaryPath()) ? undefined : `OpenCode ${OPENCODE_RUNTIME_VERSION} (about 114 MB, once)`,
130
+ async fetch() {
131
+ await ensureRuntimeBinary();
132
+ },
129
133
  async run(profile, opts, connection) {
130
134
  const label = `akm's runtime on connection "${profile.name}"`;
131
135
  const dispatch = opts.dispatch;
@@ -2,9 +2,9 @@
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  /**
5
- * Local embedder: a BERT-family embedding model run with onnxruntime-node,
6
- * its text encoded by @huggingface/tokenizers, mean-pooled over the attention
7
- * mask and L2-normalised.
5
+ * Local embedder: a BERT-family embedding model run on onnxruntime-node's binding
6
+ * (`./onnx-runtime.ts`), its text encoded by @huggingface/tokenizers, mean-pooled
7
+ * over the attention mask and L2-normalised.
8
8
  *
9
9
  * Encapsulates the model's lifecycle as instance state on a `LocalEmbedder` so
10
10
  * tests can construct fresh instances without leaking loaded models across
@@ -13,8 +13,9 @@
13
13
  */
14
14
  import fs from "node:fs";
15
15
  import path from "node:path";
16
- import { preloadOnnxRuntimeLibrary } from "../../standalone/onnxruntime-library.js";
16
+ import { toErrorMessage } from "../../core/common.js";
17
17
  import { ensureLocalModelFiles } from "./local-model-files.js";
18
+ import { hasLocalEmbeddings, loadOnnxRuntime, noLocalEmbeddingsReason, OnnxTensor } from "./onnx-runtime.js";
18
19
  /**
19
20
  * Default local model for embeddings.
20
21
  * `bge-small-en-v1.5` scores higher on MTEB benchmarks than the previous
@@ -24,10 +25,10 @@ export const DEFAULT_LOCAL_MODEL = "Xenova/bge-small-en-v1.5";
24
25
  /** The runtime the local embedder reports once its model has loaded. */
25
26
  export const LOCAL_EMBEDDING_RUNTIME = "onnxruntime-node";
26
27
  /**
27
- * The embedding runtime is not usable on this machine. `platform`: onnxruntime-node
28
- * has no native library for it, so keyword search is all there is. `load`: it has
29
- * one, which did not load (a missing package or addon, a `noexec` temp or cache
30
- * directory).
28
+ * The embedding runtime is not usable on this machine. `platform`: akm offers no
29
+ * local embeddings here, so keyword search and remote embeddings are what there
30
+ * is. `load`: it does, and the runtime could not be fetched or did not load (no
31
+ * network for the fetch, a `noexec` cache directory).
31
32
  */
32
33
  export class LocalRuntimeUnavailableError extends Error {
33
34
  kind;
@@ -55,50 +56,17 @@ const LOCAL_BATCH_SIZE = 32;
55
56
  function resolveLocalModelName(overrideModel) {
56
57
  return overrideModel || DEFAULT_LOCAL_MODEL;
57
58
  }
58
- /**
59
- * A standalone binary built for a target with no native library (`none`) must not
60
- * bundle the package: its addon for that platform does not exist. The bundler
61
- * drops only the branch a build constant rules out, so the import sits in an
62
- * `else`, not after the `throw`.
63
- */
64
- async function importOnnxRuntime() {
65
- if (typeof AKM_STANDALONE_NATIVE_MODULE !== "undefined" && AKM_STANDALONE_NATIVE_MODULE === "./native/none") {
66
- throw new Error("no native library is available for this platform");
67
- }
68
- else {
69
- await preloadOnnxRuntimeLibrary();
70
- return import("onnxruntime-node");
71
- }
72
- }
73
- /**
74
- * Whether onnxruntime-node has a native library for this machine. A standalone
75
- * binary knows from its build (`none`). The package has one for glibc Linux
76
- * x64/arm64, Apple-silicon macOS and Windows x64/arm64, and none for musl Linux
77
- * or Intel macOS.
78
- */
79
- function hasNativeLibrary() {
80
- if (typeof AKM_STANDALONE_NATIVE_MODULE !== "undefined")
81
- return AKM_STANDALONE_NATIVE_MODULE !== "./native/none";
82
- if (process.platform === "linux") {
83
- const header = process.report?.getReport()?.header;
84
- if (!header?.glibcVersionRuntime)
85
- return false;
86
- }
87
- return ["linux-x64", "linux-arm64", "darwin-arm64", "win32-x64", "win32-arm64"].includes(`${process.platform}-${process.arch}`);
88
- }
89
- async function loadOnnxRuntime() {
59
+ async function loadRuntime() {
60
+ if (!hasLocalEmbeddings())
61
+ throw new LocalRuntimeUnavailableError(noLocalEmbeddingsReason(), "platform");
90
62
  try {
91
- return await importOnnxRuntime();
63
+ return await loadOnnxRuntime();
92
64
  }
93
65
  catch (error) {
94
- const msg = error instanceof Error ? error.message : String(error);
95
- const kind = hasNativeLibrary() ? "load" : "platform";
96
- // Only the package itself missing calls for a reinstall; a platform it ships no addon for (Intel macOS) fails on
97
- // `../bin/napi-v6/<platform>/<arch>/onnxruntime_binding.node` and is reported below.
98
- if (kind === "load" && /Cannot find (?:module|package) ['"]onnxruntime-node['"]/.test(msg)) {
99
- throw new LocalRuntimeUnavailableError("Semantic search requires onnxruntime-node. Reinstall akm-cli.", kind);
100
- }
101
- throw new LocalRuntimeUnavailableError(`Failed to load the embedding runtime (onnxruntime-node): ${msg}. Semantic search with the local model is unavailable; keyword search still works.`, kind);
66
+ const msg = toErrorMessage(error).replace(/\.$/, "");
67
+ // A `noexec` mount maps nothing executable, so the binding cannot load from the cache there.
68
+ const mount = /failed to map segment/.test(msg) ? " akm's cache directory must allow executing files." : "";
69
+ throw new LocalRuntimeUnavailableError(`Failed to load the embedding runtime (onnxruntime-node): ${msg}.${mount} Semantic search with the local model is unavailable; keyword search still works.`, "load");
102
70
  }
103
71
  }
104
72
  /** The mean of one text's token vectors (padding excluded), scaled to unit length. */
@@ -116,17 +84,14 @@ function meanPooled(hidden, offset, tokens, dim) {
116
84
  return vector.map((value) => value / Math.sqrt(norm));
117
85
  }
118
86
  async function loadOnnxPipeline(modelName) {
119
- const ort = await loadOnnxRuntime();
87
+ const ort = await loadRuntime();
120
88
  const { Tokenizer } = await import("@huggingface/tokenizers");
121
89
  const modelDir = await ensureLocalModelFiles(modelName);
122
90
  const readJson = (file) => JSON.parse(fs.readFileSync(path.join(modelDir, file), "utf8"));
123
91
  const tokenizerConfig = readJson("tokenizer_config.json");
124
92
  const tokenizer = new Tokenizer(readJson("tokenizer.json"), tokenizerConfig);
125
93
  const maxLength = typeof tokenizerConfig.model_max_length === "number" ? tokenizerConfig.model_max_length : Number.POSITIVE_INFINITY;
126
- const session = await ort.InferenceSession.create(path.join(modelDir, "onnx", "model.onnx"), {
127
- executionProviders: ["cpu"],
128
- logSeverityLevel: 3,
129
- });
94
+ const session = ort.createSession(path.join(modelDir, "onnx", "model.onnx"));
130
95
  return async (texts) => {
131
96
  // A text longer than the model's maximum is cut to it. Shorter ones are padded
132
97
  // to the longest in the batch; the mask keeps padding out of the pooling.
@@ -142,13 +107,13 @@ async function loadOnnxPipeline(modelName) {
142
107
  });
143
108
  const dims = [texts.length, length];
144
109
  const inputs = {
145
- input_ids: new ort.Tensor("int64", ids, dims),
146
- attention_mask: new ort.Tensor("int64", mask, dims),
147
- token_type_ids: new ort.Tensor("int64", new BigInt64Array(texts.length * length), dims),
110
+ input_ids: new OnnxTensor("int64", ids, dims),
111
+ attention_mask: new OnnxTensor("int64", mask, dims),
112
+ token_type_ids: new OnnxTensor("int64", new BigInt64Array(texts.length * length), dims),
148
113
  };
149
114
  // Models that take fewer inputs (no token_type_ids) are fed only theirs.
150
115
  const feeds = Object.fromEntries(session.inputNames.flatMap((name) => (inputs[name] ? [[name, inputs[name]]] : [])));
151
- const outputs = await session.run(feeds);
116
+ const outputs = session.run(feeds);
152
117
  const hidden = (outputs.last_hidden_state ?? outputs.token_embeddings ?? outputs.logits)?.data;
153
118
  const dim = hidden.length / (texts.length * length);
154
119
  return encoded.map((row, i) => meanPooled(hidden, i * length * dim, row.length, dim));