akm-cli 0.9.5 → 0.9.6

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 (105) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/dist/commands/env/env-cli.js +1 -2
  3. package/dist/commands/env/secret-cli.js +1 -5
  4. package/dist/commands/feedback-cli.js +0 -4
  5. package/dist/commands/health/checks.js +0 -32
  6. package/dist/commands/health/surfaces.js +2 -2
  7. package/dist/commands/health.js +6 -15
  8. package/dist/commands/improve/autonomy-gate.js +1 -1
  9. package/dist/commands/improve/consolidate.js +25 -26
  10. package/dist/commands/improve/distill.js +2 -2
  11. package/dist/commands/improve/extract.js +8 -1
  12. package/dist/commands/improve/improve.js +3 -5
  13. package/dist/commands/improve/locks.js +13 -5
  14. package/dist/commands/improve/loop-stages.js +8 -9
  15. package/dist/commands/improve/memory/memory-contradiction-detect.js +1 -19
  16. package/dist/commands/improve/outcome-loop.js +0 -6
  17. package/dist/commands/improve/reflect.js +20 -9
  18. package/dist/commands/lint/index.js +4 -6
  19. package/dist/commands/proposal/validators/proposal-quality-validators.js +0 -7
  20. package/dist/commands/sources/bundle-config-ops.js +9 -4
  21. package/dist/commands/sources/info.js +14 -10
  22. package/dist/commands/tasks/tasks.js +12 -16
  23. package/dist/commands/workflow-cli.js +2 -2
  24. package/dist/core/common.js +61 -24
  25. package/dist/core/config/config-io.js +2 -2
  26. package/dist/core/config/config-sources.js +32 -2
  27. package/dist/core/config/config.js +1 -1
  28. package/dist/core/config/schema/engines.js +9 -7
  29. package/dist/core/config/schema/primitives.js +0 -5
  30. package/dist/core/config/schema/search.js +2 -1
  31. package/dist/core/file-lock.js +2 -1
  32. package/dist/core/maintenance-barrier.js +2 -14
  33. package/dist/core/paths.js +0 -3
  34. package/dist/core/redaction.js +2 -2
  35. package/dist/core/spawn-env.js +8 -12
  36. package/dist/core/state/migrations.js +1 -12
  37. package/dist/core/state-db.js +9 -27
  38. package/dist/core/write-source.js +12 -19
  39. package/dist/execution/directory-identity.js +36 -10
  40. package/dist/indexer/graph/graph-boost.js +0 -4
  41. package/dist/indexer/index-writer-lock.js +43 -24
  42. package/dist/indexer/index-written-assets.js +5 -6
  43. package/dist/indexer/indexer.js +2 -39
  44. package/dist/indexer/materialize-embeddings.js +85 -41
  45. package/dist/indexer/search/db-search.js +15 -48
  46. package/dist/indexer/search/ranking-contributors.js +0 -25
  47. package/dist/indexer/search/ranking.js +3 -13
  48. package/dist/integrations/agent/builder-shared.js +0 -25
  49. package/dist/integrations/agent/model-map.js +2 -60
  50. package/dist/integrations/harnesses/aider/agent-builder.js +1 -3
  51. package/dist/integrations/harnesses/amazonq/agent-builder.js +2 -7
  52. package/dist/integrations/harnesses/claude/agent-builder.js +1 -4
  53. package/dist/integrations/harnesses/codex/agent-builder.js +1 -4
  54. package/dist/integrations/harnesses/copilot/agent-builder.js +2 -6
  55. package/dist/integrations/harnesses/gemini/agent-builder.js +2 -7
  56. package/dist/integrations/harnesses/opencode/agent-builder.js +1 -4
  57. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +2 -16
  58. package/dist/integrations/harnesses/openhands/agent-builder.js +1 -3
  59. package/dist/integrations/harnesses/pi/agent-builder.js +2 -4
  60. package/dist/integrations/session-logs/index.js +0 -9
  61. package/dist/llm/client.js +75 -42
  62. package/dist/llm/embedder.js +7 -3
  63. package/dist/llm/embedders/remote.js +141 -42
  64. package/dist/registry/network.js +5 -37
  65. package/dist/runtime.js +2 -10
  66. package/dist/scripts/akm-migrate-node.js +60 -126
  67. package/dist/scripts/akm-migrate.js +60 -126
  68. package/dist/setup/engine-config.js +2 -5
  69. package/dist/setup/registry-stash-loader.js +0 -8
  70. package/dist/setup/setup.js +9 -46
  71. package/dist/setup/steps/connection-shared.js +10 -13
  72. package/dist/sources/providers/git-install.js +1 -1
  73. package/dist/storage/engines/sqlite-migrations.js +20 -1
  74. package/dist/storage/repositories/index-entries-repository.js +0 -15
  75. package/dist/tasks/backends/launchd.js +15 -20
  76. package/dist/tasks/backends/schtasks.js +18 -8
  77. package/dist/tasks/run/run-native-task.js +8 -6
  78. package/dist/tasks/source/bounded-document.js +2 -4
  79. package/dist/tasks/source/task-source-v3-frozen.js +5 -7
  80. package/dist/tasks/source/task-source-v4.js +5 -10
  81. package/dist/tasks/source/task-to-v3.js +5 -10
  82. package/dist/tasks/source/task-to-v4.js +1 -4
  83. package/dist/tasks/source-v3.js +6 -6
  84. package/dist/workflows/exec/native-executor.js +21 -31
  85. package/dist/workflows/exec/run-workflow.js +5 -6
  86. package/dist/workflows/exec/scheduler.js +3 -19
  87. package/dist/workflows/exec/step-work.js +1 -4
  88. package/dist/workflows/exec/unit-dispatch.js +2 -2
  89. package/dist/workflows/exec/worktree.js +1 -13
  90. package/dist/workflows/freeze/targets/child-workflow.js +2 -10
  91. package/dist/workflows/ir/plan-hash.js +4 -6
  92. package/dist/workflows/ir/schema-v4.js +0 -12
  93. package/dist/workflows/ir/schema.js +20 -31
  94. package/dist/workflows/parser.js +11 -52
  95. package/dist/workflows/renderer.js +2 -3
  96. package/dist/workflows/resource-limits.js +11 -41
  97. package/dist/workflows/runtime/runs.js +3 -4
  98. package/dist/workflows/source-ir/schema.js +14 -30
  99. package/dist/workflows/validator.js +1 -7
  100. package/docs/reference/configuration.md +1 -1
  101. package/package.json +1 -1
  102. package/schemas/akm-config.json +4 -23
  103. package/schemas/akm-task.json +1 -2
  104. package/schemas/akm-workflow.json +1 -13
  105. package/dist/indexer/search/semantic-status.js +0 -142
package/CHANGELOG.md CHANGED
@@ -4,6 +4,111 @@ 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.6] - 2026-08-31
8
+
9
+ The deletion release: **net −2,100 lines**, almost all of it machinery that
10
+ gated, refused, verified, or cached a judgment. Nearly every guard removed
11
+ here had **zero confirmed firings** across 38,341 production telemetry events,
12
+ while several had already broken real installs.
13
+
14
+ The standard applied is now written down in `AGENTS.md` (`## Defensive Code`):
15
+ a guard survives only if it has demonstrably helped a real user, its failure
16
+ mode costs less than the hazard it prevents, and the operation is not already
17
+ gated behind a deliberate human command. "This hazard is conceivable" is not a
18
+ justification.
19
+
20
+ ### Fixed
21
+
22
+ - **Embedding no longer discards an entire index because one batch was too
23
+ big (#874).** Remote embeddings batched by document count (100) against a
24
+ fixed 30s timeout; a single oversized batch failed the whole phase, leaving
25
+ `embeddings` at 0 rows on a real 23,857-entry bundle and silently disabling
26
+ semantic search. Batching is now bounded by a token budget, failures are
27
+ skipped-and-reported per batch, and an oversized single document is a named
28
+ skip rather than a phase failure.
29
+ - **`akm lint` no longer silently skips user directories named `.cache` or
30
+ `registry`.** Two name-based exclusion sites remained after the 0.9.5 fix, so
31
+ a bundle's own `knowledge/registry/` was never linted and reported clean.
32
+ Exclusion is now anchored to akm's resolved registry-cache path.
33
+ - **One directory can no longer register as two bundles (#870).** When
34
+ `AKM_BUNDLE_DIR` pointed at a directory already configured under another id,
35
+ akm minted a second bundle for it; `akm migrate` then enumerated every task
36
+ file twice and failed with `duplicate task migration file path` (exit 70) —
37
+ permanently, while health checks kept passing. Bundle identity is now the
38
+ resolved content root (`path.resolve(entry.path, component.root ?? ".")`) at
39
+ both registration sites, existing duplicates reconcile instead of throwing,
40
+ and a genuinely irreconcilable pair reports both bundle ids and the shared
41
+ path.
42
+
43
+ ### Removed
44
+
45
+ - **The index writer lease (#872).** It guarded a *regenerable cache*, had zero
46
+ lease events in telemetry, and a live-but-wedged holder stranded all indexing
47
+ for 12 hours — `probeLock` only reclaims a dead PID. It blocked legitimate
48
+ work twice in a single day of real use. `withAssetMutationLease` is **kept**
49
+ (it guards authored, git-backed asset writes) but its identical 12h
50
+ age-based stale reclaim is gone; only a verifiably-dead holder is reclaimed.
51
+ - **`semantic-status.ts` and its cached `blocked` verdict (#873).** A failed
52
+ probe was persisted with a 24h TTL, and search consulted that verdict
53
+ *before attempting semantic search at all* — so one failure silently
54
+ disabled a working feature for a day. Semantic search now attempts per query
55
+ and falls back to FTS with a live warning. The remaining pre-flight check is
56
+ a real-time embedding count, not a stored judgment.
57
+ - **The improve-lock 4h stale reclaim.** Same hole: `--skip-if-locked`
58
+ silently no-oped nightly `improve` for up to four hours and reported success.
59
+ - **The persisted `supportsJsonSchema` capability cache**, which was never
60
+ invalidated by `akm config set`; a stale `true` sent `response_format:
61
+ json_schema` to an incompatible endpoint with no fallback (`isRetryable`
62
+ excludes 4xx). Replaced by attempt-then-fallback held in memory for the
63
+ process only. The config field survives as an explicit user override.
64
+ - **Workflow authoring resource caps** — steps, params, route branches,
65
+ inputs, outputs, gate loops, retries, JSON depth/node, composition depth,
66
+ and exec argv/env caps. None ever fired; several duplicated OS limits.
67
+ - **`LIFETIME_UNIT_CAP` (10,000)**, which hard-aborted workflows mid-run. The
68
+ maximum ever observed was 14 units.
69
+ - **`state.db` open-path identity re-verification**, which ran on *every*
70
+ command and threw a bare `Error`.
71
+ - **The task-source 1 MiB cap** at four sites, restating the `guarded-source`
72
+ cap already deleted in 0.9.5.
73
+ - **TOCTOU identity checks** — `assertGitPublicationIdentity` (git's
74
+ `--force-with-lease` already covers it), `assertFrozenDirectoryIdentity`
75
+ (replaced with a path-containment recheck: containment and resolved-path
76
+ identity stay, device/inode comparison goes, so a remount or container
77
+ rebuild no longer aborts a dispatch), `assertTaskSourceExpectation`'s stat
78
+ fields (its content hash stays), and redundant repeat HEAD-generation
79
+ compares in `write-source`.
80
+ - **`assertNotFlag`** on persona/system-prompt content; **`MAX_ENV_BYTES`**,
81
+ **`MAX_SECRET_BYTES`**, **`MAX_FEEDBACK_TAGS`**, **`MAX_CONFIG_FILE_BYTES`**,
82
+ model-map JSON budgets, memory-contradiction family caps, and two launchd /
83
+ schtasks re-checks that duplicated an existing fallback. `map.concurrency`
84
+ now clamps instead of rejecting a human-authored value.
85
+ - **17 dead symbols**, including several whose docstrings described behavior
86
+ nothing implemented. Three "canonical" constants that call sites were
87
+ ignoring in favour of hardcoded literals were **wired in** rather than
88
+ deleted, closing the drift instead of removing the evidence of it.
89
+ - **Speculative flexibility** — `AKM_ABLATE_CONTRIBUTORS` ablation plumbing
90
+ and the unexercised `graphBoost.confidenceMode` branches.
91
+
92
+ ### Changed
93
+
94
+ - **Drifted duplicate implementations consolidated.** HTTP retry/backoff (the
95
+ generic copy's `Retry-After` parsing was unbounded and numeric-only; the
96
+ capped, date-aware one now applies everywhere), the child-process env
97
+ allowlist (the opencode-sdk copy was missing `AKM_EVENT_SOURCE` and the
98
+ Windows HOME equivalents), portable synchronous sleep (five call sites had
99
+ been silently taking the Node fallback instead of the Bun fast path), a
100
+ stacked LLM chunk retry, and a duplicate `isProcessAlive`.
101
+
102
+ ### Kept, deliberately
103
+
104
+ Not everything unused is disposable. `isVecFastPathReady` stays: a partial
105
+ `entries_vec` table does not throw, it silently returns wrong neighbours, so
106
+ there is no error for a fallback to catch. `assertSupportedKind` stays: it has
107
+ a proven independent bypass path and is the real last-line check, not a
108
+ duplicate. The maintenance barrier and registry TTL cache stay. The four
109
+ improve strategies with zero recorded invocations stay — that measures one
110
+ install's cron schedule, not their worth.
111
+
7
112
  ## [0.9.5] - 2026-08-30
8
113
 
9
114
  ### Action required after upgrading
@@ -127,8 +127,7 @@ const envCreateCommand = defineJsonCommand({
127
127
  content = fs.readFileSync(fromFile, "utf8");
128
128
  }
129
129
  else {
130
- const MAX_ENV_BYTES = 1024 * 1024; // 1 MB
131
- const buf = await readStdin(MAX_ENV_BYTES, () => new UsageError("Env file exceeds 1 MB limit.", "INVALID_FLAG_VALUE"));
130
+ const buf = await readStdin();
132
131
  content = buf.toString("utf8");
133
132
  }
134
133
  }
@@ -122,16 +122,12 @@ const secretSetCommand = defineJsonCommand({
122
122
  if (fromEnv !== undefined && fromFile !== undefined) {
123
123
  throw new UsageError("Pass only one of --from-file or --from-env (or use stdin).", "INVALID_FLAG_VALUE");
124
124
  }
125
- const MAX_SECRET_BYTES = 5 * 1024 * 1024; // 5 MB
126
125
  let value;
127
126
  if (fromFile !== undefined) {
128
127
  if (!fs.existsSync(fromFile)) {
129
128
  throw new NotFoundError(`File not found: ${fromFile}`, "FILE_NOT_FOUND");
130
129
  }
131
130
  value = fs.readFileSync(fromFile);
132
- if (value.byteLength > MAX_SECRET_BYTES) {
133
- throw new UsageError("Secret exceeds the 5 MB limit.");
134
- }
135
131
  }
136
132
  else if (fromEnv !== undefined) {
137
133
  const envVal = process.env[fromEnv];
@@ -144,7 +140,7 @@ const secretSetCommand = defineJsonCommand({
144
140
  if (process.stdin.isTTY) {
145
141
  process.stderr.write(`Enter value for secret "${name}" (Ctrl-D when done):\n`);
146
142
  }
147
- const stdinBuf = await readStdin(MAX_SECRET_BYTES, () => new UsageError("Secret exceeds the 5 MB limit."));
143
+ const stdinBuf = await readStdin();
148
144
  // Strip a single trailing newline so `echo "$TOKEN" | akm secret set`
149
145
  // stores the token without the shell-added newline. Use --from-file for
150
146
  // byte-exact storage of multi-line material (PEM keys, certs).
@@ -25,7 +25,6 @@ import { findEntryIdByRef, getEntryFilePathById, getItemRefById, } from "../stor
25
25
  import { applyFeedbackToUtilityScore } from "../storage/repositories/index-utility-repository.js";
26
26
  // ── Tag validation ────────────────────────────────────────────────────────────
27
27
  const TAG_KEY_RE = /^[a-z_][a-z0-9_]*$/;
28
- const MAX_FEEDBACK_TAGS = 10;
29
28
  function validateFeedbackTags(raw) {
30
29
  const seen = new Set();
31
30
  const out = [];
@@ -43,9 +42,6 @@ function validateFeedbackTags(raw) {
43
42
  seen.add(tag);
44
43
  out.push(tag);
45
44
  }
46
- if (out.length > MAX_FEEDBACK_TAGS) {
47
- throw new UsageError(`Too many tags: ${out.length}. Maximum is ${MAX_FEEDBACK_TAGS}.`, "INVALID_FLAG_VALUE");
48
- }
49
45
  return out;
50
46
  }
51
47
  // ── Lesson strength helper ────────────────────────────────────────────────────
@@ -607,38 +607,6 @@ export const HEALTH_CHECKS = [
607
607
  };
608
608
  },
609
609
  },
610
- {
611
- name: "semantic-search-runtime",
612
- channel: "advisory",
613
- run: (ctx) => {
614
- const blocked = ctx.semanticStatus?.status === "blocked";
615
- // The generic "status: blocked" line is not actionable when the real
616
- // problem is a configured remote embedding endpoint that is down while
617
- // semanticSearchMode leaves semantic search enabled — every index run
618
- // burns time failing against it and searches silently degrade to
619
- // keyword-only. Name the endpoint and the two ways out.
620
- const remoteReason = ctx.semanticStatus?.reason?.startsWith("remote-") === true;
621
- const endpointAdvisory = blocked && remoteReason && ctx.embeddingEndpoint
622
- ? `Configured embedding endpoint ${ctx.embeddingEndpoint} is failing ` +
623
- `(${ctx.semanticStatus?.reason}${ctx.semanticStatus?.message ? `: ${ctx.semanticStatus.message}` : ""}) ` +
624
- `while semanticSearchMode is "${ctx.semanticSearchMode ?? "off"}". Searches fall back to keyword-only. ` +
625
- `Restore the endpoint, or set semanticSearchMode to "off" (or remove embedding.endpoint to use the local model).`
626
- : undefined;
627
- return {
628
- name: "semantic-search-runtime",
629
- kind: "deterministic",
630
- status: !ctx.semanticStatus || !blocked ? "pass" : "warn",
631
- confidence: "medium",
632
- message: endpointAdvisory ??
633
- (ctx.semanticStatus
634
- ? `Semantic search status: ${ctx.semanticStatus.status}`
635
- : "No semantic-search runtime status recorded yet."),
636
- evidence: ctx.semanticStatus
637
- ? { ...ctx.semanticStatus, ...(ctx.embeddingEndpoint ? { embeddingEndpoint: ctx.embeddingEndpoint } : {}) }
638
- : undefined,
639
- };
640
- },
641
- },
642
610
  {
643
611
  name: "session-extraction",
644
612
  channel: "advisory",
@@ -14,7 +14,7 @@
14
14
  * the stash-exposure pattern. `egress-endpoints` is the one informational
15
15
  * (pass-status) entry: it emits whenever any remote endpoint is configured.
16
16
  */
17
- import { MAX_CONFIG_FILE_BYTES, readTextFileWithLimit } from "../../core/common.js";
17
+ import { readTextFile } from "../../core/common.js";
18
18
  import { CURRENT_CONFIG_VERSION } from "../../core/config/config-schema.js";
19
19
  import { compareConfigVersion } from "../../core/config/config-version.js";
20
20
  import { formatRegistryUrl } from "../../core/registry-url.js";
@@ -28,7 +28,7 @@ import { formatRegistryUrl } from "../../core/registry-url.js";
28
28
  export function collectConfigSkewAdvisory(configPath) {
29
29
  let raw;
30
30
  try {
31
- raw = JSON.parse(readTextFileWithLimit(configPath, MAX_CONFIG_FILE_BYTES, "Config file"));
31
+ raw = JSON.parse(readTextFile(configPath, "Config file"));
32
32
  }
33
33
  catch {
34
34
  return undefined;
@@ -13,7 +13,6 @@ import { classifyPathAccess, describeInaccessiblePath } from "../core/path-acces
13
13
  import { getConfigPath, getDataDir, getDbPath, getStateDbPathInDataDir } from "../core/paths.js";
14
14
  import { listExistingTableNames, openStateDatabase } from "../core/state-db.js";
15
15
  import { DURATION_UNITS, parseDuration, parseSinceToIso } from "../core/time.js";
16
- import { readSemanticStatus } from "../indexer/search/semantic-status.js";
17
16
  import { closeDatabase, openReadonlyExistingDatabase } from "../storage/repositories/index-connection.js";
18
17
  import { getAllEntries } from "../storage/repositories/index-entries-repository.js";
19
18
  import { queryTaskHistory } from "../storage/repositories/task-history-repository.js";
@@ -183,25 +182,20 @@ function gatherStaleTxnJournalsPhase(now) {
183
182
  }
184
183
  }
185
184
  /**
186
- * Semantic-search status + the config fields the embedding-endpoint and
187
- * surfaces advisories need. Best-effort: an unloadable config leaves the
188
- * config-derived fields undefined and callers fall back to generic messages.
185
+ * Config fields the surfaces advisory needs. Best-effort: an unloadable
186
+ * config leaves the field undefined and the caller falls back to a generic
187
+ * message.
189
188
  */
190
- function gatherSemanticConfigPhase() {
191
- const semanticStatus = readSemanticStatus();
192
- let semanticSearchMode;
193
- let embeddingEndpoint;
189
+ function gatherEgressConfigPhase() {
194
190
  let egressConfigView;
195
191
  try {
196
192
  const config = loadConfig();
197
- semanticSearchMode = config.semanticSearchMode;
198
- embeddingEndpoint = config.embedding?.endpoint;
199
193
  egressConfigView = config;
200
194
  }
201
195
  catch {
202
196
  // fall through with undefined
203
197
  }
204
- return { semanticStatus, semanticSearchMode, embeddingEndpoint, egressConfigView };
198
+ return { egressConfigView };
205
199
  }
206
200
  /**
207
201
  * Assemble the window's improve-pipeline summary: invoked/completed/skipped
@@ -519,7 +513,7 @@ export function akmHealth(options = {}) {
519
513
  const taskHistory = gatherTaskHistoryPhase(db, logsDb, since, stateDbPath, now);
520
514
  const { tableNames, missingTables, probe } = taskHistory;
521
515
  const staleTxnJournals = gatherStaleTxnJournalsPhase(now);
522
- const { semanticStatus, semanticSearchMode, embeddingEndpoint, egressConfigView } = gatherSemanticConfigPhase();
516
+ const { egressConfigView } = gatherEgressConfigPhase();
523
517
  const { improveSummary } = gatherImproveSummaryPhase(db, stateDbPath, since, now);
524
518
  advisories.push(...gatherAncillaryAdvisories(db, stateDbPath, since, improveSummary, options, egressConfigView));
525
519
  const engineProbes = runHealthEngineProbes();
@@ -542,9 +536,6 @@ export function akmHealth(options = {}) {
542
536
  stuckActiveTasks: taskHistory.stuckActiveTasks,
543
537
  worstTaskFailRate: taskHistory.worstTaskFailRate,
544
538
  staleTxnJournals,
545
- semanticStatus,
546
- semanticSearchMode,
547
- embeddingEndpoint,
548
539
  sessionExtraction: improveSummary.sessionExtraction,
549
540
  autoAccept: improveSummary.autoAccept,
550
541
  engineProbes,
@@ -29,7 +29,7 @@ export function describeGatedLanes(lanes) {
29
29
  }
30
30
  /** Configured capabilities that tasks doctor reports behind the autonomy gate. */
31
31
  export function configuredDirectAutonomyLanes() {
32
- return ["memoryCleanup"];
32
+ return [...DIRECT_AUTONOMY_LANES];
33
33
  }
34
34
  /**
35
35
  * True when a lane may mutate. Used by the lane that bypasses the strategy
@@ -27,6 +27,7 @@ import { findEntryIdByRef, getAllEntries, getEntryById } from "../../storage/rep
27
27
  import { getNeighborsByEntryId } from "../../storage/repositories/index-vec-repository.js";
28
28
  import { isProposalSkipped, listProposals, listProposalsReadOnly, proposalContent, } from "../proposal/repository.js";
29
29
  import { hasSupersededStatus, validateProposalFrontmatter } from "../proposal/validators/proposal-quality-validators.js";
30
+ import { DEFAULT_RANDOM_CLUSTER_FRACTION } from "./anti-collapse.js";
30
31
  import { cacheHash } from "./content-hash.js";
31
32
  import { resolveImproveLlmExecution } from "./execution.js";
32
33
  import { resolveImproveStrategy, resolveProcessEnabled } from "./improve-strategies.js";
@@ -190,14 +191,15 @@ async function clusterMemoriesBySimilarity(memories, config, stateDb, signal) {
190
191
  finally {
191
192
  embedMs += Date.now() - embedStart;
192
193
  }
193
- // Upsert newly computed vectors into the cache.
194
+ // Upsert newly computed vectors into the cache. A skipped document
195
+ // (embedBatch reports it via `undefined` rather than throwing, #874) has
196
+ // no vector to cache — omit it rather than writing a bogus embedding.
194
197
  if (stateDb && missVecs.length === missTexts.length) {
195
198
  try {
196
- const toUpsert = missIndices.map((idx, pos) => ({
197
- contentHash: contentHashes[idx],
198
- embedding: missVecs[pos],
199
- modelId,
200
- }));
199
+ const toUpsert = missIndices.flatMap((idx, pos) => {
200
+ const embedding = missVecs[pos];
201
+ return embedding ? [{ contentHash: contentHashes[idx], embedding, modelId }] : [];
202
+ });
201
203
  upsertBodyEmbeddings(stateDb, toUpsert);
202
204
  }
203
205
  catch {
@@ -783,26 +785,23 @@ async function judgeConsolidationChunks(args) {
783
785
  ...(opts.onNotices ? { onNotices: opts.onNotices } : {}),
784
786
  });
785
787
  };
786
- let raw = await callChunkLlm(`chunk ${chunkIdx + 1} failed`);
788
+ // callChunkLlm already retries once internally (llm/client.ts's
789
+ // chatCompletion, jittered 200-800ms backoff) — a second, outer retry
790
+ // here stacked an uncoordinated fixed 2s backoff on top of it. Removed;
791
+ // only mark the chunk failed once the single retry the client already
792
+ // performs has been exhausted.
793
+ const raw = await callChunkLlm(`chunk ${chunkIdx + 1} failed`);
787
794
  if (!raw.ok) {
788
- // Single retry with 2s backoff before recording chunk as lost.
789
- // Recovers transient Shredder LM Studio timeouts without significantly
790
- // extending run time. Only marks failed if both attempts fail.
791
- await new Promise((r) => setTimeout(r, 2_000));
792
- const retry = await callChunkLlm(`chunk ${chunkIdx + 1} retry failed`);
793
- if (!retry.ok) {
794
- warn(retry.error ?? `chunk ${chunkIdx + 1} failed after retry`);
795
- warnings.push(retry.error ?? `chunk ${chunkIdx + 1} failed after retry`);
796
- totalChunksProcessed++;
797
- accounting.totalChunksFailed++;
798
- // Account for the chunk's memories under the failed-chunk bucket.
799
- // judgedNoAction does NOT run on this path (it's after the success
800
- // guards) so without this the accounting invariant breaks on every
801
- // chunk-level transport/parse failure.
802
- accounting.failedChunkMemories += chunk.length;
803
- continue;
804
- }
805
- raw = retry;
795
+ warn(raw.error ?? `chunk ${chunkIdx + 1} failed`);
796
+ warnings.push(raw.error ?? `chunk ${chunkIdx + 1} failed`);
797
+ totalChunksProcessed++;
798
+ accounting.totalChunksFailed++;
799
+ // Account for the chunk's memories under the failed-chunk bucket.
800
+ // judgedNoAction does NOT run on this path (it's after the success
801
+ // guards) so without this the accounting invariant breaks on every
802
+ // chunk-level transport/parse failure.
803
+ accounting.failedChunkMemories += chunk.length;
804
+ continue;
806
805
  }
807
806
  // C9 action 1: AKM_DEBUG_LLM was a separate, undocumented env var for this
808
807
  // one diagnostic; folded into the standard AKM_VERBOSE gate (warnVerbose)
@@ -922,7 +921,7 @@ async function planConsolidation(opts, config, stashDir, _startMs, memories, war
922
921
  const antiCollapseForCluster = getImproveProcessConfig("consolidate", opts.improveProfile)?.antiCollapse ??
923
922
  {};
924
923
  if (antiCollapseForCluster.enabled !== false && clusteredMemories.length > 2) {
925
- const fraction = antiCollapseForCluster.randomClusterFraction ?? 0.05;
924
+ const fraction = antiCollapseForCluster.randomClusterFraction ?? DEFAULT_RANDOM_CLUSTER_FRACTION;
926
925
  const randomCount = Math.max(1, Math.floor(clusteredMemories.length * fraction));
927
926
  // Pick `randomCount` positions to inject random (un-clustered) members.
928
927
  // Use a seeded-ish shuffle: sort by hash of the name so it's deterministic
@@ -73,7 +73,7 @@ import { stripFrontmatterBody as stripBodyForFidelity } from "./content-hash.js"
73
73
  import { autoRepairLessonFrontmatter, autoSwapDescriptionWhenToUse, collectLessonQualityFindings, repairLessonDescriptionTruncation, } from "./distill/content-repair.js";
74
74
  import { memoryKnowledgePromotionRequiresDispatch, planMemoryKnowledgePromotion, promoteMemoryToKnowledge, } from "./distill/promote-memory.js";
75
75
  import { fetchTopSimilarLessons, persistOutputEncodingSalience, runLessonQualityJudge, writeQualityRejection, } from "./distill/quality-gate.js";
76
- import { buildClsContext, checkDistillFidelity } from "./distill-guards.js";
76
+ import { buildClsContext, checkDistillFidelity, DEFAULT_CLS_ADJACENT_COUNT } from "./distill-guards.js";
77
77
  import { deriveKnowledgeRef } from "./distill-promotion-policy.js";
78
78
  import { buildRefVocabulary, scoreEncodingSalience } from "./encoding-salience.js";
79
79
  import { resolveImproveLlmExecution } from "./execution.js";
@@ -1239,7 +1239,7 @@ async function buildDistillMessages(args) {
1239
1239
  let clsContext = "";
1240
1240
  if (clsConfig.enabled) {
1241
1241
  try {
1242
- const adjacentCount = clsConfig.adjacentCount ?? 3;
1242
+ const adjacentCount = clsConfig.adjacentCount ?? DEFAULT_CLS_ADJACENT_COUNT;
1243
1243
  // Use the asset content or input ref as the query for adjacent retrieval.
1244
1244
  const clsQuery = assetContent ? assetContent.slice(0, 500) : inputRef;
1245
1245
  const adjacentItems = await fetchSimilarLessonsFn(clsQuery, adjacentCount);
@@ -44,6 +44,7 @@ import { indexWrittenAssets } from "../../indexer/index-written-assets.js";
44
44
  import { disposeLoweredExecutionDispatchLease, } from "../../integrations/agent/execution-lowering.js";
45
45
  import { getAvailableHarnesses } from "../../integrations/session-logs/index.js";
46
46
  import { preFilterSession } from "../../integrations/session-logs/pre-filter.js";
47
+ import { isJsonSchemaKnownUnsupported } from "../../llm/client.js";
47
48
  import { callStructured, preflightStructuredLlmRunner } from "../../llm/structured-call.js";
48
49
  import { sha256Hex } from "../../runtime.js";
49
50
  import { getExtractedSessionsMap, getLastExtractRunAt, shouldSkipAlreadyExtractedSession, upsertExtractedSession, } from "../../storage/repositories/extract-sessions-repository.js";
@@ -563,7 +564,13 @@ async function runSessionExtractionLlmCall(args) {
563
564
  return payload.parseFailure ? undefined : payload;
564
565
  },
565
566
  validate: (payload) => ({ ok: true, value: payload }),
566
- maxAttempts: llmRunner.connection.supportsJsonSchema === true ? 1 : 2,
567
+ // One attempt when structured output is expected to work (not explicitly
568
+ // disabled, and this connection hasn't already proven otherwise this
569
+ // process — see `isJsonSchemaKnownUnsupported`); two when it's known
570
+ // unsupported and extraction is relying on looser prompt-contract JSON.
571
+ maxAttempts: llmRunner.connection.supportsJsonSchema !== false && !isJsonSchemaKnownUnsupported(llmRunner.connection)
572
+ ? 1
573
+ : 2,
567
574
  buildFeedback: () => "Your previous response did not contain a valid extraction payload. Respond with ONLY a JSON object matching the requested schema, with a candidates array and no prose or code fences.",
568
575
  });
569
576
  if (result.ok)
@@ -35,7 +35,7 @@ import { akmDistill } from "./distill.js";
35
35
  import { buildLatestProposalTsMap, collectEligibleRefs, collectEligibleRefsReadOnly, memoryCleanupParentRef, resolveImproveScope, shouldAnalyzeMemoryCleanup, } from "./eligibility.js";
36
36
  import { countEvalCases } from "./eval-cases.js";
37
37
  import { resolveImprovePlan, resolveImproveStrategy } from "./improve-strategies.js";
38
- import { improveLockPath, MIN_IMPROVE_LOCK_STALE_MS, releaseImproveLock, tryAcquireImproveLock } from "./locks.js";
38
+ import { improveLockPath, releaseImproveLock, tryAcquireImproveLock } from "./locks.js";
39
39
  // The cycle loop / post-loop / maintenance stages live in ./loop-stages.
40
40
  import { runImproveLoopStage, runImprovePostLoopStage } from "./loop-stages.js";
41
41
  import { analyzeMemoryCleanup } from "./memory/memory-improve.js";
@@ -115,7 +115,7 @@ function createRunWriteJournal() {
115
115
  export async function akmImprove(options = {}) {
116
116
  const setup = resolveImproveRunSetup(options);
117
117
  options = setup.options;
118
- const { budgetMs, budgetAbortController, scope, selectedStrategy, syncRepoDir, resolvedStateDbPath, resolvedLockPath, lockStaleAfterMs, } = setup;
118
+ const { budgetMs, budgetAbortController, scope, selectedStrategy, syncRepoDir, resolvedStateDbPath, resolvedLockPath, } = setup;
119
119
  let clearBudgetTimer = () => { };
120
120
  let initialGitPaths = new Set();
121
121
  const runJournal = createRunWriteJournal();
@@ -164,7 +164,7 @@ export async function akmImprove(options = {}) {
164
164
  }
165
165
  try {
166
166
  if (!options.dryRun) {
167
- const acquisition = tryAcquireImproveLock(resolvedLockPath, lockStaleAfterMs, options.skipIfLocked, {
167
+ const acquisition = tryAcquireImproveLock(resolvedLockPath, options.skipIfLocked, {
168
168
  // R25: C2 boundary-pinned path — the long-lived handle doesn't exist yet.
169
169
  dbPath: resolvedStateDbPath,
170
170
  });
@@ -484,7 +484,6 @@ function resolveImproveRunSetup(options) {
484
484
  // triage, indexing, proposal work, maintenance, and final stash sync.
485
485
  const lockBaseDir = primaryStashDir ? path.join(primaryStashDir, ".akm") : path.join(options.stashDir ?? ".", ".akm");
486
486
  const resolvedLockPath = improveLockPath(lockBaseDir);
487
- const lockStaleAfterMs = Math.max(MIN_IMPROVE_LOCK_STALE_MS, budgetMs + 10 * 60 * 1000);
488
487
  const effectiveSync = { ...improveProfile.sync, ...options.sync };
489
488
  return {
490
489
  startMs,
@@ -514,7 +513,6 @@ function resolveImproveRunSetup(options) {
514
513
  syncRepoDir,
515
514
  resolvedStateDbPath,
516
515
  resolvedLockPath,
517
- lockStaleAfterMs,
518
516
  effectiveSync,
519
517
  };
520
518
  }
@@ -9,13 +9,12 @@ import { createLockPayload, probeLock, reclaimStaleLock, releaseLock, tryAcquire
9
9
  import { tryWithMaintenanceStartBarrier, withMaintenanceStartBarrier } from "../../core/maintenance-barrier.js";
10
10
  import { describeInaccessiblePath } from "../../core/path-access.js";
11
11
  import { warn } from "../../core/warn.js";
12
- export const MIN_IMPROVE_LOCK_STALE_MS = 4 * 60 * 60 * 1000;
13
12
  export function improveLockPath(lockBaseDir) {
14
13
  return path.join(lockBaseDir, "improve.lock");
15
14
  }
16
- export function tryAcquireImproveLock(lockPath, staleAfterMs, skipIfLocked, eventsCtx) {
15
+ export function tryAcquireImproveLock(lockPath, skipIfLocked, eventsCtx) {
17
16
  let recoveryEvent;
18
- const acquire = () => tryAcquireImproveLockUnlocked(lockPath, staleAfterMs, skipIfLocked, (event) => {
17
+ const acquire = () => tryAcquireImproveLockUnlocked(lockPath, skipIfLocked, (event) => {
19
18
  recoveryEvent = event;
20
19
  });
21
20
  const result = skipIfLocked ? tryWithMaintenanceStartBarrier(acquire) : withMaintenanceStartBarrier(acquire);
@@ -37,14 +36,23 @@ export function tryAcquireImproveLock(lockPath, staleAfterMs, skipIfLocked, even
37
36
  }
38
37
  return result;
39
38
  }
40
- function tryAcquireImproveLockUnlocked(lockPath, staleAfterMs, skipIfLocked, onRecovered) {
39
+ function tryAcquireImproveLockUnlocked(lockPath, skipIfLocked, onRecovered) {
41
40
  fs.mkdirSync(path.dirname(lockPath), { recursive: true });
42
41
  const lockPayload = () => createLockPayload({ startedAt: new Date().toISOString() });
43
42
  let ownership = tryAcquireLockSync(lockPath, lockPayload());
44
43
  if (ownership) {
45
44
  return { state: "acquired", ownership };
46
45
  }
47
- const probe = probeLock(lockPath, { staleAfterMs });
46
+ // No `staleAfterMs`: only a verifiably dead holder is ever reclaimed. A
47
+ // wedged-but-alive `akm improve` (SQLite WAL + busy_timeout + BEGIN
48
+ // IMMEDIATE already serialize concurrent writes to state.db at the
49
+ // correctness layer, so this lock only avoids duplicate LOGICAL work) must
50
+ // not have its lease silently taken away purely because a clock elapsed —
51
+ // that was the #872-shaped hazard here: a live holder passed the
52
+ // PID-liveness check forever, so only a multi-hour age window could ever
53
+ // free it, stranding every `akm improve --skip-if-locked` invocation for
54
+ // up to that long while reporting success.
55
+ const probe = probeLock(lockPath);
48
56
  // Race: the holder released the lock between our failed `tryAcquireLockSync`
49
57
  // and this probe, so the probe sees no file (`absent`). Retry acquisition once
50
58
  // rather than falling through to the contended skip/throw below — otherwise we
@@ -13,7 +13,6 @@ import { getDbPath } 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";
16
- import { withIndexWriterLease } from "../../indexer/index-writer-lock.js";
17
16
  import { deriveWritableBundleIds } from "../../indexer/installations.js";
18
17
  import { collectPendingMemories, runMemoryInferencePass, } from "../../indexer/passes/memory-inference.js";
19
18
  import { resolveSourceEntries } from "../../indexer/search/search-source.js";
@@ -679,13 +678,13 @@ export async function runImproveMaintenancePasses(args) {
679
678
  graphExtractionFn,
680
679
  reindexWithIndexDbReleased,
681
680
  };
682
- const collected = await withIndexWriterLease({ purpose: "improve-maintenance", signal: budgetSignal }, () => runMaintenancePassesUnderLease(ctx, dbCell, {
681
+ const collected = await runMaintenancePassesUnderLease(ctx, dbCell, {
683
682
  actionableRefs: args.actionableRefs,
684
683
  memoryRefsForInference,
685
684
  consolidationRan: args.consolidationRan,
686
685
  allWarnings,
687
686
  openIndexDb,
688
- }));
687
+ });
689
688
  return {
690
689
  ...(collected.memoryInference ? { memoryInference: collected.memoryInference } : {}),
691
690
  ...(collected.graphExtraction ? { graphExtraction: collected.graphExtraction } : {}),
@@ -697,12 +696,12 @@ export async function runImproveMaintenancePasses(args) {
697
696
  };
698
697
  }
699
698
  /**
700
- * The maintenance sequence run under the index-writer lease (formerly the
701
- * ~389-line anonymous `withIndexWriterLease` callback): memory inference
702
- * reindex-after-inference → graph extraction → proposal hygiene (orphan purge,
703
- * expiration) → retention purges. Each pass returns its results and warnings;
704
- * this orchestrator folds warnings into the caller's `allWarnings` sink at the
705
- * same points the inline code pushed them.
699
+ * The maintenance sequence (formerly the ~389-line anonymous
700
+ * `withIndexWriterLease` callback, before #872 removed the index-rebuild
701
+ * lease): memory inference → reindex-after-inference → graph extraction →
702
+ * proposal hygiene (orphan purge, expiration) → retention purges. Each pass
703
+ * returns its results and warnings; this orchestrator folds warnings into the
704
+ * caller's `allWarnings` sink at the same points the inline code pushed them.
706
705
  */
707
706
  async function runMaintenancePassesUnderLease(ctx, dbCell, args) {
708
707
  const { allWarnings } = args;
@@ -14,7 +14,7 @@
14
14
  * # Algorithm
15
15
  *
16
16
  * 1. Collect all derived memories grouped by `parentRef` family.
17
- * 2. For each family, enumerate candidate pairs (limited to MAX_FAMILY_SIZE).
17
+ * 2. For each family, enumerate candidate pairs.
18
18
  * 3. For each pair, call the LLM to judge whether the two memories are in
19
19
  * direct factual conflict.
20
20
  * 4. For confirmed contradictions, write `contradictedBy` edges directly to
@@ -43,16 +43,6 @@ import { callStructured, preflightStructuredLlmRunner } from "../../../llm/struc
43
43
  import { resolveImproveLlmExecution } from "../execution.js";
44
44
  import { isDerivedMemory, memoryIdentityRef, resolveParentRef } from "./derived-ref.js";
45
45
  // ── Constants ────────────────────────────────────────────────────────────────
46
- /**
47
- * Maximum family size for pairwise contradiction checking. Families larger
48
- * than this are skipped to bound the LLM call count (O(n²) pairs).
49
- */
50
- const MAX_FAMILY_SIZE = 8;
51
- /**
52
- * Maximum number of contradiction pairs to check per improve run, across all
53
- * families. Prevents runaway LLM usage on stashes with many memories.
54
- */
55
- const MAX_PAIRS_PER_RUN = 20;
56
46
  /**
57
47
  * Minimum confidence required to write a contradiction edge. Below this
58
48
  * threshold the LLM may be flagging topic-overlap rather than genuine logical
@@ -218,15 +208,9 @@ export async function detectAndWriteContradictions(stashDir, config, chat, strat
218
208
  for (const [, family] of byParent) {
219
209
  if (family.length < 2)
220
210
  continue;
221
- if (family.length > MAX_FAMILY_SIZE) {
222
- result.warnings.push(`Skipping contradiction check for family of ${family.length} members (exceeds MAX_FAMILY_SIZE=${MAX_FAMILY_SIZE})`);
223
- continue;
224
- }
225
211
  result.familiesExamined++;
226
212
  for (let i = 0; i < family.length - 1; i++) {
227
213
  for (let j = i + 1; j < family.length; j++) {
228
- if (candidatePairs.length >= MAX_PAIRS_PER_RUN)
229
- break;
230
214
  const a = family[i];
231
215
  const b = family[j];
232
216
  if (!a || !b)
@@ -245,8 +229,6 @@ export async function detectAndWriteContradictions(stashDir, config, chat, strat
245
229
  continue;
246
230
  candidatePairs.push({ a, b, loser, winnerRef });
247
231
  }
248
- if (candidatePairs.length >= MAX_PAIRS_PER_RUN)
249
- break;
250
232
  }
251
233
  }
252
234
  if (candidatePairs.length === 0) {
@@ -9,12 +9,6 @@ import { getAllAssetOutcomes, getAssetOutcome, getOutcomeScoresByRef, upsertAsse
9
9
  * At α = 0.3 the window is ≈ 3 cycles.
10
10
  */
11
11
  export const OUTCOME_EMA_ALPHA = 0.3;
12
- /**
13
- * Maximum K improve cycles for the eligibility-trace window. Retrievals older
14
- * than K cycles contribute via the EMA decay naturally (they are already baked
15
- * into `expected_retrieval_rate`).
16
- */
17
- export const OUTCOME_TRACE_CYCLES = 5;
18
12
  /**
19
13
  * Warm-start cap: the maximum `outcome_score` a brand-new row can be seeded with
20
14
  * (from the utility EMA). Prevents a `[0,1]`-range utility value from generating