akm-cli 0.9.14 → 0.9.15-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/CHANGELOG.md +559 -0
  2. package/STABILITY.md +6 -3
  3. package/dist/akm +54 -1
  4. package/dist/akm-migrate +34 -1
  5. package/dist/assets/prompts/reflect-feedback-framing.md +1 -0
  6. package/dist/assets/prompts/reflect-llm-framed-contract.md +2 -0
  7. package/dist/assets/prompts/reflect-llm-schema-contract.md +2 -0
  8. package/dist/assets/tasks/core/improve.yml +1 -1
  9. package/dist/assets/tasks/core/index-refresh.yml +1 -1
  10. package/dist/assets/tasks/improve/akm-graph-refresh-weekly.yml +1 -1
  11. package/dist/assets/tasks/improve/akm-improve-catchup.yml +1 -1
  12. package/dist/assets/tasks/improve/akm-improve-consolidate.yml +1 -1
  13. package/dist/assets/tasks/improve/akm-improve-frequent.yml +1 -1
  14. package/dist/assets/tasks/improve/akm-improve-nightly.yml +1 -1
  15. package/dist/cli/retired-commands.js +0 -1
  16. package/dist/cli/shared.js +9 -0
  17. package/dist/cli/unknown-flags.js +1 -0
  18. package/dist/cli.js +40 -3
  19. package/dist/commands/config-cli.js +85 -3
  20. package/dist/commands/env/env-cli.js +1 -42
  21. package/dist/commands/env/env.js +1 -1
  22. package/dist/commands/env/secret-cli.js +1 -2
  23. package/dist/commands/health/checks.js +357 -63
  24. package/dist/commands/health/engine-usage.js +45 -0
  25. package/dist/commands/health/improve-metrics.js +18 -0
  26. package/dist/commands/health/llm-usage.js +41 -1
  27. package/dist/commands/health/plugin-staleness.js +7 -3
  28. package/dist/commands/health/version-drift.js +93 -0
  29. package/dist/commands/health/windows.js +3 -1
  30. package/dist/commands/health.js +44 -9
  31. package/dist/commands/improve/consolidate/chunking.js +4 -2
  32. package/dist/commands/improve/improve-cli.js +99 -5
  33. package/dist/commands/improve/improve-report.js +154 -0
  34. package/dist/commands/improve/improve-result-file.js +45 -33
  35. package/dist/commands/improve/improve-strategies.js +133 -3
  36. package/dist/commands/improve/improve-usage-report.js +182 -0
  37. package/dist/commands/improve/improve.js +40 -3
  38. package/dist/commands/improve/locks.js +28 -78
  39. package/dist/commands/improve/planner.js +1 -0
  40. package/dist/commands/improve/preparation.js +9 -1
  41. package/dist/commands/improve/reflect.js +44 -4
  42. package/dist/commands/models-cli.js +50 -1
  43. package/dist/commands/proposal/repository.js +8 -3
  44. package/dist/commands/proposal/validators/proposal-quality-validators.js +41 -6
  45. package/dist/commands/proposal/validators/proposal-validators.js +24 -0
  46. package/dist/commands/read/search-cli.js +38 -2
  47. package/dist/commands/read/show.js +103 -4
  48. package/dist/commands/sources/info.js +5 -1
  49. package/dist/commands/sources/installed-stashes.js +58 -16
  50. package/dist/commands/sources/self-update.js +2 -2
  51. package/dist/commands/sources/stash-cli.js +48 -0
  52. package/dist/commands/tasks/tasks-cli.js +49 -2
  53. package/dist/commands/workflow-cli.js +86 -12
  54. package/dist/core/asset/markdown-fragments.js +35 -0
  55. package/dist/core/config/config-schema.js +14 -0
  56. package/dist/core/config/config.js +302 -24
  57. package/dist/core/config/schema/embedding.js +41 -0
  58. package/dist/core/env-secret-ref.js +58 -5
  59. package/dist/core/errors.js +30 -0
  60. package/dist/core/file-lock.js +49 -15
  61. package/dist/core/improve-result.js +51 -0
  62. package/dist/core/loopback.js +17 -0
  63. package/dist/core/parent-watchdog.js +64 -0
  64. package/dist/core/paths.js +11 -0
  65. package/dist/core/run-lock.js +107 -0
  66. package/dist/core/sensitive-marker-path.js +19 -0
  67. package/dist/core/state-db.js +74 -14
  68. package/dist/indexer/index-rebuild-lock.js +73 -0
  69. package/dist/indexer/index-writer-lock.js +40 -1
  70. package/dist/indexer/index-written-assets.js +29 -1
  71. package/dist/indexer/indexer.js +93 -29
  72. package/dist/indexer/materialize-embeddings.js +564 -48
  73. package/dist/indexer/search/db-search.js +49 -2
  74. package/dist/indexer/search/search-source.js +23 -1
  75. package/dist/integrations/agent/engine-resolution.js +96 -6
  76. package/dist/integrations/agent/execution-definitions.js +6 -15
  77. package/dist/integrations/agent/execution-lowering.js +6 -1
  78. package/dist/integrations/agent/execution-preparation.js +1 -1
  79. package/dist/integrations/agent/model-map.js +123 -20
  80. package/dist/integrations/agent/prompts.js +40 -8
  81. package/dist/integrations/agent/runner-dispatch.js +9 -3
  82. package/dist/integrations/agent/runner.js +2 -0
  83. package/dist/llm/client.js +8 -3
  84. package/dist/llm/embedder.js +20 -8
  85. package/dist/llm/embedders/local.js +10 -2
  86. package/dist/llm/embedders/remote.js +497 -32
  87. package/dist/output/shapes/helpers.js +38 -2
  88. package/dist/output/shapes/models-list.js +16 -0
  89. package/dist/output/shapes/passthrough.js +2 -0
  90. package/dist/output/shapes.js +4 -0
  91. package/dist/output/text/command-format.js +29 -0
  92. package/dist/output/text/helpers.js +1 -1
  93. package/dist/output/text/improve-report.js +27 -0
  94. package/dist/{commands/env/marker-path.js → output/text/models.js} +4 -3
  95. package/dist/output/text/show-format.js +4 -0
  96. package/dist/output/text.js +4 -0
  97. package/dist/scripts/akm-migrate-node.js +25146 -21759
  98. package/dist/scripts/akm-migrate.js +24271 -20885
  99. package/dist/storage/repositories/embedding-salvage-repository.js +184 -0
  100. package/dist/storage/repositories/improve-runs-repository.js +34 -0
  101. package/dist/storage/repositories/index-fts-repository.js +49 -6
  102. package/dist/storage/repositories/index-schema.js +16 -0
  103. package/dist/storage/repositories/index-vec-repository.js +30 -0
  104. package/dist/storage/repositories/workflow-runs-repository.js +55 -18
  105. package/dist/tasks/backends/cron.js +14 -7
  106. package/dist/tasks/run/run-native-task.js +23 -1
  107. package/dist/tasks/run/run-workflow-task.js +16 -0
  108. package/dist/workflows/exec/child-workflow.js +2 -2
  109. package/dist/workflows/exec/dispatch-redaction.js +21 -9
  110. package/dist/workflows/exec/run-workflow.js +6 -5
  111. package/dist/workflows/runtime/runs.js +33 -5
  112. package/docs/migration/release-notes/0.9.15.md +133 -0
  113. package/docs/migration/release-notes/README.md +5 -0
  114. package/docs/reference/cli.md +271 -30
  115. package/docs/reference/configuration.md +234 -21
  116. package/docs/reference/data-and-telemetry.md +8 -0
  117. package/docs/reference/tasks.md +16 -1
  118. package/docs/reference/workflow-schema.md +5 -1
  119. package/package.json +1 -1
  120. package/schemas/akm-config.json +47 -0
@@ -25,7 +25,7 @@ import { allowsFragmentRef, defaultRendererRegistry } from "../../core/type-pres
25
25
  import { normalizeEmbeddingEndpoint } from "../../llm/embedders/remote.js";
26
26
  import { assertIndexPathReadable, closeDatabase, openExistingDatabase, } from "../../storage/repositories/index-connection.js";
27
27
  import { getAllEntries, getBaseBeliefStatesForDerivedTwins, getEntryById, getEntryCount, getPositiveFeedbackCountsByIds, } from "../../storage/repositories/index-entries-repository.js";
28
- import { searchFts } from "../../storage/repositories/index-fts-repository.js";
28
+ import { getIndexedMarkdownFragment, getIndexedMarkdownFragments, searchFts, } from "../../storage/repositories/index-fts-repository.js";
29
29
  import { getMeta } from "../../storage/repositories/index-meta-repository.js";
30
30
  import { getEmbeddingCount, searchVec } from "../../storage/repositories/index-vec-repository.js";
31
31
  import { getCurrentWorkflowScopeKey } from "../../workflows/authoring/scope-key.js";
@@ -451,6 +451,14 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
451
451
  });
452
452
  const rankMs = Date.now() - tRank0;
453
453
  const selected = beliefFiltered.slice(0, limit);
454
+ const fragmentSelections = selected.flatMap((ranked) => ranked.fragmentId && allowsFragmentRef(ranked.entry.type) && hasIndexedProvenance(ranked)
455
+ ? [{ entryId: ranked.id, itemRef: ranked.itemRef, fragmentId: ranked.fragmentId }]
456
+ : []);
457
+ const selectedFragments = getIndexedMarkdownFragments(db, fragmentSelections);
458
+ const selectedFragmentByEntryId = new Map();
459
+ fragmentSelections.forEach((selection, index) => {
460
+ selectedFragmentByEntryId.set(selection.entryId, selectedFragments[index]);
461
+ });
454
462
  const hits = await Promise.all(selected.map((ranked) => {
455
463
  const { entry, filePath, score, rankingMode, utilityBoosted } = ranked;
456
464
  // CLAUDE.md locks SearchHit.score in [0,1]. The boost loop deliberately
@@ -467,6 +475,7 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
467
475
  rankingMode,
468
476
  lexicalMatch: ranked.lexicalMatch,
469
477
  fragmentId: ranked.fragmentId,
478
+ indexedFragment: ranked.fragmentId ? (selectedFragmentByEntryId.get(ranked.id) ?? null) : undefined,
470
479
  defaultStashDir: stashDir,
471
480
  allSourceDirs,
472
481
  sources,
@@ -788,7 +797,19 @@ export async function buildDbHit(input) {
788
797
  // The central type-presentation contract opts those types out explicitly.
789
798
  const ref = input.fragmentId && allowsFragmentRef(input.entry.type) ? `${parentRef}#${input.fragmentId}` : parentRef;
790
799
  const editable = isEditable(absolutePath, input.config, input.sources);
791
- const estimatedTokens = typeof input.entry.fileSize === "number" ? Math.round(input.entry.fileSize / 4) : undefined;
800
+ const indexedFragment = input.indexedFragment === undefined
801
+ ? input.fragmentId && input.db
802
+ ? getIndexedMarkdownFragment(input.db, input.itemRef, input.fragmentId)
803
+ : undefined
804
+ : (input.indexedFragment ?? undefined);
805
+ const selectedRef = input.fragmentId && ref !== parentRef ? `${parentRef}#${input.fragmentId}` : undefined;
806
+ const parentEstimatedTokens = typeof input.entry.fileSize === "number"
807
+ ? Math.round(input.entry.fileSize / 4)
808
+ : indexedFragment
809
+ ? Math.round(indexedFragment.parentChars / 4)
810
+ : undefined;
811
+ const fragmentEstimatedTokens = indexedFragment ? Math.round(indexedFragment.fragmentChars / 4) : undefined;
812
+ const estimatedTokens = selectedRef === ref && fragmentEstimatedTokens !== undefined ? fragmentEstimatedTokens : parentEstimatedTokens;
792
813
  const hit = {
793
814
  type: input.entry.type,
794
815
  name: input.entry.name,
@@ -804,6 +825,32 @@ export async function buildDbHit(input) {
804
825
  score,
805
826
  whyMatched,
806
827
  ...(estimatedTokens !== undefined ? { estimatedTokens } : {}),
828
+ ...(selectedRef
829
+ ? {
830
+ selectedRef,
831
+ parentRef,
832
+ ...(indexedFragment
833
+ ? {
834
+ fragmentOrdinal: indexedFragment.ordinal + 1,
835
+ fragmentCount: indexedFragment.count,
836
+ startLine: indexedFragment.startLine,
837
+ endLine: indexedFragment.endLine,
838
+ ...(indexedFragment.previousFragmentId
839
+ ? { previousRef: `${parentRef}#${indexedFragment.previousFragmentId}` }
840
+ : {}),
841
+ ...(indexedFragment.nextFragmentId
842
+ ? { nextRef: `${parentRef}#${indexedFragment.nextFragmentId}` }
843
+ : {}),
844
+ fragmentChars: indexedFragment.fragmentChars,
845
+ fragmentEstimatedTokens,
846
+ parentChars: indexedFragment.parentChars,
847
+ ...(parentEstimatedTokens !== undefined ? { parentEstimatedTokens } : {}),
848
+ }
849
+ : parentEstimatedTokens !== undefined
850
+ ? { parentEstimatedTokens }
851
+ : {}),
852
+ }
853
+ : {}),
807
854
  // Surface optional quality (v1 spec §4.2). Omitted when entry has
808
855
  // no `quality` field so payloads stay compact for the common case.
809
856
  ...(input.entry.quality ? { quality: input.entry.quality } : {}),
@@ -238,6 +238,7 @@ export async function ensureSourceCaches(config, options) {
238
238
  const cfg = config ?? loadConfig();
239
239
  const force = options?.force === true;
240
240
  const materialize = options?.materialize !== false;
241
+ const onProgress = options?.onProgress ?? (() => { });
241
242
  // Polymorphic refresh: walk every enabled source through its registered
242
243
  // provider and call `sync()`. Every cache-backed kind (git, website, npm)
243
244
  // refreshes the same way — a bad source warns and is skipped without
@@ -249,6 +250,12 @@ export async function ensureSourceCaches(config, options) {
249
250
  // content ENDS UP; the lock's `localRoot` merely records the result. So this
250
251
  // path correctly uses the provider, not the shared `lockContentRootFor`
251
252
  // resolver that reads/writes use to agree on where content already IS.
253
+ //
254
+ // Two passes: first resolve which sources will actually sync (constructing
255
+ // each provider once, exactly as before), so the progress count ("i/n")
256
+ // reflects real syncs rather than every configured entry including managed/
257
+ // unsyncable ones; then sync them in order, reporting progress per source.
258
+ const toSync = [];
252
259
  for (const entry of getSources(cfg)) {
253
260
  if (entry.enabled === false)
254
261
  continue;
@@ -282,12 +289,27 @@ export async function ensureSourceCaches(config, options) {
282
289
  warnIfSourceUnavailableForRead(entry, provider.name);
283
290
  continue;
284
291
  }
292
+ toSync.push(provider);
293
+ }
294
+ for (const [i, provider] of toSync.entries()) {
295
+ onProgress(`Hydrating source ${i + 1}/${toSync.length}: ${provider.name}`);
296
+ let heartbeat;
285
297
  try {
286
- await provider.sync({ force, secrets: options?.secrets, ensureWebsiteMirror });
298
+ heartbeat = setInterval(() => {
299
+ onProgress(`Still hydrating source ${i + 1}/${toSync.length}: ${provider.name}...`);
300
+ }, 15000);
301
+ // `toSync` only ever holds providers whose `.sync` passed the check
302
+ // above; the optional-chain here is just to satisfy the type (the
303
+ // narrowing does not survive the array round-trip).
304
+ await provider.sync?.({ force, secrets: options?.secrets, ensureWebsiteMirror });
287
305
  }
288
306
  catch (err) {
289
307
  warn(`Warning: failed to refresh ${provider.kind} source "${provider.name}": ${err instanceof Error ? err.message : String(err)}`);
290
308
  }
309
+ finally {
310
+ if (heartbeat)
311
+ clearInterval(heartbeat);
312
+ }
291
313
  }
292
314
  }
293
315
  /**
@@ -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.");