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 +45 -0
- package/README.md +7 -8
- package/SECURITY.md +3 -3
- package/dist/cli.js +2 -2
- package/dist/commands/health/semantic-search-check.js +2 -2
- package/dist/core/npm-package.js +89 -0
- package/dist/{integrations/harnesses/opencode/runtime → core}/tar.js +25 -16
- package/dist/integrations/harnesses/opencode/runtime/binary.js +8 -75
- package/dist/integrations/harnesses/opencode/runtime/index.js +5 -1
- package/dist/llm/embedders/local.js +23 -58
- package/dist/llm/embedders/onnx-runtime.js +121 -0
- package/dist/scripts/akm-migrate-node.js +15 -28
- package/dist/scripts/akm-migrate.js +15 -28
- package/dist/setup/semantic-assets.js +9 -6
- package/dist/setup/setup.js +56 -2
- package/dist/setup/steps/embeddings.js +21 -4
- package/dist/storage/database.js +17 -57
- package/docs/architecture/akm-core-principles.md +3 -2
- package/docs/architecture/internals/health-advisories.md +1 -1
- package/docs/architecture/internals/storage-locations.md +1 -0
- package/docs/architecture/testing/testing-workflow.md +21 -12
- package/docs/guides/getting-started.md +20 -18
- package/docs/guides/recipes/headless-install.md +13 -8
- package/docs/migration/v0.9-to-v0.10.md +17 -0
- package/docs/reference/cli.md +20 -9
- package/docs/reference/configuration.md +37 -15
- package/package.json +3 -6
- package/dist/standalone/onnxruntime-library.js +0 -52
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
|
-
(
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
108
|
-
|
|
109
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
6
|
-
* akm needs of tar:
|
|
7
|
-
*
|
|
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
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
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
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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 ${
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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 ${
|
|
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
|
|
6
|
-
* its text encoded by @huggingface/tokenizers, mean-pooled
|
|
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 {
|
|
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`:
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
-
|
|
60
|
-
|
|
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
|
|
63
|
+
return await loadOnnxRuntime();
|
|
92
64
|
}
|
|
93
65
|
catch (error) {
|
|
94
|
-
const msg = error
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
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 =
|
|
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
|
|
146
|
-
attention_mask: new
|
|
147
|
-
token_type_ids: new
|
|
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 =
|
|
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));
|