akm-cli 0.9.0-rc.1 → 0.9.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (228) hide show
  1. package/CHANGELOG.md +223 -9
  2. package/SECURITY.md +23 -24
  3. package/dist/assets/help/help-improve.md +10 -10
  4. package/dist/assets/hints/cli-hints-full.md +44 -10
  5. package/dist/assets/hints/cli-hints-short.md +6 -2
  6. package/dist/assets/{profiles → improve-strategies}/default.json +1 -0
  7. package/dist/assets/{profiles → improve-strategies}/graph-refresh.json +1 -1
  8. package/dist/assets/{profiles → improve-strategies}/proactive-maintenance.json +2 -3
  9. package/dist/assets/{profiles → improve-strategies}/reflect-distill.json +3 -4
  10. package/dist/assets/stash-skeleton/README.md +28 -0
  11. package/dist/assets/stash-skeleton/facts/conventions/assets/agent.md +6 -0
  12. package/dist/assets/stash-skeleton/facts/conventions/assets/command.md +6 -0
  13. package/dist/assets/stash-skeleton/facts/conventions/assets/fact.md +12 -1
  14. package/dist/assets/stash-skeleton/facts/conventions/assets/knowledge.md +11 -1
  15. package/dist/assets/stash-skeleton/facts/conventions/assets/lesson.md +6 -0
  16. package/dist/assets/stash-skeleton/facts/conventions/assets/memory.md +9 -0
  17. package/dist/assets/stash-skeleton/facts/conventions/assets/script.md +7 -0
  18. package/dist/assets/stash-skeleton/facts/conventions/assets/skill.md +7 -0
  19. package/dist/assets/stash-skeleton/facts/conventions/assets/workflow.md +6 -0
  20. package/dist/assets/stash-skeleton/facts/conventions/backlinks.md +98 -0
  21. package/dist/assets/stash-skeleton/facts/conventions/domains.md +63 -0
  22. package/dist/assets/stash-skeleton/facts/conventions/organization.md +127 -0
  23. package/dist/assets/tasks/core/backup.yml +1 -0
  24. package/dist/assets/tasks/core/extract.yml +1 -0
  25. package/dist/assets/tasks/core/improve.yml +1 -0
  26. package/dist/assets/tasks/core/index-refresh.yml +1 -0
  27. package/dist/assets/tasks/core/sync.yml +1 -0
  28. package/dist/assets/tasks/core/version-check.yml +1 -0
  29. package/dist/assets/tasks/graph-refresh-weekly.yml +4 -4
  30. package/dist/assets/templates/html/health.html +5 -1
  31. package/dist/cli/config-migrate.js +31 -138
  32. package/dist/cli/config-validate.js +10 -8
  33. package/dist/cli.js +48 -14
  34. package/dist/commands/agent/agent-dispatch.js +17 -16
  35. package/dist/commands/agent/agent-support.js +0 -24
  36. package/dist/commands/agent/contribute-cli.js +5 -15
  37. package/dist/commands/backup-cli.js +54 -0
  38. package/dist/commands/config-cli.js +45 -159
  39. package/dist/commands/env/secret.js +8 -5
  40. package/dist/commands/health/checks.js +130 -83
  41. package/dist/commands/health/html-report.js +4 -0
  42. package/dist/commands/health/improve-metrics.js +30 -32
  43. package/dist/commands/health/llm-usage.js +19 -19
  44. package/dist/commands/health/md-report.js +4 -0
  45. package/dist/commands/health/metrics.js +2 -1
  46. package/dist/commands/health/surfaces.js +5 -4
  47. package/dist/commands/health.js +1 -1
  48. package/dist/commands/improve/consolidate/chunking.js +2 -2
  49. package/dist/commands/improve/consolidate.js +28 -25
  50. package/dist/commands/improve/distill/promote-memory.js +5 -12
  51. package/dist/commands/improve/distill/quality-gate.js +5 -7
  52. package/dist/commands/improve/distill.js +16 -5
  53. package/dist/commands/improve/eligibility.js +22 -12
  54. package/dist/commands/improve/extract-cli.js +47 -19
  55. package/dist/commands/improve/extract.js +110 -62
  56. package/dist/commands/improve/improve-cli.js +38 -16
  57. package/dist/commands/improve/improve-result-file.js +30 -24
  58. package/dist/commands/improve/improve-strategies.js +137 -0
  59. package/dist/commands/improve/improve.js +60 -30
  60. package/dist/commands/improve/locks.js +66 -45
  61. package/dist/commands/improve/loop-stages.js +75 -33
  62. package/dist/commands/improve/memory/memory-belief.js +79 -7
  63. package/dist/commands/improve/memory/memory-contradiction-detect.js +12 -4
  64. package/dist/commands/improve/preparation.js +71 -73
  65. package/dist/commands/improve/procedural.js +3 -2
  66. package/dist/commands/improve/recombine.js +2 -1
  67. package/dist/commands/improve/reflect.js +119 -214
  68. package/dist/commands/improve/shared.js +11 -5
  69. package/dist/commands/lint/base-linter.js +152 -42
  70. package/dist/commands/mv-cli.js +809 -0
  71. package/dist/commands/proposal/proposal-cli.js +18 -8
  72. package/dist/commands/proposal/propose.js +64 -69
  73. package/dist/commands/read/knowledge.js +436 -4
  74. package/dist/commands/read/remember-cli.js +39 -2
  75. package/dist/commands/read/search-cli.js +6 -1
  76. package/dist/commands/registry-cli.js +29 -14
  77. package/dist/commands/remember.js +2 -0
  78. package/dist/commands/sources/init.js +13 -14
  79. package/dist/commands/sources/schema-repair.js +2 -4
  80. package/dist/commands/sources/source-add.js +62 -73
  81. package/dist/commands/sources/source-manage.js +50 -46
  82. package/dist/commands/sources/stash-cli.js +41 -4
  83. package/dist/commands/tasks/default-tasks.js +12 -12
  84. package/dist/commands/tasks/tasks-cli.js +7 -3
  85. package/dist/commands/tasks/tasks.js +113 -18
  86. package/dist/commands/wiki-cli.js +9 -10
  87. package/dist/core/asset/frontmatter.js +12 -2
  88. package/dist/core/common.js +5 -3
  89. package/dist/core/config/config-io.js +28 -17
  90. package/dist/core/config/config-schema.js +358 -66
  91. package/dist/core/config/config-types.js +3 -3
  92. package/dist/core/config/config-version.js +29 -0
  93. package/dist/core/config/config-walker.js +98 -27
  94. package/dist/core/config/config.js +132 -266
  95. package/dist/core/config/deep-merge.js +41 -0
  96. package/dist/core/config/engine-semantics.js +32 -0
  97. package/dist/core/errors.js +2 -2
  98. package/dist/core/extra-params.js +61 -0
  99. package/dist/core/file-lock.js +201 -56
  100. package/dist/core/improve-result.js +178 -0
  101. package/dist/core/maintenance-barrier.js +119 -0
  102. package/dist/core/migration-backup.js +416 -0
  103. package/dist/core/paths.js +3 -0
  104. package/dist/core/redaction.js +358 -0
  105. package/dist/core/state/migrations.js +17 -2
  106. package/dist/core/state-db.js +44 -1
  107. package/dist/indexer/db/db.js +116 -1
  108. package/dist/indexer/graph/graph-extraction.js +28 -16
  109. package/dist/indexer/index-writer-lock.js +31 -24
  110. package/dist/indexer/index-written-assets.js +15 -6
  111. package/dist/indexer/indexer.js +47 -2
  112. package/dist/indexer/passes/memory-inference.js +10 -6
  113. package/dist/indexer/passes/metadata.js +250 -0
  114. package/dist/indexer/search/db-search.js +111 -44
  115. package/dist/indexer/search/fts-query.js +41 -0
  116. package/dist/indexer/search/ranking-contributors.js +48 -0
  117. package/dist/indexer/search/ranking.js +36 -23
  118. package/dist/indexer/search/search-fields.js +11 -1
  119. package/dist/integrations/agent/builder-shared.js +7 -0
  120. package/dist/integrations/agent/builders.js +5 -52
  121. package/dist/integrations/agent/config.js +3 -143
  122. package/dist/integrations/agent/detect.js +17 -2
  123. package/dist/integrations/agent/engine-resolution.js +202 -0
  124. package/dist/integrations/agent/index.js +1 -2
  125. package/dist/integrations/agent/model-aliases.js +7 -2
  126. package/dist/integrations/agent/profiles.js +6 -99
  127. package/dist/integrations/agent/runner-dispatch.js +76 -13
  128. package/dist/integrations/agent/runner.js +76 -207
  129. package/dist/integrations/agent/spawn.js +4 -6
  130. package/dist/integrations/harnesses/aider/agent-builder.js +2 -3
  131. package/dist/integrations/harnesses/aider/index.js +0 -1
  132. package/dist/integrations/harnesses/amazonq/agent-builder.js +2 -3
  133. package/dist/integrations/harnesses/amazonq/index.js +0 -1
  134. package/dist/integrations/harnesses/claude/agent-builder.js +2 -3
  135. package/dist/integrations/harnesses/claude/index.js +0 -2
  136. package/dist/integrations/harnesses/codex/agent-builder.js +2 -3
  137. package/dist/integrations/harnesses/codex/index.js +0 -1
  138. package/dist/integrations/harnesses/copilot/agent-builder.js +2 -3
  139. package/dist/integrations/harnesses/copilot/index.js +0 -1
  140. package/dist/integrations/harnesses/gemini/agent-builder.js +2 -3
  141. package/dist/integrations/harnesses/gemini/index.js +0 -1
  142. package/dist/integrations/harnesses/index.js +1 -24
  143. package/dist/integrations/harnesses/opencode/agent-builder.js +2 -3
  144. package/dist/integrations/harnesses/opencode/index.js +0 -6
  145. package/dist/integrations/harnesses/opencode-sdk/harness.js +1 -6
  146. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +274 -191
  147. package/dist/integrations/harnesses/openhands/agent-builder.js +2 -3
  148. package/dist/integrations/harnesses/openhands/index.js +0 -1
  149. package/dist/integrations/harnesses/pi/agent-builder.js +2 -3
  150. package/dist/integrations/harnesses/pi/index.js +0 -1
  151. package/dist/integrations/harnesses/types.js +1 -32
  152. package/dist/integrations/lockfile.js +32 -21
  153. package/dist/llm/client.js +48 -14
  154. package/dist/llm/feature-gate.js +15 -47
  155. package/dist/llm/graph-extract.js +1 -1
  156. package/dist/llm/index-passes.js +8 -42
  157. package/dist/llm/memory-infer-impl.js +1 -1
  158. package/dist/llm/usage-persist.js +4 -0
  159. package/dist/llm/usage-telemetry.js +35 -5
  160. package/dist/output/shapes/helpers.js +2 -1
  161. package/dist/output/shapes/passthrough.js +2 -0
  162. package/dist/output/text/helpers.js +3 -1
  163. package/dist/schemas/akm-config.json +11013 -8600
  164. package/dist/schemas/akm-task.json +87 -0
  165. package/dist/schemas/akm-workflow.json +57 -13
  166. package/dist/scripts/migrate-storage.js +8591 -509
  167. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +8623 -484
  168. package/dist/setup/detected-engines.js +142 -0
  169. package/dist/setup/engine-config.js +89 -0
  170. package/dist/setup/setup.js +236 -132
  171. package/dist/setup/steps/connection.js +61 -32
  172. package/dist/setup/steps/platforms.js +4 -4
  173. package/dist/setup/steps.js +3 -2
  174. package/dist/storage/database.js +13 -1
  175. package/dist/storage/engines/sqlite-migrations.js +1 -0
  176. package/dist/storage/repositories/improve-runs-repository.js +5 -5
  177. package/dist/storage/repositories/task-history-repository.js +78 -0
  178. package/dist/storage/repositories/workflow-runs-repository.js +9 -8
  179. package/dist/tasks/parser.js +138 -52
  180. package/dist/tasks/runner.js +71 -75
  181. package/dist/tasks/schema.js +1 -1
  182. package/dist/tasks/validator.js +11 -6
  183. package/dist/wiki/wiki.js +9 -8
  184. package/dist/workflows/authoring/workflow-program-template.yaml +3 -3
  185. package/dist/workflows/concurrency-policy.js +15 -0
  186. package/dist/workflows/db.js +65 -13
  187. package/dist/workflows/exec/brief.js +18 -24
  188. package/dist/workflows/exec/frozen-judge.js +47 -0
  189. package/dist/workflows/exec/native-executor.js +124 -65
  190. package/dist/workflows/exec/report.js +93 -33
  191. package/dist/workflows/exec/run-workflow.js +38 -25
  192. package/dist/workflows/exec/scheduler.js +12 -41
  193. package/dist/workflows/exec/step-work.js +91 -35
  194. package/dist/workflows/ir/compile.js +13 -26
  195. package/dist/workflows/ir/freeze.js +243 -0
  196. package/dist/workflows/ir/plan-hash.js +40 -5
  197. package/dist/workflows/ir/schema.js +542 -1
  198. package/dist/workflows/parser.js +7 -0
  199. package/dist/workflows/program/parser.js +132 -23
  200. package/dist/workflows/program/project.js +3 -4
  201. package/dist/workflows/program/schema.js +2 -2
  202. package/dist/workflows/resource-limits.js +20 -0
  203. package/dist/workflows/runtime/plan-classifier.js +187 -0
  204. package/dist/workflows/runtime/runs.js +54 -47
  205. package/dist/workflows/runtime/workflow-asset-loader.js +0 -22
  206. package/dist/workflows/validator.js +25 -0
  207. package/docs/data-and-telemetry.md +2 -2
  208. package/docs/migration/release-notes/0.6.0.md +1 -1
  209. package/docs/migration/release-notes/0.7.0.md +5 -4
  210. package/docs/migration/v0.8-to-v0.9.md +401 -0
  211. package/package.json +4 -2
  212. package/schemas/akm-config.json +16638 -0
  213. package/schemas/akm-task.json +87 -0
  214. package/schemas/akm-workflow.json +372 -0
  215. package/dist/commands/improve/improve-profiles.js +0 -168
  216. package/dist/core/config/config-migration.js +0 -602
  217. package/dist/core/deep-merge.js +0 -38
  218. package/dist/llm/call-ai.js +0 -62
  219. package/dist/setup/legacy-config.js +0 -106
  220. package/docs/README.md +0 -104
  221. /package/dist/assets/{profiles → improve-strategies}/catchup.json +0 -0
  222. /package/dist/assets/{profiles → improve-strategies}/consolidate.json +0 -0
  223. /package/dist/assets/{profiles → improve-strategies}/frequent.json +0 -0
  224. /package/dist/assets/{profiles → improve-strategies}/memory-focus.json +0 -0
  225. /package/dist/assets/{profiles → improve-strategies}/quick.json +0 -0
  226. /package/dist/assets/{profiles → improve-strategies}/recombine-only.json +0 -0
  227. /package/dist/assets/{profiles → improve-strategies}/synthesize.json +0 -0
  228. /package/dist/assets/{profiles → improve-strategies}/thorough.json +0 -0
@@ -68,8 +68,7 @@
68
68
  * `pattern: "local-runner"`, `structuredOutput: "native-json"`, and no
69
69
  * `resume`). Exported standalone so that task only adds a registry entry.
70
70
  */
71
- import { assertNotFlag } from "../../agent/builder-shared.js";
72
- import { resolveModel } from "../../agent/model-aliases.js";
71
+ import { assertNotFlag, resolveDispatchModel, } from "../../agent/builder-shared.js";
73
72
  /** Canonical harness/platform id used for model-alias resolution. */
74
73
  export const OPENHANDS_PLATFORM = "openhands";
75
74
  /**
@@ -117,7 +116,7 @@ export const openhandsBuilder = {
117
116
  args.push(`--task=${buildTaskPayload(req)}`);
118
117
  let env;
119
118
  if (req.model) {
120
- const resolved = resolveModel(req.model, OPENHANDS_PLATFORM, profile.modelAliases, profile.globalModelAliases);
119
+ const resolved = resolveDispatchModel(req, profile, OPENHANDS_PLATFORM);
121
120
  // Model travels via env, not argv — OpenHands' documented channel.
122
121
  env = { [OPENHANDS_MODEL_ENV]: resolved };
123
122
  }
@@ -24,7 +24,6 @@ function caps(c) {
24
24
  detection: false,
25
25
  configImport: false,
26
26
  runtimeIdentity: false,
27
- v1Migration: false,
28
27
  ...c,
29
28
  };
30
29
  }
@@ -48,8 +48,7 @@
48
48
  * are registry-side). Exported standalone so that task only adds a registry
49
49
  * entry.
50
50
  */
51
- import { assertNotFlag } from "../../agent/builder-shared.js";
52
- import { resolveModel } from "../../agent/model-aliases.js";
51
+ import { assertNotFlag, resolveDispatchModel, } from "../../agent/builder-shared.js";
53
52
  /** Canonical harness/platform id used for model-alias resolution. */
54
53
  export const PI_PLATFORM = "pi";
55
54
  /**
@@ -87,7 +86,7 @@ export const piBuilder = {
87
86
  args.push("--system-prompt", req.systemPrompt);
88
87
  }
89
88
  if (req.model) {
90
- const resolved = resolveModel(req.model, PI_PLATFORM, profile.modelAliases, profile.globalModelAliases);
89
+ const resolved = resolveDispatchModel(req, profile, PI_PLATFORM);
91
90
  args.push("--model", resolved);
92
91
  }
93
92
  if (req.schema) {
@@ -24,7 +24,6 @@ function caps(c) {
24
24
  detection: false,
25
25
  configImport: false,
26
26
  runtimeIdentity: false,
27
- v1Migration: false,
28
27
  ...c,
29
28
  };
30
29
  }
@@ -4,11 +4,7 @@
4
4
  /**
5
5
  * Shared base for harness descriptors (#566).
6
6
  *
7
- * Provides the default v1-profile-name matcher so the inference logic lives in
8
- * ONE place instead of being duplicated across the old `guessAgentPlatform()`
9
- * (config-migration) and the `name.includes("claude")` heuristic (setup). A
10
- * concrete harness sets `id`/`aliases`/`capabilities` and, when its v1 profile
11
- * names could be decorated (e.g. `"opencode-sdk-fast"`), `v1ProfilePrefixes`.
7
+ * Provides shared optional descriptor fields for concrete harnesses.
12
8
  */
13
9
  export class BaseHarness {
14
10
  runtimeId;
@@ -21,31 +17,4 @@ export class BaseHarness {
21
17
  presenceEnv;
22
18
  resultExtractor;
23
19
  sessionLogProvider;
24
- /**
25
- * Lowercase prefixes that a decorated v1 profile name may start with and
26
- * still belong to this harness (e.g. `["opencode-sdk"]`). The canonical id
27
- * and aliases are always matched in addition to these; an empty list means
28
- * only exact id/alias matches.
29
- */
30
- v1ProfilePrefixes = [];
31
- matchesV1ProfileName(name) {
32
- // Only harnesses with a v1→v2 mapping participate; others never claim a
33
- // legacy profile name (so unknown names are dropped, not misclassified).
34
- if (!this.capabilities.v1Migration)
35
- return false;
36
- const lower = name.trim().toLowerCase();
37
- if (!lower)
38
- return false;
39
- if (lower === this.id.toLowerCase())
40
- return true;
41
- for (const alias of this.aliases) {
42
- if (lower === alias.toLowerCase())
43
- return true;
44
- }
45
- for (const prefix of this.v1ProfilePrefixes) {
46
- if (lower.startsWith(prefix.toLowerCase()))
47
- return true;
48
- }
49
- return false;
50
- }
51
20
  }
@@ -4,8 +4,9 @@
4
4
  import fs from "node:fs";
5
5
  import path from "node:path";
6
6
  import { writeFileAtomic } from "../core/common.js";
7
- import { rethrowIfTestIsolationError } from "../core/errors.js";
8
- import { probeLock, releaseLock, tryAcquireLockSync } from "../core/file-lock.js";
7
+ import { ConfigError, rethrowIfTestIsolationError } from "../core/errors.js";
8
+ import { createLockPayload, probeLock, reclaimStaleLock, releaseLock, tryAcquireLockSync } from "../core/file-lock.js";
9
+ import { acquireMaintenanceBarrier } from "../core/maintenance-barrier.js";
9
10
  import { getDataDir, getLockfileLockPath, getLockfilePath } from "../core/paths.js";
10
11
  // ── Lock sentinel ────────────────────────────────────────────────────────────
11
12
  const LOCK_MAX_RETRIES = 3;
@@ -15,23 +16,26 @@ async function acquireLockSentinel() {
15
16
  // Ensure the directory exists before attempting to create the sentinel.
16
17
  fs.mkdirSync(path.dirname(sentinelPath), { recursive: true });
17
18
  for (let attempt = 0; attempt < LOCK_MAX_RETRIES; attempt++) {
18
- if (tryAcquireLockSync(sentinelPath, String(process.pid))) {
19
- return true; // Sentinel created — we own the lock.
19
+ const releaseBarrier = acquireMaintenanceBarrier();
20
+ try {
21
+ const ownership = tryAcquireLockSync(sentinelPath, createLockPayload());
22
+ if (ownership) {
23
+ return () => releaseLock(ownership);
24
+ }
25
+ const probe = probeLock(sentinelPath);
26
+ if (probe.state === "stale" && reclaimStaleLock(sentinelPath, probe)) {
27
+ continue; // Reclaimed — retry immediately.
28
+ }
20
29
  }
21
- if (probeLock(sentinelPath).state === "stale") {
22
- releaseLock(sentinelPath);
23
- continue; // Reclaimed — retry immediately.
30
+ finally {
31
+ releaseBarrier();
24
32
  }
25
33
  // Another process holds the lock — wait briefly before retrying.
26
34
  if (attempt < LOCK_MAX_RETRIES - 1) {
27
35
  await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_DELAY_MS));
28
36
  }
29
37
  }
30
- // Best-effort: proceed without the lock rather than failing the install.
31
- return false;
32
- }
33
- function releaseLockSentinel() {
34
- releaseLock(getLockfileLockPath());
38
+ throw new ConfigError(`Could not acquire lockfile sentinel at ${sentinelPath}; refusing to write without exclusive ownership.`, "INVALID_CONFIG_FILE");
35
39
  }
36
40
  // ── Read / Write ────────────────────────────────────────────────────────────
37
41
  export function readLockfile() {
@@ -50,36 +54,43 @@ export function readLockfile() {
50
54
  return [];
51
55
  }
52
56
  }
53
- export function writeLockfile(entries) {
57
+ function writeLockfileUnlocked(entries) {
54
58
  // Always write to $DATA — never to the legacy $CONFIG location.
55
59
  const lockfilePath = getLockfilePath();
56
60
  const dir = path.dirname(lockfilePath);
57
61
  fs.mkdirSync(dir, { recursive: true });
58
62
  writeFileAtomic(lockfilePath, `${JSON.stringify(entries, null, 2)}\n`);
59
63
  }
64
+ export async function writeLockfile(entries) {
65
+ const release = await acquireLockSentinel();
66
+ try {
67
+ writeLockfileUnlocked(entries);
68
+ }
69
+ finally {
70
+ release();
71
+ }
72
+ }
60
73
  export async function upsertLockEntry(entry) {
61
- const acquired = await acquireLockSentinel();
74
+ const release = await acquireLockSentinel();
62
75
  try {
63
76
  const entries = readLockfile();
64
77
  const withoutExisting = entries.filter((e) => e.id !== entry.id);
65
- writeLockfile([...withoutExisting, entry]);
78
+ writeLockfileUnlocked([...withoutExisting, entry]);
66
79
  }
67
80
  finally {
68
- if (acquired)
69
- releaseLockSentinel();
81
+ release();
70
82
  }
71
83
  }
72
84
  export async function removeLockEntry(id) {
73
85
  if (!fs.existsSync(getDataDir()))
74
86
  return;
75
- const acquired = await acquireLockSentinel();
87
+ const release = await acquireLockSentinel();
76
88
  try {
77
89
  const entries = readLockfile();
78
- writeLockfile(entries.filter((e) => e.id !== id));
90
+ writeLockfileUnlocked(entries.filter((e) => e.id !== id));
79
91
  }
80
92
  finally {
81
- if (acquired)
82
- releaseLockSentinel();
93
+ release();
83
94
  }
84
95
  }
85
96
  // ── Helpers ─────────────────────────────────────────────────────────────────
@@ -12,14 +12,19 @@
12
12
  */
13
13
  import { fetchWithTimeout } from "../core/common.js";
14
14
  import { resolveSecret } from "../core/config/config.js";
15
+ import { formatExtraParamsIssue, validateExtraParams } from "../core/extra-params.js";
15
16
  import { escapeJsonStringControls, parseJsonResponse, stripCodeFences, stripThinkBlocks } from "../core/parse.js";
17
+ import { redactSensitiveText } from "../core/redaction.js";
16
18
  import { warnVerbose } from "../core/warn.js";
19
+ import { DEFAULT_LLM_TIMEOUT_MS } from "../integrations/agent/config.js";
17
20
  import { emitLlmUsage, extractUsageTokens } from "./usage-telemetry.js";
18
21
  // Re-export shared parse utilities so existing importers of `client.ts` continue
19
22
  // to resolve `parseJsonResponse` and `parseEmbeddedJsonResponse` from this module.
20
23
  export { escapeJsonStringControls, parseEmbeddedJsonResponse, parseJsonResponse, stripCodeFences, stripThinkBlocks, } from "../core/parse.js";
21
24
  /** Maximum length of an LLM error response body included in thrown errors. */
22
25
  const ERROR_BODY_MAX_LEN = 200;
26
+ /** Stable OpenAI-compatible response-schema name used for every structured call. */
27
+ const JSON_SCHEMA_RESPONSE_NAME = "akm_response";
23
28
  /**
24
29
  * Redact credential-shaped substrings from an upstream error body before
25
30
  * including it in a thrown Error. The body is also trimmed to a fixed length
@@ -178,7 +183,11 @@ export async function chatCompletion(config, messages, options) {
178
183
  return chatCompletionReal(config, messages, options);
179
184
  }
180
185
  async function chatCompletionReal(config, messages, options) {
181
- const effectiveTimeoutMs = options?.timeoutMs ?? config.timeoutMs ?? 120_000;
186
+ const effectiveTimeoutMs = options && Object.hasOwn(options, "timeoutMs")
187
+ ? (options.timeoutMs ?? null)
188
+ : Object.hasOwn(config, "timeoutMs")
189
+ ? (config.timeoutMs ?? null)
190
+ : DEFAULT_LLM_TIMEOUT_MS;
182
191
  const started = Date.now();
183
192
  try {
184
193
  return await chatCompletionAttempt(config, messages, options, effectiveTimeoutMs);
@@ -189,8 +198,9 @@ async function chatCompletionReal(config, messages, options) {
189
198
  // Timeout-budget guard: if the first attempt already burned most of the
190
199
  // budget, a second attempt cannot complete — skip the retry.
191
200
  const elapsed = Date.now() - started;
192
- const remaining = effectiveTimeoutMs - elapsed;
193
- if (elapsed >= effectiveTimeoutMs * RETRY_BUDGET_FRACTION || remaining <= 0) {
201
+ const remaining = effectiveTimeoutMs === null ? null : effectiveTimeoutMs - elapsed;
202
+ if (effectiveTimeoutMs !== null &&
203
+ (elapsed >= effectiveTimeoutMs * RETRY_BUDGET_FRACTION || (remaining !== null && remaining <= 0))) {
194
204
  throw err;
195
205
  }
196
206
  // Signal the caller so it can bump `retryAttempts` (NOT `failureCount`).
@@ -210,6 +220,11 @@ async function chatCompletionReal(config, messages, options) {
210
220
  * transient failures.
211
221
  */
212
222
  async function chatCompletionAttempt(config, messages, options, timeoutMs) {
223
+ if (config.extraParams !== undefined) {
224
+ const issue = validateExtraParams(config.extraParams)[0];
225
+ if (issue)
226
+ throw new Error(formatExtraParamsIssue("LLM extraParams", issue));
227
+ }
213
228
  const headers = { "Content-Type": "application/json" };
214
229
  const resolvedKey = resolveSecret(config.apiKey);
215
230
  if (resolvedKey) {
@@ -220,7 +235,12 @@ async function chatCompletionAttempt(config, messages, options, timeoutMs) {
220
235
  // guess is wrong. Users who need a cap can set llm.maxTokens in config.
221
236
  const resolvedMaxTokens = options?.maxTokens ?? config.maxTokens;
222
237
  const responseFormat = options?.responseSchema && config.supportsJsonSchema
223
- ? { response_format: { type: "json_schema", json_schema: { schema: options.responseSchema, strict: true } } }
238
+ ? {
239
+ response_format: {
240
+ type: "json_schema",
241
+ json_schema: { name: JSON_SCHEMA_RESPONSE_NAME, schema: options.responseSchema, strict: true },
242
+ },
243
+ }
224
244
  : {};
225
245
  // Wall-clock start for per-call usage telemetry (#576). Captured here so the
226
246
  // emitted duration covers the full request/response/parse cycle of a single
@@ -252,22 +272,22 @@ async function chatCompletionAttempt(config, messages, options, timeoutMs) {
252
272
  // caller-driven cancellations. Map both to typed LlmCallError.
253
273
  const msg = err instanceof Error ? err.message : String(err);
254
274
  if (err instanceof DOMException && err.name === "AbortError") {
255
- throw new LlmCallError(`Request timed out after ${timeoutMs}ms`, "timeout");
275
+ throw new LlmCallError(`Request timed out${timeoutMs === null ? "" : ` after ${timeoutMs}ms`}`, "timeout");
256
276
  }
257
277
  if (msg.includes("timed out")) {
258
- throw new LlmCallError(`Request timed out after ${timeoutMs}ms`, "timeout");
278
+ throw new LlmCallError(`Request timed out${timeoutMs === null ? "" : ` after ${timeoutMs}ms`}`, "timeout");
259
279
  }
260
280
  throw new LlmCallError(`Network error: ${msg}`, "network_error");
261
281
  }
262
282
  if (!response.ok) {
263
283
  const rawBody = await response.text().catch(() => "");
264
- const safeBody = redactErrorBody(rawBody);
284
+ const safeBody = redactSensitiveText(redactErrorBody(rawBody), resolvedKey ? [resolvedKey] : []);
265
285
  const status = response.status;
266
286
  if (status === 429) {
267
287
  throw new LlmCallError(`LLM request rate limited (429) ${config.endpoint}: ${safeBody}`, "rate_limited", status);
268
288
  }
269
289
  if (status >= 500 && isHtmlResponse(rawBody)) {
270
- throw new LlmCallError(`LLM provider returned HTML instead of JSON (${status}) ${config.endpoint}: ${htmlExcerpt(rawBody)}`, "provider_html_error", status);
290
+ throw new LlmCallError(`LLM provider returned HTML instead of JSON (${status}) ${config.endpoint}: ${redactSensitiveText(htmlExcerpt(rawBody), resolvedKey ? [resolvedKey] : [])}`, "provider_html_error", status);
271
291
  }
272
292
  if (status >= 500) {
273
293
  throw new LlmCallError(`LLM provider error (${status}) ${config.endpoint}: ${safeBody}`, "provider_error", status);
@@ -279,14 +299,14 @@ async function chatCompletionAttempt(config, messages, options, timeoutMs) {
279
299
  // we can categorize an HTML page distinctly from a malformed-JSON parse_error.
280
300
  const rawOkBody = await response.text();
281
301
  if (isHtmlResponse(rawOkBody)) {
282
- throw new LlmCallError(`LLM provider returned HTML instead of JSON (${response.status}) ${config.endpoint}: ${htmlExcerpt(rawOkBody)}`, "provider_html_error", response.status);
302
+ throw new LlmCallError(`LLM provider returned HTML instead of JSON (${response.status}) ${config.endpoint}: ${redactSensitiveText(htmlExcerpt(rawOkBody), resolvedKey ? [resolvedKey] : [])}`, "provider_html_error", response.status);
283
303
  }
284
304
  let json;
285
305
  try {
286
306
  json = JSON.parse(rawOkBody);
287
307
  }
288
308
  catch {
289
- throw new LlmCallError(`LLM response was not valid JSON ${config.endpoint}: ${redactErrorBody(rawOkBody)}`, "parse_error", response.status);
309
+ throw new LlmCallError(`LLM response was not valid JSON ${config.endpoint}: ${redactSensitiveText(redactErrorBody(rawOkBody), resolvedKey ? [resolvedKey] : [])}`, "parse_error", response.status);
290
310
  }
291
311
  // Per-call usage telemetry (#576). Best-effort and fully isolated: a missing
292
312
  // or garbled usage block still records duration + model, and a throwing sink
@@ -300,7 +320,7 @@ async function chatCompletionAttempt(config, messages, options, timeoutMs) {
300
320
  });
301
321
  const content = (json.choices?.[0]?.message?.content ?? "").trim();
302
322
  const reasoning = (json.choices?.[0]?.message?.reasoning_content ?? "").trim();
303
- return content || reasoning;
323
+ return redactSensitiveText(content || reasoning, resolvedKey ? [resolvedKey] : []);
304
324
  }
305
325
  /**
306
326
  * Strip `<think>` blocks, code fences, and escape control characters in JSON
@@ -325,6 +345,16 @@ export async function isLlmAvailable(config) {
325
345
  }
326
346
  }
327
347
  // ── Capability probe ────────────────────────────────────────────────────────
348
+ const CAPABILITY_PROBE_JSON_SCHEMA = {
349
+ type: "object",
350
+ properties: {
351
+ ok: { type: "boolean", const: true },
352
+ ingest: { type: "boolean", const: true },
353
+ lint: { type: "boolean", const: true },
354
+ },
355
+ required: ["ok", "ingest", "lint"],
356
+ additionalProperties: false,
357
+ };
328
358
  /**
329
359
  * Ask the model to emit a strict JSON object so we know whether the knowledge
330
360
  * wiki ingest/lint flows can rely on structured output. Failure is non-fatal —
@@ -332,7 +362,7 @@ export async function isLlmAvailable(config) {
332
362
  */
333
363
  export async function probeLlmCapabilities(config) {
334
364
  try {
335
- const raw = await chatCompletion(config, [
365
+ const raw = await chatCompletion({ ...config, supportsJsonSchema: true }, [
336
366
  {
337
367
  role: "system",
338
368
  content: "You return only valid JSON. No prose, no markdown fences.",
@@ -341,13 +371,17 @@ export async function probeLlmCapabilities(config) {
341
371
  role: "user",
342
372
  content: 'Return exactly this JSON object and nothing else: {"ok": true, "ingest": true, "lint": true}',
343
373
  },
344
- ], { maxTokens: 64, temperature: 0 });
374
+ ], { maxTokens: 64, temperature: 0, responseSchema: CAPABILITY_PROBE_JSON_SCHEMA });
345
375
  if (!raw)
346
376
  return { reachable: false, structuredOutput: false, error: "empty response" };
347
377
  const parsed = parseJsonResponse(raw);
348
378
  return {
349
379
  reachable: true,
350
- structuredOutput: Boolean(parsed && parsed.ok === true),
380
+ structuredOutput: Boolean(parsed &&
381
+ Object.keys(parsed).length === 3 &&
382
+ parsed.ok === true &&
383
+ parsed.ingest === true &&
384
+ parsed.lint === true),
351
385
  };
352
386
  }
353
387
  catch (err) {
@@ -3,54 +3,39 @@
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  /**
5
5
  * For each feature key, return the effective enabled state by reading the
6
- * new 0.8.0 config shape. Defaults match the legacy `LlmFeatureFlags` docstrings.
6
+ * 0.9.0 config shape. Defaults match the legacy `LlmFeatureFlags` docstrings.
7
7
  */
8
8
  // Defaults below mirror the legacy LlmFeatureFlags docstrings so existing
9
9
  // behaviour is preserved when a config is silent on a flag.
10
10
  const FEATURE_LOCATION = {
11
- // Legacy default: false → memory_consolidation only runs when explicitly enabled
12
- // (either via the user's improve profile or the built-in `default` profile).
13
- memory_consolidation: (cfg) => cfg.profiles?.improve?.default?.processes?.consolidate?.enabled ?? false,
14
- // 0.8.0 unified gate: replaces the legacy `feedback_distillation` key.
15
- // The orchestration gate (planner) and the LLM-call gate now share the same
16
- // source of truth: `processes.distill.enabled`. Default: true (matches the
17
- // built-in `default` profile).
18
- distill: (cfg) => cfg.profiles?.improve?.default?.processes?.distill?.enabled ?? true,
19
11
  // Legacy default: true
20
- memory_inference: (cfg) => cfg.profiles?.improve?.default?.processes?.memoryInference?.enabled ?? true,
12
+ memory_inference: (cfg) => cfg.index?.memory?.enabled ?? true,
21
13
  // Legacy default: true
22
- graph_extraction: (cfg) => cfg.profiles?.improve?.default?.processes?.graphExtraction?.enabled ?? true,
14
+ graph_extraction: (cfg) => cfg.index?.graph?.enabled ?? true,
23
15
  // Legacy default: false
24
16
  metadata_enhance: (cfg) => cfg.index?.metadataEnhance?.enabled ?? false,
25
- // Default ON since R3 (docs/design/improve-self-learning-analysis.md G5):
26
- // distill is a primary acquisition path, so the gate guards minted content by
27
- // default. The judge fails CLOSED (07 P0-2): no LLM / timeout / parse failure
28
- // reject the proposal rather than passing it through — an unjudgeable proposal
29
- // must not slip into the stash. Opt out via
30
- // profiles.improve.default.processes.distill.qualityGate.enabled: false.
31
- lesson_quality_gate: (cfg) => cfg.profiles?.improve?.default?.processes?.distill?.qualityGate?.enabled ?? true,
32
- // Legacy default: false
33
- proposal_quality_gate: (cfg) => cfg.profiles?.improve?.default?.processes?.reflect?.qualityGate?.enabled ?? false,
34
- // Legacy default: false
35
- memory_contradiction_detection: (cfg) => cfg.profiles?.improve?.default?.processes?.consolidate?.contradictionDetection?.enabled ?? false,
36
17
  // Always on at the LLM-wrapper level. Enablement is decided ONCE at the
37
18
  // extract entry point (`akmExtract`): the `extract.enabled` process toggle
38
- // gates extract as a STAGE of `akm improve` (the active improve profile, per
19
+ // gates extract as a STAGE of `akm improve` (the active improve strategy, per
39
20
  // #593/#594), while an explicit `akm extract` command always runs. Gating the
40
21
  // inner LLM calls on `default.processes.extract.enabled` here was a footgun —
41
- // dropping extract from the daily improve profile silently disabled the
22
+ // dropping extract from the daily improve strategy silently disabled the
42
23
  // standalone `akm extract` command. (cfg unused — kept for resolver signature.)
43
24
  session_extraction: (_cfg) => true,
44
25
  };
45
26
  /**
46
27
  * Pure predicate: is the named feature gate enabled in `config`?
47
28
  *
48
- * Reads from the unified 0.8.0 config shape. Defaults follow the legacy
29
+ * Reads from the unified 0.9.0 config shape. Defaults follow the legacy
49
30
  * `LlmFeatureFlags` docstring defaults.
50
31
  */
51
- export function isLlmFeatureEnabled(config, feature) {
32
+ export function isLlmFeatureEnabled(config, feature, improveEnabled) {
52
33
  if (!config)
53
34
  return false;
35
+ // Improve resolves enablement from its selected strategy and passes the
36
+ // decision in. This module does not own a second improve config lookup.
37
+ if (improveEnabled !== undefined)
38
+ return improveEnabled;
54
39
  const resolver = FEATURE_LOCATION[feature];
55
40
  if (!resolver)
56
41
  return false;
@@ -67,13 +52,13 @@ const DEFAULT_TIMEOUT_MS = 600_000;
67
52
  */
68
53
  export async function tryLlmFeature(feature, config, fn, fallback, opts) {
69
54
  const resolveFallback = async () => typeof fallback === "function" ? await fallback() : fallback;
70
- if (!isLlmFeatureEnabled(config, feature)) {
55
+ if (!isLlmFeatureEnabled(config, feature, opts?.enabled)) {
71
56
  opts?.onFallback?.({ feature, reason: "disabled" });
72
57
  return resolveFallback();
73
58
  }
74
- const timeoutMs = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
59
+ const timeoutMs = opts && Object.hasOwn(opts, "timeoutMs") ? (opts.timeoutMs ?? null) : DEFAULT_TIMEOUT_MS;
75
60
  try {
76
- if (timeoutMs <= 0) {
61
+ if (timeoutMs === null || timeoutMs <= 0) {
77
62
  return await fn();
78
63
  }
79
64
  return await runWithTimeout(fn, timeoutMs, feature);
@@ -96,7 +81,7 @@ export async function tryLlmFeature(feature, config, fn, fallback, opts) {
96
81
  export function isProcessEnabled(section, processName, config) {
97
82
  if (!config)
98
83
  return false;
99
- // index.metadataEnhance is a first-class new-shape entry.
84
+ // Index passes are first-class 0.9 entries.
100
85
  if (section === "index") {
101
86
  if (processName === "metadata_enhance" || processName === "metadataEnhance") {
102
87
  return config.index?.metadataEnhance?.enabled ?? true;
@@ -108,23 +93,6 @@ export function isProcessEnabled(section, processName, config) {
108
93
  return isLlmFeatureEnabled(config, "graph_extraction");
109
94
  }
110
95
  }
111
- if (section === "improve") {
112
- const processes = config.profiles?.improve?.default?.processes;
113
- const entry = processes?.[processName];
114
- if (entry && typeof entry.enabled === "boolean")
115
- return entry.enabled;
116
- // Fallback to default-enabled state for known processes.
117
- switch (processName) {
118
- case "reflect":
119
- case "distill":
120
- case "consolidate":
121
- case "memoryInference":
122
- case "graphExtraction":
123
- return true;
124
- default:
125
- return false;
126
- }
127
- }
128
96
  return false;
129
97
  }
130
98
  /** Specific error class so call sites and the wrapper can tell timeouts apart from generic throws. */
@@ -16,7 +16,7 @@
16
16
  * `mock.module("../src/llm/graph-extract", ...)` without hitting a network.
17
17
  *
18
18
  * Locked v1 contract (#208): the LLM connection always comes from the
19
- * shared `akm.llm` block — never from a per-pass override. Callers obtain
19
+ * selected named LLM engine. Callers obtain
20
20
  * the connection via `resolveIndexPassLLM("graph", config)` and pass it
21
21
  * straight through.
22
22
  */
@@ -1,50 +1,16 @@
1
1
  // This Source Code Form is subject to the terms of the Mozilla Public
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
- import { getDefaultLlmConfig, getIndexPassConfig } from "../core/config/config.js";
4
+ import { materializeLlmConnection, resolveLlmEngineUse } from "../integrations/agent/engine-resolution.js";
5
5
  /**
6
- * Map a pass name (as used by callers — "memory", "graph", etc.) to the
7
- * matching key under `profiles.improve.default.processes`. Pass names with
8
- * no improve-profile counterpart return undefined and resolve via the
9
- * default LLM only.
6
+ * Resolve standalone index passes from the index section only. Improve
7
+ * strategies own improve-triggered calls and are intentionally not consulted.
10
8
  */
11
- function improveProcessKeyForPass(passName) {
12
- switch (passName) {
13
- case "memory":
14
- return "memoryInference";
15
- case "graph":
16
- return "graphExtraction";
17
- default:
18
- return undefined;
19
- }
20
- }
21
9
  export function resolveIndexPassLLM(passName, config) {
22
- // Gate 1 — explicit opt-out via the index-config block stays authoritative.
23
- const passConfig = getIndexPassConfig(config.index, passName);
24
- if (passConfig?.llm === false)
10
+ const pass = config.index?.[passName];
11
+ if (pass?.enabled === false)
25
12
  return undefined;
26
- // Gate 2 — per-process profile from the improve profile, when present.
27
- // This is the path that lets
28
- // profiles.improve.default.processes.graphExtraction.profile = "ministral-3b"
29
- // actually take effect on the graph pass instead of being silently
30
- // ignored.
31
- const processKey = improveProcessKeyForPass(passName);
32
- if (processKey) {
33
- const processConfig = config.profiles?.improve?.default?.processes?.[processKey];
34
- // Honor enabled === false here too — an explicit disable wins.
35
- if (processConfig?.enabled === false)
36
- return undefined;
37
- const profileName = processConfig?.profile;
38
- if (profileName) {
39
- const profile = config.profiles?.llm?.[profileName];
40
- if (profile)
41
- return profile;
42
- // A named-but-missing profile is a configuration error in spirit, but
43
- // we fall through to default rather than throwing — callers gracefully
44
- // treat `undefined` as "pass disabled" and emitting a hard throw here
45
- // would take the whole improve run down on a typo.
46
- }
47
- }
48
- // Gate 3 — fall back to the default LLM profile.
49
- return getDefaultLlmConfig(config);
13
+ const defaults = config.index?.defaults;
14
+ const resolved = resolveLlmEngineUse(config, [defaults ?? {}, pass ?? {}], { optional: true });
15
+ return resolved ? materializeLlmConnection(resolved) : undefined;
50
16
  }
@@ -14,7 +14,7 @@
14
14
  * `mock.module("../src/llm/memory-infer", ...)` without hitting a network.
15
15
  *
16
16
  * Locked v1 contract (#208): the LLM connection always comes from the
17
- * shared `akm.llm` block — never from a per-pass override. Callers obtain
17
+ * selected named LLM engine. Callers obtain
18
18
  * the connection via `resolveIndexPassLLM("memory", config)` and pass it
19
19
  * straight through.
20
20
  */
@@ -31,6 +31,10 @@ function toEventMetadata(record) {
31
31
  const metadata = { durationMs: record.durationMs };
32
32
  if (record.stage !== undefined)
33
33
  metadata.stage = record.stage;
34
+ if (record.engine !== undefined)
35
+ metadata.engine = record.engine;
36
+ if (record.process !== undefined)
37
+ metadata.process = record.process;
34
38
  if (record.model !== undefined)
35
39
  metadata.model = record.model;
36
40
  if (record.finishReason !== undefined)
@@ -24,7 +24,7 @@
24
24
  * `undefined` outside any `withLlmStage` scope.
25
25
  */
26
26
  import { AsyncLocalStorage } from "node:async_hooks";
27
- const stageStorage = new AsyncLocalStorage();
27
+ const attributionStorage = new AsyncLocalStorage();
28
28
  let usageSink;
29
29
  /**
30
30
  * Run `fn` with `stage` as the ambient LLM stage. Any `chatCompletion` call
@@ -32,12 +32,12 @@ let usageSink;
32
32
  * helpers and nested `withLlmStage` calls — the innermost wins) is attributed
33
33
  * to `stage`. Returns whatever `fn` returns; never alters control flow.
34
34
  */
35
- export function withLlmStage(stage, fn) {
36
- return stageStorage.run(stage, fn);
35
+ export function withLlmStage(stage, fn, attribution = {}) {
36
+ return attributionStorage.run({ ...attributionStorage.getStore(), ...attribution, stage }, fn);
37
37
  }
38
38
  /** The ambient LLM stage for the current async context, or `undefined` outside any {@link withLlmStage} scope. */
39
39
  export function currentLlmStage() {
40
- return stageStorage.getStore();
40
+ return attributionStorage.getStore()?.stage;
41
41
  }
42
42
  /**
43
43
  * Install the process-wide usage sink. Replaces any previously installed sink.
@@ -69,7 +69,13 @@ export function emitLlmUsage(record) {
69
69
  if (!sink)
70
70
  return;
71
71
  try {
72
- sink({ ...record, stage: record.stage ?? currentLlmStage() });
72
+ const ambient = attributionStorage.getStore();
73
+ sink({
74
+ ...record,
75
+ stage: record.stage ?? ambient?.stage,
76
+ engine: record.engine ?? ambient?.engine,
77
+ process: record.process ?? ambient?.process,
78
+ });
73
79
  }
74
80
  catch {
75
81
  // Telemetry must never break a real run.
@@ -78,6 +84,30 @@ export function emitLlmUsage(record) {
78
84
  function asFiniteNonNegative(value) {
79
85
  return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
80
86
  }
87
+ function asNonEmptyString(value) {
88
+ return typeof value === "string" && value.length > 0 ? value : undefined;
89
+ }
90
+ /** Decode durable event metadata with the same validation used by health aggregation. */
91
+ export function decodeLlmUsageRecord(value) {
92
+ if (!value || typeof value !== "object")
93
+ return undefined;
94
+ const raw = value;
95
+ const durationMs = asFiniteNonNegative(raw.durationMs);
96
+ if (durationMs === undefined)
97
+ return undefined;
98
+ const record = { durationMs };
99
+ for (const key of ["stage", "engine", "process", "model", "finishReason"]) {
100
+ const decoded = asNonEmptyString(raw[key]);
101
+ if (decoded !== undefined)
102
+ record[key] = decoded;
103
+ }
104
+ for (const key of ["promptTokens", "completionTokens", "totalTokens", "reasoningTokens"]) {
105
+ const decoded = asFiniteNonNegative(raw[key]);
106
+ if (decoded !== undefined)
107
+ record[key] = decoded;
108
+ }
109
+ return record;
110
+ }
81
111
  /**
82
112
  * Project a provider `usage` block into the token fields of an
83
113
  * {@link LlmUsageRecord}. Missing or garbled values are omitted (not zeroed)