akm-cli 0.9.1-beta.1 → 0.9.1-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +18 -1
  2. package/dist/cli/parse-args.js +7 -1
  3. package/dist/commands/env/child-env.js +14 -0
  4. package/dist/commands/improve/eval-cases.js +2 -0
  5. package/dist/commands/improve/memory/memory-improve.js +1 -0
  6. package/dist/commands/lint/index.js +5 -1
  7. package/dist/commands/sources/add-cli.js +8 -2
  8. package/dist/commands/sources/migration-help.js +12 -3
  9. package/dist/commands/sources/self-update.js +9 -1
  10. package/dist/core/adapter/adapters/agent-skills-adapter.js +32 -16
  11. package/dist/core/adapter/adapters/akm-lint.js +6 -2
  12. package/dist/core/adapter/adapters/akm-task-adapter.js +4 -2
  13. package/dist/core/adapter/adapters/dotenv-adapter.js +21 -0
  14. package/dist/core/asset/frontmatter.js +6 -1
  15. package/dist/core/common.js +81 -3
  16. package/dist/core/config/config-io.js +5 -45
  17. package/dist/core/config/schema/engines.js +14 -3
  18. package/dist/core/extra-params.js +11 -0
  19. package/dist/core/fs-txn.js +15 -2
  20. package/dist/core/json-schema.js +19 -2
  21. package/dist/core/paths.js +16 -2
  22. package/dist/core/redaction.js +22 -1
  23. package/dist/core/state-db.js +1 -0
  24. package/dist/core/write-source.js +26 -2
  25. package/dist/indexer/indexer.js +31 -6
  26. package/dist/indexer/search/db-search.js +17 -2
  27. package/dist/indexer/walk/walker.js +6 -1
  28. package/dist/integrations/agent/detect.js +13 -1
  29. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +21 -0
  30. package/dist/integrations/lockfile.js +10 -0
  31. package/dist/llm/client.js +14 -19
  32. package/dist/llm/embedder.js +23 -3
  33. package/dist/llm/embedders/remote.js +27 -2
  34. package/dist/output/html-render.js +40 -1
  35. package/dist/runtime.js +23 -1
  36. package/dist/scripts/akm-migrate-node.js +303 -107
  37. package/dist/scripts/akm-migrate.js +303 -107
  38. package/dist/setup/setup.js +22 -7
  39. package/dist/sources/providers/git-install.js +25 -2
  40. package/dist/sources/snapshot-fetchers/content-extract.js +63 -1
  41. package/dist/storage/database.js +71 -12
  42. package/dist/storage/engines/sqlite-migrations.js +61 -2
  43. package/dist/storage/repositories/index-connection.js +11 -1
  44. package/dist/storage/repositories/index-meta-repository.js +11 -0
  45. package/dist/storage/repositories/index-schema.js +17 -2
  46. package/dist/storage/repositories/index-vec-repository.js +43 -5
  47. package/dist/storage/sqlite-pragmas.js +12 -1
  48. package/dist/tasks/runner.js +84 -7
  49. package/dist/tasks/scheduler-invocation.js +19 -0
  50. package/dist/tasks/schema.js +21 -1
  51. package/dist/text-import-hook.mjs +1 -1
  52. package/dist/workflows/exec/native-executor.js +8 -0
  53. package/dist/workflows/exec/step-work.js +10 -2
  54. package/dist/workflows/parser.js +26 -1
  55. package/package.json +1 -1
  56. package/schemas/akm-config.json +10 -5
  57. package/schemas/akm-workflow.json +7 -3
@@ -81,7 +81,11 @@ export function txnFileHash(filePath) {
81
81
  return txnHash(fs.readFileSync(filePath));
82
82
  }
83
83
  export function fsyncTxnFile(filePath) {
84
- const fd = fs.openSync(filePath, "r");
84
+ // Open for WRITE. Windows implements fsync as FlushFileBuffers, which
85
+ // requires write access on the handle — a read-only descriptor fails with
86
+ // EACCES/EPERM, so every proposal accept and reject failed on that platform.
87
+ // POSIX accepts "r+" here just as readily as "r".
88
+ const fd = fs.openSync(filePath, "r+");
85
89
  try {
86
90
  fs.fsyncSync(fd);
87
91
  }
@@ -91,7 +95,16 @@ export function fsyncTxnFile(filePath) {
91
95
  }
92
96
  export function fsyncTxnDir(dirPath) {
93
97
  try {
94
- fsyncTxnFile(dirPath);
98
+ // Read-only, unlike {@link fsyncTxnFile}: a directory cannot be opened for
99
+ // write on POSIX (EISDIR), and on Windows this whole operation is
100
+ // unsupported anyway and falls into the catch.
101
+ const fd = fs.openSync(dirPath, "r");
102
+ try {
103
+ fs.fsyncSync(fd);
104
+ }
105
+ finally {
106
+ fs.closeSync(fd);
107
+ }
95
108
  }
96
109
  catch {
97
110
  // Directory fsync is unavailable on some platforms.
@@ -171,6 +171,13 @@ function pushIssue(issues, path, keyword, kind, message) {
171
171
  function isPlainObject(value) {
172
172
  return typeof value === "object" && value !== null && !Array.isArray(value);
173
173
  }
174
+ /** Values the runtime's reference-equality `enum` check can enforce correctly. */
175
+ function isSupportedEnumValue(value) {
176
+ return (value === null ||
177
+ typeof value === "string" ||
178
+ typeof value === "boolean" ||
179
+ (typeof value === "number" && Number.isFinite(value)));
180
+ }
174
181
  function checkDefinitionNode(schema, path, issues, depth) {
175
182
  if (depth > MAX_DEFINITION_DEPTH) {
176
183
  pushIssue(issues, path, "(depth)", "malformed", `schema nesting exceeds the depth limit of ${MAX_DEFINITION_DEPTH}`);
@@ -199,8 +206,18 @@ function checkDefinitionNode(schema, path, issues, depth) {
199
206
  }
200
207
  }
201
208
  }
202
- if (schema.enum !== undefined && (!Array.isArray(schema.enum) || schema.enum.length === 0)) {
203
- pushIssue(issues, [...path, "enum"], "enum", "malformed", `"enum" must be a non-empty array of allowed values`);
209
+ if (schema.enum !== undefined) {
210
+ if (!Array.isArray(schema.enum) || schema.enum.length === 0) {
211
+ pushIssue(issues, [...path, "enum"], "enum", "malformed", `"enum" must be a non-empty array of allowed values`);
212
+ }
213
+ else {
214
+ schema.enum.forEach((value, index) => {
215
+ if (isSupportedEnumValue(value))
216
+ return;
217
+ pushIssue(issues, [...path, "enum", index], "enum", "unsupported", `"enum" values must be JSON primitives (string, finite number, boolean, or null) in the workflow ` +
218
+ `schema subset — object and array enum members cannot be matched by the runtime subset`);
219
+ });
220
+ }
204
221
  }
205
222
  for (const keyword of ["allOf", "anyOf", "oneOf"]) {
206
223
  const branches = schema[keyword];
@@ -160,9 +160,23 @@ export function getCacheDir(env = process.env) {
160
160
  }
161
161
  const home = env.HOME?.trim();
162
162
  if (!home)
163
- return path.join("/tmp", "akm-cache");
163
+ return homelessFallbackDir("akm-cache");
164
164
  return path.join(home, ".cache", "akm");
165
165
  }
166
+ /**
167
+ * Last-resort directory when neither the XDG variable nor HOME is set.
168
+ *
169
+ * Scoped by uid. A fixed `/tmp/akm-<kind>` path is world-shared and entirely
170
+ * predictable: on a multi-user host the first uid to run akm owns the
171
+ * directory and every other user then reads and writes the same databases, and
172
+ * any local user can pre-create the path (or a symlink at it) and wait. Adding
173
+ * the uid gives each account its own path; the caller still creates it with
174
+ * restrictive permissions.
175
+ */
176
+ function homelessFallbackDir(kind) {
177
+ const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
178
+ return path.join(os.tmpdir(), uid === undefined ? kind : `${kind}-${uid}`);
179
+ }
166
180
  // ── Data directory ───────────────────────────────────────────────────────────
167
181
  /**
168
182
  * Returns the XDG data directory for akm (`~/.local/share/akm` on Linux/macOS,
@@ -209,7 +223,7 @@ export function getDataDir(env = process.env, platform = process.platform) {
209
223
  return path.join(xdgDataHome, "akm");
210
224
  const home = env.HOME?.trim();
211
225
  if (!home)
212
- return path.join("/tmp", "akm-data");
226
+ return homelessFallbackDir("akm-data");
213
227
  return path.join(home, ".local", "share", "akm");
214
228
  }
215
229
  export function getDbPath(env = process.env) {
@@ -322,7 +322,7 @@ function addPlainMatches(coverageDelta, text, needle) {
322
322
  * unlike {@link redactSensitiveText}, which requires the exact secret value
323
323
  * up front, this catches credentials no caller ever knew to list. No
324
324
  * truncation is applied; callers that need a length cap (e.g.
325
- * {@link redactErrorBody} in src/llm/client.ts) apply it themselves.
325
+ * {@link redactErrorBody}) apply it themselves.
326
326
  *
327
327
  * Targets:
328
328
  * - `Bearer <token>` headers echoed back by a provider
@@ -367,6 +367,27 @@ export function redactCredentialPatterns(input) {
367
367
  * that is a memory-exhaustion hazard reachable from ordinary command output.
368
368
  * The encoded-form path never had the bug because it always worked this way.
369
369
  */
370
+ /** Max characters of a provider error body worth surfacing in a message. */
371
+ const ERROR_BODY_MAX_LEN = 200;
372
+ /**
373
+ * Make an HTTP error body safe to put in an error message: pattern-redact
374
+ * credential shapes, then clip. Provider bodies can echo the credential that
375
+ * was sent and can be megabytes of HTML, and these messages travel — into
376
+ * persisted status files, `--json` output, and agent transcripts.
377
+ *
378
+ * Lives here rather than beside one transport because every HTTP client in the
379
+ * codebase needs it; the embeddings transport originally lacked it and leaked
380
+ * raw 10 MB bodies into `semantic-status.json`.
381
+ */
382
+ export function redactErrorBody(input) {
383
+ if (!input)
384
+ return "";
385
+ let out = redactCredentialPatterns(input);
386
+ if (out.length > ERROR_BODY_MAX_LEN) {
387
+ out = `${out.slice(0, ERROR_BODY_MAX_LEN)}…`;
388
+ }
389
+ return out;
390
+ }
370
391
  export function redactSensitiveText(text, sensitiveValues) {
371
392
  const values = [...new Set(sensitiveValues)]
372
393
  .filter((value) => value.length > 0)
@@ -150,6 +150,7 @@ export function openStateDatabase(dbPath) {
150
150
  exec: db.exec.bind(db),
151
151
  run: db.run.bind(db),
152
152
  transaction: db.transaction.bind(db),
153
+ loadExtension: db.loadExtension.bind(db),
153
154
  get inTransaction() {
154
155
  return db.inTransaction;
155
156
  },
@@ -33,7 +33,7 @@ import { ensureAkmMarkdownType } from "./asset/akm-markdown.js";
33
33
  import { assetPathForName, stashDirFor } from "./asset/asset-placement.js";
34
34
  import { conceptIdFromTypeName, displayRef } from "./asset/resolve-ref.js";
35
35
  import { deriveBundleId } from "./bundle-id.js";
36
- import { isWithin, resolveStashDir } from "./common.js";
36
+ import { existingFileMode, isWithin, resolveStashDir, writeFileAtomic } from "./common.js";
37
37
  import { resolveConfiguredSources } from "./config/config.js";
38
38
  import { ConfigError, UsageError } from "./errors.js";
39
39
  import { sanitizeCommitMessage } from "./git-message.js";
@@ -333,7 +333,11 @@ export async function writeAssetToSource(source, config, ref, content) {
333
333
  const preflight = preflightGitPathMutation(source, filePath);
334
334
  try {
335
335
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
336
- fs.writeFileSync(filePath, normalized, "utf8");
336
+ // Atomic: truncate-and-rewrite left a window in which a crash, a full disk,
337
+ // or a concurrent reader saw a half-written or empty asset — destroying user
338
+ // content that was fine a moment earlier. writeFileAtomic writes a sibling
339
+ // temp file, fdatasyncs it, and renames over the target.
340
+ writeFileAtomic(filePath, normalized, existingFileMode(filePath));
337
341
  recordWriteTargetPath(source, filePath);
338
342
  // #652: run-scoped write provenance — the canonical asset write is the
339
343
  // single largest contributor to an improve run's written-path set.
@@ -1003,11 +1007,31 @@ function ensureWritable(source, config) {
1003
1007
  throw new UsageError(`Source "${source.name}" is not writable. Set \`writable: true\` on the source config entry to enable writes.`, "INVALID_FLAG_VALUE");
1004
1008
  }
1005
1009
  }
1010
+ /**
1011
+ * MS-DOS device names Windows still reserves in every directory, with or
1012
+ * without an extension (CON, PRN, AUX, NUL, COM1-9, LPT1-9).
1013
+ */
1014
+ const WINDOWS_RESERVED_DEVICE_NAMES = new Set([
1015
+ "con",
1016
+ "prn",
1017
+ "aux",
1018
+ "nul",
1019
+ ...Array.from({ length: 9 }, (_, i) => `com${i + 1}`),
1020
+ ...Array.from({ length: 9 }, (_, i) => `lpt${i + 1}`),
1021
+ ]);
1006
1022
  function resolveAssetFilePath(source, ref) {
1007
1023
  const basename = path.posix.basename(ref.name.replaceAll("\\", "/")).replace(/\.md$/i, "").toLowerCase();
1008
1024
  if (basename === "index" || basename === "log") {
1009
1025
  throw new UsageError(`Reserved concept name "${basename}" cannot be written.`, "INVALID_FLAG_VALUE");
1010
1026
  }
1027
+ // Windows resolves these names as DEVICES no matter the directory or the
1028
+ // extension, so `CON.md` is not a file — a write goes to the console and a
1029
+ // read blocks on console input. Rejected on every platform so a stash stays
1030
+ // portable: an asset authored on Linux must not become unopenable when the
1031
+ // same bundle is used on Windows.
1032
+ if (WINDOWS_RESERVED_DEVICE_NAMES.has(basename)) {
1033
+ throw new UsageError(`Asset name "${basename}" is a reserved Windows device name and cannot be written.`, "INVALID_FLAG_VALUE");
1034
+ }
1011
1035
  const typeDir = stashDirFor(ref.type);
1012
1036
  if (!typeDir) {
1013
1037
  throw new UsageError(`Unknown asset type "${ref.type}". Cannot resolve a write path.`, "INVALID_FLAG_VALUE");
@@ -19,7 +19,7 @@ import { closeDatabase, openExistingDatabase, openIndexDatabase } from "../stora
19
19
  import { deleteEntriesByDirAndStash, deleteEntriesByDirExceptKeys, deleteEntriesByIds, deleteEntriesByStashDir, deleteUsageEventsByEntryIds, findEntryIdByRef, getAllEntries, getEmbeddableEntryCount, getEntryCount, getIndexedDirPathsByStashDir, getIndexedStashDirsByDir, relinkUsageEvents, upsertEntry, upsertWorkflowDocument, } from "../storage/repositories/index-entries-repository.js";
20
20
  import { rebuildFts } from "../storage/repositories/index-fts-repository.js";
21
21
  import { clearStaleCacheEntries } from "../storage/repositories/index-llm-cache-repository.js";
22
- import { deleteIndexDirState, deleteIndexDirStatesByStashDir, getMeta, setMeta, upsertIndexDirState, } from "../storage/repositories/index-meta-repository.js";
22
+ import { deleteIndexDirState, deleteIndexDirStatesByStashDir, deleteMeta, getMeta, setMeta, upsertIndexDirState, } from "../storage/repositories/index-meta-repository.js";
23
23
  import { upsertUtilityScore } from "../storage/repositories/index-utility-repository.js";
24
24
  import { getAllEntriesForEmbedding, getEmbeddingCount, isVecAvailable, isVecFastPathReady, purgeEmbeddings, setVecFastPathReady, upsertEmbedding, warnIfVecMissing, } from "../storage/repositories/index-vec-repository.js";
25
25
  import { takeWorkflowDocument } from "../workflows/runtime/document-cache.js";
@@ -158,7 +158,12 @@ async function runWalkPhase(ctx) {
158
158
  async function runEmbeddingPhase(ctx) {
159
159
  const { db, config, signal, onProgress } = ctx;
160
160
  throwIfAborted(signal);
161
- ctx.embeddingResult = await generateEmbeddingsForDb(db, config, onProgress);
161
+ // Forward the signal. Without it generateEmbeddingsForDb's abort machinery was
162
+ // inert — its throwIfAborted checks and the signal it threads into embedBatch
163
+ // (which RemoteEmbedder passes to every fetch and LocalEmbedder honours between
164
+ // chunks) never saw a controller. Ctrl-C and the improve budget abort could not
165
+ // stop the embedding phase, the longest phase of an index run.
166
+ ctx.embeddingResult = await generateEmbeddingsForDb(db, config, onProgress, signal);
162
167
  ctx.timing.tEmbedEnd = Date.now();
163
168
  }
164
169
  /**
@@ -1357,9 +1362,20 @@ async function generateEmbeddingsForDb(db, config, onProgress, signal) {
1357
1362
  const currentFingerprint = deriveSemanticProviderFingerprint(config.embedding);
1358
1363
  const storedFingerprint = getMeta(db, "embeddingFingerprint");
1359
1364
  if (storedFingerprint && storedFingerprint !== currentFingerprint) {
1360
- // Model/provider changed → stored vectors are incompatible. Clear them
1361
- // (same dimension, so keep the vec table); re-embedded by this index run.
1362
- purgeEmbeddings(db);
1365
+ // Model/provider changed → stored vectors are incompatible. Clear them;
1366
+ // re-embedded by this index run.
1367
+ //
1368
+ // The vec table goes too. "Same dimension, so keep the vec table" only held
1369
+ // for a same-width model swap: entries_vec is a vec0 virtual table declared
1370
+ // at a FIXED width, so after a dimension-changing model change every insert
1371
+ // failed against the old width, and ensureSchema's dim-change rebuild never
1372
+ // fired because it only runs for callers that pass an explicit
1373
+ // embeddingDim. The stale table survived `--full` — the exact remedy the
1374
+ // warning recommended. Clearing the stored dim lets the next ensureSchema
1375
+ // materialize it at the new width; until then the fast-path flag reads
1376
+ // false (no table) and search uses the complete BLOB table.
1377
+ purgeEmbeddings(db, { dropVecTable: true });
1378
+ deleteMeta(db, "embeddingDim");
1363
1379
  }
1364
1380
  try {
1365
1381
  const { embedBatch } = await import("../llm/embedder.js");
@@ -1404,6 +1420,7 @@ async function generateEmbeddingsForDb(db, config, onProgress, signal) {
1404
1420
  let storedCount = 0;
1405
1421
  let skippedCount = 0;
1406
1422
  let vecFailedCount = 0;
1423
+ let vecUnavailableCount = 0;
1407
1424
  db.transaction(() => {
1408
1425
  for (let i = 0; i < allEntries.length; i++) {
1409
1426
  const res = upsertEmbedding(db, allEntries[i].id, embeddings[i]);
@@ -1415,6 +1432,8 @@ async function generateEmbeddingsForDb(db, config, onProgress, signal) {
1415
1432
  }
1416
1433
  if (res.vec === "failed")
1417
1434
  vecFailedCount++;
1435
+ if (res.vec === "unavailable")
1436
+ vecUnavailableCount++;
1418
1437
  }
1419
1438
  })();
1420
1439
  if (skippedCount > 0) {
@@ -1424,7 +1443,13 @@ async function generateEmbeddingsForDb(db, config, onProgress, signal) {
1424
1443
  // instead of inferring readiness from stored-BLOB counts. Any failure
1425
1444
  // marks the fast path degraded, routing search to the JS-cosine fallback
1426
1445
  // over the (complete) BLOB table — honest degradation, not a hard failure.
1427
- setVecFastPathReady(db, vecFailedCount === 0);
1446
+ //
1447
+ // 'unavailable' has to degrade the flag too. It means no vec row was
1448
+ // written at all, so marking the fast path ready left a later open (a
1449
+ // different runtime, or sqlite-vec installed afterwards) trusting an
1450
+ // empty entries_vec and returning zero semantic hits against a fully
1451
+ // populated BLOB table.
1452
+ setVecFastPathReady(db, vecFailedCount === 0 && vecUnavailableCount === 0);
1428
1453
  if (vecFailedCount > 0) {
1429
1454
  warn(`[embed] ${vecFailedCount} sqlite-vec fast-path insert${vecFailedCount === 1 ? "" : "s"} failed — ` +
1430
1455
  "semantic search will use the slower JS-cosine fallback over stored embeddings. " +
@@ -86,6 +86,21 @@ export function shouldQueryPositiveFeedbackCounts(utilityDecayRaw) {
86
86
  return boost > 1.0;
87
87
  }
88
88
  // ── Main search entrypoint ───────────────────────────────────────────────────
89
+ /**
90
+ * Whether an embedding provider is actually configured.
91
+ *
92
+ * A remote provider needs BOTH endpoint and model. A LOCAL provider needs
93
+ * neither — `embedding.localModel` selects a transformers model that runs in
94
+ * process. Checking only the remote pair told every local-provider user that
95
+ * "no embedding provider is configured" and pointed them at
96
+ * `akm config set embedding '{"endpoint":...}'`, which is the wrong remedy and
97
+ * hid the real diagnostic recorded in the semantic status.
98
+ */
99
+ function hasConfiguredEmbeddingProvider(config) {
100
+ if (config.embedding?.localModel)
101
+ return true;
102
+ return Boolean(config.embedding?.endpoint && config.embedding?.model);
103
+ }
89
104
  export async function searchLocal(input) {
90
105
  const { query, searchType, limit, stashDir, sources, config } = input;
91
106
  const filters = input.filters;
@@ -105,7 +120,7 @@ export async function searchLocal(input) {
105
120
  if (rawStatus && rawStatus.providerFingerprint !== currentFingerprint) {
106
121
  warnings.push("Embedding config changed. Run 'akm index --full' to rebuild the semantic index with the new provider.");
107
122
  }
108
- else if (!config.embedding?.endpoint || !config.embedding?.model) {
123
+ else if (!hasConfiguredEmbeddingProvider(config)) {
109
124
  // #480: when semantic mode is `auto` but no embedding provider is
110
125
  // configured (e.g. `akm setup --yes` ran without picking one), telling
111
126
  // the user to "run akm setup" is misleading — they just did. Surface
@@ -120,7 +135,7 @@ export async function searchLocal(input) {
120
135
  }
121
136
  }
122
137
  if (config.semanticSearchMode === "auto" && semanticStatus === "blocked") {
123
- if (!config.embedding?.endpoint || !config.embedding?.model) {
138
+ if (!hasConfiguredEmbeddingProvider(config)) {
124
139
  // F7/A2: same predicate as the `pending` branch above (#480) — a
125
140
  // `blocked` status can outlive the provider config that produced it
126
141
  // (e.g. the embedding config was later unset). This is not a fault;
@@ -123,7 +123,12 @@ function walkStashGit(stashRoot, options) {
123
123
  for (const relFile of files) {
124
124
  const absPath = path.join(stashRoot, relFile);
125
125
  try {
126
- if (fs.statSync(absPath).isFile()) {
126
+ // lstat, not stat: a tracked symlink must not be dereferenced. statSync
127
+ // follows the link, so a target outside stashRoot would be read and
128
+ // indexed. The manual walk below already skips symlinks for exactly this
129
+ // reason; the two walkers have to agree regardless of whether the stash
130
+ // happens to sit inside a git repo.
131
+ if (fs.lstatSync(absPath).isFile()) {
127
132
  results.push(buildFileContext(stashRoot, absPath));
128
133
  }
129
134
  }
@@ -74,6 +74,16 @@ const DEFAULT_PATHEXT = ".COM;.EXE;.BAT;.CMD";
74
74
  * on win32 or when the env supplies PATHEXT (which is also the seam tests use
75
75
  * to cover Windows resolution from a POSIX runner).
76
76
  */
77
+ /**
78
+ * Script shims that `child_process.spawn` cannot execute directly on Windows.
79
+ *
80
+ * Node refuses to spawn `.bat`/`.cmd` without a shell (the CVE-2024-27980
81
+ * hardening), so a detection that returns one of these reports an agent as
82
+ * available that dispatch then fails to launch. They stay in the candidate
83
+ * list — a shim is better than reporting the CLI as missing — but they sort
84
+ * last, so a real `.exe` next to `claude.cmd` wins.
85
+ */
86
+ const NON_SPAWNABLE_SUFFIXES = new Set([".cmd", ".bat"]);
77
87
  function executableSuffixes(envSource) {
78
88
  const pathext = envSource.PATHEXT ?? envSource.Pathext ?? envSource.pathext;
79
89
  if (process.platform !== "win32" && !pathext)
@@ -83,7 +93,9 @@ function executableSuffixes(envSource) {
83
93
  .map((ext) => ext.trim())
84
94
  .filter(Boolean)
85
95
  .map((ext) => (ext.startsWith(".") ? ext : `.${ext}`));
86
- return ["", ...exts];
96
+ const directlySpawnable = exts.filter((ext) => !NON_SPAWNABLE_SUFFIXES.has(ext.toLowerCase()));
97
+ const shims = exts.filter((ext) => NON_SPAWNABLE_SUFFIXES.has(ext.toLowerCase()));
98
+ return ["", ...directlySpawnable, ...shims];
87
99
  }
88
100
  let detectOverrides;
89
101
  /** TEST-ONLY. Swap the detection implementations; pass undefined to restore. */
@@ -118,6 +118,12 @@ let _testServer = null;
118
118
  // Test seam replacing the real `createOpencode` import (see __setServerFactory).
119
119
  let _serverFactory = null;
120
120
  let _exitHookInstalled = false;
121
+ /**
122
+ * True once `process.on("exit")` has fired. Close paths consult it because an
123
+ * exit handler runs after the event loop has stopped: no timer scheduled there
124
+ * will ever fire, so any cleanup that needs to happen must happen inline.
125
+ */
126
+ let _processExiting = false;
121
127
  /**
122
128
  * Test-only seam: inject a fake {@link SdkServer} so `runOpencodeSdk` can be
123
129
  * exercised without the real `@opencode-ai/sdk` (which would spin up a server).
@@ -424,6 +430,20 @@ async function createManagedOpencode(options) {
424
430
  }
425
431
  if (childExited())
426
432
  return;
433
+ if (_processExiting) {
434
+ // During `process.on("exit")` the event loop is finished, so a timer
435
+ // scheduled here would never fire and the SIGKILL escalation could not
436
+ // happen at all — a server child that ignores SIGTERM outlived akm.
437
+ // Exit handlers must be synchronous, so escalate immediately instead of
438
+ // waiting out a grace period we have no way to wait for.
439
+ try {
440
+ proc.kill("SIGKILL");
441
+ }
442
+ catch {
443
+ /* already dead */
444
+ }
445
+ return;
446
+ }
427
447
  closeEscalation = setTimeout(() => {
428
448
  closeEscalation = undefined;
429
449
  if (childExited())
@@ -525,6 +545,7 @@ async function startServer(profile, sdkConfig, env, registryKey, startupSignal)
525
545
  if (!_exitHookInstalled) {
526
546
  _exitHookInstalled = true;
527
547
  process.once("exit", () => {
548
+ _processExiting = true;
528
549
  void closeServer();
529
550
  });
530
551
  }
@@ -126,6 +126,16 @@ function readLockfileOrThrow() {
126
126
  if (!Array.isArray(parsed)) {
127
127
  throw new ConfigError(`Refusing to modify lockfile ${lockfilePath}: existing content is not a JSON array. Fix or remove the file by hand before retrying — every existing lock entry would otherwise be lost.`, "INVALID_CONFIG_FILE");
128
128
  }
129
+ // Refuse rather than filter. This is the WRITE path's read: everything it
130
+ // returns is what gets written back, so silently dropping entries that fail
131
+ // per-entry validation destroyed them on the next write — the same
132
+ // data-losing overwrite the two refusals above exist to prevent, just at
133
+ // entry granularity instead of file granularity.
134
+ const invalid = parsed.filter((entry) => !isValidLockfileEntry(entry));
135
+ if (invalid.length > 0) {
136
+ throw new ConfigError(`Refusing to modify lockfile ${lockfilePath}: ${invalid.length} existing entr${invalid.length === 1 ? "y is" : "ies are"} malformed. ` +
137
+ "Fix or remove the file by hand before retrying — those entries would otherwise be lost.", "INVALID_CONFIG_FILE");
138
+ }
129
139
  return parsed.filter(isValidLockfileEntry);
130
140
  }
131
141
  /**
@@ -9,34 +9,22 @@
9
9
  */
10
10
  import { fetchWithTimeout, readBodyWithByteCap } from "../core/common.js";
11
11
  import { resolveSecret } from "../core/config/config.js";
12
+ import { ENV_REFERENCE_PATTERN } from "../core/config/schema/primitives.js";
12
13
  import { formatExtraParamsIssue, validateExtraParams } from "../core/extra-params.js";
13
14
  import { parseJsonResponse } from "../core/parse.js";
14
- import { redactCredentialPatterns, redactSensitiveText } from "../core/redaction.js";
15
+ import { redactErrorBody, redactSensitiveText } from "../core/redaction.js";
15
16
  import { warnVerbose } from "../core/warn.js";
16
17
  import { DEFAULT_LLM_TIMEOUT_MS } from "../integrations/agent/config.js";
17
18
  import { emitLlmUsage, extractUsageTokens, } from "./usage-telemetry.js";
18
- /** Maximum length of an LLM error response body included in thrown errors. */
19
+ /** Maximum length of an upstream response excerpt included in thrown errors. */
19
20
  const ERROR_BODY_MAX_LEN = 200;
20
21
  /** Stable OpenAI-compatible response-schema name used for every structured call. */
21
22
  const JSON_SCHEMA_RESPONSE_NAME = "akm_response";
22
23
  /**
23
- * Redact credential-shaped substrings from an upstream error body before
24
- * including it in a thrown Error. The body is also trimmed to a fixed length
25
- * so that a verbose provider response cannot leak large amounts of context.
26
- *
27
- * The pattern set itself lives in {@link redactCredentialPatterns}
28
- * (src/core/redaction.ts) so other output paths (e.g. task run logs) can
29
- * reuse it without this function's length cap.
24
+ * Re-exported from src/core/redaction.ts, where it now lives so every HTTP
25
+ * transport can apply the same hardening the embeddings client needs it too.
30
26
  */
31
- export function redactErrorBody(input) {
32
- if (!input)
33
- return "";
34
- let out = redactCredentialPatterns(input);
35
- if (out.length > ERROR_BODY_MAX_LEN) {
36
- out = `${out.slice(0, ERROR_BODY_MAX_LEN)}…`;
37
- }
38
- return out;
39
- }
27
+ export { redactErrorBody } from "../core/redaction.js";
40
28
  /**
41
29
  * Detect a response body that is an HTML document rather than the expected
42
30
  * JSON. LM Studio (and similar local providers) can serve their web UI on
@@ -223,7 +211,14 @@ async function chatCompletionAttempt(config, messages, options, timeoutMs) {
223
211
  throw new Error(formatExtraParamsIssue("LLM extraParams", issue));
224
212
  }
225
213
  const headers = { "Content-Type": "application/json" };
226
- const resolvedKey = resolveSecret(config.apiKey);
214
+ // Resolve ONLY a whole-string env reference. Every live caller already hands
215
+ // us the materialized credential (materializeLlmConnection / materializeFrozenLlm
216
+ // resolve `$VAR` upstream, and engine config REQUIRES the symbolic form), so
217
+ // re-running the substitution over a literal key mangled any credential
218
+ // containing `$` — `sk-live$ecret` lost everything from the `$` onward, and
219
+ // the request failed with an opaque 401. The narrow check keeps the symbolic
220
+ // form working for any direct caller that still passes one.
221
+ const resolvedKey = ENV_REFERENCE_PATTERN.test(config.apiKey ?? "") ? resolveSecret(config.apiKey) : config.apiKey;
227
222
  if (resolvedKey) {
228
223
  headers.Authorization = `Bearer ${resolvedKey}`;
229
224
  }
@@ -65,12 +65,32 @@ export async function embed(text, embeddingConfig, signal) {
65
65
  const cached = getCachedEmbedding(key);
66
66
  if (cached)
67
67
  return cached;
68
- const result = embeddingConfig && hasRemoteEndpoint(embeddingConfig)
69
- ? await new RemoteEmbedder(embeddingConfig).embed(text, signal)
70
- : await getLocalEmbedder().embed(text, signal);
68
+ const result = await embedOnce(text, embeddingConfig, signal);
71
69
  setCachedEmbedding(key, result);
72
70
  return result;
73
71
  }
72
+ /**
73
+ * Resolve a single embedding through the configured provider.
74
+ *
75
+ * The local branch must honour `localModel` exactly as {@link embedBatch}
76
+ * does. The singleton is constructed with no default model, so routing through
77
+ * `getLocalEmbedder().embed()` silently used DEFAULT_LOCAL_MODEL: queries were
78
+ * embedded with a different model than the index was built with. Nothing
79
+ * detected it, because the provider fingerprint keys on `localModel`, so no
80
+ * purge or "pending" status ever fired — a dimension mismatch made semantic
81
+ * ranking contribute nothing, and a same-dimension override silently produced
82
+ * meaningless cross-model scores.
83
+ */
84
+ async function embedOnce(text, embeddingConfig, signal) {
85
+ if (embeddingConfig && hasRemoteEndpoint(embeddingConfig)) {
86
+ return new RemoteEmbedder(embeddingConfig).embed(text, signal);
87
+ }
88
+ const localModel = embeddingConfig?.localModel;
89
+ if (localModel) {
90
+ return getLocalEmbedder().embedWithModel(text, localModel);
91
+ }
92
+ return getLocalEmbedder().embed(text, signal);
93
+ }
74
94
  /**
75
95
  * Generate embeddings for multiple texts in batch.
76
96
  * Uses the OpenAI-compatible batch API for remote endpoints (batches of 100).
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { fetchWithTimeout, isHttpUrl, readBodyWithByteCap } from "../../core/common.js";
11
11
  import { resolveSecret } from "../../core/config/config.js";
12
+ import { redactErrorBody, redactSensitiveText } from "../../core/redaction.js";
12
13
  const DEFAULT_REMOTE_BATCH_SIZE = 100;
13
14
  /** Cheap token estimator: 4 chars ≈ 1 token. Used in verbose logging and error messages. */
14
15
  export function estimateTokenCount(text) {
@@ -54,7 +55,7 @@ export class RemoteEmbedder {
54
55
  throw err;
55
56
  return "";
56
57
  });
57
- throw new Error(`Embedding request failed (${response.status}): ${errBody}`);
58
+ throw new Error(`Embedding request failed (${response.status}): ${this.safeErrorBody(errBody)}`);
58
59
  }
59
60
  const json = JSON.parse(await readBodyWithByteCap(response, undefined, { bodyTimeoutMs: 30_000, signal }));
60
61
  if (!json.data?.[0]?.embedding) {
@@ -94,7 +95,7 @@ export class RemoteEmbedder {
94
95
  throw err;
95
96
  return "";
96
97
  });
97
- throw new Error(`Embedding batch request failed (${response.status}): ${respBody}`);
98
+ throw new Error(`Embedding batch request failed (${response.status}): ${this.safeErrorBody(respBody)}`);
98
99
  }
99
100
  const json = JSON.parse(await readBodyWithByteCap(response, undefined, { bodyTimeoutMs: 30_000, signal }));
100
101
  if (!json.data || json.data.length !== batch.length) {
@@ -119,6 +120,21 @@ export class RemoteEmbedder {
119
120
  }
120
121
  return headers;
121
122
  }
123
+ /**
124
+ * Make a provider error body safe to embed in a thrown Error, matching the
125
+ * hardening llm/client.ts applies on the identical path: pattern-redact
126
+ * credential shapes, exact-scrub this connection's own key, and clip.
127
+ *
128
+ * These messages are durable — generateEmbeddingsForDb surfaces them as
129
+ * `embeddingResult.message`, which is written to semantic-status.json and
130
+ * replayed by `akm info` (including `--json`) until the next successful
131
+ * index, and printed on every vector-search attempt. Raw bodies reached that
132
+ * far unredacted and uncapped, at readBodyWithByteCap's 10 MB default.
133
+ */
134
+ safeErrorBody(body) {
135
+ const resolvedKey = resolveSecret(this.config.apiKey);
136
+ return redactSensitiveText(redactErrorBody(body), resolvedKey ? [resolvedKey] : []);
137
+ }
122
138
  }
123
139
  /**
124
140
  * L2-normalize a vector to unit length.
@@ -144,6 +160,15 @@ export function normalizeEmbeddingEndpoint(endpoint) {
144
160
  if (normalizedPath.endsWith("/embeddings")) {
145
161
  return parsed.toString();
146
162
  }
163
+ // Ollama's NATIVE embedding route is `/api/embed` (and the older
164
+ // `/api/embeddings`). Appending "/embeddings" to it produced
165
+ // `/api/embed/embeddings`, a 404 — so pointing akm at the native endpoint,
166
+ // which is what its own options like ollamaOptions and contextLength are
167
+ // for, could never work. An explicit path that is already an embedding route
168
+ // is left alone.
169
+ if (normalizedPath.endsWith("/embed")) {
170
+ return parsed.toString();
171
+ }
147
172
  parsed.pathname = normalizedPath ? `${normalizedPath}/embeddings` : "/embeddings";
148
173
  return parsed.toString();
149
174
  }
@@ -14,8 +14,28 @@
14
14
  */
15
15
  import fs from "node:fs";
16
16
  import path from "node:path";
17
+ import healthTemplate from "../assets/templates/html/health.html" with { type: "text" };
17
18
  import { getDirname } from "../runtime.js";
18
19
  const TEMPLATES_DIR = path.join(getDirname(import.meta.url), "../assets/templates/html");
20
+ /**
21
+ * Templates embedded at build time, keyed by command name.
22
+ *
23
+ * `bun build --compile` embeds only what is imported `with { type: "text" }`;
24
+ * a plain `readFileSync` from a path relative to `import.meta.url` resolves
25
+ * into the virtual `/$bunfs` tree and misses. `akm health --report --format
26
+ * html` therefore crashed with ENOENT (exit 70) on the standalone binary that
27
+ * the CLI's own install error and the CHANGELOG promote as the runtime-free
28
+ * option. The text import works on all three runtimes: natively on Bun, via
29
+ * scripts/node-runtime/text-import-hook.mjs on Node, and embedded in the
30
+ * compiled binary.
31
+ */
32
+ const EMBEDDED_TEMPLATES = {
33
+ // bun-types declares `*.html` as an `HTMLBundle` (its HTML-bundler entrypoint
34
+ // feature), which is not what a `type: "text"` import yields — the value is
35
+ // the file's contents as a string on every runtime. The cast reconciles the
36
+ // ambient declaration with the actual import attribute.
37
+ health: healthTemplate,
38
+ };
19
39
  /**
20
40
  * Resolve the on-disk template path for a command's bespoke `<command>.html`.
21
41
  * The command name is sanitized to a bare basename so a hostile command
@@ -37,9 +57,28 @@ const TOKEN_RE = /%%[A-Z_]+%%/g;
37
57
  * matching the skill renderer's behaviour.
38
58
  */
39
59
  export function renderHtml(templatePath, replacements) {
40
- const html = fs.readFileSync(templatePath, "utf8");
60
+ const html = readTemplate(templatePath);
41
61
  return html.replace(TOKEN_RE, (token) => replacements[token] ?? token);
42
62
  }
63
+ /**
64
+ * Read a template from disk, falling back to the embedded copy.
65
+ *
66
+ * Disk stays primary so an operator (or a test) editing
67
+ * `src/assets/templates/html/<name>.html` sees the change without a rebuild.
68
+ * The fallback covers the standalone binary, where the file does not exist on
69
+ * any real filesystem.
70
+ */
71
+ function readTemplate(templatePath) {
72
+ try {
73
+ return fs.readFileSync(templatePath, "utf8");
74
+ }
75
+ catch (err) {
76
+ const embedded = EMBEDDED_TEMPLATES[path.basename(templatePath, ".html")];
77
+ if (embedded !== undefined)
78
+ return embedded;
79
+ throw err;
80
+ }
81
+ }
43
82
  /**
44
83
  * Minimal HTML entity escaping for text interpolated into templates. Escapes
45
84
  * the single quote as well as the double quote so escaped values are safe in