akm-cli 0.9.14 → 0.9.15-beta.1

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 (110) hide show
  1. package/CHANGELOG.md +397 -0
  2. package/STABILITY.md +6 -3
  3. package/dist/assets/prompts/reflect-feedback-framing.md +1 -0
  4. package/dist/assets/prompts/reflect-llm-framed-contract.md +2 -0
  5. package/dist/assets/prompts/reflect-llm-schema-contract.md +2 -0
  6. package/dist/assets/tasks/core/improve.yml +1 -1
  7. package/dist/assets/tasks/core/index-refresh.yml +1 -1
  8. package/dist/assets/tasks/improve/akm-graph-refresh-weekly.yml +1 -1
  9. package/dist/assets/tasks/improve/akm-improve-catchup.yml +1 -1
  10. package/dist/assets/tasks/improve/akm-improve-consolidate.yml +1 -1
  11. package/dist/assets/tasks/improve/akm-improve-frequent.yml +1 -1
  12. package/dist/assets/tasks/improve/akm-improve-nightly.yml +1 -1
  13. package/dist/cli/retired-commands.js +0 -1
  14. package/dist/cli/shared.js +9 -0
  15. package/dist/cli/unknown-flags.js +1 -0
  16. package/dist/cli.js +3 -2
  17. package/dist/commands/config-cli.js +85 -3
  18. package/dist/commands/env/env-cli.js +1 -42
  19. package/dist/commands/env/env.js +1 -1
  20. package/dist/commands/env/secret-cli.js +1 -2
  21. package/dist/commands/health/checks.js +357 -63
  22. package/dist/commands/health/engine-usage.js +45 -0
  23. package/dist/commands/health/improve-metrics.js +18 -0
  24. package/dist/commands/health/llm-usage.js +41 -1
  25. package/dist/commands/health/plugin-staleness.js +7 -3
  26. package/dist/commands/health/version-drift.js +93 -0
  27. package/dist/commands/health/windows.js +3 -1
  28. package/dist/commands/health.js +44 -9
  29. package/dist/commands/improve/consolidate/chunking.js +4 -2
  30. package/dist/commands/improve/improve-cli.js +99 -5
  31. package/dist/commands/improve/improve-report.js +154 -0
  32. package/dist/commands/improve/improve-result-file.js +45 -33
  33. package/dist/commands/improve/improve-strategies.js +133 -3
  34. package/dist/commands/improve/improve-usage-report.js +182 -0
  35. package/dist/commands/improve/improve.js +40 -3
  36. package/dist/commands/improve/locks.js +27 -78
  37. package/dist/commands/improve/planner.js +1 -0
  38. package/dist/commands/improve/preparation.js +9 -1
  39. package/dist/commands/improve/reflect.js +44 -4
  40. package/dist/commands/models-cli.js +50 -1
  41. package/dist/commands/proposal/repository.js +8 -3
  42. package/dist/commands/proposal/validators/proposal-quality-validators.js +41 -6
  43. package/dist/commands/proposal/validators/proposal-validators.js +24 -0
  44. package/dist/commands/read/search-cli.js +38 -2
  45. package/dist/commands/read/show.js +103 -4
  46. package/dist/commands/sources/info.js +5 -1
  47. package/dist/commands/sources/self-update.js +2 -2
  48. package/dist/commands/sources/stash-cli.js +31 -0
  49. package/dist/commands/tasks/tasks-cli.js +49 -2
  50. package/dist/commands/workflow-cli.js +86 -12
  51. package/dist/core/asset/markdown-fragments.js +35 -0
  52. package/dist/core/config/config-schema.js +14 -0
  53. package/dist/core/config/config.js +302 -24
  54. package/dist/core/env-secret-ref.js +58 -5
  55. package/dist/core/errors.js +30 -0
  56. package/dist/core/improve-result.js +51 -0
  57. package/dist/core/loopback.js +17 -0
  58. package/dist/core/paths.js +11 -0
  59. package/dist/core/run-lock.js +96 -0
  60. package/dist/core/sensitive-marker-path.js +19 -0
  61. package/dist/core/state-db.js +74 -14
  62. package/dist/indexer/index-rebuild-lock.js +73 -0
  63. package/dist/indexer/index-writer-lock.js +40 -1
  64. package/dist/indexer/index-written-assets.js +21 -1
  65. package/dist/indexer/indexer.js +18 -17
  66. package/dist/indexer/materialize-embeddings.js +282 -32
  67. package/dist/indexer/search/db-search.js +49 -2
  68. package/dist/integrations/agent/engine-resolution.js +96 -6
  69. package/dist/integrations/agent/execution-definitions.js +6 -15
  70. package/dist/integrations/agent/execution-lowering.js +6 -1
  71. package/dist/integrations/agent/execution-preparation.js +1 -1
  72. package/dist/integrations/agent/model-map.js +123 -20
  73. package/dist/integrations/agent/prompts.js +40 -8
  74. package/dist/integrations/agent/runner-dispatch.js +9 -3
  75. package/dist/integrations/agent/runner.js +2 -0
  76. package/dist/llm/client.js +8 -3
  77. package/dist/llm/embedder.js +20 -8
  78. package/dist/llm/embedders/local.js +10 -2
  79. package/dist/llm/embedders/remote.js +188 -21
  80. package/dist/output/shapes/helpers.js +38 -2
  81. package/dist/output/shapes/models-list.js +16 -0
  82. package/dist/output/shapes/passthrough.js +2 -0
  83. package/dist/output/shapes.js +4 -0
  84. package/dist/output/text/command-format.js +29 -0
  85. package/dist/output/text/helpers.js +1 -1
  86. package/dist/output/text/improve-report.js +27 -0
  87. package/dist/{commands/env/marker-path.js → output/text/models.js} +4 -3
  88. package/dist/output/text/show-format.js +4 -0
  89. package/dist/output/text.js +4 -0
  90. package/dist/scripts/akm-migrate-node.js +24798 -21732
  91. package/dist/scripts/akm-migrate.js +23408 -20343
  92. package/dist/storage/repositories/improve-runs-repository.js +34 -0
  93. package/dist/storage/repositories/index-fts-repository.js +49 -6
  94. package/dist/storage/repositories/index-vec-repository.js +30 -0
  95. package/dist/storage/repositories/workflow-runs-repository.js +55 -18
  96. package/dist/tasks/backends/cron.js +14 -7
  97. package/dist/tasks/run/run-workflow-task.js +16 -0
  98. package/dist/workflows/exec/child-workflow.js +2 -2
  99. package/dist/workflows/exec/dispatch-redaction.js +21 -9
  100. package/dist/workflows/exec/run-workflow.js +6 -5
  101. package/dist/workflows/runtime/runs.js +33 -5
  102. package/docs/migration/release-notes/0.9.15.md +52 -0
  103. package/docs/migration/release-notes/README.md +4 -0
  104. package/docs/reference/cli.md +245 -29
  105. package/docs/reference/configuration.md +180 -19
  106. package/docs/reference/data-and-telemetry.md +8 -0
  107. package/docs/reference/tasks.md +16 -1
  108. package/docs/reference/workflow-schema.md +5 -1
  109. package/package.json +1 -1
  110. package/schemas/akm-config.json +8 -0
@@ -4,11 +4,14 @@
4
4
  import fs from "node:fs";
5
5
  import os from "node:os";
6
6
  import path from "node:path";
7
+ import { resolveSecret } from "../../core/config/config.js";
7
8
  import { deepMergeConfig } from "../../core/config/deep-merge.js";
9
+ import { SECRET_STORE_REFERENCE_PATTERN } from "../../core/config/schema/primitives.js";
8
10
  import { ConfigError } from "../../core/errors.js";
9
11
  import { formatExtraParamsIssue, validateExtraParams } from "../../core/extra-params.js";
10
12
  import { collectSensitiveValues } from "../../core/redaction.js";
11
13
  import { warn } from "../../core/warn.js";
14
+ import { resolveSecretFromStore } from "../../sources/snapshot-fetchers/secret-seam.js";
12
15
  import { getHarness } from "../harnesses/index.js";
13
16
  import { DEFAULT_AGENT_TIMEOUT_MS, DEFAULT_LLM_TIMEOUT_MS } from "./config.js";
14
17
  import { getBuiltinAgentProfile } from "./profiles.js";
@@ -70,6 +73,21 @@ export function lookupApiKeyFileValue(filePath) {
70
73
  return undefined;
71
74
  }
72
75
  }
76
+ /**
77
+ * Best-effort, non-throwing read of a secret-store-backed credential's
78
+ * current value (#953), for redaction inventories and health probes that
79
+ * must never fail just because a value collector ran ahead of the real
80
+ * dispatch — an unresolvable reference is reported by
81
+ * {@link resolveLlmCredentialValue} at the actual call.
82
+ */
83
+ export function lookupApiKeySecretRefValue(ref) {
84
+ try {
85
+ return resolveSecret(ref, resolveSecretFromStore);
86
+ }
87
+ catch {
88
+ return undefined;
89
+ }
90
+ }
73
91
  function selectedEngineName(config, layers, llmOnly) {
74
92
  for (let index = layers.length - 1; index >= 0; index--) {
75
93
  const layer = layers[index];
@@ -94,9 +112,14 @@ function resolveCredential(name, engine, config) {
94
112
  const apiKey = ownValue(engine, "apiKey");
95
113
  if (apiKey !== undefined) {
96
114
  const explicit = envName(apiKey);
97
- if (!explicit)
98
- throw new ConfigError(`Engine "${name}" has an invalid symbolic apiKey reference.`, "INVALID_CONFIG_FILE");
99
- return { names: [explicit], required: true };
115
+ if (explicit)
116
+ return { names: [explicit], required: true };
117
+ // #953: a secret-store reference has no env descriptor — resolved
118
+ // separately onto `ResolvedLlmUse.apiKeySecretRef` — mirroring how
119
+ // apiKeyFile above is its own credential source.
120
+ if (SECRET_STORE_REFERENCE_PATTERN.test(apiKey))
121
+ return undefined;
122
+ throw new ConfigError(`Engine "${name}" has an invalid symbolic apiKey reference.`, "INVALID_CONFIG_FILE");
100
123
  }
101
124
  // #905: an explicit apiKeyFile is its own credential source — resolved
102
125
  // separately onto `ResolvedLlmUse.apiKeyFile` — so it does not also fall
@@ -142,11 +165,66 @@ export function resolveCredentialFromEnv(credential, envSource = process.env) {
142
165
  * so a whole operation observes one stable credential value instead of
143
166
  * re-reading the file on every dispatch within it.
144
167
  */
145
- export function resolveLlmCredentialValue(engine, credential, apiKeyFile, envSource = process.env) {
168
+ export function resolveLlmCredentialValue(engine, credential, apiKeyFile, apiKeySecretRef, envSource = process.env) {
146
169
  const envValue = resolveCredentialFromEnv(credential, envSource);
147
170
  if (envValue !== undefined)
148
171
  return envValue;
149
- return apiKeyFile !== undefined ? readApiKeyFile(engine, apiKeyFile) : undefined;
172
+ if (apiKeyFile !== undefined)
173
+ return readApiKeyFile(engine, apiKeyFile);
174
+ // #953: a secret-store reference is the last fallback tier, reusing the
175
+ // same resolveSecret() helper llm/client.ts and embedders/remote.ts call
176
+ // directly — throws SECRET_REFERENCE_UNRESOLVED naming only the reference.
177
+ return apiKeySecretRef !== undefined ? resolveSecret(apiKeySecretRef, resolveSecretFromStore) : undefined;
178
+ }
179
+ /**
180
+ * Non-throwing credential-presence check for `akm health`, the
181
+ * improve-strategy probe (#953), and improve's own plan builder (#957): an env
182
+ * value is present, or a file-backed credential is readable and non-empty, or
183
+ * a secret-store reference resolves. Never reads env/disk/store speculatively
184
+ * beyond what's needed to answer "is something here", and never throws on a
185
+ * broken/missing source — that is reported by {@link resolveLlmCredentialValue}
186
+ * at the real dispatch.
187
+ *
188
+ * On failure, `reference` and `reason` name WHICH env var / file / secret
189
+ * reference is missing (never its value) — the operator's own shell often
190
+ * passes the same check a scheduler's stripped-down environment fails, so the
191
+ * caller needs to say which reference is the problem. Callers that must never
192
+ * name the reference (`akm health`'s evidence/message) use only `.available`.
193
+ */
194
+ export function describeLlmCredentialAvailability(resolved, env = process.env) {
195
+ if (resolved.credential?.required) {
196
+ if (resolved.credential.names.some((name) => Boolean(env[name]?.trim())))
197
+ return { available: true };
198
+ const reference = `$${resolved.credential.names[0]}`;
199
+ return { available: false, reference, reason: `${reference} is not set in this environment` };
200
+ }
201
+ if (resolved.apiKeyFile !== undefined) {
202
+ if (lookupApiKeyFileValue(resolved.apiKeyFile) !== undefined)
203
+ return { available: true };
204
+ return {
205
+ available: false,
206
+ reference: resolved.apiKeyFile,
207
+ reason: `apiKeyFile ${resolved.apiKeyFile} is missing or empty`,
208
+ };
209
+ }
210
+ if (resolved.apiKeySecretRef !== undefined) {
211
+ if (lookupApiKeySecretRefValue(resolved.apiKeySecretRef) !== undefined)
212
+ return { available: true };
213
+ return {
214
+ available: false,
215
+ reference: resolved.apiKeySecretRef,
216
+ reason: `${resolved.apiKeySecretRef} did not resolve from the secret store`,
217
+ };
218
+ }
219
+ return { available: true };
220
+ }
221
+ /**
222
+ * Thin boolean wrapper over {@link describeLlmCredentialAvailability} for
223
+ * callers (`akm health`'s engine-reachability probes) that only need a
224
+ * yes/no answer and never surface the reference.
225
+ */
226
+ export function isLlmCredentialAvailable(resolved, env = process.env) {
227
+ return describeLlmCredentialAvailability(resolved, env).available;
150
228
  }
151
229
  /** Collect materialized engine credentials for output and persistence redaction. */
152
230
  export function collectEngineCredentialValues(config, envSource = process.env) {
@@ -168,6 +246,13 @@ export function collectEngineCredentialValues(config, envSource = process.env) {
168
246
  if (value)
169
247
  values.add(value);
170
248
  }
249
+ // #953: secret-store-backed credential — best-effort for the same reason.
250
+ const apiKey = ownValue(engine, "apiKey");
251
+ if (apiKey !== undefined && SECRET_STORE_REFERENCE_PATTERN.test(apiKey)) {
252
+ const value = lookupApiKeySecretRefValue(apiKey);
253
+ if (value)
254
+ values.add(value);
255
+ }
171
256
  }
172
257
  return collectSensitiveValues(values);
173
258
  }
@@ -235,11 +320,14 @@ export function resolveLlmEngineUse(config, layers, options = {}) {
235
320
  delete connection[key];
236
321
  }
237
322
  const apiKeyFile = ownValue(engine, "apiKeyFile");
323
+ const apiKeyRaw = ownValue(engine, "apiKey");
324
+ const apiKeySecretRef = apiKeyRaw !== undefined && SECRET_STORE_REFERENCE_PATTERN.test(apiKeyRaw) ? apiKeyRaw : undefined;
238
325
  return {
239
326
  engine: name,
240
327
  connection: sterileRecord(connection),
241
328
  credential: resolveCredential(name, engine, config),
242
329
  ...(apiKeyFile !== undefined ? { apiKeyFile: expandHomePath(apiKeyFile) } : {}),
330
+ ...(apiKeySecretRef !== undefined ? { apiKeySecretRef } : {}),
243
331
  timeoutMs: effectiveTimeout(engine, layers, DEFAULT_LLM_TIMEOUT_MS),
244
332
  };
245
333
  }
@@ -270,7 +358,7 @@ export function materializeLlmConnectionWithCredential(resolved, credentialValue
270
358
  * engine has no env descriptor.
271
359
  */
272
360
  export function materializeLlmConnection(resolved, envSource = process.env) {
273
- return materializeLlmConnectionWithCredential(resolved, resolveLlmCredentialValue(resolved.engine, resolved.credential, resolved.apiKeyFile, envSource));
361
+ return materializeLlmConnectionWithCredential(resolved, resolveLlmCredentialValue(resolved.engine, resolved.credential, resolved.apiKeyFile, resolved.apiKeySecretRef, envSource));
274
362
  }
275
363
  function lowerAgentEngine(name, engine, config) {
276
364
  const harness = getHarness(engine.platform);
@@ -319,6 +407,7 @@ function lowerAgentEngine(name, engine, config) {
319
407
  fallbackConnection: fallback.connection,
320
408
  ...(fallback.credential ? { fallbackCredential: fallback.credential } : {}),
321
409
  ...(fallback.apiKeyFile ? { fallbackApiKeyFile: fallback.apiKeyFile } : {}),
410
+ ...(fallback.apiKeySecretRef ? { fallbackApiKeySecretRef: fallback.apiKeySecretRef } : {}),
322
411
  fallbackTimeoutMs: fallback.timeoutMs,
323
412
  }
324
413
  : {}),
@@ -340,6 +429,7 @@ export function resolveEngine(name, config) {
340
429
  connection: resolved.connection,
341
430
  ...(resolved.credential ? { credential: resolved.credential } : {}),
342
431
  ...(resolved.apiKeyFile ? { apiKeyFile: resolved.apiKeyFile } : {}),
432
+ ...(resolved.apiKeySecretRef ? { apiKeySecretRef: resolved.apiKeySecretRef } : {}),
343
433
  timeoutMs: resolved.timeoutMs,
344
434
  };
345
435
  }
@@ -3,6 +3,7 @@
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  import { cloneExecutionJsonObject } from "../../execution/json.js";
5
5
  import { DEFAULT_AGENT_TIMEOUT_MS, DEFAULT_LLM_TIMEOUT_MS } from "./config.js";
6
+ import { engineModelAndInference } from "./model-map.js";
6
7
  import { OPENCODE_SDK_SERVER_BIN } from "./profiles.js";
7
8
  /**
8
9
  * Canonical engine-setting marker: an SDK without its own model selected the
@@ -22,25 +23,15 @@ function withoutUndefined(value) {
22
23
  }
23
24
  function engineDefaults(engine) {
24
25
  const defaults = {};
25
- if (own(engine, "model"))
26
- defaults.model = engine.model;
26
+ const { model, inference } = engineModelAndInference(engine);
27
+ if (model !== undefined)
28
+ defaults.model = model;
27
29
  if (own(engine, "timeoutMs"))
28
30
  defaults.timeout = engine.timeoutMs;
29
31
  if (engine.kind === "agent" && own(engine, "workspace"))
30
32
  defaults.workspace = engine.workspace;
31
- if (engine.kind === "llm") {
32
- const inference = withoutUndefined({
33
- temperature: ownValue(engine, "temperature"),
34
- maxTokens: ownValue(engine, "maxTokens"),
35
- supportsJsonSchema: ownValue(engine, "supportsJsonSchema"),
36
- extraParams: ownValue(engine, "extraParams"),
37
- contextLength: ownValue(engine, "contextLength"),
38
- enableThinking: ownValue(engine, "enableThinking"),
39
- reasoningEffort: ownValue(engine, "reasoningEffort"),
40
- });
41
- if (Object.keys(inference).length > 0)
42
- defaults.inference = inference;
43
- }
33
+ if (inference !== undefined)
34
+ defaults.inference = inference;
44
35
  return Object.freeze(defaults);
45
36
  }
46
37
  function configuredSdkFallback(engine, config) {
@@ -210,13 +210,16 @@ function snapshotRunnerSpec(input, options = {}) {
210
210
  if (own(cloned, "timeoutMs"))
211
211
  validateTimeout(cloned.timeoutMs, "execution runner material.timeoutMs");
212
212
  if (kind === "llm") {
213
- assertKeys(cloned, ["kind", "engine", "connection", "credential", "apiKeyFile", "timeoutMs"], "execution runner material");
213
+ assertKeys(cloned, ["kind", "engine", "connection", "credential", "apiKeyFile", "apiKeySecretRef", "timeoutMs"], "execution runner material");
214
214
  validateConnection(cloned.connection, "execution runner material.connection", !options.allowMissingLlmModel);
215
215
  if (own(cloned, "credential"))
216
216
  validateCredential(cloned.credential, "execution runner material.credential");
217
217
  // #905: a path, not the secret itself — as safe to freeze as `credential`'s
218
218
  // env-var name.
219
219
  validateOptionalString(cloned, "apiKeyFile", "execution runner material");
220
+ // #953: a `secret://<name>` reference, not the secret itself — same
221
+ // freeze-safety rationale as apiKeyFile above.
222
+ validateOptionalString(cloned, "apiKeySecretRef", "execution runner material");
220
223
  }
221
224
  else if (kind === "agent") {
222
225
  assertKeys(cloned, ["kind", "engine", "profile", "timeoutMs"], "execution runner material");
@@ -230,6 +233,7 @@ function snapshotRunnerSpec(input, options = {}) {
230
233
  "fallbackConnection",
231
234
  "fallbackCredential",
232
235
  "fallbackApiKeyFile",
236
+ "fallbackApiKeySecretRef",
233
237
  "fallbackTimeoutMs",
234
238
  "timeoutMs",
235
239
  ], "execution runner material");
@@ -241,6 +245,7 @@ function snapshotRunnerSpec(input, options = {}) {
241
245
  validateCredential(cloned.fallbackCredential, "execution runner material.fallbackCredential");
242
246
  }
243
247
  validateOptionalString(cloned, "fallbackApiKeyFile", "execution runner material");
248
+ validateOptionalString(cloned, "fallbackApiKeySecretRef", "execution runner material");
244
249
  if (own(cloned, "fallbackTimeoutMs")) {
245
250
  validateTimeout(cloned.fallbackTimeoutMs, "execution runner material.fallbackTimeoutMs");
246
251
  }
@@ -54,7 +54,7 @@ export function prepareResolvedExecution(options) {
54
54
  ...(options.invocationDefaults ? { invocationDefaults: options.invocationDefaults } : {}),
55
55
  ...(options.current ? { current: options.current } : {}),
56
56
  engines: executionEngineDefinitionsFromConfig(config),
57
- modelMap: options.modelMap ?? loadModelMap().map,
57
+ modelMap: options.modelMap ?? loadModelMap({ engines: config.engines }).map,
58
58
  invocationKind: options.invocationKind,
59
59
  ...(options.authorizeTools ? { authorizeTools: options.authorizeTools } : {}),
60
60
  });
@@ -69,13 +69,25 @@ function parseProfileLayer(value, source, jsonPath) {
69
69
  if (typeof value === "string")
70
70
  return requireNonemptyString(value, source, jsonPath);
71
71
  const record = requireRecord(value, source, jsonPath);
72
- assertOnlyKeys(record, ["model", "inference"], source, jsonPath);
73
- if (!Object.hasOwn(record, "model") && !Object.hasOwn(record, "inference")) {
74
- invalid(source, jsonPath, "structured profile must contain model and/or inference");
72
+ assertOnlyKeys(record, ["model", "inference", "engine"], source, jsonPath);
73
+ if (!Object.hasOwn(record, "model") && !Object.hasOwn(record, "inference") && !Object.hasOwn(record, "engine")) {
74
+ invalid(source, jsonPath, "structured profile must contain model, inference, and/or engine");
75
+ }
76
+ if (Object.hasOwn(record, "model") && Object.hasOwn(record, "engine")) {
77
+ invalid(source, jsonPath, "model and engine cannot both be set; engine is an indirection for the model value");
75
78
  }
76
79
  const out = {};
77
80
  if (Object.hasOwn(record, "model"))
78
81
  out.model = requireNonemptyString(record.model, source, `${jsonPath}.model`);
82
+ if (Object.hasOwn(record, "engine")) {
83
+ const rawEngine = requireNonemptyString(record.engine, source, `${jsonPath}.engine`);
84
+ const engine = rawEngine.toLowerCase();
85
+ assertSafeMapKey(engine, source, `${jsonPath}.engine`);
86
+ if (!ENGINE_KEY_PATTERN.test(engine)) {
87
+ invalid(source, `${jsonPath}.engine`, "engine reference must be lowercase kebab-case");
88
+ }
89
+ out.engine = engine;
90
+ }
79
91
  if (Object.hasOwn(record, "inference")) {
80
92
  if (record.inference === null)
81
93
  out.inference = null;
@@ -161,10 +173,21 @@ function mergeProfiles(base, overlay) {
161
173
  const out = {};
162
174
  if (base?.model !== undefined)
163
175
  out.model = base.model;
176
+ if (base?.engine !== undefined)
177
+ out.engine = base.engine;
164
178
  if (base && Object.hasOwn(base, "inference"))
165
179
  out.inference = base.inference;
166
- if (next.model !== undefined)
180
+ // A literal `model` and an `engine` indirection are mutually exclusive within
181
+ // one layer (enforced at parse time); across layers, whichever the nearer
182
+ // layer sets replaces the other so the merged profile keeps that invariant.
183
+ if (next.model !== undefined) {
167
184
  out.model = next.model;
185
+ delete out.engine;
186
+ }
187
+ if (next.engine !== undefined) {
188
+ out.engine = next.engine;
189
+ delete out.model;
190
+ }
168
191
  if (Object.hasOwn(next, "inference")) {
169
192
  out.inference =
170
193
  next.inference !== null && next.inference !== undefined && isJsonObject(base?.inference)
@@ -173,14 +196,50 @@ function mergeProfiles(base, overlay) {
173
196
  }
174
197
  return Object.freeze(out);
175
198
  }
176
- /** Overlay user fields over installed fields, then enforce usable merged profiles. */
177
- export function mergeModelMapLayers(installed, user) {
199
+ /**
200
+ * Derive the `{ model, inference }` a model-map profile borrows from a
201
+ * configured engine (#946). Shared verbatim by
202
+ * `execution-definitions.ts`'s own execution-defaults derivation
203
+ * (`engineDefaults`) so the two paths cannot silently diverge. `model` is
204
+ * copied verbatim from the engine's own config value; it must already be
205
+ * meaningful for the model-map column's platform (akm does not translate
206
+ * between an engine's connection and an agent platform's own provider
207
+ * registry). Only `kind: "llm"` engines contribute inference defaults — an
208
+ * agent-kind engine's schema carries no temperature/thinking fields.
209
+ */
210
+ export function engineModelAndInference(engine) {
211
+ const out = {};
212
+ if (Object.hasOwn(engine, "model") && engine.model !== undefined)
213
+ out.model = engine.model;
214
+ if (engine.kind === "llm") {
215
+ const inference = {};
216
+ if (Object.hasOwn(engine, "temperature"))
217
+ inference.temperature = engine.temperature;
218
+ if (Object.hasOwn(engine, "maxTokens"))
219
+ inference.maxTokens = engine.maxTokens;
220
+ if (Object.hasOwn(engine, "supportsJsonSchema"))
221
+ inference.supportsJsonSchema = engine.supportsJsonSchema;
222
+ if (Object.hasOwn(engine, "extraParams"))
223
+ inference.extraParams = engine.extraParams;
224
+ if (Object.hasOwn(engine, "contextLength"))
225
+ inference.contextLength = engine.contextLength;
226
+ if (Object.hasOwn(engine, "enableThinking"))
227
+ inference.enableThinking = engine.enableThinking;
228
+ if (Object.hasOwn(engine, "reasoningEffort"))
229
+ inference.reasoningEffort = engine.reasoningEffort;
230
+ if (Object.keys(inference).length > 0)
231
+ out.inference = inference;
232
+ }
233
+ return Object.freeze(out);
234
+ }
235
+ /** Overlay user fields over installed fields, per (alias, column), without resolving `engine` indirection. */
236
+ function mergeRawProfileLayers(installed, user) {
178
237
  const aliases = new Map();
179
238
  const apply = (layer) => {
180
- for (const [alias, engines] of Object.entries(layer.aliases)) {
239
+ for (const [alias, layerEngines] of Object.entries(layer.aliases)) {
181
240
  const mergedEngines = aliases.get(alias) ?? new Map();
182
- for (const [engine, profile] of Object.entries(engines)) {
183
- mergedEngines.set(engine, mergeProfiles(mergedEngines.get(engine), profile));
241
+ for (const [engineKey, profile] of Object.entries(layerEngines)) {
242
+ mergedEngines.set(engineKey, mergeProfiles(mergedEngines.get(engineKey), profile));
184
243
  }
185
244
  aliases.set(alias, mergedEngines);
186
245
  }
@@ -188,14 +247,55 @@ export function mergeModelMapLayers(installed, user) {
188
247
  apply(installed);
189
248
  if (user)
190
249
  apply(user);
250
+ return aliases;
251
+ }
252
+ /**
253
+ * The overlaid-but-unresolved profile per (alias, column), before any
254
+ * `engine` indirection is expanded. `akm models list` (#946) uses this to
255
+ * report whether a column resolves through a literal `model` or an `engine`
256
+ * reference — information {@link mergeModelMapLayers} collapses away once it
257
+ * performs the final resolution.
258
+ */
259
+ export function mergedModelMapProfiles(installed, user) {
260
+ const aliases = mergeRawProfileLayers(installed, user);
261
+ const out = [];
262
+ for (const [alias, engineProfiles] of aliases)
263
+ out.push([alias, freezeRecord(engineProfiles)]);
264
+ return freezeRecord(out);
265
+ }
266
+ /**
267
+ * Overlay user fields over installed fields, resolve any `engine` indirection
268
+ * against configured engines, then enforce usable merged profiles.
269
+ */
270
+ export function mergeModelMapLayers(installed, user, engines) {
271
+ const aliases = mergeRawProfileLayers(installed, user);
191
272
  const resolvedAliases = [];
192
- for (const [alias, engines] of aliases) {
273
+ for (const [alias, engineProfiles] of aliases) {
193
274
  const resolvedEngines = [];
194
- for (const [engine, profile] of engines) {
195
- if (profile.model === undefined) {
196
- invalid("merged models.json", `$.aliases.${alias}.${engine}.model`, "a usable model is required after overlay");
275
+ for (const [engineKey, profile] of engineProfiles) {
276
+ let model = profile.model;
277
+ let inference = Object.hasOwn(profile, "inference") ? profile.inference : undefined;
278
+ if (model === undefined && profile.engine !== undefined) {
279
+ const target = ownValue(engines, profile.engine);
280
+ if (target === undefined) {
281
+ invalid("merged models.json", `$.aliases.${alias}.${engineKey}.engine`, `references unknown engine ${JSON.stringify(profile.engine)}; a usable model is required after overlay`);
282
+ }
283
+ const expansion = engineModelAndInference(target);
284
+ if (expansion.model === undefined) {
285
+ invalid("merged models.json", `$.aliases.${alias}.${engineKey}.engine`, `engine ${JSON.stringify(profile.engine)} has no usable model; a usable model is required after overlay`);
286
+ }
287
+ model = expansion.model;
288
+ inference =
289
+ inference === undefined
290
+ ? expansion.inference
291
+ : inference !== null && isJsonObject(expansion.inference)
292
+ ? mergeJsonValue(expansion.inference, inference)
293
+ : inference;
197
294
  }
198
- resolvedEngines.push([engine, Object.freeze({ ...profile, model: profile.model })]);
295
+ if (model === undefined) {
296
+ invalid("merged models.json", `$.aliases.${alias}.${engineKey}.model`, "a usable model is required after overlay");
297
+ }
298
+ resolvedEngines.push([engineKey, Object.freeze(inference !== undefined ? { model, inference } : { model })]);
199
299
  }
200
300
  resolvedAliases.push([alias, freezeRecord(resolvedEngines)]);
201
301
  }
@@ -314,15 +414,18 @@ export function readInstalledModelMapText(options = {}) {
314
414
  }
315
415
  throw new ConfigError(`Installed models.json is missing from this AKM installation (checked: ${candidates.join(", ")}).`, "INVALID_CONFIG_FILE", "Reinstall AKM so its dist/assets/models.json package asset is restored.");
316
416
  }
317
- /** Load the installed authority plus the optional operator overlay. */
318
- export function loadModelMap(options = {}) {
417
+ /** Read and parse the installed authority plus the optional operator overlay, without resolving them. */
418
+ export function loadModelMapLayers(options = {}) {
319
419
  const installed = parseModelMapLayer(readInstalledModelMapText(options), "installed models.json");
320
420
  const userPath = userModelMapPath(options.env);
321
421
  const userText = readModelMapFile(userPath, "User models.json", true);
322
- let user;
323
- if (userText !== undefined)
324
- user = parseModelMapLayer(userText, `user models.json (${userPath})`);
325
- return Object.freeze({ map: mergeModelMapLayers(installed, user), userPath, userStatus: user ? "loaded" : "absent" });
422
+ const user = userText !== undefined ? parseModelMapLayer(userText, `user models.json (${userPath})`) : undefined;
423
+ return Object.freeze({ installed, ...(user ? { user } : {}), userPath, userStatus: user ? "loaded" : "absent" });
424
+ }
425
+ /** Load the installed authority plus the optional operator overlay. */
426
+ export function loadModelMap(options = {}) {
427
+ const { installed, user, userPath, userStatus } = loadModelMapLayers(options);
428
+ return Object.freeze({ map: mergeModelMapLayers(installed, user, options.engines), userPath, userStatus });
326
429
  }
327
430
  function targetExistsError(target, detail) {
328
431
  return new UsageError(`${detail}: ${target}`, "RESOURCE_ALREADY_EXISTS", "Move the existing target aside, then retry. Use --overwrite only for a stable regular file you intend to replace.");
@@ -25,6 +25,7 @@
25
25
  * `frontmatter` is optional — the proposal queue parses it from `content`
26
26
  * during validation. We carry it through if the agent supplies it.
27
27
  */
28
+ import reflectFeedbackFraming from "../../assets/prompts/reflect-feedback-framing.md" with { type: "text" };
28
29
  import reflectLlmFramedContract from "../../assets/prompts/reflect-llm-framed-contract.md" with { type: "text" };
29
30
  import reflectLlmSchemaContract from "../../assets/prompts/reflect-llm-schema-contract.md" with { type: "text" };
30
31
  import reflectOutputRepair from "../../assets/prompts/reflect-output-repair.md" with { type: "text" };
@@ -56,6 +57,25 @@ function hintForType(type) {
56
57
  function knownTypeList() {
57
58
  return [...placementTypes()].sort().join(", ");
58
59
  }
60
+ /**
61
+ * Default cap (in characters) on the asset content injected into
62
+ * {@link buildReflectPrompt}, and the floor a caller-supplied
63
+ * {@link ReflectPromptInput.contentBudgetChars} is never allowed to go below.
64
+ * Exists to stay well under OS ARG_MAX when the prompt is passed as a CLI
65
+ * argument to opencode/claude — large assets (wiki snapshots, long runbooks)
66
+ * would otherwise trigger E2BIG on posix_spawn. Agent/SDK runners always use
67
+ * this flat value; the direct-LLM path can raise it per #952 (see the reflect
68
+ * dispatch site in `src/commands/improve/reflect.ts`).
69
+ */
70
+ export const REFLECT_CONTENT_CAP = 12_000;
71
+ /**
72
+ * Marker appended to truncated asset content when it exceeds the active
73
+ * content budget (#952). Exported so `sanitizeReflectPayload` can detect a
74
+ * model that echoed this notice back into its rewrite instead of proposing
75
+ * real content, and so the output contracts can reference the exact string
76
+ * to forbid.
77
+ */
78
+ export const REFLECT_TRUNCATION_MARKER = "... [truncated — focus on the visible portion]";
59
79
  /**
60
80
  * Common envelope every prompt asks the agent to honour when NO draft file
61
81
  * path is available. The wrapper code uses `JSON.parse(stdout)` to extract
@@ -87,6 +107,7 @@ function fileWriteContract(draftFilePath) {
87
107
  `Write the complete improved asset content to: ${draftFilePath}`,
88
108
  "Use your file-editing tools to create or overwrite that file.",
89
109
  "Do NOT output JSON to stdout. Do NOT print the file contents. Just write the file.",
110
+ `Never include the text "${REFLECT_TRUNCATION_MARKER}" or any other content from outside the provided asset content in the file you write.`,
90
111
  "When done, output a single line on stdout: DRAFT_WRITTEN confidence=<0.0-1.0>",
91
112
  "`confidence` is REQUIRED and must be your honest self-rated [0, 1] score for this proposal:",
92
113
  " • 0.90+ — fixes a real defect or adds load-bearing missing content; reviewer would clearly accept.",
@@ -124,10 +145,14 @@ export function reflectLlmResponseContract(mode, targetScoped) {
124
145
  .replace("{{FIELD_RULE}}", targetScoped
125
146
  ? "The response has exactly the required fields `content`, `confidence`, and `frontmatterPatch`; do not echo `ref` or arbitrary `frontmatter`."
126
147
  : "The response has exactly the required fields `ref`, `content`, `confidence`, and `frontmatterPatch`; `ref` must identify the selected asset.")
148
+ .replaceAll("{{TRUNCATION_MARKER}}", REFLECT_TRUNCATION_MARKER)
127
149
  .trim();
128
150
  }
129
151
  const refLine = targetScoped ? "" : "AKM_REFLECT_REF: <selected asset ref>\n";
130
- return reflectLlmFramedContract.replace("{{REF_LINE}}", refLine).trim();
152
+ return reflectLlmFramedContract
153
+ .replace("{{REF_LINE}}", refLine)
154
+ .replaceAll("{{TRUNCATION_MARKER}}", REFLECT_TRUNCATION_MARKER)
155
+ .trim();
131
156
  }
132
157
  export function buildReflectOutputRepairPrompt(mode, targetScoped) {
133
158
  return reflectOutputRepair.replace("{{OUTPUT_CONTRACT}}", reflectLlmResponseContract(mode, targetScoped)).trim();
@@ -194,6 +219,10 @@ export function buildReflectPrompt(input) {
194
219
  }
195
220
  // Change 3 & 4 — feedback moved before asset content; missing else branch added
196
221
  if (input.feedback && input.feedback.length > 0) {
222
+ // #952 — feedback lines are unverified reports, not verified facts. Without
223
+ // this caveat, models fabricated whole new sections asserting whatever a
224
+ // feedback line claimed (invented incident dates, ports, disk layouts).
225
+ sections.push(reflectFeedbackFraming.trim());
197
226
  sections.push("Recent feedback / signals:");
198
227
  for (const line of input.feedback)
199
228
  sections.push(`- ${line}`);
@@ -238,17 +267,20 @@ export function buildReflectPrompt(input) {
238
267
  }
239
268
  }
240
269
  if (input.assetContent?.trim()) {
241
- // Cap at 12 000 chars to stay well under OS ARG_MAX when the prompt is
242
- // passed as a CLI argument to opencode/claude. Large assets (wiki snapshots,
243
- // long runbooks) would otherwise trigger E2BIG on posix_spawn.
244
- const REFLECT_CONTENT_CAP = 12_000;
270
+ // Cap defaults to REFLECT_CONTENT_CAP (12 000 chars) to stay well under OS
271
+ // ARG_MAX when the prompt is passed as a CLI argument to opencode/claude
272
+ // — large assets (wiki snapshots, long runbooks) would otherwise trigger
273
+ // E2BIG on posix_spawn. Direct-LLM callers never touch argv, so they may
274
+ // pass a larger, context-aware `contentBudgetChars` (#952); it is never
275
+ // allowed below the flat floor, which the caller enforces before calling in.
276
+ const contentCap = input.contentBudgetChars ?? REFLECT_CONTENT_CAP;
245
277
  const body = input.assetContent.trimEnd();
246
- const truncated = body.length > REFLECT_CONTENT_CAP;
278
+ const truncated = body.length > contentCap;
247
279
  sections.push(truncated
248
- ? `Current asset content (first ${REFLECT_CONTENT_CAP} chars — full asset is ${body.length} chars):`
280
+ ? `Current asset content (first ${contentCap} chars — full asset is ${body.length} chars):`
249
281
  : "Current asset content (verbatim):");
250
282
  sections.push("```");
251
- sections.push(truncated ? `${body.slice(0, REFLECT_CONTENT_CAP)}\n... [truncated — focus on the visible portion]` : body);
283
+ sections.push(truncated ? `${body.slice(0, contentCap)}\n${REFLECT_TRUNCATION_MARKER}` : body);
252
284
  sections.push("```");
253
285
  }
254
286
  else if (input.ref) {
@@ -20,7 +20,7 @@ import { assertNever } from "../../core/assert.js";
20
20
  import { collectSensitiveValues, isEnvPassthroughValueSafeToExpose, redactSensitiveText, redactSensitiveValue, } from "../../core/redaction.js";
21
21
  import { spawnEnvNamesFor } from "../../core/spawn-env.js";
22
22
  import { closeServer as disposeOpencodeSdkServers, opencodeSdkServerEnvironmentNames, runOpencodeSdk, } from "../harnesses/opencode-sdk/sdk-runner.js";
23
- import { lookupApiKeyFileValue, lookupCredentialFromEnv, materializeLlmConnection, materializeLlmConnectionWithCredential, resolveLlmCredentialValue, } from "./engine-resolution.js";
23
+ import { lookupApiKeyFileValue, lookupApiKeySecretRefValue, lookupCredentialFromEnv, materializeLlmConnection, materializeLlmConnectionWithCredential, resolveLlmCredentialValue, } from "./engine-resolution.js";
24
24
  import { materializeLlmRunnerConnection, materializeLlmRunnerConnectionWithCredential, } from "./runner.js";
25
25
  import { runAgent } from "./spawn.js";
26
26
  const liveRunnerDispatchLeases = new WeakMap();
@@ -134,10 +134,10 @@ export function acquireRunnerDispatchLease(spec, envSource = process.env) {
134
134
  },
135
135
  });
136
136
  const primaryCredential = spec.kind === "llm"
137
- ? resolveLlmCredentialValue(spec.engine, spec.credential, spec.apiKeyFile, credentialSource)
137
+ ? resolveLlmCredentialValue(spec.engine, spec.credential, spec.apiKeyFile, spec.apiKeySecretRef, credentialSource)
138
138
  : undefined;
139
139
  const fallbackCredential = spec.kind === "sdk"
140
- ? resolveLlmCredentialValue(spec.engine, spec.fallbackCredential, spec.fallbackApiKeyFile, credentialSource)
140
+ ? resolveLlmCredentialValue(spec.engine, spec.fallbackCredential, spec.fallbackApiKeyFile, spec.fallbackApiKeySecretRef, credentialSource)
141
141
  : undefined;
142
142
  const handle = Object.create(null);
143
143
  Object.defineProperty(handle, "toJSON", {
@@ -229,6 +229,12 @@ export function collectDispatchSensitiveValues(spec, opts, envSource = opts.envS
229
229
  add(lookupApiKeyFileValue(spec.apiKeyFile));
230
230
  if (spec.kind === "sdk" && spec.fallbackApiKeyFile)
231
231
  add(lookupApiKeyFileValue(spec.fallbackApiKeyFile));
232
+ // #953: a secret-store-backed credential is read at dispatch too — same
233
+ // best-effort scrub-set inclusion rationale.
234
+ if (spec.kind === "llm" && spec.apiKeySecretRef)
235
+ add(lookupApiKeySecretRefValue(spec.apiKeySecretRef));
236
+ if (spec.kind === "sdk" && spec.fallbackApiKeySecretRef)
237
+ add(lookupApiKeySecretRefValue(spec.fallbackApiKeySecretRef));
232
238
  if (spec.kind !== "llm") {
233
239
  for (const value of Object.values(spec.profile.env ?? {}))
234
240
  add(value);
@@ -9,6 +9,7 @@ export function materializeLlmRunnerConnection(runner) {
9
9
  connection: runner.connection,
10
10
  ...(runner.credential ? { credential: runner.credential } : {}),
11
11
  ...(runner.apiKeyFile ? { apiKeyFile: runner.apiKeyFile } : {}),
12
+ ...(runner.apiKeySecretRef ? { apiKeySecretRef: runner.apiKeySecretRef } : {}),
12
13
  timeoutMs: runner.timeoutMs ?? null,
13
14
  });
14
15
  }
@@ -19,6 +20,7 @@ export function materializeLlmRunnerConnectionWithCredential(runner, credentialV
19
20
  connection: runner.connection,
20
21
  ...(runner.credential ? { credential: runner.credential } : {}),
21
22
  ...(runner.apiKeyFile ? { apiKeyFile: runner.apiKeyFile } : {}),
23
+ ...(runner.apiKeySecretRef ? { apiKeySecretRef: runner.apiKeySecretRef } : {}),
22
24
  timeoutMs: runner.timeoutMs ?? null,
23
25
  }, credentialValue);
24
26
  }
@@ -290,12 +290,17 @@ async function chatCompletionAttemptOnce(config, messages, options, timeoutMs, i
290
290
  },
291
291
  }
292
292
  : {};
293
+ // #949: which of these two wire forms a backend honors is a fact about the
294
+ // backend (and any gateway in front of it), not about akm's own `provider`
295
+ // label — a llama.cpp build honors chat_template_kwargs, a bare
296
+ // `enable_thinking` is honored by nothing observed, and a gateway
297
+ // (freellmapi, Bifrost) can drop either one depending on how it was built.
298
+ // Send both whenever thinking is explicitly resolved so the same engine
299
+ // block keeps working across a direct vhost or any gateway in front of it.
293
300
  const resolvedEnableThinking = options?.enableThinking ?? config.enableThinking;
294
301
  const thinkingParams = resolvedEnableThinking === undefined
295
302
  ? {}
296
- : config.provider === "vllm"
297
- ? { chat_template_kwargs: { enable_thinking: resolvedEnableThinking } }
298
- : { enable_thinking: resolvedEnableThinking };
303
+ : { chat_template_kwargs: { enable_thinking: resolvedEnableThinking }, enable_thinking: resolvedEnableThinking };
299
304
  const reasoningEffortParams = config.reasoningEffort === undefined ? {} : { reasoning_effort: config.reasoningEffort };
300
305
  const requestBody = JSON.stringify({
301
306
  model: config.model,