akm-cli 0.9.15-beta.4 → 0.9.15-beta.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
- ## [0.9.15-beta.4] - 2026-09-10
7
+ ## [0.9.15-beta.5] - 2026-09-10
8
8
 
9
9
  ### Added
10
10
 
@@ -159,6 +159,32 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
159
159
  (#951).** These are akm's resolved data/config/cache/state directories, so a
160
160
  script can read `akm info --format json | jq -r .dataDir` instead of hardcoding
161
161
  a path that differs between a host install and a container.
162
+ - **A real `curate_rerank` implementation: `search.curateRerank`, a cross-
163
+ encoder rerank pass over `akm curate`'s already-selected candidates (#951).**
164
+ `curate_rerank` was a dead `llm.features.*` key removed in 0.8.0 with no wire
165
+ call ever implemented under it. `search.curateRerank.{enabled, endpoint,
166
+ model, apiKey, timeoutMs, topN}` configures a standalone `/rerank`-style
167
+ endpoint (`src/llm/rerank-client.ts`, `POST {model, query, documents}` →
168
+ `{results: [{index, relevance_score}]}`); when enabled, curate sends its top
169
+ `topN` (default 8) already-ranked candidates and reorders them by the
170
+ endpoint's scores. Disabled by default, and best-effort like every other
171
+ bounded LLM feature: a misconfigured endpoint, network failure, timeout, or
172
+ malformed response falls back to curate's own ranking unchanged rather than
173
+ failing the command. Deliberately its own config arm rather than a third
174
+ `engines` kind alongside `"llm"`/`"agent"` — that union's kinds are
175
+ load-bearing through execution-lowering, runner dispatch, and the harness
176
+ model map, none of which a reranker touches.
177
+ - **Per-run task log files are purged past the retention window (#951).**
178
+ `tasks/logs/<taskId>/<timestamp>.log` (the per-run human-readable tail;
179
+ `logs.db` is the durable, already-purged record) had file separation but no
180
+ cleanup, so old run files accumulated forever. `akm improve`'s existing
181
+ retention pass now also deletes `.log` files under `getTaskLogDir()` older
182
+ than the same `improve.eventRetentionDays` window (default 90d, `0` disables
183
+ it) it already uses for `task_logs`/events/`improve_runs` — bounded to that
184
+ one directory, one level of `<taskId>` subdirectories, `.log` files only.
185
+ Path-agnostic bundled scripts and the `akm show env/<name>`/`akm task list`
186
+ items from the same review were previously confirmed shipped and are
187
+ unchanged here.
162
188
  - **`akm index --reembed` forces a full re-embed (#955).** Bypasses the
163
189
  compatibility check above entirely and purges + regenerates every stored
164
190
  embedding, for the rare case where the check's verdict should not be trusted. A
@@ -477,6 +503,32 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
477
503
 
478
504
  ### Fixed
479
505
 
506
+ - **An alias-level `engine` binding in `models.json` is honoured on every
507
+ platform column (#946).** The per-platform nested form
508
+ (`"fast": {"opencode": {"engine": "local-fast"}}`) already worked, but the
509
+ flat shorthand from this issue's own acceptance criteria
510
+ (`"fast": {"engine": "local-fast"}`) did not: `parseModelMapLayer` treated
511
+ every key under an alias as a platform-column name, so `engine` was parsed
512
+ as a fourth platform holding the model string, the three real columns kept
513
+ their hardcoded cloud defaults, and `akm agent --model fast` silently
514
+ dispatched to a cloud model instead of the configured local engine. The
515
+ flat `model`/`inference`/`engine` keys are now a wildcard default merged
516
+ onto every column, a same-alias per-platform entry still overrides it, and
517
+ an alias naming an unknown engine fails with a `ConfigError` rather than
518
+ falling back.
519
+ - **Two `improve` runs colliding on the lock is transient, not a broken
520
+ config (#948).** The lock-held path threw a `ConfigError`, surfacing as
521
+ exit 78 (`INVALID_CONFIG_FILE`) and telling a supervisor to stop retrying
522
+ an ordinary collision. It is now a `TransientError`/`IMPROVE_LOCK_HELD` at
523
+ exit 75, matching the `MAINTENANCE_BARRIER_BUSY`/`INDEX_DB_CONTENDED`
524
+ treatment the index rebuild lock already had.
525
+ - **The embedding path no longer leaks a raw `database is locked` (#956).**
526
+ `reclassifyIndexDbContention` moved out of `indexer.ts` into a shared
527
+ module and `materialize-embeddings.ts`'s embedding-generation catch now
528
+ routes through it, so contention produces the same "index database is
529
+ busy" wording as every other path instead of the raw driver string.
530
+ Control flow is unchanged: this was always non-fatal.
531
+
480
532
  - **Starting a workflow ref that already has an active run in a different scope
481
533
  now warns instead of silently duplicating it (#942).** `akm workflow run
482
534
  <ref>`'s per-scope concurrency guard is unchanged by design — two unrelated
@@ -2,7 +2,7 @@
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
  import path from "node:path";
5
- import { ConfigError } from "../../core/errors.js";
5
+ import { TransientError } from "../../core/errors.js";
6
6
  import { appendEvent } from "../../core/events.js";
7
7
  import { releaseLock } from "../../core/file-lock.js";
8
8
  import { tryWithMaintenanceStartBarrier, withMaintenanceStartBarrier } from "../../core/maintenance-barrier.js";
@@ -65,7 +65,14 @@ function tryAcquireImproveLockUnlocked(lockPath, skipIfLocked, onRecovered) {
65
65
  warn(`[improve] another improve run holds the lock (PID ${pid}, started ${startedAt}); skipping (--skip-if-locked)`);
66
66
  return { state: "skipped" };
67
67
  }
68
- throw new ConfigError(`akm improve is already running (PID ${pid}, started ${startedAt}). Delete ${lockPath} to force.`, "INVALID_CONFIG_FILE");
68
+ // #948 field follow-up: two legitimate `improve` invocations colliding on
69
+ // this lock is ordinary, retryable contention — not a broken config file.
70
+ // A `ConfigError` here surfaced as exit 78 (INVALID_CONFIG_FILE) and told
71
+ // a supervisor to stop retrying a normal lock collision. Reclassified to
72
+ // `TransientError`/`IMPROVE_LOCK_HELD` (exit 75), mirroring the
73
+ // `MAINTENANCE_BARRIER_BUSY`/`INDEX_DB_CONTENDED` treatment #956 already
74
+ // applied to the index rebuild lock and the maintenance-start barrier.
75
+ throw new TransientError(`akm improve is already running (PID ${pid}, started ${startedAt}). Delete ${lockPath} to force.`, "IMPROVE_LOCK_HELD");
69
76
  }
70
77
  export function releaseImproveLock(ownership) {
71
78
  releaseLock(ownership);
@@ -9,7 +9,7 @@ import { DEFAULT_GRAPH_EXTRACTION_BATCH_SIZE, loadConfig } from "../../core/conf
9
9
  import { UsageError } from "../../core/errors.js";
10
10
  import { appendEvent } from "../../core/events.js";
11
11
  import { openLogsDatabase, purgeOldTaskLogs } from "../../core/logs-db.js";
12
- import { getDbPath } from "../../core/paths.js";
12
+ import { getDbPath, getTaskLogDir } from "../../core/paths.js";
13
13
  import { withStateDb } from "../../core/state-db.js";
14
14
  import { info } from "../../core/warn.js";
15
15
  import { DEFAULT_GRAPH_EXTRACTION_INCLUDE_TYPES, runGraphExtractionPass, } from "../../indexer/graph/graph-extraction.js";
@@ -25,6 +25,7 @@ import { closeDatabase, openIndexDatabase } from "../../storage/repositories/ind
25
25
  import { getEntryByRef } from "../../storage/repositories/index-entries-repository.js";
26
26
  import { clearAssetOutcomeMissing, countAssetOutcomeMissing, deleteAssetOutcomeMissingBefore, listAssetOutcomeMissingState, stampAssetOutcomeMissing, } from "../../storage/repositories/outcome-repository.js";
27
27
  import { clearAssetSalienceMissing, countAssetSalienceMissing, deleteAssetSalienceMissingBefore, listAssetSalienceMissingState, stampAssetSalienceMissing, } from "../../storage/repositories/salience-repository.js";
28
+ import { purgeOldTaskLogFiles } from "../../tasks/run/task-log.js";
28
29
  import { expireStaleProposals, listProposals, purgeOrphanProposals } from "../proposal/repository.js";
29
30
  import { checkDeadUrls } from "../url-checker.js";
30
31
  import { DEFAULT_RETENTION_DAYS as CYCLE_METRICS_RETENTION_DAYS, runCollapseDetector } from "./collapse-detector.js";
@@ -1124,6 +1125,25 @@ export function runRetentionPurgePass(ctx) {
1124
1125
  }
1125
1126
  }
1126
1127
  }
1128
+ // Per-run flat log files under getTaskLogDir() (#951): logs.db above is
1129
+ // the durable record and already retention-purged, so the transitional
1130
+ // `<taskId>/<timestamp>.log` tail files can be deleted on the same
1131
+ // window without losing anything. A separate try/catch — a filesystem
1132
+ // problem here must not block the DB purges above.
1133
+ try {
1134
+ const taskLogFilesPurged = purgeOldTaskLogFiles(undefined, retentionDays);
1135
+ if (taskLogFilesPurged > 0) {
1136
+ info(`[improve] task log files purge: ${taskLogFilesPurged} file(s) older than ${retentionDays}d removed from ${getTaskLogDir()}`);
1137
+ }
1138
+ appendEvent({
1139
+ eventType: "task_log_files_purged",
1140
+ ref: "task_log_files/_purge",
1141
+ metadata: { purgedCount: taskLogFilesPurged, retentionDays },
1142
+ }, eventsCtx);
1143
+ }
1144
+ catch (err) {
1145
+ warnings.push(`task log files purge failed: ${errMessage(err)}`);
1146
+ }
1127
1147
  }
1128
1148
  return { warnings };
1129
1149
  }
@@ -4,7 +4,7 @@
4
4
  import { isDeepStrictEqual } from "node:util";
5
5
  import { defineGroupCommand, defineJsonCommand, output } from "../cli/shared.js";
6
6
  import { loadConfig } from "../core/config/config.js";
7
- import { copyDefaultModelMap, loadModelMapLayers, mergedModelMapProfiles, mergeModelMapLayers, } from "../integrations/agent/model-map.js";
7
+ import { copyDefaultModelMap, loadModelMapLayers, mergedModelMapProfiles, mergeModelMapLayers, WILDCARD_ENGINE_KEY, } from "../integrations/agent/model-map.js";
8
8
  /**
9
9
  * Compute the effective alias table (#946): every (alias, column) pair from
10
10
  * the fully resolved map, labeled with where its value came from.
@@ -25,6 +25,11 @@ function modelsListRows() {
25
25
  const rows = [];
26
26
  for (const [alias, columns] of Object.entries(resolved.aliases)) {
27
27
  for (const [column, profile] of Object.entries(columns)) {
28
+ // The wildcard default (#946) is an alias-wide fallback, not a real
29
+ // platform column an operator would dispatch to; report it folded into
30
+ // the real per-platform rows above instead of as its own row.
31
+ if (column === WILDCARD_ENGINE_KEY)
32
+ continue;
28
33
  const raw = rawProfiles[alias]?.[column];
29
34
  const via = raw?.engine !== undefined ? "engine" : "literal";
30
35
  const defaultProfile = defaultsOnly.aliases[alias]?.[column];
@@ -25,6 +25,8 @@ import { copySearchHitAttribution, getSearchHitAttribution, usageEventAttributio
25
25
  import { findSourceForPath, resolveSourceEntries } from "../../indexer/search/search-source.js";
26
26
  import { insertUsageEvent } from "../../indexer/usage/usage-events.js";
27
27
  import { estimateTokenCount } from "../../llm/embedders/remote.js";
28
+ import { tryLlmFeature } from "../../llm/feature-gate.js";
29
+ import { rerankDocuments } from "../../llm/rerank-client.js";
28
30
  import { truncateDescription } from "../../output/shapes/helpers.js";
29
31
  import { TELEMETRY_BUSY_TIMEOUT_MS, withIndexDb } from "../../storage/repositories/index-db.js";
30
32
  import { findEntryIdByRef, getItemRefById } from "../../storage/repositories/index-entries-repository.js";
@@ -145,7 +147,7 @@ export async function curateSearchResults(query, result, limit, selectedType, ev
145
147
  // fixtures) with a `SearchResponse` that was never type-filtered.
146
148
  const stashHits = selectedType && selectedType !== "any" ? allStashHits.filter((hit) => hit.type === selectedType) : allStashHits;
147
149
  const selected = selectCuratedStashHits(query, stashHits, limit);
148
- const selectedStashHits = selected.selected;
150
+ const selectedStashHits = await maybeRerankCuratedStashHits(query, selected.selected);
149
151
  const supportRefsByRef = selected.supportRefsByRef;
150
152
  // F4/R-019: respect `--limit` for registry fill instead of hard-capping it
151
153
  // at a bare literal 2 — the remaining slots after stash hits ARE the cap.
@@ -594,6 +596,37 @@ function appendCurateSupportRef(supportRefsByRef, ownerRef, supportRef) {
594
596
  return;
595
597
  supportRefsByRef.set(ownerRef, [...existing, supportRef]);
596
598
  }
599
+ /** Default number of `selectCuratedStashHits` candidates sent to the reranker when `search.curateRerank.topN` isn't set. */
600
+ const DEFAULT_CURATE_RERANK_TOP_N = 8;
601
+ /**
602
+ * Optional cross-encoder rerank pass over curate's already-selected, already-
603
+ * ranked candidates (#951). Disabled by default (`search.curateRerank.enabled`
604
+ * is falsy) and, when enabled, best-effort: any failure (misconfigured
605
+ * endpoint, network error, timeout, malformed response) falls back to
606
+ * `selectCuratedStashHits`'s own ranking unchanged — a reranker outage must
607
+ * never turn into a curate failure.
608
+ *
609
+ * Only the top `topN` (default {@link DEFAULT_CURATE_RERANK_TOP_N}) already-
610
+ * selected hits are sent (bounded request size); anything past that keeps its
611
+ * original position appended after the reranked prefix.
612
+ */
613
+ async function maybeRerankCuratedStashHits(query, hits) {
614
+ if (hits.length <= 1)
615
+ return hits;
616
+ const config = loadConfig();
617
+ const rerankConfig = config.search?.curateRerank;
618
+ return tryLlmFeature("curate_rerank", config, async () => {
619
+ const topN = rerankConfig?.topN ?? DEFAULT_CURATE_RERANK_TOP_N;
620
+ const head = hits.slice(0, topN);
621
+ const tail = hits.slice(topN);
622
+ const documents = head.map((hit) => [hit.name, hit.description].filter(Boolean).join(" — "));
623
+ const ranked = await rerankDocuments(rerankConfig ?? {}, query, documents);
624
+ const rerankedHead = ranked
625
+ .map(({ index }) => head[index])
626
+ .filter((hit) => hit !== undefined);
627
+ return [...rerankedHead, ...tail];
628
+ }, hits, { timeoutMs: rerankConfig?.timeoutMs ?? null });
629
+ }
597
630
  function selectCuratedStashHits(query, hits, limit) {
598
631
  const intent = parseCurateIntent(query);
599
632
  const collapsed = collapseCurateFamilies(query, hits);
@@ -6,7 +6,7 @@
6
6
  * former `config-schema.ts` monolith — no behavior change.
7
7
  */
8
8
  import { z } from "zod";
9
- import { nonEmptyString, nonNegativeNumber, positiveInt } from "./primitives.js";
9
+ import { httpUrl, nonEmptyString, nonNegativeNumber, positiveInt, symbolicOrWarnApiKey } from "./primitives.js";
10
10
  // ── Search ──────────────────────────────────────────────────────────────────
11
11
  const SearchGraphBoostSchema = z
12
12
  .object({
@@ -22,10 +22,41 @@ const SearchGraphBoostSchema = z
22
22
  confidenceWeight: z.number().finite().min(0).max(1).default(0.2).optional(),
23
23
  })
24
24
  .passthrough();
25
+ /**
26
+ * `search.curateRerank` (#951) — an optional cross-encoder rerank pass over
27
+ * `akm curate`'s already-selected candidates.
28
+ *
29
+ * Deliberately its own small config arm rather than a third member of the
30
+ * `engines` map (`EngineConfigSchema` in ./engines.ts): that union's "llm" /
31
+ * "agent" kinds are load-bearing all the way through execution-lowering,
32
+ * runner dispatch, and the harness model map (100+ call sites narrow on
33
+ * `engine.kind`). A reranker is neither — it never dispatches an agent or
34
+ * lowers to a chat-completions call — so folding it into that union would
35
+ * force every one of those call sites to account for a kind they can't do
36
+ * anything with. `endpoint` + `model` (+ optional `apiKey`) is the same
37
+ * connection shape as an LLM engine without inheriting that machinery.
38
+ *
39
+ * `curate_rerank` was removed as a dead `llm.features.*` key in 0.8.0 (no
40
+ * implementation ever sent a request); this is a new, real implementation,
41
+ * disabled by default.
42
+ */
43
+ export const CurateRerankConfigSchema = z
44
+ .object({
45
+ enabled: z.boolean().optional(),
46
+ /** Full URL of the reranker's rerank endpoint, e.g. `http://host:port/rerank`. */
47
+ endpoint: httpUrl.optional(),
48
+ model: nonEmptyString.optional(),
49
+ apiKey: symbolicOrWarnApiKey("search.curateRerank.apiKey").optional(),
50
+ timeoutMs: positiveInt.optional(),
51
+ /** How many of curate's already-ranked candidates to send to the reranker. Default 8. */
52
+ topN: positiveInt.max(50).optional(),
53
+ })
54
+ .passthrough();
25
55
  export const SearchConfigSchema = z
26
56
  .object({
27
57
  minScore: nonNegativeNumber.optional(),
28
58
  defaultExcludeTypes: z.array(nonEmptyString).optional(),
29
59
  graphBoost: SearchGraphBoostSchema.optional(),
60
+ curateRerank: CurateRerankConfigSchema.optional(),
30
61
  })
31
62
  .passthrough();
@@ -84,6 +84,7 @@ const TRANSIENT_HINTS = {
84
84
  STATE_DB_CONTENDED: "Another akm process is writing state.db right now. Wait a few seconds and retry; commands that support --skip-if-locked can skip instead of failing.",
85
85
  INDEX_DB_CONTENDED: "Another akm process is writing index.db; retry shortly, or pass --skip-if-locked on scheduled runs.",
86
86
  MAINTENANCE_BARRIER_BUSY: "Another akm process is registering a lock or lease right now. Retry shortly, or pass --skip-if-locked on scheduled index/improve/workflow runs.",
87
+ IMPROVE_LOCK_HELD: "Another akm improve run holds the whole-run lock right now. Wait for it to finish and retry, or pass --skip-if-locked on scheduled runs.",
87
88
  };
88
89
  /** Default hint for each NotFoundError code. */
89
90
  const NOT_FOUND_HINTS = {
@@ -0,0 +1,56 @@
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
+ * Shared index.db contention reclassification (field follow-up to #956).
6
+ *
7
+ * Extracted out of `indexer.ts` so both `akmIndex`'s outer catch AND
8
+ * `generateEmbeddingsForDb`'s own catch (`materialize-embeddings.ts`) can
9
+ * reuse the ONE classifier instead of each building a raw
10
+ * `Semantic search verification failed: <driver message>` string. Living in
11
+ * its own module (rather than one importing the other) avoids the import
12
+ * cycle `indexer.ts` <-> `materialize-embeddings.ts` would otherwise form.
13
+ */
14
+ import { AkmError, TransientError } from "../core/errors.js";
15
+ import { probeLock } from "../core/file-lock.js";
16
+ import { formatLockHolderPid } from "../core/run-lock.js";
17
+ import { isSqliteContentionError } from "../core/state-db.js";
18
+ import { indexRebuildLockPath } from "./index-rebuild-lock.js";
19
+ /**
20
+ * Read-only description of the rebuild lock's current holder, appended to a
21
+ * reclassified index.db contention message when known (field follow-up to
22
+ * #956). `probeLock` only inspects the sentinel — it never acquires or
23
+ * mutates it — so this is safe to call from inside an error path.
24
+ */
25
+ function describeIndexRebuildLockHolder() {
26
+ const probe = probeLock(indexRebuildLockPath());
27
+ if (probe.state !== "held")
28
+ return "";
29
+ return ` The rebuild lock is currently held by pid ${formatLockHolderPid({
30
+ pid: probe.holderPid,
31
+ launcherPid: probe.launcherPid ?? null,
32
+ })}.`;
33
+ }
34
+ /**
35
+ * Reclassify a contention-shaped error escaping the walk, index, or
36
+ * embedding phase into a retryable-shortly `TransientError` (field
37
+ * follow-up to #956, dev-team field review 2026-09-10): a concurrent writer
38
+ * (another `akm index`, a source-update embedding pass, the per-command
39
+ * background reindex) can make index.db busy, and the raw SQLite driver
40
+ * error ("database is locked") used to escape as exit 70
41
+ * (internal/unclassified) instead of the "retry shortly" contract exit 75
42
+ * gives a scheduler to branch on — mirroring `STATE_DB_CONTENDED`'s
43
+ * precedent for state.db (`core/state-db.ts`). Reuses the ONE shared
44
+ * classifier, `isSqliteContentionError`, rather than a second one. An error
45
+ * that is already a classified akm error (e.g. a `STATE_DB_CONTENDED`
46
+ * TransientError from an inner state.db write) is never re-wrapped — only a
47
+ * raw, unclassified error matching the shared contention shape is
48
+ * reclassified. Every other error is rethrown unchanged.
49
+ */
50
+ export function reclassifyIndexDbContention(error) {
51
+ if (error instanceof AkmError || !isSqliteContentionError(error))
52
+ return error;
53
+ const contended = new TransientError(`akm's index database is busy (another akm process is writing it); retry shortly.${describeIndexRebuildLockHolder()}`, "INDEX_DB_CONTENDED");
54
+ contended.cause = error;
55
+ return contended;
56
+ }
@@ -7,14 +7,12 @@ import { detectAdapterId } from "../core/adapter/detect-adapter.js";
7
7
  import { adapterForId } from "../core/adapter/registry.js";
8
8
  import { isHttpUrl, toErrorMessage } from "../core/common.js";
9
9
  import { concurrentMap } from "../core/concurrent.js";
10
- import { AkmError, ConfigError, TransientError } from "../core/errors.js";
11
- import { probeLock } from "../core/file-lock.js";
10
+ import { ConfigError } from "../core/errors.js";
12
11
  import { defaultConcurrencyForEndpoint } from "../core/loopback.js";
13
12
  import { classifyPathAccess, describeInaccessiblePath } from "../core/path-access.js";
14
13
  import { getDbPath } from "../core/paths.js";
15
14
  import { SCRIPT_EXTENSIONS } from "../core/recognition-util.js";
16
- import { formatLockHolderPid } from "../core/run-lock.js";
17
- import { isSqliteContentionError, withStateDb } from "../core/state-db.js";
15
+ import { withStateDb } from "../core/state-db.js";
18
16
  import { isVerbose, warn, warnOnce, warnVerbose } from "../core/warn.js";
19
17
  import { disposeLoweredExecutionDispatchLease, } from "../integrations/agent/execution-lowering.js";
20
18
  import { isLlmFeatureEnabled } from "../llm/feature-gate.js";
@@ -30,7 +28,7 @@ import { upsertUtilityScore } from "../storage/repositories/index-utility-reposi
30
28
  import { getEmbeddingCount, isVecAvailable, isVecFastPathReady, warnIfVecMissing, } from "../storage/repositories/index-vec-repository.js";
31
29
  import { assertIndexedWorkflowSourceIdentity, WorkflowSourceIdentityError } from "../workflows/source-files.js";
32
30
  import { deleteStoredGraph } from "./db/graph-db.js";
33
- import { indexRebuildLockPath } from "./index-rebuild-lock.js";
31
+ import { reclassifyIndexDbContention } from "./index-db-contention.js";
34
32
  import { deriveEntryProvenance, deriveInstallations } from "./installations.js";
35
33
  import { indexedPathMatchesOwner, resolveAdapterConceptOwner, } from "./lookup/adapter-concept-owner.js";
36
34
  import { generateEmbeddingsForDb } from "./materialize-embeddings.js";
@@ -374,44 +372,13 @@ let akmIndexOverride;
374
372
  export function _setAkmIndexForTests(fake) {
375
373
  akmIndexOverride = fake;
376
374
  }
377
- /**
378
- * Read-only description of the rebuild lock's current holder, appended to a
379
- * reclassified index.db contention message when known (field follow-up to
380
- * #956). `probeLock` only inspects the sentinel it never acquires or
381
- * mutates it so this is safe to call from inside an error path.
382
- */
383
- function describeIndexRebuildLockHolder() {
384
- const probe = probeLock(indexRebuildLockPath());
385
- if (probe.state !== "held")
386
- return "";
387
- return ` The rebuild lock is currently held by pid ${formatLockHolderPid({
388
- pid: probe.holderPid,
389
- launcherPid: probe.launcherPid ?? null,
390
- })}.`;
391
- }
392
- /**
393
- * Reclassify a contention-shaped error escaping the walk, index, or
394
- * embedding phase into a retryable-shortly `TransientError` (field
395
- * follow-up to #956, dev-team field review 2026-09-10): a concurrent writer
396
- * (another `akm index`, a source-update embedding pass, the per-command
397
- * background reindex) can make index.db busy, and the raw SQLite driver
398
- * error ("database is locked") used to escape as exit 70
399
- * (internal/unclassified) instead of the "retry shortly" contract exit 75
400
- * gives a scheduler to branch on — mirroring `STATE_DB_CONTENDED`'s
401
- * precedent for state.db (`core/state-db.ts`). Reuses the ONE shared
402
- * classifier, `isSqliteContentionError`, rather than a second one. An error
403
- * that is already a classified akm error (e.g. a `STATE_DB_CONTENDED`
404
- * TransientError from an inner state.db write) is never re-wrapped — only a
405
- * raw, unclassified error matching the shared contention shape is
406
- * reclassified. Every other error is rethrown unchanged.
407
- */
408
- export function reclassifyIndexDbContention(error) {
409
- if (error instanceof AkmError || !isSqliteContentionError(error))
410
- return error;
411
- const contended = new TransientError(`akm's index database is busy (another akm process is writing it); retry shortly.${describeIndexRebuildLockHolder()}`, "INDEX_DB_CONTENDED");
412
- contended.cause = error;
413
- return contended;
414
- }
375
+ // Moved to its own module (field follow-up to #956) so
376
+ // `generateEmbeddingsForDb` (materialize-embeddings.ts) can reuse the same
377
+ // classifier without an indexer.ts <-> materialize-embeddings.ts import
378
+ // cycle. Re-exported here for back-compat with existing call sites/tests
379
+ // that import it from `./indexer`. See index-db-contention.ts for the full
380
+ // rationale.
381
+ export { reclassifyIndexDbContention };
415
382
  export async function akmIndex(options) {
416
383
  try {
417
384
  const override = akmIndexOverride;
@@ -12,6 +12,7 @@ import { purgeEmbeddingSalvage, relabelEmbeddingSalvageFingerprint, reuseSalvage
12
12
  import { getEmbeddableEntryCount } from "../storage/repositories/index-entries-repository.js";
13
13
  import { deleteMeta, getMeta, setMeta } from "../storage/repositories/index-meta-repository.js";
14
14
  import { getAllEntriesForEmbedding, getEmbeddingCount, isVecFastPathComplete, isVecFastPathReady, purgeEmbeddings, sampleEmbeddedEntriesForCanary, setVecFastPathReady, upsertEmbedding, } from "../storage/repositories/index-vec-repository.js";
15
+ import { reclassifyIndexDbContention } from "./index-db-contention.js";
15
16
  /** Identifies the embedding provider+model+dimension a stored vector was generated with. */
16
17
  export function deriveSemanticProviderFingerprint(embedding) {
17
18
  if (isDeterministicEmbedEnabled()) {
@@ -732,7 +733,20 @@ export async function generateEmbeddingsForDb(db, config, onProgress, signal, en
732
733
  }
733
734
  }
734
735
  catch (error) {
735
- const message = error instanceof Error ? error.message : String(error);
736
+ // Field follow-up to #956 (dev-team field review 2026-09-10): a
737
+ // contention-shaped error (another akm process writing index.db right
738
+ // now) used to escape this catch as a raw driver string ("database is
739
+ // locked"), reaching this user-facing message unclassified even though
740
+ // the acquisition-time path (`akmIndex`'s outer catch) already
741
+ // reclassifies the same shape into `TransientError("INDEX_DB_CONTENDED")`.
742
+ // Reuses that ONE shared classifier rather than a second one — see
743
+ // `index-db-contention.ts`. This catch stays non-fatal (a caller sees
744
+ // `success: false` and a message, never a thrown error): the run
745
+ // continues through the remaining index phases exactly as it did
746
+ // before, only the message is now classified when the error is
747
+ // contention-shaped.
748
+ const reclassified = reclassifyIndexDbContention(error);
749
+ const message = reclassified instanceof Error ? reclassified.message : String(reclassified);
736
750
  warn("Embedding generation failed, continuing without:", message);
737
751
  onProgress({ phase: "embeddings", message: `Embedding generation failed: ${message}` });
738
752
  return {
@@ -26,6 +26,18 @@ let standaloneInstalledModelMapText;
26
26
  const ENGINE_KEY_PATTERN = new RegExp(ENGINE_NAME_PATTERN_SOURCE);
27
27
  const ALIAS_KEY_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
28
28
  const RESERVED_MAP_KEYS = new Set(["__proto__", "constructor", "prototype", "tostring"]);
29
+ /**
30
+ * A profile's own field names (#946). When one of these appears directly
31
+ * under an alias — e.g. `"fast": { "engine": "local-fast" }` — it is not a
32
+ * per-platform column named `model`/`inference`/`engine`; it is a wildcard
33
+ * default profile applied to every platform column of that alias, exactly
34
+ * the flat shorthand the original proposal asked for. A nested per-platform
35
+ * entry in the same alias (`"fast": { "claude": { ... } }`) still overrides
36
+ * the wildcard for that one column, so both forms compose.
37
+ */
38
+ const PROFILE_FIELD_KEYS = new Set(["model", "inference", "engine"]);
39
+ /** Internal key the wildcard default profile is stored under (never user-writable directly). */
40
+ export const WILDCARD_ENGINE_KEY = "*";
29
41
  function assertSafeMapKey(key, source, jsonPath) {
30
42
  if (RESERVED_MAP_KEYS.has(key.toLowerCase())) {
31
43
  invalid(source, jsonPath, "reserved prototype-like key is not allowed");
@@ -137,7 +149,18 @@ export function parseModelMapLayer(text, source) {
137
149
  }
138
150
  const engines = [];
139
151
  const enginesSeen = new Map();
140
- for (const [rawEngine, rawProfile] of Object.entries(engineRecord)) {
152
+ const wildcardFields = {};
153
+ const platformEntries = [];
154
+ for (const [rawKey, rawValue] of Object.entries(engineRecord)) {
155
+ if (PROFILE_FIELD_KEYS.has(rawKey))
156
+ wildcardFields[rawKey] = rawValue;
157
+ else
158
+ platformEntries.push([rawKey, rawValue]);
159
+ }
160
+ if (Object.keys(wildcardFields).length > 0) {
161
+ engines.push([WILDCARD_ENGINE_KEY, parseProfileLayer(wildcardFields, source, `$.aliases.${rawAlias}`)]);
162
+ }
163
+ for (const [rawEngine, rawProfile] of platformEntries) {
141
164
  const engine = rawEngine.toLowerCase();
142
165
  assertSafeMapKey(engine, source, `$.aliases.${rawAlias}.${rawEngine}`);
143
166
  if (!ENGINE_KEY_PATTERN.test(engine)) {
@@ -238,6 +261,18 @@ function mergeRawProfileLayers(installed, user) {
238
261
  const apply = (layer) => {
239
262
  for (const [alias, layerEngines] of Object.entries(layer.aliases)) {
240
263
  const mergedEngines = aliases.get(alias) ?? new Map();
264
+ const wildcard = ownValue(layerEngines, WILDCARD_ENGINE_KEY);
265
+ if (wildcard !== undefined) {
266
+ // This layer's wildcard default (#946) overlays every platform column
267
+ // already accumulated from earlier layers, before this layer's own
268
+ // explicit per-platform entries (applied below) get a chance to
269
+ // override it for their one column.
270
+ for (const [engineKey, profile] of mergedEngines) {
271
+ if (engineKey === WILDCARD_ENGINE_KEY)
272
+ continue;
273
+ mergedEngines.set(engineKey, mergeProfiles(profile, wildcard));
274
+ }
275
+ }
241
276
  for (const [engineKey, profile] of Object.entries(layerEngines)) {
242
277
  mergedEngines.set(engineKey, mergeProfiles(mergedEngines.get(engineKey), profile));
243
278
  }
@@ -316,7 +351,9 @@ export function resolveModelMapAlias(input, engine, map) {
316
351
  const alias = input.toLowerCase();
317
352
  const selectedEngine = engine.toLowerCase();
318
353
  const tier = ownValue(map.aliases, alias);
319
- const profile = ownValue(tier, selectedEngine);
354
+ // A specific platform column always wins; the alias-level wildcard default
355
+ // (#946) is only a fallback for a column no layer ever set explicitly.
356
+ const profile = ownValue(tier, selectedEngine) ?? ownValue(tier, WILDCARD_ENGINE_KEY);
320
357
  if (profile !== undefined)
321
358
  return selectionFromProfile(input, profile);
322
359
  const knownAliasUnmappedForEngine = tier !== undefined;
@@ -9,6 +9,10 @@ const FEATURE_LOCATION = {
9
9
  memory_inference: (cfg) => cfg.index?.memory?.enabled ?? true,
10
10
  graph_extraction: (cfg) => cfg.index?.graph?.enabled ?? true,
11
11
  metadata_enhance: (cfg) => cfg.index?.metadataEnhance?.enabled ?? false,
12
+ // #951: a real implementation of the dead `curate_rerank` key removed in
13
+ // 0.8.0. Off by default — it requires a `search.curateRerank.endpoint` a
14
+ // caller must explicitly configure.
15
+ curate_rerank: (cfg) => Boolean(cfg.search?.curateRerank?.enabled),
12
16
  // Always on at the LLM-wrapper level. Enablement is decided ONCE at the
13
17
  // extract entry point (`akmExtract`): the `extract.enabled` process toggle
14
18
  // gates extract as a STAGE of `akm improve` (the active improve strategy, per
@@ -0,0 +1,113 @@
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
+ * HTTP client for a standalone cross-encoder rerank endpoint (#951).
6
+ *
7
+ * `curate_rerank` was a dead `llm.features.*` key removed in 0.8.0 — no wire
8
+ * call was ever implemented under it. This is the first real implementation:
9
+ * a small, dedicated client (NOT routed through `llm/client.ts`'s chat-
10
+ * completions transport, since a reranker speaks a different, much smaller
11
+ * contract) for the request/response shape a TEI/Cohere-style `/rerank`
12
+ * endpoint accepts:
13
+ *
14
+ * POST <endpoint>
15
+ * { "model": "<name>", "query": "<text>", "documents": ["<text>", ...] }
16
+ *
17
+ * 200 OK
18
+ * { "results": [ { "index": 0, "relevance_score": 0.83 }, ... ] }
19
+ *
20
+ * `results` need not be sorted or complete — {@link rerankDocuments} sorts by
21
+ * `relevance_score` descending and callers treat a missing index as
22
+ * "unscored" (kept in its original relative position, after every scored
23
+ * document). Deliberately independent of `EngineConfigSchema`'s "llm"/"agent"
24
+ * kinds — see the comment on `CurateRerankConfigSchema` in
25
+ * `core/config/schema/search.ts` for why.
26
+ */
27
+ import { fetchWithTimeout, readBodyWithByteCap } from "../core/common.js";
28
+ import { resolveSecret } from "../core/config/config.js";
29
+ import { isApiKeyReference } from "../core/config/schema/primitives.js";
30
+ import { redactErrorBody, redactSensitiveText } from "../core/redaction.js";
31
+ import { resolveSecretFromStore } from "../sources/snapshot-fetchers/secret-seam.js";
32
+ const DEFAULT_RERANK_TIMEOUT_MS = 10_000;
33
+ export class RerankCallError extends Error {
34
+ code;
35
+ constructor(message, code) {
36
+ super(message);
37
+ this.name = "RerankCallError";
38
+ this.code = code;
39
+ }
40
+ }
41
+ /**
42
+ * Call a configured rerank endpoint and return documents ordered by
43
+ * relevance to `query`, most relevant first. Any document the endpoint
44
+ * didn't return a score for keeps its original relative order, appended
45
+ * after every scored document (never dropped).
46
+ *
47
+ * Throws {@link RerankCallError} on any transport/parse failure — callers
48
+ * that want a graceful fallback should use `tryLlmFeature("curate_rerank", ...)`
49
+ * (`llm/feature-gate.ts`), matching every other bounded in-tree LLM/rerank
50
+ * call site.
51
+ */
52
+ export async function rerankDocuments(config, query, documents) {
53
+ if (!config.endpoint) {
54
+ throw new RerankCallError("search.curateRerank.endpoint is not configured.", "provider_error");
55
+ }
56
+ if (documents.length === 0)
57
+ return [];
58
+ const resolvedKey = config.apiKey && isApiKeyReference(config.apiKey)
59
+ ? resolveSecret(config.apiKey, resolveSecretFromStore)
60
+ : config.apiKey;
61
+ const headers = { "Content-Type": "application/json" };
62
+ if (resolvedKey)
63
+ headers.Authorization = `Bearer ${resolvedKey}`;
64
+ const timeoutMs = config.timeoutMs ?? DEFAULT_RERANK_TIMEOUT_MS;
65
+ const requestBody = JSON.stringify({
66
+ ...(config.model ? { model: config.model } : {}),
67
+ query,
68
+ documents,
69
+ });
70
+ let response;
71
+ try {
72
+ response = await fetchWithTimeout(config.endpoint, { method: "POST", headers, body: requestBody }, timeoutMs);
73
+ }
74
+ catch (err) {
75
+ const msg = err instanceof Error ? err.message : String(err);
76
+ if (msg.includes("timed out")) {
77
+ throw new RerankCallError(`Rerank request timed out after ${timeoutMs}ms`, "timeout");
78
+ }
79
+ throw new RerankCallError(`Rerank network error: ${msg}`, "network_error");
80
+ }
81
+ if (!response.ok) {
82
+ const rawBody = await readBodyWithByteCap(response).catch(() => "");
83
+ const safeBody = redactSensitiveText(redactErrorBody(rawBody), resolvedKey ? [resolvedKey] : []);
84
+ throw new RerankCallError(`Rerank request failed (${response.status}) ${config.endpoint}: ${safeBody}`, "provider_error");
85
+ }
86
+ const rawBody = await readBodyWithByteCap(response);
87
+ let json;
88
+ try {
89
+ json = JSON.parse(rawBody);
90
+ }
91
+ catch {
92
+ throw new RerankCallError(`Rerank response was not valid JSON ${config.endpoint}: ${redactSensitiveText(redactErrorBody(rawBody), resolvedKey ? [resolvedKey] : [])}`, "parse_error");
93
+ }
94
+ if (!Array.isArray(json.results)) {
95
+ throw new RerankCallError(`Rerank response from ${config.endpoint} has no "results" array.`, "parse_error");
96
+ }
97
+ const scored = new Map();
98
+ for (const entry of json.results) {
99
+ if (typeof entry.index !== "number")
100
+ continue;
101
+ const score = typeof entry.relevance_score === "number" ? entry.relevance_score : entry.score;
102
+ if (typeof score === "number")
103
+ scored.set(entry.index, score);
104
+ }
105
+ const rankedScored = [...scored.entries()]
106
+ .map(([index, score]) => ({ index, score }))
107
+ .sort((a, b) => b.score - a.score);
108
+ const unscored = documents
109
+ .map((_, index) => index)
110
+ .filter((index) => !scored.has(index))
111
+ .map((index) => ({ index, score: Number.NEGATIVE_INFINITY }));
112
+ return [...rankedScored, ...unscored];
113
+ }
@@ -7072,7 +7072,8 @@ var init_errors = __esm(() => {
7072
7072
  RUN_LEASE_HELD: "Wait for the named engine invocation to finish or for the lease to expire, then retry. `akm workflow status <id>` shows the current lease.",
7073
7073
  STATE_DB_CONTENDED: "Another akm process is writing state.db right now. Wait a few seconds and retry; commands that support --skip-if-locked can skip instead of failing.",
7074
7074
  INDEX_DB_CONTENDED: "Another akm process is writing index.db; retry shortly, or pass --skip-if-locked on scheduled runs.",
7075
- MAINTENANCE_BARRIER_BUSY: "Another akm process is registering a lock or lease right now. Retry shortly, or pass --skip-if-locked on scheduled index/improve/workflow runs."
7075
+ MAINTENANCE_BARRIER_BUSY: "Another akm process is registering a lock or lease right now. Retry shortly, or pass --skip-if-locked on scheduled index/improve/workflow runs.",
7076
+ IMPROVE_LOCK_HELD: "Another akm improve run holds the whole-run lock right now. Wait for it to finish and retry, or pass --skip-if-locked on scheduled runs."
7076
7077
  };
7077
7078
  NOT_FOUND_HINTS = {
7078
7079
  ASSET_NOT_FOUND: "Run `akm search <query>` or `akm index` to refresh the index.",
@@ -76378,10 +76379,19 @@ var SearchGraphBoostSchema = exports_external.object({
76378
76379
  confidenceMode: exports_external.enum(["blend"]).default("blend").optional(),
76379
76380
  confidenceWeight: exports_external.number().finite().min(0).max(1).default(0.2).optional()
76380
76381
  }).passthrough();
76382
+ var CurateRerankConfigSchema = exports_external.object({
76383
+ enabled: exports_external.boolean().optional(),
76384
+ endpoint: httpUrl.optional(),
76385
+ model: nonEmptyString.optional(),
76386
+ apiKey: symbolicOrWarnApiKey("search.curateRerank.apiKey").optional(),
76387
+ timeoutMs: positiveInt.optional(),
76388
+ topN: positiveInt.max(50).optional()
76389
+ }).passthrough();
76381
76390
  var SearchConfigSchema = exports_external.object({
76382
76391
  minScore: nonNegativeNumber.optional(),
76383
76392
  defaultExcludeTypes: exports_external.array(nonEmptyString).optional(),
76384
- graphBoost: SearchGraphBoostSchema.optional()
76393
+ graphBoost: SearchGraphBoostSchema.optional(),
76394
+ curateRerank: CurateRerankConfigSchema.optional()
76385
76395
  }).passthrough();
76386
76396
 
76387
76397
  // src/core/config/schema/setup.ts
@@ -93483,6 +93493,7 @@ init_warn();
93483
93493
  var MODEL_MAP_VERSION = 1;
93484
93494
  var ENGINE_KEY_PATTERN = new RegExp(ENGINE_NAME_PATTERN_SOURCE);
93485
93495
  var RESERVED_MAP_KEYS = new Set(["__proto__", "constructor", "prototype", "tostring"]);
93496
+ var PROFILE_FIELD_KEYS = new Set(["model", "inference", "engine"]);
93486
93497
 
93487
93498
  // src/integrations/harnesses/opencode-sdk/sdk-runner.ts
93488
93499
  var _servers = new Map;
@@ -97472,6 +97483,34 @@ async function embedBatch(texts, embeddingConfig, signal, onSkip, onBatch) {
97472
97483
  return results;
97473
97484
  }
97474
97485
 
97486
+ // src/indexer/index-db-contention.ts
97487
+ init_errors();
97488
+
97489
+ // src/indexer/index-rebuild-lock.ts
97490
+ init_paths();
97491
+ init_warn();
97492
+ function indexRebuildLockPath() {
97493
+ return getIndexRebuildLockPath();
97494
+ }
97495
+
97496
+ // src/indexer/index-db-contention.ts
97497
+ function describeIndexRebuildLockHolder() {
97498
+ const probe = probeLock(indexRebuildLockPath());
97499
+ if (probe.state !== "held")
97500
+ return "";
97501
+ return ` The rebuild lock is currently held by pid ${formatLockHolderPid({
97502
+ pid: probe.holderPid,
97503
+ launcherPid: probe.launcherPid ?? null
97504
+ })}.`;
97505
+ }
97506
+ function reclassifyIndexDbContention(error2) {
97507
+ if (error2 instanceof AkmError || !isSqliteContentionError(error2))
97508
+ return error2;
97509
+ const contended = new TransientError(`akm's index database is busy (another akm process is writing it); retry shortly.${describeIndexRebuildLockHolder()}`, "INDEX_DB_CONTENDED");
97510
+ contended.cause = error2;
97511
+ return contended;
97512
+ }
97513
+
97475
97514
  // src/indexer/materialize-embeddings.ts
97476
97515
  function deriveSemanticProviderFingerprint(embedding) {
97477
97516
  if (isDeterministicEmbedEnabled()) {
@@ -97882,7 +97921,8 @@ ${listed}${more}` });
97882
97921
  clearInterval(heartbeatTimer);
97883
97922
  }
97884
97923
  } catch (error2) {
97885
- const message = error2 instanceof Error ? error2.message : String(error2);
97924
+ const reclassified = reclassifyIndexDbContention(error2);
97925
+ const message = reclassified instanceof Error ? reclassified.message : String(reclassified);
97886
97926
  warn("Embedding generation failed, continuing without:", message);
97887
97927
  onProgress({ phase: "embeddings", message: `Embedding generation failed: ${message}` });
97888
97928
  return {
@@ -7071,7 +7071,8 @@ var init_errors = __esm(() => {
7071
7071
  RUN_LEASE_HELD: "Wait for the named engine invocation to finish or for the lease to expire, then retry. `akm workflow status <id>` shows the current lease.",
7072
7072
  STATE_DB_CONTENDED: "Another akm process is writing state.db right now. Wait a few seconds and retry; commands that support --skip-if-locked can skip instead of failing.",
7073
7073
  INDEX_DB_CONTENDED: "Another akm process is writing index.db; retry shortly, or pass --skip-if-locked on scheduled runs.",
7074
- MAINTENANCE_BARRIER_BUSY: "Another akm process is registering a lock or lease right now. Retry shortly, or pass --skip-if-locked on scheduled index/improve/workflow runs."
7074
+ MAINTENANCE_BARRIER_BUSY: "Another akm process is registering a lock or lease right now. Retry shortly, or pass --skip-if-locked on scheduled index/improve/workflow runs.",
7075
+ IMPROVE_LOCK_HELD: "Another akm improve run holds the whole-run lock right now. Wait for it to finish and retry, or pass --skip-if-locked on scheduled runs."
7075
7076
  };
7076
7077
  NOT_FOUND_HINTS = {
7077
7078
  ASSET_NOT_FOUND: "Run `akm search <query>` or `akm index` to refresh the index.",
@@ -75706,10 +75707,19 @@ var SearchGraphBoostSchema = exports_external.object({
75706
75707
  confidenceMode: exports_external.enum(["blend"]).default("blend").optional(),
75707
75708
  confidenceWeight: exports_external.number().finite().min(0).max(1).default(0.2).optional()
75708
75709
  }).passthrough();
75710
+ var CurateRerankConfigSchema = exports_external.object({
75711
+ enabled: exports_external.boolean().optional(),
75712
+ endpoint: httpUrl.optional(),
75713
+ model: nonEmptyString.optional(),
75714
+ apiKey: symbolicOrWarnApiKey("search.curateRerank.apiKey").optional(),
75715
+ timeoutMs: positiveInt.optional(),
75716
+ topN: positiveInt.max(50).optional()
75717
+ }).passthrough();
75709
75718
  var SearchConfigSchema = exports_external.object({
75710
75719
  minScore: nonNegativeNumber.optional(),
75711
75720
  defaultExcludeTypes: exports_external.array(nonEmptyString).optional(),
75712
- graphBoost: SearchGraphBoostSchema.optional()
75721
+ graphBoost: SearchGraphBoostSchema.optional(),
75722
+ curateRerank: CurateRerankConfigSchema.optional()
75713
75723
  }).passthrough();
75714
75724
 
75715
75725
  // src/core/config/schema/setup.ts
@@ -93478,6 +93488,7 @@ init_warn();
93478
93488
  var MODEL_MAP_VERSION = 1;
93479
93489
  var ENGINE_KEY_PATTERN = new RegExp(ENGINE_NAME_PATTERN_SOURCE);
93480
93490
  var RESERVED_MAP_KEYS = new Set(["__proto__", "constructor", "prototype", "tostring"]);
93491
+ var PROFILE_FIELD_KEYS = new Set(["model", "inference", "engine"]);
93481
93492
 
93482
93493
  // src/integrations/harnesses/opencode-sdk/sdk-runner.ts
93483
93494
  var _servers = new Map;
@@ -97428,6 +97439,34 @@ async function embedBatch(texts, embeddingConfig, signal, onSkip, onBatch) {
97428
97439
  return results;
97429
97440
  }
97430
97441
 
97442
+ // src/indexer/index-db-contention.ts
97443
+ init_errors();
97444
+
97445
+ // src/indexer/index-rebuild-lock.ts
97446
+ init_paths();
97447
+ init_warn();
97448
+ function indexRebuildLockPath() {
97449
+ return getIndexRebuildLockPath();
97450
+ }
97451
+
97452
+ // src/indexer/index-db-contention.ts
97453
+ function describeIndexRebuildLockHolder() {
97454
+ const probe = probeLock(indexRebuildLockPath());
97455
+ if (probe.state !== "held")
97456
+ return "";
97457
+ return ` The rebuild lock is currently held by pid ${formatLockHolderPid({
97458
+ pid: probe.holderPid,
97459
+ launcherPid: probe.launcherPid ?? null
97460
+ })}.`;
97461
+ }
97462
+ function reclassifyIndexDbContention(error2) {
97463
+ if (error2 instanceof AkmError || !isSqliteContentionError(error2))
97464
+ return error2;
97465
+ const contended = new TransientError(`akm's index database is busy (another akm process is writing it); retry shortly.${describeIndexRebuildLockHolder()}`, "INDEX_DB_CONTENDED");
97466
+ contended.cause = error2;
97467
+ return contended;
97468
+ }
97469
+
97431
97470
  // src/indexer/materialize-embeddings.ts
97432
97471
  function deriveSemanticProviderFingerprint(embedding) {
97433
97472
  if (isDeterministicEmbedEnabled()) {
@@ -97838,7 +97877,8 @@ ${listed}${more}` });
97838
97877
  clearInterval(heartbeatTimer);
97839
97878
  }
97840
97879
  } catch (error2) {
97841
- const message = error2 instanceof Error ? error2.message : String(error2);
97880
+ const reclassified = reclassifyIndexDbContention(error2);
97881
+ const message = reclassified instanceof Error ? reclassified.message : String(reclassified);
97842
97882
  warn("Embedding generation failed, continuing without:", message);
97843
97883
  onProgress({ phase: "embeddings", message: `Embedding generation failed: ${message}` });
97844
97884
  return {
@@ -36,6 +36,65 @@ export function resolveTaskLogPath(logDir, taskId, startedAtIso) {
36
36
  return "";
37
37
  }
38
38
  }
39
+ /**
40
+ * Delete per-run flat log files (`<logDir>/<taskId>/<timestamp>.log`) older
41
+ * than `retentionDays` (#951). These files are a transitional human-readable
42
+ * tail only — the durable record lives in logs.db, already purged by
43
+ * {@link purgeOldTaskLogs} in ../../core/logs-db — so deleting an old file
44
+ * here loses nothing that isn't already retained (and separately purged)
45
+ * there.
46
+ *
47
+ * Bounded by construction: only ever reads/deletes inside `logDir` (defaults
48
+ * to {@link getTaskLogDir}), one level of `<taskId>` subdirectories, `.log`
49
+ * files only. A missing/unreadable directory is a no-op, not an error —
50
+ * mirrors the best-effort contract the rest of this module holds for log
51
+ * persistence.
52
+ */
53
+ export function purgeOldTaskLogFiles(logDir, retentionDays = 90) {
54
+ if (!Number.isFinite(retentionDays) || retentionDays <= 0)
55
+ return 0;
56
+ const dir = logDir ?? getTaskLogDir();
57
+ const cutoffMs = Date.now() - retentionDays * 86_400_000;
58
+ let deleted = 0;
59
+ let taskDirs;
60
+ try {
61
+ taskDirs = fs.readdirSync(dir, { withFileTypes: true });
62
+ }
63
+ catch (error) {
64
+ rethrowIfTestIsolationError(error);
65
+ return 0;
66
+ }
67
+ for (const taskDirEntry of taskDirs) {
68
+ if (!taskDirEntry.isDirectory())
69
+ continue;
70
+ const taskDirPath = path.join(dir, taskDirEntry.name);
71
+ let logFiles;
72
+ try {
73
+ logFiles = fs.readdirSync(taskDirPath, { withFileTypes: true });
74
+ }
75
+ catch (error) {
76
+ rethrowIfTestIsolationError(error);
77
+ continue;
78
+ }
79
+ for (const logFile of logFiles) {
80
+ if (!logFile.isFile() || !logFile.name.endsWith(".log"))
81
+ continue;
82
+ const logFilePath = path.join(taskDirPath, logFile.name);
83
+ try {
84
+ const stat = fs.statSync(logFilePath);
85
+ if (stat.mtimeMs < cutoffMs) {
86
+ fs.unlinkSync(logFilePath);
87
+ deleted++;
88
+ }
89
+ }
90
+ catch (error) {
91
+ rethrowIfTestIsolationError(error);
92
+ // Best-effort: a file that vanished or can't be stat'd/removed is skipped.
93
+ }
94
+ }
95
+ }
96
+ return deleted;
97
+ }
39
98
  /**
40
99
  * Redact logs.db rows against the SAME contiguous text the file sink sees.
41
100
  *
@@ -22,6 +22,22 @@ fix for a scheduled or opportunistic index run is the same:
22
22
  `akm index --skip-if-locked` steps aside (exit 0) instead of contending at
23
23
  all.
24
24
 
25
+ Two more contention paths collided on the same "config error instead of
26
+ transient" anti-pattern (#948 field follow-up, 2026-09-10). `akm improve`'s
27
+ whole-run lock (only one `improve` runs at a time) used to fail a losing
28
+ contender with `{"code":"INVALID_CONFIG_FILE"}` at exit 78 when no
29
+ `--skip-if-locked` was passed — a config-error exit for ordinary contention
30
+ between two legitimate `improve` invocations. It is now `TransientError`
31
+ code `IMPROVE_LOCK_HELD` at exit 75, naming the current holder's pid and
32
+ start time; `--skip-if-locked` is unchanged (still exit 0). Separately, the
33
+ index rebuild's embedding-verification step (`[index:verify] Semantic search
34
+ verification failed: ...`) could still surface a raw, unclassified
35
+ `database is locked` message even though the acquisition path was already
36
+ fixed — that message is now built through the same `INDEX_DB_CONTENDED`
37
+ reclassification as the rest of the index path. This step does not throw, so
38
+ it does not change `akm index`'s own exit code; only the message text
39
+ changed.
40
+
25
41
  The six shipped scheduled `improve` task templates now run with
26
42
  `--require-engines`, which aborts (exit 78) before any index work when a
27
43
  process's engine or credential cannot be resolved in the task's own
@@ -2362,7 +2362,7 @@ akm improve report --since 7d # ...aggregated over every real run start
2362
2362
  | `--require-feedback-signal` | Only process assets with recent feedback signals |
2363
2363
  | `--strategy <name>` | Override the active improve strategy (a built-in or entry under `improve.strategies`) |
2364
2364
  | `--json-to-stdout` | Also emit the full persisted JSON result on stdout for a live run. Without this flag, stdout stays empty. Dry-runs always emit their result and are never persisted. |
2365
- | `--skip-if-locked` | If another improve run already holds the lock, skip gracefully (exit 0) instead of failing with "already running" (exit 78). Use for high-frequency scheduled runs so they don't pile up failures while a longer run is in progress. |
2365
+ | `--skip-if-locked` | If another improve run already holds the lock, skip gracefully (exit 0) instead of failing with "already running" (exit 75, `TransientError`, code `IMPROVE_LOCK_HELD` — field follow-up to #948: two legitimate `improve` invocations colliding on this lock is ordinary, retryable contention, not a broken config file). Use for high-frequency scheduled runs so they don't pile up failures while a longer run is in progress. |
2366
2366
  | `--require-engines` | Abort (exit 78, before any indexing, lock, or log side effect) if the active strategy would enable a process whose engine or credential cannot be resolved in this process's environment, OR whose endpoint fails a bounded reachability probe — the same probe `akm health`'s `default-llm-engine`/`configured-engines` checks run, once per distinct endpoint. Without this flag, improve degrades gracefully: it skips the affected processes and reports them in the result's `skippedProcesses`. Recommended alongside `--skip-if-locked` for scheduled runs, since the operator's own shell can pass config validation while a scheduler's stripped-down environment (see #953) cannot. |
2367
2367
  | `--show-prompt` | Print the composed reflect prompt (#952) for one asset and exit — before any lock, index write, or engine dispatch. Requires a fully-qualified asset ref as the scope (`akm improve lessons/my-lesson --show-prompt`); rejected with a type or whole-bundle scope. The default output format is JSON, which carries the prompt as a `prompt` field (escaped into one line) alongside the resolved `engine`/`engineKind`; pass `--format text` to print the prompt itself, unwrapped and readable by eye. |
2368
2368
  | `--sync` / `--no-sync` | Commit (and optionally push) the git-backed primary bundle when the run finishes. Default: on for git-backed bundles (per profile config). |
@@ -514,6 +514,23 @@ taking about the same wall time as a single one against a healthy endpoint.
514
514
  | --- | --- |
515
515
  | `search.graphBoost.*` | Entity-graph relevance boost: `directBoostPerEntity`/`directBoostCap` (directly related entities), `hopBoostPerEntity`/`hopBoostCap` (multi-hop, capped at `maxHops` ≤ 3), `confidenceMode` (`blend`, the only supported value), `confidenceWeight` (0–1, default `0.2`) |
516
516
 
517
+ ### Curate rerank (#951)
518
+
519
+ An optional cross-encoder rerank pass over `akm curate`'s already-selected
520
+ candidates, via a standalone `/rerank`-style HTTP endpoint (NOT one of the
521
+ `engines.*` `"llm"`/`"agent"` kinds). Disabled by default; a misconfigured
522
+ endpoint, network failure, timeout, or malformed response falls back to
523
+ curate's own ranking unchanged.
524
+
525
+ | Key | Purpose |
526
+ | --- | --- |
527
+ | `search.curateRerank.enabled` | Turn the rerank pass on (default `false`) |
528
+ | `search.curateRerank.endpoint` | Full URL of the reranker's rerank endpoint |
529
+ | `search.curateRerank.model` | Model name sent to the endpoint (optional) |
530
+ | `search.curateRerank.apiKey` | `$VAR`/`secret://<name>` credential reference (optional) |
531
+ | `search.curateRerank.timeoutMs` | Request timeout (default `10000`) |
532
+ | `search.curateRerank.topN` | How many of curate's ranked candidates to send (default `8`, max `50`) |
533
+
517
534
  ## Feedback
518
535
 
519
536
  `feedback` shapes the `akm feedback` taxonomy:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akm-cli",
3
- "version": "0.9.15-beta.4",
3
+ "version": "0.9.15-beta.5",
4
4
  "type": "module",
5
5
  "description": "akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cursor.",
6
6
  "keywords": [
@@ -596,6 +596,34 @@
596
596
  }
597
597
  },
598
598
  "additionalProperties": true
599
+ },
600
+ "curateRerank": {
601
+ "type": "object",
602
+ "properties": {
603
+ "enabled": {
604
+ "type": "boolean"
605
+ },
606
+ "endpoint": {
607
+ "type": "string"
608
+ },
609
+ "model": {
610
+ "type": "string",
611
+ "minLength": 1
612
+ },
613
+ "apiKey": {
614
+ "type": "string"
615
+ },
616
+ "timeoutMs": {
617
+ "type": "integer",
618
+ "exclusiveMinimum": 0
619
+ },
620
+ "topN": {
621
+ "type": "integer",
622
+ "exclusiveMinimum": 0,
623
+ "maximum": 50
624
+ }
625
+ },
626
+ "additionalProperties": true
599
627
  }
600
628
  },
601
629
  "additionalProperties": true
@@ -2296,6 +2324,34 @@
2296
2324
  }
2297
2325
  },
2298
2326
  "additionalProperties": true
2327
+ },
2328
+ "curateRerank": {
2329
+ "type": "object",
2330
+ "properties": {
2331
+ "enabled": {
2332
+ "type": "boolean"
2333
+ },
2334
+ "endpoint": {
2335
+ "type": "string"
2336
+ },
2337
+ "model": {
2338
+ "type": "string",
2339
+ "minLength": 1
2340
+ },
2341
+ "apiKey": {
2342
+ "type": "string"
2343
+ },
2344
+ "timeoutMs": {
2345
+ "type": "integer",
2346
+ "exclusiveMinimum": 0
2347
+ },
2348
+ "topN": {
2349
+ "type": "integer",
2350
+ "exclusiveMinimum": 0,
2351
+ "maximum": 50
2352
+ }
2353
+ },
2354
+ "additionalProperties": true
2299
2355
  }
2300
2356
  },
2301
2357
  "additionalProperties": true