akm-cli 0.10.261014-beta → 0.10.261016-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.
Files changed (52) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.md +7 -8
  3. package/SECURITY.md +3 -3
  4. package/STABILITY.md +2 -1
  5. package/dist/assets/hints/cli-hints-full.md +6 -4
  6. package/dist/assets/hints/cli-hints-short.md +4 -2
  7. package/dist/assets/opencode/akm-plugin.js +16 -8
  8. package/dist/assets/prompts/workflow-unit-preamble.md +3 -1
  9. package/dist/cli.js +2 -2
  10. package/dist/commands/health/plugin-staleness.js +86 -22
  11. package/dist/commands/health/semantic-search-check.js +2 -2
  12. package/dist/commands/read/search-cli.js +2 -2
  13. package/dist/commands/read/show.js +15 -3
  14. package/dist/core/asset/markdown.js +9 -0
  15. package/dist/core/config/config-walker.js +35 -32
  16. package/dist/core/config/schema/connections.js +6 -3
  17. package/dist/core/config/schema/harnesses.js +8 -1
  18. package/dist/core/config/schema/primitives.js +12 -1
  19. package/dist/core/config/schema/sources-bundles.js +2 -2
  20. package/dist/core/npm-package.js +124 -0
  21. package/dist/{integrations/harnesses/opencode/runtime → core}/tar.js +25 -16
  22. package/dist/integrations/harnesses/opencode/runtime/binary.js +8 -75
  23. package/dist/integrations/harnesses/opencode/runtime/index.js +5 -1
  24. package/dist/llm/embedders/local.js +23 -58
  25. package/dist/llm/embedders/onnx-runtime.js +121 -0
  26. package/dist/output/renderers.js +2 -2
  27. package/dist/output/shapes/helpers.js +2 -0
  28. package/dist/output/text/show-format.js +7 -0
  29. package/dist/registry/semver.js +12 -0
  30. package/dist/scripts/akm-migrate-node.js +21638 -5760
  31. package/dist/scripts/akm-migrate.js +21645 -5767
  32. package/dist/setup/semantic-assets.js +9 -6
  33. package/dist/setup/setup.js +56 -2
  34. package/dist/setup/steps/embeddings.js +21 -4
  35. package/dist/storage/database.js +17 -57
  36. package/dist/tasks/backends/cron.js +19 -0
  37. package/docs/architecture/akm-core-principles.md +3 -2
  38. package/docs/architecture/internals/health-advisories.md +2 -2
  39. package/docs/architecture/internals/storage-locations.md +1 -0
  40. package/docs/architecture/specs/ref.md +4 -3
  41. package/docs/architecture/testing/testing-workflow.md +28 -20
  42. package/docs/guides/discover-and-load.md +12 -4
  43. package/docs/guides/getting-started.md +23 -20
  44. package/docs/guides/recipes/headless-install.md +13 -8
  45. package/docs/guides/use-with-any-agent.md +4 -1
  46. package/docs/maintainers/release-checklist.md +15 -8
  47. package/docs/migration/v0.9-to-v0.10.md +17 -0
  48. package/docs/reference/cli.md +42 -12
  49. package/docs/reference/configuration.md +37 -15
  50. package/package.json +6 -9
  51. package/schemas/akm-config.json +535 -302
  52. package/dist/standalone/onnxruntime-library.js +0 -52
@@ -2,14 +2,16 @@
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
- * Semantic-search asset preparation for the setup wizard. The embedding
6
- * runtime (onnxruntime-node) is a normal package dependency; setup never
7
- * mutates the installed CLI. This module only prepares the model.
5
+ * Semantic-search asset preparation for setup. For the built-in local embedder
6
+ * that is its runtime (onnxruntime-node's binding and library) and its model,
7
+ * both fetched into akm's cache; for an embedding endpoint, a check that it
8
+ * answers.
8
9
  */
9
10
  import * as p from "../cli/clack.js";
10
11
  import { isHttpUrl } from "../core/common.js";
11
12
  import { checkEmbeddingAvailability, DEFAULT_LOCAL_MODEL } from "../llm/embedder.js";
12
13
  import { effectiveEmbedding } from "../llm/embedders/connection.js";
14
+ import { ONNXRUNTIME_VERSION } from "../llm/embedders/onnx-runtime.js";
13
15
  // Approximate first-download size of the default local model (DEFAULT_LOCAL_MODEL), used in the setup note.
14
16
  const LOCAL_MODEL_APPROX_SIZE_MB = 127;
15
17
  export function isRemoteEmbeddingConfig(embedding) {
@@ -23,7 +25,8 @@ export function describeSemanticSearchAssets(embedding) {
23
25
  return [`• Embedding endpoint: ${embedding?.provider ?? "custom"} / ${embedding?.model} (no local model download)`];
24
26
  }
25
27
  return [
26
- `• Local embedding model: ${embedding?.localModel ?? DEFAULT_LOCAL_MODEL} (~${LOCAL_MODEL_APPROX_SIZE_MB} MB download on first use)`,
28
+ `• ONNX Runtime ${ONNXRUNTIME_VERSION}, the local embedder's runtime (~86 MB download, once)`,
29
+ `• Local embedding model: ${embedding?.localModel ?? DEFAULT_LOCAL_MODEL} (~${LOCAL_MODEL_APPROX_SIZE_MB} MB download, once)`,
27
30
  ];
28
31
  }
29
32
  export async function prepareSemanticSearchAssets(config) {
@@ -32,7 +35,7 @@ export async function prepareSemanticSearchAssets(config) {
32
35
  const spin = p.spinner();
33
36
  spin.start(remote
34
37
  ? "Checking remote embedding endpoint..."
35
- : `Downloading local embedding model (${embedding?.localModel ?? DEFAULT_LOCAL_MODEL})...`);
38
+ : `Downloading the local embedder's runtime and model (${embedding?.localModel ?? DEFAULT_LOCAL_MODEL})...`);
36
39
  const result = await checkEmbeddingAvailability(embedding);
37
40
  if (!result.available) {
38
41
  spin.stop("Semantic-search assets could not be prepared.");
@@ -50,6 +53,6 @@ export async function prepareSemanticSearchAssets(config) {
50
53
  return { ok: false, reason: "local-model-download", message: result.message };
51
54
  }
52
55
  }
53
- spin.stop(remote ? "Remote embedding endpoint is ready." : "Local embedding model downloaded and ready.");
56
+ spin.stop(remote ? "Remote embedding endpoint is ready." : "Local embedder's runtime and model downloaded and ready.");
54
57
  return { ok: true };
55
58
  }
@@ -19,7 +19,9 @@ import path from "node:path";
19
19
  import { isDeepStrictEqual } from "node:util";
20
20
  import * as p from "../cli/clack.js";
21
21
  import { akmInit } from "../commands/sources/init.js";
22
+ import { targetRows } from "../commands/targets.js";
22
23
  import { deriveBundleIds } from "../core/bundle-id.js";
24
+ import { toErrorMessage } from "../core/common.js";
23
25
  import { bundleEntryToSourceEntry, DEFAULT_CONFIG, defaultConnection, loadUserConfig, mutateConfigWithPrecommit, parseAndValidateConfigText, primaryBundlePath, validateCompleteConfig, } from "../core/config/config.js";
24
26
  import { readConfigText } from "../core/config/config-io.js";
25
27
  import { listTopLevelConfigKeys } from "../core/config/config-schema.js";
@@ -30,6 +32,8 @@ import { getConfigPath, getDefaultStashDir, isTransientStashPath } from "../core
30
32
  import { warn, warnOnce } from "../core/warn.js";
31
33
  import { akmIndex } from "../indexer/indexer.js";
32
34
  import { readLockfile } from "../integrations/lockfile.js";
35
+ import { checkEmbeddingAvailability } from "../llm/embedder.js";
36
+ import { effectiveEmbedding } from "../llm/embedders/connection.js";
33
37
  import { detectEnvironment, renderDetectionSummary } from "./detect.js";
34
38
  import { bail, prompt } from "./prompt.js";
35
39
  import { prepareSemanticSearchAssets } from "./semantic-assets.js";
@@ -305,6 +309,50 @@ function finalizeSetupDraft(draft) {
305
309
  delete finalized.defaultBundle;
306
310
  return finalized;
307
311
  }
312
+ /** Where a process of `config` runs, as `akm info` and `akm health` say it. */
313
+ function processRow(config, process) {
314
+ return targetRows({ config }).find((row) => row.type === "process" && row.process === process);
315
+ }
316
+ /**
317
+ * What `--yes` and `--from` do for local embeddings once the config is saved: fetch the local embedder's runtime and
318
+ * model and load them. Undefined when the saved config does not embed locally.
319
+ */
320
+ async function prepareLocalEmbeddings(config, noProbe) {
321
+ if (processRow(config, "embedding")?.kind !== "local")
322
+ return undefined;
323
+ if (noProbe)
324
+ return "skipped";
325
+ const result = await checkEmbeddingAvailability(effectiveEmbedding(config));
326
+ if (result.available)
327
+ return "ready";
328
+ warn(`[akm setup] Semantic search is blocked: ${result.message}`);
329
+ return `blocked: ${result.message}`;
330
+ }
331
+ /** The wizard's offer to fetch what akm's own agent runtime runs, when the saved config runs agent work there. */
332
+ async function offerAgentRuntimeFetch(config) {
333
+ if (processRow(config, "agents")?.kind !== "connection")
334
+ return;
335
+ const { agentRuntime } = await import("../integrations/agent/runtime.js");
336
+ const pending = agentRuntime.pendingFetch();
337
+ if (pending === undefined)
338
+ return;
339
+ const fetchNow = await prompt(() => p.confirm({
340
+ message: `Agent work runs on akm's own runtime. Fetch ${pending} for it now, rather than on the first agent run?`,
341
+ initialValue: true,
342
+ }));
343
+ if (!fetchNow)
344
+ return;
345
+ const spin = p.spinner();
346
+ spin.start(`Fetching ${pending}...`);
347
+ try {
348
+ await agentRuntime.fetch();
349
+ spin.stop("akm's own agent runtime is ready.");
350
+ }
351
+ catch (error) {
352
+ spin.stop("akm's own agent runtime could not be fetched.");
353
+ p.log.warn(`${toErrorMessage(error)}\nThe first agent run tries again.`);
354
+ }
355
+ }
308
356
  const NOTHING_DETECTED = {
309
357
  harnesses: [],
310
358
  localServers: [],
@@ -538,6 +586,8 @@ export async function runSetupWizard(opts) {
538
586
  // Non-interactive setup paths never reach this interactive-only step.
539
587
  p.log.step("Scheduled Tasks");
540
588
  await stepScheduledTasks();
589
+ if (opts?.noProbe !== true)
590
+ await offerAgentRuntimeFetch(savedConfig);
541
591
  if (semanticSearchMode.mode === "auto") {
542
592
  if (semanticSearchMode.prepareAssets) {
543
593
  const ready = await prepareSemanticSearchAssets(savedConfig);
@@ -624,12 +674,13 @@ export async function runSetupWithDefaults(opts) {
624
674
  const { config, credentials } = await applyNonInteractiveDefaults(current, stashDir, opts);
625
675
  const finalConfig = finalizeSetupDraft(config);
626
676
  validateCompleteConfig(finalConfig);
627
- const { precommit: initResult } = await saveSetupConfig(current, finalConfig, async () => {
677
+ const { config: savedConfig, precommit: initResult } = await saveSetupConfig(current, finalConfig, async () => {
628
678
  storeCredentials(credentials);
629
679
  if (opts.noInit)
630
680
  return undefined;
631
681
  return akmInit({ dir: stashDir, setDefault: true, persistConfig: false });
632
682
  });
683
+ const semanticAssets = await prepareLocalEmbeddings(savedConfig, opts.noProbe);
633
684
  return {
634
685
  configPath: getConfigPath(),
635
686
  bundleDir: stashDir,
@@ -637,6 +688,7 @@ export async function runSetupWithDefaults(opts) {
637
688
  written: true,
638
689
  fields: Object.keys(finalConfig).filter((k) => finalConfig[k] !== undefined),
639
690
  runs: describeRuns(finalConfig),
691
+ ...(semanticAssets ? { semanticAssets } : {}),
640
692
  };
641
693
  }
642
694
  /**
@@ -736,12 +788,13 @@ export async function runSetupFromConfig(opts) {
736
788
  const finalizedMerged = finalizeSetupDraft(merged);
737
789
  // Reject an invalid merged config before touching the stash.
738
790
  validateCompleteConfig(finalizedMerged);
739
- const { precommit: initResult } = await saveSetupConfig(current, finalizedMerged, async () => {
791
+ const { config: savedConfig, precommit: initResult } = await saveSetupConfig(current, finalizedMerged, async () => {
740
792
  storeCredentials(credentials);
741
793
  if (opts.noInit)
742
794
  return undefined;
743
795
  return akmInit({ dir: stashDir, setDefault: true, persistConfig: false });
744
796
  });
797
+ const semanticAssets = await prepareLocalEmbeddings(savedConfig, opts.noProbe);
745
798
  return {
746
799
  configPath: getConfigPath(),
747
800
  bundleDir: stashDir,
@@ -749,6 +802,7 @@ export async function runSetupFromConfig(opts) {
749
802
  written: true,
750
803
  fields: Object.keys(incoming).filter((k) => incoming[k] !== undefined),
751
804
  runs: describeRuns(finalizedMerged),
805
+ ...(semanticAssets ? { semanticAssets } : {}),
752
806
  };
753
807
  }
754
808
  // ── Setup --from <file> bootstrap helper ────────────────────────────────────
@@ -3,7 +3,10 @@
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  /**
5
5
  * Setup step 4: embeddings. A model the connection serves, akm's built-in local
6
- * embedder, another endpoint, or off (which is semantic search off).
6
+ * embedder, another endpoint, or off (which is semantic search off). The local
7
+ * embedder is offered only where akm has local embeddings
8
+ * (`llm/embedders/onnx-runtime.ts`); everywhere else the choice is an endpoint
9
+ * or keyword search.
7
10
  */
8
11
  import * as p from "../../cli/clack.js";
9
12
  import { defaultConnection, } from "../../core/config/config.js";
@@ -11,6 +14,7 @@ import { apiKeyReference, resolveApiKey, storedCredential } from "../../core/con
11
14
  import { sameEndpoint } from "../../core/endpoint-url.js";
12
15
  import { UsageError } from "../../core/errors.js";
13
16
  import { redactUrlCredentials } from "../../core/redaction.js";
17
+ import { hasLocalEmbeddings, noLocalEmbeddingsReason } from "../../llm/embedders/onnx-runtime.js";
14
18
  import { isEmbeddingModel } from "../../llm/model-list.js";
15
19
  import { prompt } from "../prompt.js";
16
20
  import { describeSemanticSearchAssets, isRemoteEmbeddingConfig } from "../semantic-assets.js";
@@ -45,6 +49,10 @@ function warnOfDroppedKey(current, endpoint, keyGiven, interactive) {
45
49
  }
46
50
  function fromFlags(input) {
47
51
  const { flags } = input;
52
+ if (flags.embedding === "local" && !hasLocalEmbeddings()) {
53
+ warnUser(`${noLocalEmbeddingsReason()} --embedding local is ignored: semantic search stays off.`, false);
54
+ return { kind: "off" };
55
+ }
48
56
  if (flags.embedding === "off" || flags.embedding === "local")
49
57
  return { kind: flags.embedding };
50
58
  const model = flags.embedding;
@@ -81,9 +89,12 @@ async function askSource(input) {
81
89
  const hasServing = servingConnection(input) !== undefined;
82
90
  const heldModel = hasServing && current.embedding?.endpoint === undefined ? current.embedding?.model : undefined;
83
91
  const connectionModels = [...new Set([...(heldModel ? [heldModel] : []), ...served])];
92
+ const local = hasLocalEmbeddings();
93
+ if (!local)
94
+ p.log.info(noLocalEmbeddingsReason());
84
95
  const options = [
85
96
  ...connectionModels.map((model) => ({ value: `model:${model}`, label: model, hint: "served by the connection" })),
86
- { value: "local", label: "Built-in local embedder", hint: "no server needed" },
97
+ ...(local ? [{ value: "local", label: "Built-in local embedder", hint: "no server needed" }] : []),
87
98
  { value: "endpoint", label: "Another endpoint" },
88
99
  { value: "off", label: "Off", hint: "keyword search only" },
89
100
  ];
@@ -93,7 +104,9 @@ async function askSource(input) {
93
104
  ? `model:${heldModel}`
94
105
  : served[0]
95
106
  ? `model:${served[0]}`
96
- : "local";
107
+ : local
108
+ ? "local"
109
+ : "off";
97
110
  const choice = await prompt(() => p.select({ message: "Which embedding source should akm use?", options, initialValue }));
98
111
  if (choice === "local" || choice === "off")
99
112
  return { kind: choice };
@@ -104,7 +117,7 @@ async function askSource(input) {
104
117
  ...(current.embedding?.endpoint ? { initialValue: current.embedding.endpoint } : {}),
105
118
  });
106
119
  if (endpoint === undefined)
107
- return { kind: "local" };
120
+ return { kind: local ? "local" : "off" };
108
121
  // A key belongs to the endpoint it was given for, so the current one is offered for that endpoint alone.
109
122
  const unchangedEndpoint = current.embedding?.endpoint !== undefined && sameEndpoint(current.embedding.endpoint, endpoint);
110
123
  const held = unchangedEndpoint
@@ -141,6 +154,10 @@ export async function stepEmbeddings(input) {
141
154
  return { answer, semantic: { mode: "off", prepareAssets: false } };
142
155
  const after = embeddingAfter(input, answer);
143
156
  p.note(describeSemanticSearchAssets(after).join("\n"), "Semantic Search Assets");
157
+ if (!input.probe) {
158
+ p.log.info("Offline run: semantic search is not checked or prepared now; the first `akm index` does both.");
159
+ return { answer, semantic: { mode: "auto", prepareAssets: false } };
160
+ }
144
161
  const prepareAssets = await prompt(() => p.confirm({
145
162
  message: isRemoteEmbeddingConfig(after)
146
163
  ? "Check the embedding endpoint and verify semantic search now?"
@@ -125,66 +125,32 @@ function loadBunSqlite() {
125
125
  }
126
126
  let betterSqlite3Ctor;
127
127
  /**
128
- * The binding is absent or unbuildable — a toolchain/install problem.
128
+ * The package is missing, or it carries no binding for this platform. Its N-API
129
+ * bindings ship prebuilt in the package (see package.json → pinNotes), so neither
130
+ * a build toolchain nor the Node version decides whether it loads.
129
131
  */
130
- const MISSING_BINDING_REMEDY = "akm could not load 'better-sqlite3', the SQLite driver it needs on Node.js.\n" +
131
- " • Reinstall akm with a working C/C++ build toolchain so its optional\n" +
132
- " 'better-sqlite3' native binding builds (a global `npm i -g better-sqlite3`\n" +
133
- " will NOT be resolved — Node loads it from akm's own node_modules).\n" +
134
- " • Or run akm under Bun, which has a built-in SQLite driver and needs no native build.";
135
- /**
136
- * Recognize a binding built for a DIFFERENT Node ABI than the one now running,
137
- * and answer with the one command that fixes it.
138
- *
139
- * This is the most likely failure a real user hits, and it is not a broken
140
- * install: a native addon is compiled (or a prebuilt binary is selected) for
141
- * the Node major present at `npm install` time. Upgrade Node afterwards and the
142
- * same file no longer loads.
143
- *
144
- * It is matched rather than described because the symptom text varies and the
145
- * previous wording only named ONE of them. The prebuilt-binary path — which is
146
- * now the normal path, since better-sqlite3 is pinned to a version publishing a
147
- * prebuild for every supported Node (see package.json → pinNotes) — reports
148
- * `Module did not self-register`, saying nothing about versions at all. A
149
- * from-source build reports the explicit `NODE_MODULE_VERSION` mismatch. Asking
150
- * the user to decide which bullet applies is the step worth deleting.
151
- */
152
- export function abiMismatchRemedy(message) {
153
- const ABI_MISMATCH_SHAPES = [
154
- "did not self-register", // prebuilt binary for another ABI
155
- "NODE_MODULE_VERSION", // explicit mismatch, from-source build
156
- "was compiled against a different", // same, older phrasing
157
- "invalid ELF header", // binary for another platform/arch entirely
158
- ];
159
- if (!ABI_MISMATCH_SHAPES.some((shape) => message.includes(shape)))
160
- return undefined;
161
- return ("akm could not load 'better-sqlite3': its native binding was built for a different\n" +
162
- `Node.js version than the one now running (this Node is ABI ${process.versions.modules}).\n` +
163
- "This is what happens when Node is upgraded after akm is installed. It is NOT a\n" +
164
- "broken install, and reinstalling akm is not required.\n" +
165
- " Fix: npm rebuild better-sqlite3 # in akm's install directory\n" +
166
- " Or: npm install -g akm-cli # reinstall, rebuilding against this Node\n" +
167
- " Or: run akm under Bun, whose built-in SQLite driver needs no native binding.");
132
+ function missingBindingRemedy() {
133
+ return ("akm could not load 'better-sqlite3', the SQLite driver it needs on Node.js.\n" +
134
+ " • Its package carries prebuilt bindings for Linux and macOS on x64 and arm64 and for\n" +
135
+ ` Windows on x64 and arm64; this machine is ${process.platform} ${process.arch}.\n` +
136
+ " • Reinstall akm (`npm install -g akm-cli`) if the package is missing (a global\n" +
137
+ " `npm i -g better-sqlite3` will NOT be resolved — Node loads it from akm's own node_modules).\n" +
138
+ " • Or run akm under Bun, which has a built-in SQLite driver and needs no native binding.");
168
139
  }
169
140
  function loadBetterSqlite3() {
170
141
  if (!betterSqlite3Ctor) {
171
142
  // Runtime-gated dynamic require: only reached when NOT on Bun, so Bun never
172
143
  // resolves or loads the optional `better-sqlite3` native dependency.
173
144
  // `better-sqlite3` is an optionalDependency, so `npm i` can succeed without
174
- // it (or with a native build that failed). Convert the raw MODULE_NOT_FOUND
175
- // into an actionable message instead of a cryptic onboarding crash.
145
+ // it. Convert the raw MODULE_NOT_FOUND into an actionable message instead of
146
+ // a cryptic onboarding crash.
176
147
  let mod;
177
148
  try {
178
149
  mod = nodeRequire("better-sqlite3");
179
150
  }
180
151
  catch (err) {
181
- // An ABI mismatch does NOT arrive here — `require` succeeds and the
182
- // failure lands at construction (see openNodeDatabase). This path is a
183
- // genuinely absent or unresolvable module. `abiMismatchRemedy` is still
184
- // consulted because a from-source build CAN fail at load with the
185
- // explicit NODE_MODULE_VERSION message.
186
152
  const raw = err instanceof Error ? err.message : String(err);
187
- throw new Error(`${abiMismatchRemedy(raw) ?? MISSING_BINDING_REMEDY}\n Underlying load error: ${raw}`);
153
+ throw new Error(`${missingBindingRemedy()}\n Underlying load error: ${raw}`);
188
154
  }
189
155
  betterSqlite3Ctor = mod.default ?? mod;
190
156
  }
@@ -201,23 +167,17 @@ function openNodeDatabase(path, opts) {
201
167
  options.readonly = opts.readonly;
202
168
  if (opts?.create === false)
203
169
  options.fileMustExist = true;
204
- // Construction, not `require`, is where an ABI mismatch surfaces.
205
- // `require("better-sqlite3")` SUCCEEDS against a binding built for another
206
- // Node ABI — the package resolves its `.node` file lazily — so the loader's
207
- // catch never sees this error and cannot explain it. Verified against a real
208
- // ABI-127 binding under Node 24 (ABI 137): `require()` returned a function
209
- // and `new Database(...)` threw. Wrapping the require alone left the most
210
- // likely real-world failure reported as a bare Node internals message.
211
170
  let db;
212
171
  try {
213
172
  db = opts ? new BetterSqlite3(path, options) : new BetterSqlite3(path);
214
173
  }
215
174
  catch (err) {
175
+ // The package loads its binding at the first construction, not at `require`, so a platform it carries no
176
+ // binding for fails here.
216
177
  const raw = err instanceof Error ? err.message : String(err);
217
- const remedy = abiMismatchRemedy(raw);
218
- if (!remedy)
178
+ if (!raw.includes("better_sqlite3.node"))
219
179
  throw err;
220
- throw new Error(`${remedy}\n Underlying error: ${raw}`);
180
+ throw new Error(`${missingBindingRemedy()}\n Underlying error: ${raw}`);
221
181
  }
222
182
  return {
223
183
  prepare: db.prepare.bind(db),
@@ -438,6 +438,15 @@ function readCrontab(exec) {
438
438
  const stderr = (result.stderr ?? "").trim();
439
439
  if (((result.stdout ?? "").trim() === "" && stderr === "") || /no crontab/i.test(stderr))
440
440
  return "";
441
+ // `crontab` reads the spool through its setgid `crontab` group (setuid root on some distributions). A process
442
+ // started with no-new-privileges (an agent sandbox, a container, systemd `NoNewPrivileges=`) runs it without that
443
+ // privilege, and the spool refuses the read. The crontab itself is fine, so say so instead of passing on a bare
444
+ // `fopen` error. Without no-new-privileges a refusal has another cause and is reported as what it said.
445
+ if (result.noNewPrivileges && /permission denied/i.test(stderr)) {
446
+ throw new ConfigError("crontab -l could not read the crontab spool (permission denied). `crontab` reads it through its setgid " +
447
+ "(or setuid) bit, which this process, started with no-new-privileges (an agent sandbox, a container, " +
448
+ "systemd NoNewPrivileges=), does not grant; run this from a normal shell to see the installed tasks.", "INVALID_CONFIG_FILE", "Run the command from a normal login shell or terminal, not from a sandboxed process.");
449
+ }
441
450
  throw new ConfigError(`crontab -l failed (exit ${result.status}): ${result.stderr || result.stdout || "no output"}.`, "INVALID_CONFIG_FILE", "The `crontab` binary ran but did not report success; check its output above.");
442
451
  }
443
452
  function writeCrontab(exec, content) {
@@ -463,6 +472,15 @@ function replaceCrontab(exec, existing, next) {
463
472
  throw err;
464
473
  }
465
474
  }
475
+ /** Whether this process has Linux's no-new-privileges flag, which every child (`crontab` included) inherits. */
476
+ function hasNoNewPrivileges() {
477
+ try {
478
+ return /^NoNewPrivs:\s*1$/m.test(fs.readFileSync("/proc/self/status", "utf8"));
479
+ }
480
+ catch {
481
+ return false;
482
+ }
483
+ }
466
484
  function defaultCronExec() {
467
485
  const run = (args, input) => {
468
486
  const r = spawnSync("crontab", args, { encoding: "utf8", ...(input !== undefined ? { input } : {}) });
@@ -471,6 +489,7 @@ function defaultCronExec() {
471
489
  stdout: r.stdout ?? "",
472
490
  stderr: r.stderr ?? "",
473
491
  enoent: r.error?.code === "ENOENT",
492
+ noNewPrivileges: r.status !== 0 && hasNoNewPrivileges(),
474
493
  };
475
494
  };
476
495
  return {
@@ -225,8 +225,9 @@ Why: an accidental API is a compatibility promise nobody agreed to make.
225
225
 
226
226
  Enforced by: `tests/contracts/api-surface.test.ts` (the export list),
227
227
  `tests/integration/install/package-exports.test.ts` (the `exports` map),
228
- `package.json` (`engines.node` and the `preinstall` check), and the
229
- `node-smoke` job in `.github/workflows/ci.yml` (Node 22 and 24).
228
+ `package.json` (`engines.node`), the Node version check in the launchers and
229
+ `src/cli.ts`, and the `node-smoke` job in `.github/workflows/ci.yml` (Node 22
230
+ and 24).
230
231
 
231
232
  ### 10. Agent harnesses are thin, replaceable internals
232
233
 
@@ -26,7 +26,7 @@ changes.
26
26
  | `connections-and-harnesses` | One table of every configured connection (endpoint, whether its key is held — a `$VAR` set in this shell, a `credentials.json` entry, or none — and whether the endpoint answers) and harness (platform, whether its binary is on `PATH`, and with a probe its `--version`), then the work that runs on them: improve, judges, agents, extract, enrich, index, embedding and curate rerank, each naming the connection or harness and the model it resolves to through the same resolver the calls use (`evidence.rows`, typed `connection`, `harness` or `process`). `warn` when a key variable is unset, an endpoint does not answer, a harness is not installed, several connections (or harnesses) are configured and none is called `default` (said once, with the fix: name one `default`), or a work row cannot resolve because of the config; `unknown` when neither a connection nor a harness is configured; otherwise `pass`. Endpoint reachability is one bounded `GET /models` and the version is the harness's own `--version`; with `--no-probe` neither runs and the message says so. | Correct the named connection, key or harness; run `akm setup` when nothing is configured. A work row whose parts run somewhere other than the row's own target lists them in `overrides`, each with the kind (connection or harness), name and model it runs on: that is a per-process pin, working as set. |
27
27
  | `task-fail-rate` | ≥5% of scheduled task runs failed in the window (exit 143/70 recurring), or a single task_id fails at/above the same threshold. (#943) For `command`-kind (agent/LLM dispatch) failures specifically, `evidence.agentFailureReasonCounts` always carries a `detail.reason` breakdown (`timeout`, `non_zero_exit`, `spawn_failed`, …), and the warn message names the dominant reason — e.g. `(timeout-dominant: 9/12 command-task failures)` — when one reason is at least half of the counted command-task failures. | Triage as a bug: `akm task doctor`, inspect failing lane logs. For a native shell/script task, exit-143 = killed/timeout, exit-70 = internal error. For a `command`-kind (agent/LLM) task, read `detail.reason` from `akm task history` instead of the exit code — `timeout` means the dispatch's own deadline (not an outer process kill) expired; `non_zero_exit`/`spawn_failed`/`aborted`/etc. name the other failure shapes. |
28
28
  | `data-dir-usage` | The data directory is more than 3× the live databases (`state.db` + `index.db` + `logs.db`), or one top-level subdirectory holds more than half of it; the message names that subdirectory with its size and share. | Look at the named subdirectory. `backups/tasks/` holds one snapshot per `akm migrate apply` run that rewrote a task file; everything under `backups/` is yours to prune. |
29
- | `semantic-search` | What the last `akm index` recorded about semantic search, with the index's entry and vector counts read now; no model load, no request. `pass` when every entry has a vector (local: on `onnxruntime-node`) or `semanticSearchMode` is `off`; `warn` when vectors lag entries, local embeddings ran on another runtime, or the platform has no native ONNX Runtime library (`unavailable: platform`: Alpine/musl, Intel macOS, where keyword search is the healthy state); `fail` when the last index found it `blocked` (endpoint or model download failed) or `unavailable: load` (the library is there but did not load: a missing package or addon, a `noexec` temp or cache directory), with the reason; `unknown` before the first index. A hard check, so a `fail` fails `akm health`. | `fail`: fix the reason named, then run `akm index`; the check reads the next index's record. `warn` for the platform: set `semanticSearchMode` to `off`, or `embedding.endpoint` to a remote embedding service. |
29
+ | `semantic-search` | What the last `akm index` recorded about semantic search, with the index's entry and vector counts read now; no model load, no request. `pass` when every entry has a vector (local: on `onnxruntime-node`) or `semanticSearchMode` is `off`; `warn` when vectors lag entries, local embeddings ran on another runtime, or akm offers no local embeddings on this platform (`unavailable: platform`, where keyword search or an endpoint is the default); `fail` when the last index found it `blocked` (endpoint or model download failed) or `unavailable: load` (the runtime could not be downloaded or did not load, for example from a `noexec` cache directory), with the reason; `unknown` before the first index. A hard check, so a `fail` fails `akm health`. | `fail`: fix the reason named, then run `akm index`; the check reads the next index's record. `warn` for the platform: `akm setup --embedding off` for keyword search, or `akm setup --embedding <model> --embedding-endpoint <url>` for a remote endpoint. |
30
30
  | `stash-git-exposure` | `env/` or `secrets/` assets are git-tracked **and** a remote is set — `git push` can leak keys. | `git rm --cached` the files, add `env/`+`secrets/` to `.gitignore` (a rule alone does not untrack). |
31
31
  | `session-extraction` | (#914) Derived from the `extract_sessions_seen` ledger for the last 7 days — not `improve_runs`, which the hook-driven `akm proposal extract --session-id ...` never writes. `unknown` when nothing was recorded; `warn` when every session in the window was skipped for an infrastructure reason (`llm_unavailable`, `read_failed`, `exception`, `locked_concurrent`), naming the reason and engine; otherwise `pass` with per-outcome counts. | An `unknown` machine may be deliberately not configured for extraction, or may have stopped harvesting — check for recent sessions. A `warn` names the broken engine/reason directly. |
32
32
  | `auto-accept-validation` | Proposals passed the confidence gate but failed validation (bad frontmatter, truncation). | Review the affected pending proposals via `akm proposal list`; they were held, not lost. |
@@ -37,7 +37,7 @@ changes.
37
37
  | `index-state-generation` | Durable usage links in `state.db` disagree with the current searchable index generation, which an interrupted cross-database update leaves behind. | Stop concurrent writers, then run `akm index --full` to relink state to the current index. |
38
38
  | `cli-version` | (#950) Compares the installed akm-cli version against the latest GitHub release (the same source `akm upgrade` trusts). `--probe`-gated: `unknown` "not probed" with `--no-probe`; offline/rate-limited also degrades to `unknown`, never a false `warn`. | Run `akm upgrade` when a newer release is reported. An `unknown` result carries no staleness claim either way — check manually if it matters. |
39
39
  | `scheduler-binary` | (#953) Reads the scheduler's recorded akm invocation for the installed tasks (the same binding `task sync`/`task doctor` already read — no crontab/launchd/schtasks text parsing) and runs it with `--version`, compared against the running CLI's version. `--probe`-gated: `unknown` "not probed" with `--no-probe`. `unknown` when no scheduled task is installed or the recorded binary cannot be executed. | A `warn` names both versions — upgrading akm through a different installer than the one active at the last `task sync` left the schedule pointed at the old binary; run `akm task sync` to rebind it. |
40
- | `plugin-version` / `opencode-plugin-version` | (itlackey/akm#832,) `plugin-version` reports each installed Claude Code harness plugin's version and its declared `AKM_VERSION_RANGE` admission of the running CLI. `opencode-plugin-version` does the same for the OpenCode plugin's own bundled `akm-cli` (read from its `package.json`, never executed) — it runs in-process and shares the host's databases, so its lag matters the same way. `plugin-version`'s newest-tag lookup is `--probe`-gated: with `--no-probe` its row is `unknown` ("not probed") and only the local range check still runs. `opencode-plugin-version` reads local files only and is never gated. | A `warn` names the stale/out-of-range plugin and the update command (`claude plugin update <ref>` for the Claude plugin; update the OpenCode plugin for the bundled copy). |
40
+ | `plugin-version` / `opencode-plugin-version` | (itlackey/akm#832,) `plugin-version` reports each installed Claude Code harness plugin's version and its declared `AKM_VERSION_RANGE` admission of the running CLI. `opencode-plugin-version` does the same for the OpenCode plugin's own bundled `akm-cli` (read from its `package.json`, never executed) — it runs in-process and shares the host's databases, so its lag matters the same way. OpenCode 1 caches each spec in its own folder, so `opencode-plugin-version` reads the one folder of the spec the global OpenCode config names under `plugin` (`akm-opencode`, read as `akm-opencode@latest`, or `akm-opencode@next`), and for `akm-opencode@<version>` reports the plugin as pinned with no staleness verdict; with no entry, or no folder for it, it reads `packages/akm-opencode`, and it never scans the other cached specs. The newest version is read on the channel the marketplace clone follows: on the remote's default branch the newest stable tag, on any other branch (such as `next`) the release tag on that branch's head, and on a clone pinned to a tag that tag. `plugin-version`'s newest-tag lookup is `--probe`-gated: with `--no-probe` its row is `unknown` ("not probed") and only the local range check still runs. `opencode-plugin-version` reads local files only and is never gated. | A `warn` names the stale/out-of-range plugin and the update command (`claude plugin update <ref>` for the Claude plugin; update the OpenCode plugin for the bundled copy). |
41
41
 
42
42
  ## Connections, harnesses and the work on them
43
43
 
@@ -810,6 +810,7 @@ adapter recognizes — `schema.md` + `pages/` is the probe) contains:
810
810
  | `$CACHE/tasks/logs/<task-id>/` | Per-run stdout/stderr log files (`<ISO-ts>.log`) | No cleanup |
811
811
  | `$CACHE/bin/rg` | Auto-downloaded ripgrep binary | Permanent |
812
812
  | `$CACHE/opencode/<version>/opencode` | The OpenCode binary akm's own runtime runs (about 240 MB), fetched from the npm registry on its first use | Permanent; safe to delete (fetched again) |
813
+ | `$CACHE/onnxruntime/<version>-<platform>-<arch>/` | `onnxruntime_binding.node` and the ONNX Runtime library it links, which the local embedder loads; fetched from the npm registry by `akm setup --embedding local` or the first local embedding (about 36 MB kept). Must be on a mount that allows executing files | Permanent; safe to delete (fetched again) |
813
814
  | `$CACHE/index/unresolved-sources/<stash>/<name>` | Synthetic placeholder path for a configured source whose content root did not resolve this run; never written to disk, only reported as a `SearchSource.path`. Moved out of `$STASH/.akm/unresolved-sources/` (itlackey/akm#890). | N/A (not a real directory) |
814
815
 
815
816
  Cache-backed bundles (`git`, `website`, `npm`) are materialised into `$CACHE`
@@ -132,8 +132,9 @@ runs collapsed to `-`, trimmed) and match the fragment against those slugs;
132
132
  fall back to a case-insensitive match against raw heading text. The selected
133
133
  region runs from that heading to the next heading of the same or higher level.
134
134
  A fragment that matches no heading is an error that **lists the available
135
- fragment slugs** — that error is the discovery mechanism, replacing the removed
136
- `toc` view mode.
135
+ fragment slugs**. The outline itself is `akm show <ref> --detail brief`, whose
136
+ `toc` lists every heading with the slug that selects it; it replaces the
137
+ removed `toc` view mode.
137
138
 
138
139
  **Every other item kind — adapter-owned selector.** When one item exposes
139
140
  multiple exports the adapter MAY append a stable fragment
@@ -157,7 +158,7 @@ is removed. It was a second argv parser (`normalizeShowArgv` rewrote
157
158
  | --- | --- |
158
159
  | `akm show X section "Auth"` | `akm show X#auth` |
159
160
  | `akm show X full` | `akm show X` (no fragment = whole item) |
160
- | `akm show X toc` | `akm show X#<unmatched>` lists available fragment slugs |
161
+ | `akm show X toc` | `akm show X --detail brief` (its `toc` field) |
161
162
  | `akm show X lines 10 30` | removed — every response carries `path`; slice the file |
162
163
  | `akm show X frontmatter` | removed — if a raw-YAML projection proves necessary it returns as a `--shape` value, the designated projection axis |
163
164
 
@@ -240,19 +240,27 @@ sets the same path and restores it from a model/source-identified Actions
240
240
  cache. Candidate tags and manual runs are restore-only; only the scheduled
241
241
  default-branch run may save a cache entry.
242
242
 
243
- The same gate runs real semantic index/search round-trips on `onnxruntime-node`,
244
- asserts the `embeddingRuntime` the index reports, and compares the embedder's
245
- vectors with the Transformers.js reference in
246
- `tests/fixtures/embedder-reference-vectors.json`. Package acceptance separately
247
- verifies the ordinary npm package surface; there is no installed-consumer
248
- compatibility architecture or copied semantic runtime to audit.
243
+ The same gate runs real semantic index/search round-trips on `onnxruntime-node`'s
244
+ binding, asserts the `embeddingRuntime` the index reports, and compares the
245
+ embedder's vectors with the Transformers.js reference in
246
+ `tests/fixtures/embedder-reference-vectors.json`. The binding and its library are
247
+ shared across runs the same way as the model: `tests/_helpers/onnx-runtime-cache.ts`
248
+ copies the repo-local `.ci-cache/akm/onnxruntime/` into each sandbox's akm cache
249
+ and keeps a folder a run fetched there, and gated CI caches that directory under
250
+ a key from `src/llm/embedders/onnx-runtime.ts`, which pins the ONNX Runtime
251
+ version. Package acceptance separately verifies
252
+ the ordinary npm package surface.
249
253
 
250
254
  `tests/integration/standalone-semantic.test.ts` (the same `AKM_SEMANTIC_TESTS=1`
251
- gate) builds the host's standalone binary and indexes a small stash with it,
252
- asserting a ready status, a vector per entry and the native runtime. Gated CI
253
- runs it in the Semantic Search job (Linux) and in the Native Scheduler job on
254
- macOS and Windows, the only places the binary's preload of each platform's ONNX
255
- Runtime library runs.
255
+ gate) builds the host's standalone binary and asserts it embeds no `.node` file.
256
+ Where akm offers local embeddings it runs `akm setup --yes --embedding local`,
257
+ expects the binding and the library in the cache folder, and indexes a small
258
+ stash, asserting a ready status, a vector per entry and the native runtime.
259
+ Everywhere else it asserts the default: setup warns and saves keyword search, an
260
+ index with semantic search on reports it unavailable for the platform, `akm
261
+ health` warns, and search is keyword. Gated CI runs it in the Semantic Search job
262
+ (Linux) and in the Native Scheduler job on macOS (local embeddings) and Windows
263
+ (the default), the only places the binary runs on those platforms.
256
264
 
257
265
  ### What to test explicitly
258
266
 
@@ -282,7 +290,8 @@ This validates three deployment methods across four Linux families:
282
290
  - bun-based install: Ubuntu, Debian, Alpine, Fedora
283
291
  - compiled binary install: Ubuntu, Debian, Fedora
284
292
  - npm install: Debian on `node:24-slim`, which has no compiler, python or make
285
- (`npm install -g` of the packed package; the install's exit code is the check)
293
+ (`npm install -g --ignore-scripts` of the packed package, as npm 12 installs by
294
+ default; the install's exit code and the smoke test are the check)
286
295
 
287
296
  Binary validation currently excludes Alpine. The compiled Linux binary used in
288
297
  this repo's Docker tests is not packaged for Alpine/musl compatibility, so the
@@ -397,9 +406,9 @@ For a local release gate without Docker, use:
397
406
  ./tests/release-check.sh --skip-docker
398
407
  ```
399
408
 
400
- That script checks the packaged artifact: bin targets, the migrator, package
401
- acceptance, and the upgrade rehearsal against the packed tarball. Lint, the
402
- typecheck and the test suites are `bun run check`.
409
+ That script checks the packaged artifact: bin targets, the migrator and package
410
+ acceptance. Lint, the typecheck and the test suites are `bun run check`; the
411
+ upgrade rehearsal runs in CI.
403
412
 
404
413
  The Release workflow runs Gated CI (real embeddings, Docker, and the
405
414
  Linux/macOS/Windows schedulers) on the commit it publishes and requires CI to
@@ -512,17 +521,16 @@ throws naming the fix instead. Env overrides:
512
521
  resolving the highest published stable version below the candidate's
513
522
  `package.json` version.
514
523
  - `AKM_CANDIDATE_TARBALL=<path>` — use an already-packed candidate tarball
515
- (`tests/release-check.sh` passes `$PACKAGE_CANDIDATE`) instead of running
516
- `npm pack` on the working tree (which requires `dist/cli.js` — run
517
- `bun run build` first).
524
+ instead of running `npm pack` on the working tree (which requires
525
+ `dist/cli.js` — run `bun run build` first).
518
526
 
519
527
  Fetched previous-release tarballs are cached under
520
528
  `${TMPDIR:-/tmp}/akm-upgrade-rehearsal/previous-release/<version>/` so repeat
521
529
  runs do not re-hit the network. It is wired into CI as the `upgrade-rehearsal`
522
530
  job (`.github/workflows/ci.yml`), which runs on pushes to `main` and release
523
531
  branches, on manual runs and on every commit of a pull request that is ready
524
- for review (a draft pull request skips it until it is marked ready), and into
525
- `tests/release-check.sh` right after packing the release candidate.
532
+ for review (a draft pull request skips it until it is marked ready). The Release
533
+ workflow requires that CI run to pass on the commit it publishes.
526
534
 
527
535
  ## Coverage Gap Guide
528
536
 
@@ -73,14 +73,21 @@ lookup to one installed bundle when a conceptId is ambiguous across bundles.
73
73
 
74
74
  ## 4. Load a document section
75
75
 
76
- Knowledge docs and other markdown assets can be large. Append `#fragment` to
77
- a ref to return one section by heading slug instead of the whole document:
76
+ Knowledge docs and other markdown assets can be large. Read the outline first,
77
+ then only the sections you need. `--detail brief` returns the document's `toc`
78
+ without the body, and appending `#<slug>` to a ref returns one section:
78
79
 
79
80
  ```sh
81
+ akm show knowledge/api-guide --detail brief
82
+ # → {"toc": [{"level": 2, "text": "Authentication", "slug": "authentication", "startLine": 12}, ...], ...}
80
83
  akm show knowledge/api-guide#authentication
81
84
  # → just the "Authentication" section, not the full document
82
85
  ```
83
86
 
87
+ Every `slug` in `toc` selects its section, including repeated headings, which
88
+ get `-1`, `-2`, ... suffixes. A whole-document show carries the same `toc`
89
+ next to the body.
90
+
84
91
  Search can also return an opaque `#akm-fragment-...` selector for a bounded
85
92
  piece of a long document. The exact selector remains the default. For memories
86
93
  whose opening context helps interpret a later matching turn, opt into bounded
@@ -96,8 +103,9 @@ reports `contextTruncated` in JSON. The response also distinguishes canonical
96
103
  `parentRef` from `selectedRef` and includes fragment position, line bounds,
97
104
  neighbors, and separate fragment/parent size estimates.
98
105
 
99
- An unmatched fragment lists the available slugs instead of erroring, so a
100
- guess that misses still tells you what to try next.
106
+ An unmatched fragment is an error that lists the available slugs, so a guess
107
+ that misses still tells you what to try next; `--detail brief` is the way to
108
+ see them without guessing.
101
109
 
102
110
  ## 5. Record the outcome
103
111