akm-cli 0.9.0-beta.54 → 0.9.0-beta.56

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 (103) hide show
  1. package/dist/cli.js +5 -3
  2. package/dist/commands/agent/contribute-cli.js +2 -3
  3. package/dist/commands/env/env-cli.js +187 -202
  4. package/dist/commands/env/secret-cli.js +109 -121
  5. package/dist/commands/feedback-cli.js +152 -155
  6. package/dist/commands/health/advisories.js +151 -0
  7. package/dist/commands/health/improve-metrics.js +754 -0
  8. package/dist/commands/health/llm-usage.js +65 -0
  9. package/dist/commands/health/md-report.js +103 -0
  10. package/dist/commands/health/metrics.js +278 -0
  11. package/dist/commands/health/task-runs.js +135 -0
  12. package/dist/commands/health/types.js +18 -0
  13. package/dist/commands/health/windows.js +196 -0
  14. package/dist/commands/health.js +14 -1624
  15. package/dist/commands/improve/anti-collapse.js +170 -0
  16. package/dist/commands/improve/collapse-detector.js +3 -2
  17. package/dist/commands/improve/consolidate.js +636 -633
  18. package/dist/commands/improve/dedup.js +1 -1
  19. package/dist/commands/improve/distill/content-repair.js +202 -0
  20. package/dist/commands/improve/distill/promote-memory.js +228 -0
  21. package/dist/commands/improve/distill/quality-gate.js +233 -0
  22. package/dist/commands/improve/distill-guards.js +127 -0
  23. package/dist/commands/improve/distill.js +49 -575
  24. package/dist/commands/improve/extract-cli.js +74 -76
  25. package/dist/commands/improve/extract.js +6 -4
  26. package/dist/commands/improve/hot-probation.js +45 -0
  27. package/dist/commands/improve/improve-auto-accept.js +3 -2
  28. package/dist/commands/improve/improve-cli.js +14 -13
  29. package/dist/commands/improve/improve-result-file.js +2 -1
  30. package/dist/commands/improve/improve.js +6 -5
  31. package/dist/commands/improve/loop-stages.js +19 -21
  32. package/dist/commands/improve/preparation.js +4 -2
  33. package/dist/commands/improve/procedural.js +10 -31
  34. package/dist/commands/improve/recombine.js +19 -43
  35. package/dist/commands/improve/reflect.js +1 -1
  36. package/dist/commands/improve/schema-similarity-gate.js +168 -0
  37. package/dist/commands/improve/shared.js +48 -0
  38. package/dist/commands/observability-cli.js +4 -4
  39. package/dist/commands/proposal/drain-policies.js +2 -2
  40. package/dist/commands/proposal/drain.js +1 -1
  41. package/dist/commands/proposal/legacy-import.js +115 -0
  42. package/dist/commands/proposal/proposal-cli.js +3 -3
  43. package/dist/commands/proposal/proposal.js +2 -1
  44. package/dist/commands/proposal/propose.js +1 -1
  45. package/dist/commands/proposal/repository.js +829 -0
  46. package/dist/commands/proposal/validators/proposals.js +5 -920
  47. package/dist/commands/read/remember-cli.js +132 -137
  48. package/dist/commands/read/search-cli.js +1 -1
  49. package/dist/commands/registry-cli.js +76 -87
  50. package/dist/commands/sources/add-cli.js +90 -94
  51. package/dist/commands/sources/history.js +1 -1
  52. package/dist/commands/sources/schema-repair.js +1 -1
  53. package/dist/commands/sources/sources-cli.js +3 -3
  54. package/dist/commands/sources/stash-cli.js +1 -1
  55. package/dist/commands/tasks/tasks-cli.js +1 -2
  56. package/dist/commands/wiki-cli.js +2 -3
  57. package/dist/core/common.js +3 -3
  58. package/dist/core/config/config-schema.js +6 -0
  59. package/dist/core/deep-merge.js +38 -0
  60. package/dist/core/events.js +2 -1
  61. package/dist/core/logs-db.js +8 -13
  62. package/dist/core/paths.js +14 -14
  63. package/dist/core/state-db.js +13 -1140
  64. package/dist/indexer/db/db.js +96 -723
  65. package/dist/indexer/db/entry-mapper.js +41 -0
  66. package/dist/indexer/db/schema.js +516 -0
  67. package/dist/indexer/feedback/utility-policy.js +75 -0
  68. package/dist/indexer/graph/graph-extraction.js +2 -1
  69. package/dist/indexer/index-writer-lock.js +9 -0
  70. package/dist/indexer/indexer.js +78 -23
  71. package/dist/indexer/search/fts-query.js +51 -0
  72. package/dist/integrations/agent/spawn.js +15 -66
  73. package/dist/llm/embedders/cache.js +3 -1
  74. package/dist/output/text/helpers.js +13 -0
  75. package/dist/registry/resolve.js +5 -0
  76. package/dist/scripts/migrate-storage.js +6908 -7447
  77. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +44 -43
  78. package/dist/setup/legacy-config.js +106 -0
  79. package/dist/setup/prompt.js +57 -0
  80. package/dist/setup/providers.js +14 -0
  81. package/dist/setup/semantic-assets.js +124 -0
  82. package/dist/setup/setup.js +24 -1607
  83. package/dist/setup/steps/connection.js +734 -0
  84. package/dist/setup/steps/output.js +31 -0
  85. package/dist/setup/steps/platforms.js +124 -0
  86. package/dist/setup/steps/semantic.js +27 -0
  87. package/dist/setup/steps/sources.js +222 -0
  88. package/dist/setup/steps/stashdir.js +42 -0
  89. package/dist/setup/steps/tasks.js +152 -0
  90. package/dist/storage/repositories/canaries-repository.js +107 -0
  91. package/dist/storage/repositories/consolidation-repository.js +38 -0
  92. package/dist/storage/repositories/embeddings-repository.js +72 -0
  93. package/dist/storage/repositories/events-repository.js +187 -0
  94. package/dist/storage/repositories/extract-sessions-repository.js +96 -0
  95. package/dist/storage/repositories/improve-runs-repository.js +130 -0
  96. package/dist/storage/repositories/index-db.js +4 -7
  97. package/dist/storage/repositories/proposals-repository.js +220 -0
  98. package/dist/storage/repositories/recombine-repository.js +213 -0
  99. package/dist/storage/repositories/task-history-repository.js +93 -0
  100. package/dist/storage/sqlite-pragmas.js +3 -3
  101. package/dist/tasks/runner.js +2 -1
  102. package/package.json +1 -1
  103. package/dist/commands/improve/homeostatic.js +0 -497
@@ -7,6 +7,11 @@
7
7
  * Walks users through service detection, embedding/LLM setup,
8
8
  * registry selection, stash sources, and agent platform discovery.
9
9
  * Collects all choices and writes config once at the end.
10
+ *
11
+ * This module holds the wizard orchestration; the individual wizard steps,
12
+ * config-shape adapters, prompt shims, provider table, and semantic-asset
13
+ * preparation live in sibling modules (`steps/*`, `legacy-config`, `prompt`,
14
+ * `providers`, `semantic-assets`).
10
15
  */
11
16
  import { promises as dnsPromises } from "node:dns";
12
17
  import fs from "node:fs";
@@ -14,31 +19,32 @@ import os from "node:os";
14
19
  import path from "node:path";
15
20
  import * as p from "../cli/clack.js";
16
21
  import { akmInit } from "../commands/sources/init.js";
17
- import { detectServerDefault, isCiEnvironment, registerDefaultTasks } from "../commands/tasks/default-tasks.js";
18
- import { akmTasksAdd, akmTasksList, akmTasksSetEnabled, akmTasksSync } from "../commands/tasks/tasks.js";
19
- import { isHttpUrl } from "../core/common.js";
20
- import { DEFAULT_CONFIG, getDefaultLlmConfig, getEffectiveRegistries, loadUserConfig, saveConfig, } from "../core/config/config.js";
22
+ import { DEFAULT_CONFIG, getDefaultLlmConfig, loadUserConfig, saveConfig } from "../core/config/config.js";
21
23
  import { backupExistingConfig } from "../core/config/config-io.js";
24
+ import { deepMergeConfig } from "../core/deep-merge.js";
22
25
  import { ConfigError, UsageError } from "../core/errors.js";
23
- import { assertSafeStashDir, getConfigPath, getDefaultStashDir, isTransientStashPath } from "../core/paths.js";
26
+ import { getConfigPath, getDefaultStashDir, isTransientStashPath } from "../core/paths.js";
24
27
  import { warn } from "../core/warn.js";
25
- import { closeDatabase, isVecAvailable, openIndexDatabase } from "../indexer/db/db.js";
26
28
  import { akmIndex } from "../indexer/indexer.js";
27
29
  import { clearSemanticStatus, deriveSemanticProviderFingerprint, writeSemanticStatus, } from "../indexer/search/semantic-status.js";
28
30
  import { detectAgentCliProfiles, pickDefaultAgentProfile } from "../integrations/agent/index.js";
29
- import { defaultProfileName, v1ProfilePlatform } from "../integrations/harnesses/index.js";
31
+ import { defaultProfileName } from "../integrations/harnesses/index.js";
30
32
  import { probeLlmCapabilities } from "../llm/client.js";
31
- import { checkEmbeddingAvailability, DEFAULT_LOCAL_MODEL, isTransformersAvailable } from "../llm/embedder.js";
32
33
  import { getOutputMode } from "../output/context.js";
33
- import { getDirname, spawn } from "../runtime.js";
34
- import { saveGitStash } from "../sources/providers/git.js";
35
- import { backendNameForPlatform } from "../tasks/backends/index.js";
36
- import { listEmbeddedTasks } from "../tasks/embedded.js";
37
- import { parseSchedule } from "../tasks/schedule.js";
38
- import { detectAgentPlatforms, detectEnvironment, detectLMStudio, detectOllama, renderDetectionSummary, } from "./detect.js";
34
+ import { detectEnvironment, detectLMStudio, renderDetectionSummary, } from "./detect.js";
39
35
  import { detectHarnessConfigs } from "./harness-config-import.js";
40
- import { loadSetupStashes } from "./registry-stash-loader.js";
36
+ import { applyLegacyAgent, applyLegacyLlm } from "./legacy-config.js";
37
+ import { bail, prompt } from "./prompt.js";
38
+ import { PROVIDER_DEFAULTS } from "./providers.js";
39
+ import { prepareSemanticSearchAssets } from "./semantic-assets.js";
41
40
  import { createSetupContext, runSetupSteps } from "./steps.js";
41
+ import { stepAgentConnection, stepLlm, stepOllama, stepSmallModelConnection } from "./steps/connection.js";
42
+ import { stepOutputConfig } from "./steps/output.js";
43
+ import { printCapabilitySummary, stepAgentCliDetection, stepAgentPlatforms, stepAgentSelection, } from "./steps/platforms.js";
44
+ import { stepSemanticSearch } from "./steps/semantic.js";
45
+ import { stepAdditionalSources, stepAddSources, stepRegistries } from "./steps/sources.js";
46
+ import { stepStashDir } from "./steps/stashdir.js";
47
+ import { stepDefaultImproveTasks, stepScheduledTasks } from "./steps/tasks.js";
42
48
  // ── Setup sandbox guard ─────────────────────────────────────────────────────
43
49
  /**
44
50
  * Refuse to persist an explicit `--dir /tmp/...` stashDir to the user's
@@ -88,259 +94,7 @@ function applyStashIsolationToEnv(stashDir, dirExplicitlyProvided) {
88
94
  return;
89
95
  process.env.AKM_STASH_DIR = stashDir;
90
96
  }
91
- /** Read the currently-configured LLM connection from a loaded config. */
92
- function getCurrentLlm(config) {
93
- return getDefaultLlmConfig(config);
94
- }
95
- /** Read a synthesised legacy-shape agent block from the new-shape AkmConfig. */
96
- function getCurrentAgentBlock(config) {
97
- if (!config.profiles?.agent && !config.defaults?.agent)
98
- return undefined;
99
- const block = {};
100
- if (config.defaults?.agent)
101
- block.default = config.defaults.agent;
102
- if (config.profiles?.agent) {
103
- const profiles = {};
104
- for (const [name, raw] of Object.entries(config.profiles.agent)) {
105
- profiles[name] = {
106
- ...(raw.platform === "opencode-sdk" ? { sdkMode: true } : {}),
107
- ...(raw.model ? { model: raw.model } : {}),
108
- ...(raw.bin ? { bin: raw.bin } : {}),
109
- ...(raw.args ? { args: raw.args } : {}),
110
- };
111
- }
112
- block.profiles = profiles;
113
- }
114
- return block;
115
- }
116
- /** Apply an LLM connection patch onto the new-shape config. */
117
- function applyLegacyLlm(config, llm) {
118
- if (!llm) {
119
- // Clear the default LLM profile.
120
- const name = config.defaults?.llm ?? "default";
121
- const remaining = { ...(config.profiles?.llm ?? {}) };
122
- delete remaining[name];
123
- return {
124
- profiles: { ...(config.profiles ?? {}), llm: remaining },
125
- defaults: { ...(config.defaults ?? {}), llm: undefined },
126
- };
127
- }
128
- const name = config.defaults?.llm ?? "default";
129
- return {
130
- profiles: {
131
- ...(config.profiles ?? {}),
132
- llm: { ...(config.profiles?.llm ?? {}), [name]: llm },
133
- },
134
- defaults: { ...(config.defaults ?? {}), llm: name },
135
- };
136
- }
137
- /** Apply a legacy-shape agent block onto the new-shape config. */
138
- function applyLegacyAgent(config, agent) {
139
- if (!agent) {
140
- return {
141
- profiles: { ...(config.profiles ?? {}), agent: undefined },
142
- defaults: { ...(config.defaults ?? {}), agent: undefined },
143
- };
144
- }
145
- const v2Profiles = { ...(config.profiles?.agent ?? {}) };
146
- for (const [name, profile] of Object.entries(agent.profiles ?? {})) {
147
- // #566: resolve the platform via the harness registry instead of the old
148
- // `name.includes("claude") ? "claude" : "opencode"` heuristic, which
149
- // silently mapped Cursor/Copilot/any new harness to "opencode". An explicit
150
- // sdkMode flag still wins; otherwise we ask the registry. A name the
151
- // registry does not recognize is surfaced (warn) rather than silently
152
- // misclassified, then kept as a best-effort "opencode" profile so the user
153
- // does not lose a profile they explicitly configured.
154
- let platform;
155
- if (profile.sdkMode) {
156
- platform = "opencode-sdk";
157
- }
158
- else {
159
- const resolved = v1ProfilePlatform(name);
160
- if (resolved) {
161
- platform = resolved;
162
- }
163
- else {
164
- warn(`[akm setup] Agent profile "${name}" did not match any known harness; ` +
165
- `defaulting its platform to "opencode". Set its platform explicitly in config if this is wrong.`);
166
- platform = "opencode";
167
- }
168
- }
169
- v2Profiles[name] = {
170
- platform,
171
- ...(profile.bin ? { bin: profile.bin } : {}),
172
- ...(profile.args ? { args: profile.args } : {}),
173
- ...(profile.model ? { model: profile.model } : {}),
174
- };
175
- }
176
- return {
177
- profiles: { ...(config.profiles ?? {}), agent: v2Profiles },
178
- defaults: { ...(config.defaults ?? {}), agent: agent.default },
179
- };
180
- }
181
- // ── Constants ───────────────────────────────────────────────────────────────
182
- // Approximate first-download sizes used in the setup note.
183
- // LOCAL_MODEL_APPROX_SIZE_MB tracks the default local model (DEFAULT_LOCAL_MODEL).
184
- const LOCAL_MODEL_APPROX_SIZE_MB = 130;
185
- // SQLITE_VEC_APPROX_SIZE_MB reflects the optional sqlite-vec install footprint.
186
- const SQLITE_VEC_APPROX_SIZE_MB = 5;
187
97
  // ── Helpers ─────────────────────────────────────────────────────────────────
188
- function bail() {
189
- p.cancel("Setup cancelled. No changes were saved.");
190
- process.exit(0);
191
- }
192
- /**
193
- * Check if a prompt result was cancelled (Escape). If so, ask the user
194
- * whether they really want to quit. Returns true if the user chose to
195
- * stay (i.e. the caller should re-prompt), or calls bail() to exit.
196
- *
197
- * @internal Exported for testing only.
198
- */
199
- export async function onCancel(value) {
200
- if (!p.isCancel(value))
201
- return false;
202
- const confirmExit = await p.confirm({
203
- message: "Exit the wizard? No changes will be saved.",
204
- initialValue: false,
205
- });
206
- // Only exit when the user explicitly confirms "Yes".
207
- // Pressing Escape on the confirmation (isCancel) or choosing "No"
208
- // both mean "stay in the wizard".
209
- if (confirmExit === true) {
210
- bail();
211
- }
212
- // User chose to stay
213
- return true;
214
- }
215
- /**
216
- * Run a prompt function in a loop, retrying if the user presses Escape
217
- * but decides to stay. Returns the non-cancelled result.
218
- */
219
- async function prompt(fn) {
220
- for (;;) {
221
- const result = await fn();
222
- if (await onCancel(result))
223
- continue;
224
- return result;
225
- }
226
- }
227
- /**
228
- * Like `prompt`, but pressing Escape returns `null` instead of re-prompting.
229
- * Use inside sub-actions so the user can back out to the parent menu.
230
- */
231
- async function promptOrBack(fn) {
232
- const result = await fn();
233
- if (p.isCancel(result))
234
- return null;
235
- return result;
236
- }
237
- function configuredSourceKey(source) {
238
- return `${source.type}:${source.path ?? source.url ?? source.name ?? "unknown"}`;
239
- }
240
- function describeConfiguredSource(source) {
241
- const target = source.path ?? source.url ?? "(unknown target)";
242
- const typeLabel = source.type === "git" ? "Git" : source.type === "filesystem" ? "Filesystem" : source.type;
243
- return {
244
- value: configuredSourceKey(source),
245
- label: source.name ?? target,
246
- hint: `${typeLabel}: ${target}`,
247
- };
248
- }
249
- function renderConfiguredSourceList(sources) {
250
- return sources
251
- .map((source) => {
252
- const described = describeConfiguredSource(source);
253
- return `- ${described.label} (${described.hint})`;
254
- })
255
- .join("\n");
256
- }
257
- function renderInstalledSourceList(installed) {
258
- return installed.map((entry) => `- ${entry.id} (${entry.source})`).join("\n");
259
- }
260
- function cloneLlmConfig(llm) {
261
- if (!llm)
262
- return undefined;
263
- return {
264
- ...llm,
265
- ...(llm.capabilities ? { capabilities: { ...llm.capabilities } } : {}),
266
- ...(llm.extraParams ? { extraParams: { ...llm.extraParams } } : {}),
267
- };
268
- }
269
- async function stepAdditionalSources(currentSources) {
270
- const sources = [...currentSources];
271
- let addMore = true;
272
- while (addMore) {
273
- const action = await prompt(() => p.select({
274
- message: "Add another stash source?",
275
- options: [
276
- { value: "done", label: "Done — no more sources" },
277
- { value: "github-repo", label: "GitHub repository", hint: "custom URL" },
278
- { value: "filesystem", label: "Filesystem path", hint: "local directory" },
279
- ],
280
- initialValue: "done",
281
- }));
282
- if (action === "done") {
283
- addMore = false;
284
- break;
285
- }
286
- if (action === "github-repo") {
287
- const url = await promptOrBack(() => p.text({
288
- message: "Enter the GitHub repository URL:",
289
- placeholder: "https://github.com/owner/repo",
290
- validate: (v) => {
291
- if (!v?.trim())
292
- return "URL cannot be empty";
293
- },
294
- }));
295
- if (url === null)
296
- continue;
297
- const name = await promptOrBack(() => p.text({
298
- message: "Give this stash a name (optional):",
299
- placeholder: "my-repo",
300
- }));
301
- if (name === null)
302
- continue;
303
- const entry = { type: "git", url: url.trim() };
304
- if (name.trim())
305
- entry.name = name.trim();
306
- if (!sources.some((s) => s.url === entry.url)) {
307
- sources.push(entry);
308
- }
309
- else {
310
- p.log.warn("This URL is already configured.");
311
- }
312
- }
313
- if (action === "filesystem") {
314
- const fsPath = await promptOrBack(() => p.text({
315
- message: "Enter the directory path:",
316
- placeholder: "/path/to/stash",
317
- validate: (v) => {
318
- if (!v?.trim())
319
- return "Path cannot be empty";
320
- },
321
- }));
322
- if (fsPath === null)
323
- continue;
324
- const resolved = fsPath.trim();
325
- const name = await promptOrBack(() => p.text({
326
- message: "Give this stash a name (optional):",
327
- placeholder: "my-stash",
328
- }));
329
- if (name === null)
330
- continue;
331
- const entry = { type: "filesystem", path: resolved };
332
- if (name.trim())
333
- entry.name = name.trim();
334
- if (!sources.some((s) => s.path === entry.path)) {
335
- sources.push(entry);
336
- }
337
- else {
338
- p.log.warn("This path is already configured.");
339
- }
340
- }
341
- }
342
- return sources;
343
- }
344
98
  /**
345
99
  * Quick connectivity check. Returns true if we can resolve a hostname
346
100
  * the user has already implicitly trusted within 3 seconds, false
@@ -369,1286 +123,7 @@ export async function isOnline() {
369
123
  return false;
370
124
  }
371
125
  }
372
- function isRemoteEmbeddingConfig(embedding) {
373
- return isHttpUrl(embedding?.endpoint);
374
- }
375
- /**
376
- * @internal Exported for testing only.
377
- */
378
- export function describeSemanticSearchAssets(embedding) {
379
- if (isRemoteEmbeddingConfig(embedding)) {
380
- return [
381
- `• Embedding endpoint: ${embedding?.provider ?? "custom"} / ${embedding?.model} (no local model download)`,
382
- `• sqlite-vec acceleration: optional native extension (~${SQLITE_VEC_APPROX_SIZE_MB} MB when installed separately)`,
383
- ];
384
- }
385
- return [
386
- `• Local embedding model: ${embedding?.localModel ?? DEFAULT_LOCAL_MODEL} (~${LOCAL_MODEL_APPROX_SIZE_MB} MB download on first use)`,
387
- `• sqlite-vec acceleration: optional native extension (~${SQLITE_VEC_APPROX_SIZE_MB} MB when installed separately)`,
388
- ];
389
- }
390
- export async function stepSemanticSearch(current, embedding) {
391
- const enabled = await prompt(() => p.confirm({
392
- message: "Enable semantic search?",
393
- initialValue: current.semanticSearchMode !== "off",
394
- }));
395
- if (!enabled) {
396
- return { mode: "off", prepareAssets: false };
397
- }
398
- p.note(describeSemanticSearchAssets(embedding).join("\n"), "Semantic Search Assets");
399
- const prepareAssets = await prompt(() => p.confirm({
400
- message: isRemoteEmbeddingConfig(embedding)
401
- ? "Check the embedding endpoint and verify semantic search now?"
402
- : "Download and verify semantic-search assets now?",
403
- initialValue: true,
404
- }));
405
- return { mode: "auto", prepareAssets };
406
- }
407
- async function prepareSemanticSearchAssets(config) {
408
- const remote = isRemoteEmbeddingConfig(config.embedding);
409
- // For local embeddings, ensure the required package is installed first.
410
- if (!remote) {
411
- if (!isTransformersAvailable()) {
412
- const spin = p.spinner();
413
- spin.start("Installing @huggingface/transformers...");
414
- try {
415
- const pkgRoot = path.resolve(getDirname(import.meta.url), "../..");
416
- const proc = spawn(["bun", "add", "@huggingface/transformers"], {
417
- cwd: pkgRoot,
418
- stdout: "pipe",
419
- stderr: "pipe",
420
- });
421
- await proc.exited;
422
- if (proc.exitCode !== 0) {
423
- const stderr = await new Response(proc.stderr).text();
424
- throw new Error(stderr || `exit code ${proc.exitCode}`);
425
- }
426
- spin.stop("@huggingface/transformers installed.");
427
- }
428
- catch (err) {
429
- const msg = err instanceof Error ? err.message : String(err);
430
- spin.stop("Could not install @huggingface/transformers.");
431
- p.log.warn(`Automatic install failed: ${msg}\n` +
432
- "Install it manually with: bun add @huggingface/transformers\n" +
433
- "Then re-run `akm setup` or `akm index --full --verbose`.");
434
- return { ok: false, reason: "missing-package", message: `Automatic install failed: ${msg}` };
435
- }
436
- }
437
- }
438
- const spin = p.spinner();
439
- spin.start(remote
440
- ? "Checking remote embedding endpoint..."
441
- : `Downloading local embedding model (${config.embedding?.localModel ?? DEFAULT_LOCAL_MODEL})...`);
442
- const result = await checkEmbeddingAvailability(config.embedding);
443
- if (!result.available) {
444
- spin.stop("Semantic-search assets could not be prepared.");
445
- if (result.reason === "remote-unreachable") {
446
- p.log.warn("The remote embedding endpoint is not reachable. Check your endpoint and credentials, then retry `akm index --full --verbose`.");
447
- return { ok: false, reason: "remote-network", message: "The remote embedding endpoint is not reachable." };
448
- }
449
- else if (result.reason === "missing-package") {
450
- p.log.warn("@huggingface/transformers is not installed. Install it with: bun add @huggingface/transformers\n" +
451
- "Then re-run `akm setup` or `akm index --full --verbose`.");
452
- return { ok: false, reason: "missing-package", message: "@huggingface/transformers is not installed." };
453
- }
454
- else {
455
- p.log.warn(`The local embedding model could not be downloaded: ${result.message}\n` +
456
- "Retry `akm index --full --verbose` after confirming local model downloads are permitted.");
457
- return { ok: false, reason: "local-model-download", message: result.message };
458
- }
459
- }
460
- spin.stop(remote ? "Remote embedding endpoint is ready." : "Local embedding model downloaded and ready.");
461
- let db;
462
- let probeDir;
463
- try {
464
- probeDir = fs.mkdtempSync(path.join(os.tmpdir(), "akm-setup-vec-probe-"));
465
- db = openIndexDatabase(path.join(probeDir, "probe.db"), config.embedding?.dimension ? { embeddingDim: config.embedding.dimension } : undefined);
466
- if (isVecAvailable(db)) {
467
- p.log.info("sqlite-vec is available for fast vector search.");
468
- }
469
- else {
470
- p.log.info("sqlite-vec is not available. Semantic search will use the JS fallback until the optional extension is installed.");
471
- }
472
- }
473
- catch (error) {
474
- const message = error instanceof Error ? error.message : String(error);
475
- p.log.warn(`Could not open the local database or check for sqlite-vec. Semantic search will use the JS fallback. (${message})\n` +
476
- "Check file permissions and available disk space in the cache directory, or run `akm index --full --verbose` to diagnose.");
477
- }
478
- finally {
479
- if (db)
480
- closeDatabase(db);
481
- if (probeDir) {
482
- try {
483
- fs.rmSync(probeDir, { recursive: true, force: true });
484
- }
485
- catch {
486
- /* ignore cleanup failure */
487
- }
488
- }
489
- }
490
- return { ok: true };
491
- }
492
- // ── Steps ───────────────────────────────────────────────────────────────────
493
- async function stepStashDir(current, options) {
494
- const defaultDir = options?.preferredDir ?? current.stashDir ?? getDefaultStashDir();
495
- if (options?.nonInteractive) {
496
- return defaultDir;
497
- }
498
- const choice = await prompt(() => p.select({
499
- message: "Where should akm store skills, commands, and other assets?",
500
- options: [
501
- { value: "default", label: defaultDir, hint: current.stashDir ? "current" : "default" },
502
- { value: "custom", label: "Enter a custom path..." },
503
- ],
504
- }));
505
- if (choice === "default")
506
- return defaultDir;
507
- const customPath = await prompt(() => p.text({
508
- message: "Enter the stash directory path:",
509
- placeholder: defaultDir,
510
- validate: (v) => {
511
- if (!v?.trim())
512
- return "Path cannot be empty";
513
- try {
514
- assertSafeStashDir(v.trim());
515
- }
516
- catch (err) {
517
- if (err instanceof Error)
518
- return err.message;
519
- return "Refused: unsafe stash directory";
520
- }
521
- },
522
- }));
523
- return customPath.trim();
524
- }
525
- async function stepOllama(current) {
526
- const spin = p.spinner();
527
- spin.start("Checking for Ollama...");
528
- const ollama = await detectOllama();
529
- if (!ollama.available) {
530
- spin.stop("Ollama not detected");
531
- p.log.info("Ollama is not running. Embeddings will use the built-in local model.\n" +
532
- "To use Ollama later, install it from https://ollama.com and re-run `akm setup`.");
533
- // Preserve existing embedding config when Ollama is not available
534
- return { embedding: current.embedding };
535
- }
536
- spin.stop(`Ollama detected at ${ollama.endpoint}`);
537
- if (ollama.models.length > 0) {
538
- p.log.info(`Available models: ${ollama.models.join(", ")}`);
539
- }
540
- // Embedding model selection
541
- const embeddingModels = ollama.models.filter((m) => m.includes("embed") || m.includes("nomic") || m.includes("minilm") || m.includes("bge"));
542
- const hasEmbeddingModels = embeddingModels.length > 0;
543
- let embedding;
544
- const embeddingOptions = [];
545
- for (const m of embeddingModels) {
546
- embeddingOptions.push({ value: m, label: m, hint: "Ollama" });
547
- }
548
- embeddingOptions.push({
549
- value: "local",
550
- label: "Built-in local embeddings",
551
- hint: "no server needed",
552
- });
553
- if (current.embedding) {
554
- embeddingOptions.push({
555
- value: "keep",
556
- label: `Keep current: ${current.embedding.provider ?? current.embedding.endpoint}`,
557
- hint: current.embedding.model,
558
- });
559
- }
560
- const embChoice = await prompt(() => p.select({
561
- message: "Which embedding provider should akm use?",
562
- options: embeddingOptions,
563
- initialValue: hasEmbeddingModels ? embeddingModels[0] : "local",
564
- }));
565
- if (embChoice === "keep") {
566
- embedding = current.embedding;
567
- }
568
- else if (embChoice !== "local") {
569
- // Ask for dimension — different models produce different sizes.
570
- // Common dimensions: nomic-embed-text=768, mxbai-embed-large=1024,
571
- // all-minilm/bge-small=384. Default based on selected model.
572
- const knownDims = {
573
- nomic: 768,
574
- mxbai: 1024,
575
- minilm: 384,
576
- bge: 384,
577
- qwen3: 1024,
578
- };
579
- const guessedDim = Object.entries(knownDims).find(([k]) => embChoice.includes(k))?.[1] ?? 384;
580
- p.note("Embedding dimension must match the model. Common values: 384 (BGE small), 768 (BGE base), 1024 (BGE large). Press Enter to accept the detected default.", "Embedding dimension");
581
- const dimChoice = await prompt(() => p.text({
582
- message: `Embedding dimension for ${embChoice}:`,
583
- placeholder: String(guessedDim),
584
- defaultValue: String(guessedDim),
585
- validate: (v) => {
586
- const n = Number(v);
587
- if (!Number.isInteger(n) || n <= 0)
588
- return "Must be a positive integer";
589
- },
590
- }));
591
- embedding = {
592
- provider: "ollama",
593
- endpoint: `${ollama.endpoint}/v1/embeddings`,
594
- model: embChoice,
595
- dimension: Number(dimChoice),
596
- };
597
- p.note([
598
- "Recommended Qwen embedding models (modern, high context support):",
599
- " • qwen3-embedding-0.6b — fast and lightweight (ollama pull qwen3-embedding-0.6b)",
600
- " • qwen3-embedding-4b — higher quality (ollama pull qwen3-embedding-4b)",
601
- "",
602
- "For long documents (wiki pages, large files), set context length to avoid 400 errors:",
603
- " akm config set embedding.contextLength 8192",
604
- ].join("\n"), "Embedding tips");
605
- }
606
- // else: undefined → use built-in local
607
- // Surface Ollama details to the LLM step so it can offer Ollama as a preset.
608
- const ollamaChatModels = ollama.models.filter((m) => !embeddingModels.includes(m));
609
- return { embedding, ollamaEndpoint: ollama.endpoint, ollamaChatModels };
610
- }
611
- const LLM_PRESETS = [
612
- {
613
- value: "anthropic",
614
- label: "Anthropic Claude (OpenAI SDK compat beta)",
615
- endpoint: "https://api.anthropic.com/v1/chat/completions",
616
- defaultModel: "claude-sonnet-4-5",
617
- hint: "beta OpenAI-compat layer; set AKM_LLM_API_KEY; override the model if the default is unavailable",
618
- },
619
- {
620
- value: "openai",
621
- label: "OpenAI",
622
- endpoint: "https://api.openai.com/v1/chat/completions",
623
- defaultModel: "gpt-4o-mini",
624
- hint: "AKM_LLM_API_KEY required",
625
- },
626
- {
627
- value: "google",
628
- label: "Google Gemini (OpenAI-compat)",
629
- endpoint: "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
630
- defaultModel: "gemini-2.0-flash",
631
- hint: "OpenAI-compat endpoint, AKM_LLM_API_KEY required",
632
- },
633
- ];
634
- /**
635
- * Step 3a: pick an LLM provider. Used for indexing-time metadata enhancement.
636
- *
637
- * @internal Exported for testing only.
638
- */
639
- export async function stepLlm(current, ollamaEndpoint, ollamaChatModels, lmStudio, harnessConfigs) {
640
- // Build "Import from <Harness>" options and prepend them before LLM_PRESETS
641
- const harnessOptions = (harnessConfigs ?? []).map((h) => ({
642
- value: `harness:${h.harnessName}`,
643
- label: `Import from ${h.harnessName}`,
644
- hint: [h.provider, h.model].filter(Boolean).join(" / ") || "detected",
645
- }));
646
- const options = [
647
- ...harnessOptions,
648
- ...LLM_PRESETS.map((preset) => ({
649
- value: preset.value,
650
- label: preset.label,
651
- hint: preset.hint,
652
- })),
653
- ];
654
- const ollamaAvailable = Boolean(ollamaEndpoint && ollamaChatModels && ollamaChatModels.length > 0);
655
- if (ollamaAvailable) {
656
- options.push({
657
- value: "ollama",
658
- label: "Ollama (local)",
659
- hint: ollamaChatModels?.[0] ?? "local",
660
- });
661
- }
662
- const lmStudioHint = lmStudio?.available
663
- ? `${lmStudio.models.length} model${lmStudio.models.length === 1 ? "" : "s"} detected`
664
- : "http://localhost:1234";
665
- options.push({ value: "lmstudio", label: "LM Studio / local server", hint: lmStudioHint });
666
- options.push({ value: "custom", label: "Custom OpenAI-compatible endpoint" });
667
- options.push({ value: "none", label: "Skip LLM", hint: "no metadata enhancement during indexing" });
668
- const currentLlm = getCurrentLlm(current);
669
- if (currentLlm) {
670
- options.push({
671
- value: "keep",
672
- label: `Keep current: ${currentLlm.provider ?? currentLlm.endpoint}`,
673
- hint: currentLlm.model,
674
- });
675
- }
676
- const initialValue = currentLlm ? "keep" : ollamaAvailable ? "ollama" : (LLM_PRESETS[0]?.value ?? "none");
677
- const choice = await prompt(() => p.select({
678
- message: "Configure an LLM for richer metadata during indexing:",
679
- options,
680
- initialValue,
681
- }));
682
- if (choice === "keep")
683
- return cloneLlmConfig(currentLlm);
684
- if (choice === "none")
685
- return undefined;
686
- // Handle "Import from <Harness>" choices
687
- if (typeof choice === "string" && choice.startsWith("harness:")) {
688
- const harness = (harnessConfigs ?? []).find((h) => `harness:${h.harnessName}` === choice);
689
- if (!harness)
690
- return undefined;
691
- // Show a summary before accepting
692
- p.log.info(`Importing LLM config from ${harness.harnessName}: ` +
693
- [harness.provider, harness.model, harness.baseUrl].filter(Boolean).join(", "));
694
- const llmConfig = {
695
- endpoint: harness.baseUrl ?? "",
696
- model: harness.model ?? "",
697
- temperature: 0.3,
698
- maxTokens: 1024,
699
- };
700
- if (harness.provider)
701
- llmConfig.provider = harness.provider;
702
- if (harness.baseUrl)
703
- llmConfig.endpoint = harness.baseUrl;
704
- return llmConfig;
705
- }
706
- let llm;
707
- if (choice === "ollama") {
708
- const modelChoice = await prompt(() => p.select({
709
- message: "Which Ollama model?",
710
- options: (ollamaChatModels ?? []).map((m) => ({ value: m, label: m })),
711
- initialValue: ollamaChatModels?.[0],
712
- }));
713
- llm = {
714
- provider: "ollama",
715
- endpoint: `${ollamaEndpoint}/v1/chat/completions`,
716
- model: modelChoice,
717
- temperature: 0.3,
718
- maxTokens: 1024,
719
- };
720
- }
721
- else if (choice === "lmstudio") {
722
- const currentLmsLlm = currentLlm?.provider === "lmstudio" ? currentLlm : undefined;
723
- const defaultEndpoint = currentLmsLlm?.endpoint ??
724
- (lmStudio?.endpoint ? `${lmStudio.endpoint}/v1/chat/completions` : "http://localhost:1234/v1/chat/completions");
725
- const endpoint = await prompt(() => p.text({
726
- message: "Endpoint URL:",
727
- placeholder: defaultEndpoint,
728
- defaultValue: defaultEndpoint,
729
- validate: (v) => {
730
- if (!v?.trim())
731
- return "Endpoint cannot be empty";
732
- if (!v.startsWith("http://") && !v.startsWith("https://"))
733
- return "Must start with http:// or https://";
734
- },
735
- }));
736
- let model;
737
- const lmsModels = lmStudio?.available && lmStudio.models.length > 0 ? lmStudio.models : [];
738
- if (lmsModels.length > 0) {
739
- const modelChoice = await prompt(() => p.select({
740
- message: "Model name:",
741
- options: [
742
- ...lmsModels.map((m) => ({ value: m, label: m })),
743
- { value: "__manual__", label: "Enter manually..." },
744
- ],
745
- initialValue: currentLmsLlm?.model && lmsModels.includes(currentLmsLlm.model) ? currentLmsLlm.model : lmsModels[0],
746
- }));
747
- if (modelChoice === "__manual__") {
748
- model = await prompt(() => p.text({
749
- message: "Model name:",
750
- placeholder: currentLmsLlm?.model ?? "local-model",
751
- ...(currentLmsLlm?.model ? { defaultValue: currentLmsLlm.model } : {}),
752
- validate: (v) => (!v?.trim() ? "Model name cannot be empty" : undefined),
753
- }));
754
- }
755
- else {
756
- model = modelChoice;
757
- }
758
- }
759
- else {
760
- model = await prompt(() => p.text({
761
- message: "Model name:",
762
- placeholder: currentLmsLlm?.model ?? "local-model",
763
- ...(currentLmsLlm?.model ? { defaultValue: currentLmsLlm.model } : {}),
764
- validate: (v) => (!v?.trim() ? "Model name cannot be empty" : undefined),
765
- }));
766
- }
767
- llm = {
768
- provider: "lmstudio",
769
- endpoint: endpoint.trim(),
770
- model: model.trim(),
771
- temperature: 0.3,
772
- maxTokens: 1024,
773
- };
774
- }
775
- else if (choice === "custom") {
776
- const currentCustomLlm = currentLlm?.provider === "custom" ? currentLlm : undefined;
777
- const endpoint = await prompt(() => p.text({
778
- message: "OpenAI-compatible chat completions endpoint:",
779
- placeholder: currentCustomLlm?.endpoint ?? "https://your-host/v1/chat/completions",
780
- ...(currentCustomLlm?.endpoint ? { defaultValue: currentCustomLlm.endpoint } : {}),
781
- validate: (v) => {
782
- if (!v?.trim())
783
- return "Endpoint cannot be empty";
784
- if (!v.startsWith("http://") && !v.startsWith("https://"))
785
- return "Endpoint must start with http:// or https://";
786
- },
787
- }));
788
- const model = await prompt(() => p.text({
789
- message: "Model name:",
790
- placeholder: currentCustomLlm?.model ?? "gpt-4o-mini",
791
- ...(currentCustomLlm?.model ? { defaultValue: currentCustomLlm.model } : {}),
792
- validate: (v) => {
793
- if (!v?.trim())
794
- return "Model name cannot be empty";
795
- },
796
- }));
797
- llm = {
798
- provider: "custom",
799
- endpoint: endpoint.trim(),
800
- model: model.trim(),
801
- temperature: 0.3,
802
- maxTokens: 1024,
803
- };
804
- }
805
- else {
806
- const preset = LLM_PRESETS.find((p) => p.value === choice);
807
- if (!preset)
808
- return undefined;
809
- const model = await prompt(() => p.text({
810
- message: `Model for ${preset.label}:`,
811
- placeholder: preset.defaultModel,
812
- defaultValue: preset.defaultModel,
813
- validate: (v) => {
814
- if (!v?.trim())
815
- return "Model name cannot be empty";
816
- },
817
- }));
818
- llm = {
819
- provider: preset.value,
820
- endpoint: preset.endpoint,
821
- model: model.trim() || preset.defaultModel,
822
- temperature: 0.3,
823
- maxTokens: 1024,
824
- };
825
- }
826
- // Remind the user about API key placement. We do not offer a "store in config"
827
- // option because saveConfig() strips apiKey fields before writing — persisting
828
- // secrets would need an encrypted/secure store that we don't ship.
829
- const needsKey = llm.provider !== "ollama" && !llm.endpoint.includes("localhost");
830
- if (needsKey && !process.env.AKM_LLM_API_KEY) {
831
- p.log.info("This provider requires an API key. Set AKM_LLM_API_KEY in your shell (e.g. `export AKM_LLM_API_KEY=...`) before running `akm index`.");
832
- }
833
- // Capability probe — best-effort, never blocks setup.
834
- const probeSpin = p.spinner();
835
- probeSpin.start("Probing LLM (structured-output round-trip)...");
836
- const probe = await probeLlmCapabilities(llm);
837
- if (probe.reachable && probe.structuredOutput) {
838
- probeSpin.stop("LLM reachable; structured output verified.");
839
- llm.capabilities = { ...(llm.capabilities ?? {}), structuredOutput: true };
840
- }
841
- else if (probe.reachable) {
842
- probeSpin.stop("LLM reachable but structured-output probe failed.");
843
- llm.capabilities = { ...(llm.capabilities ?? {}), structuredOutput: false };
844
- }
845
- else {
846
- probeSpin.stop("LLM not reachable.");
847
- p.log.warn(`Could not reach the LLM endpoint${probe.error ? ` (${probe.error})` : ""}. Configuration was saved; verify your endpoint and API key, then retry.`);
848
- }
849
- return llm;
850
- }
851
- export async function stepRegistries(current) {
852
- const defaults = DEFAULT_CONFIG.registries ?? [];
853
- const currentRegistries = current.registries ?? defaults;
854
- const defaultUrls = new Set(defaults.map((r) => r.url));
855
- const enabledUrls = new Set(currentRegistries.filter((r) => r.enabled !== false).map((r) => r.url));
856
- // Collect custom (non-default) registries to preserve them
857
- const customRegistries = currentRegistries.filter((r) => !defaultUrls.has(r.url));
858
- // Show default registries for toggling
859
- const options = defaults.map((r) => ({
860
- value: r.url,
861
- label: r.name ?? r.url,
862
- hint: r.provider ?? "static index",
863
- }));
864
- if (customRegistries.length > 0) {
865
- p.log.info(`You have ${customRegistries.length} custom registr${customRegistries.length === 1 ? "y" : "ies"} that will be preserved.`);
866
- }
867
- const selected = await prompt(() => p.multiselect({
868
- message: "Which built-in registries should be enabled?",
869
- options,
870
- initialValues: options.filter((o) => enabledUrls.has(o.value)).map((o) => o.value),
871
- }));
872
- // If all defaults are selected and there are no custom registries,
873
- // return undefined to use the built-in defaults (avoids pinning)
874
- const allDefaultsSelected = defaults.every((r) => selected.includes(r.url));
875
- if (allDefaultsSelected && customRegistries.length === 0) {
876
- return undefined;
877
- }
878
- // Build explicit list: toggled defaults + preserved custom registries
879
- const result = defaults.map((r) => ({
880
- ...r,
881
- enabled: selected.includes(r.url),
882
- }));
883
- // Re-add custom registries unchanged
884
- for (const custom of customRegistries) {
885
- result.push(custom);
886
- }
887
- return result;
888
- }
889
- /**
890
- * @internal Exported for testing only.
891
- */
892
- export async function stepAddSources(current, options) {
893
- const existingSources = [...(current.sources ?? [])];
894
- const sources = [];
895
- if (existingSources.length > 0) {
896
- p.note(renderConfiguredSourceList(existingSources), "Configured stash sources");
897
- const options = existingSources.map(describeConfiguredSource);
898
- const selected = await prompt(() => p.multiselect({
899
- message: "Configured stash sources — uncheck any you want to disable:",
900
- options,
901
- initialValues: options.map((option) => option.value),
902
- required: false,
903
- }));
904
- for (const source of existingSources) {
905
- if (selected.includes(configuredSourceKey(source))) {
906
- sources.push(source);
907
- }
908
- }
909
- }
910
- if ((current.installed?.length ?? 0) > 0) {
911
- p.note(renderInstalledSourceList(current.installed ?? []), "Installed managed stashes (preserved)");
912
- }
913
- // ── Registry-driven stash recommendations ─────────────────────────────
914
- // Fetch available stashes from the official registry (cached, stale-ok).
915
- // Falls back to the bundled list when the registry is unreachable.
916
- const registryUrl = getEffectiveRegistries(current)[0]?.url ??
917
- "https://raw.githubusercontent.com/itlackey/akm-registry/main/index.json";
918
- const availableStashes = await loadSetupStashes(registryUrl);
919
- if (availableStashes.length > 0) {
920
- const existingUrls = new Set(sources.map((s) => s.url));
921
- const stashOptions = availableStashes.map((s) => ({
922
- value: s.url,
923
- label: s.name,
924
- hint: existingUrls.has(s.url) ? `${s.description} (already added)` : s.description || s.source,
925
- }));
926
- // Pre-check: already-installed stashes OR default-selected on fresh install
927
- const initialValues = sources.length > 0
928
- ? stashOptions.filter((o) => existingUrls.has(o.value)).map((o) => o.value)
929
- : availableStashes.filter((s) => s.defaultSelected).map((s) => s.url);
930
- const selectedUrls = await prompt(() => p.multiselect({
931
- message: availableStashes[0]?.source === "registry"
932
- ? "Available stashes from the AKM registry — toggle to add or remove:"
933
- : "Recommended stash sources — toggle to add or remove:",
934
- options: stashOptions,
935
- initialValues,
936
- required: false,
937
- }));
938
- // Add newly selected stashes
939
- for (const url of selectedUrls) {
940
- if (!existingUrls.has(url)) {
941
- const entry = availableStashes.find((s) => s.url === url);
942
- sources.push({ type: "git", url, name: entry?.name });
943
- existingUrls.add(url);
944
- }
945
- }
946
- // Remove deselected stashes that were previously configured
947
- for (const entry of availableStashes) {
948
- if (existingUrls.has(entry.url) && !selectedUrls.includes(entry.url)) {
949
- const idx = sources.findIndex((s) => s.url === entry.url);
950
- if (idx !== -1) {
951
- sources.splice(idx, 1);
952
- existingUrls.delete(entry.url);
953
- p.log.info(`Removed ${entry.name}.`);
954
- }
955
- }
956
- }
957
- }
958
- if (options?.promptForAdditional === false) {
959
- return sources;
960
- }
961
- return stepAdditionalSources(sources);
962
- }
963
- async function stepAgentPlatforms(current) {
964
- const platforms = detectAgentPlatforms();
965
- if (platforms.length === 0) {
966
- p.log.info("No agent platform configurations detected.");
967
- return [];
968
- }
969
- const existingPaths = new Set((current.sources ?? []).map((s) => s.path));
970
- // Filter out platforms already configured
971
- const newPlatforms = platforms.filter((pl) => !existingPaths.has(pl.path));
972
- if (newPlatforms.length === 0) {
973
- p.log.info(`Detected ${platforms.length} agent platform(s), all already configured as stash sources.`);
974
- return [];
975
- }
976
- const selected = await prompt(() => p.multiselect({
977
- message: "Found agent platform configurations. Add as stash sources?",
978
- options: newPlatforms.map((pl) => ({
979
- value: pl.path,
980
- label: pl.name,
981
- hint: pl.path,
982
- })),
983
- required: false,
984
- }));
985
- const entries = [];
986
- for (const selectedPath of selected) {
987
- const platform = newPlatforms.find((pl) => pl.path === selectedPath);
988
- if (platform) {
989
- entries.push({
990
- type: "filesystem",
991
- path: platform.path,
992
- name: platform.name.toLowerCase().replace(/\s+/g, "-"),
993
- });
994
- }
995
- }
996
- return entries;
997
- }
998
- /**
999
- * Step 1/2: Configure the small model connection used for metadata and bounded LLM features.
1000
- *
1001
- * Detects Ollama automatically and pre-selects it. The user may also choose
1002
- * OpenAI, LM Studio, a custom endpoint, or skip the step entirely.
1003
- */
1004
- export async function stepSmallModelConnection(current) {
1005
- p.log.step("Step 1/2: Configure your small model connection");
1006
- p.note([
1007
- "This connection is used for background processing:",
1008
- " • akm index (metadata enhancement)",
1009
- " • akm distill (lesson distillation)",
1010
- " • akm remember --enrich (memory compression)",
1011
- " • akm curate --rerank (search reranking)",
1012
- ].join("\n"));
1013
- // Probe for Ollama and LM Studio in the background while showing the note.
1014
- const spin = p.spinner();
1015
- spin.start("Detecting local services...");
1016
- const [ollama, lmStudio] = await Promise.all([detectOllama(), detectLMStudio()]);
1017
- const detectedServices = [
1018
- ollama.available ? `Ollama at ${ollama.endpoint}` : null,
1019
- lmStudio.available ? `LM Studio at ${lmStudio.endpoint}` : null,
1020
- ]
1021
- .filter(Boolean)
1022
- .join(", ");
1023
- spin.stop(detectedServices ? `Detected: ${detectedServices}` : "No local services detected");
1024
- const ollamaEndpoint = ollama.available ? ollama.endpoint : undefined;
1025
- const providerOptions = [];
1026
- if (ollama.available) {
1027
- providerOptions.push({
1028
- value: "ollama",
1029
- label: "Ollama (local)",
1030
- hint: `detected at ${ollama.endpoint}`,
1031
- });
1032
- }
1033
- const lmStudioHint = lmStudio.available
1034
- ? `${lmStudio.models.length} model${lmStudio.models.length === 1 ? "" : "s"} detected`
1035
- : "http://localhost:1234";
1036
- providerOptions.push({ value: "openai", label: "OpenAI", hint: "requires AKM_LLM_API_KEY" }, { value: "lmstudio", label: "LM Studio / local server", hint: lmStudioHint }, { value: "custom", label: "Custom OpenAI-compatible endpoint" }, { value: "skip", label: "Skip — disable enrichment features" });
1037
- const currentLlmSmall = getCurrentLlm(current);
1038
- if (currentLlmSmall) {
1039
- providerOptions.push({
1040
- value: "keep",
1041
- label: `Keep current: ${currentLlmSmall.provider ?? currentLlmSmall.endpoint}`,
1042
- hint: currentLlmSmall.model,
1043
- });
1044
- }
1045
- const initialValue = currentLlmSmall ? "keep" : ollama.available ? "ollama" : "openai";
1046
- const providerChoice = await prompt(() => p.select({
1047
- message: "Provider:",
1048
- options: providerOptions,
1049
- initialValue,
1050
- }));
1051
- if (providerChoice === "keep") {
1052
- return { llm: cloneLlmConfig(currentLlmSmall), skipped: false, ollamaEndpoint };
1053
- }
1054
- if (providerChoice === "skip") {
1055
- p.note([
1056
- "Enrichment features disabled:",
1057
- " • akm index — metadata enhancement disabled",
1058
- " • akm distill — lesson generation",
1059
- " • akm remember --enrich",
1060
- " • akm curate --rerank",
1061
- "",
1062
- "You can configure this later with `akm setup`.",
1063
- ].join("\n"), "Warning");
1064
- return { llm: undefined, skipped: true, ollamaEndpoint };
1065
- }
1066
- let llm;
1067
- if (providerChoice === "ollama") {
1068
- const ollamaChatModels = ollama.models.filter((m) => !m.includes("embed") && !m.includes("nomic") && !m.includes("minilm") && !m.includes("bge"));
1069
- let model;
1070
- if (ollamaChatModels.length > 0) {
1071
- model = await prompt(() => p.select({
1072
- message: "Model name:",
1073
- options: [
1074
- ...ollamaChatModels.map((m) => ({ value: m, label: m })),
1075
- { value: "__custom__", label: "Enter a model name manually..." },
1076
- ],
1077
- initialValue: ollamaChatModels[0],
1078
- }));
1079
- if (model === "__custom__") {
1080
- model = await prompt(() => p.text({
1081
- message: "Model name:",
1082
- placeholder: "llama3.2",
1083
- validate: (v) => (!v?.trim() ? "Model name cannot be empty" : undefined),
1084
- }));
1085
- }
1086
- }
1087
- else {
1088
- const currentOllamaModel = currentLlmSmall?.provider === "ollama" ? (currentLlmSmall.model ?? "llama3.2") : "llama3.2";
1089
- model = await prompt(() => p.text({
1090
- message: "Model name (e.g. llama3.2):",
1091
- placeholder: currentOllamaModel,
1092
- defaultValue: currentOllamaModel,
1093
- validate: (v) => (!v?.trim() ? "Model name cannot be empty" : undefined),
1094
- }));
1095
- }
1096
- llm = {
1097
- provider: "ollama",
1098
- endpoint: `${ollama.endpoint}/v1/chat/completions`,
1099
- model: model.trim(),
1100
- temperature: 0.3,
1101
- maxTokens: 1024,
1102
- };
1103
- }
1104
- else if (providerChoice === "openai") {
1105
- const currentOpenAiModel = currentLlmSmall?.provider === "openai" ? (currentLlmSmall.model ?? "gpt-4o-mini") : "gpt-4o-mini";
1106
- const model = await prompt(() => p.text({
1107
- message: "Model name:",
1108
- placeholder: currentOpenAiModel,
1109
- defaultValue: currentOpenAiModel,
1110
- validate: (v) => (!v?.trim() ? "Model name cannot be empty" : undefined),
1111
- }));
1112
- if (!process.env.AKM_LLM_API_KEY) {
1113
- p.log.info("Set AKM_LLM_API_KEY in your shell before running `akm index`.");
1114
- }
1115
- llm = {
1116
- provider: "openai",
1117
- endpoint: "https://api.openai.com/v1/chat/completions",
1118
- model: model.trim() || currentOpenAiModel,
1119
- temperature: 0.3,
1120
- maxTokens: 1024,
1121
- };
1122
- }
1123
- else if (providerChoice === "lmstudio") {
1124
- const currentLmsEndpoint = currentLlmSmall?.provider === "lmstudio"
1125
- ? (currentLlmSmall.endpoint ?? `${lmStudio.endpoint}/v1/chat/completions`)
1126
- : `${lmStudio.endpoint}/v1/chat/completions`;
1127
- const currentLmsModel = currentLlmSmall?.provider === "lmstudio" ? currentLlmSmall.model : undefined;
1128
- const endpoint = await prompt(() => p.text({
1129
- message: "Endpoint URL:",
1130
- placeholder: currentLmsEndpoint,
1131
- defaultValue: currentLmsEndpoint,
1132
- validate: (v) => {
1133
- if (!v?.trim())
1134
- return "Endpoint cannot be empty";
1135
- if (!v.startsWith("http://") && !v.startsWith("https://"))
1136
- return "Must start with http:// or https://";
1137
- },
1138
- }));
1139
- let model;
1140
- const lmsModels = lmStudio.available && lmStudio.models.length > 0 ? lmStudio.models : [];
1141
- if (lmsModels.length > 0) {
1142
- const modelChoice = await prompt(() => p.select({
1143
- message: "Model name:",
1144
- options: [
1145
- ...lmsModels.map((m) => ({ value: m, label: m })),
1146
- { value: "__manual__", label: "Enter manually..." },
1147
- ],
1148
- initialValue: currentLmsModel && lmsModels.includes(currentLmsModel) ? currentLmsModel : lmsModels[0],
1149
- }));
1150
- if (modelChoice === "__manual__") {
1151
- model = await prompt(() => p.text({
1152
- message: "Model name:",
1153
- placeholder: currentLmsModel ?? "local-model",
1154
- ...(currentLmsModel ? { defaultValue: currentLmsModel } : {}),
1155
- validate: (v) => (!v?.trim() ? "Model name cannot be empty" : undefined),
1156
- }));
1157
- }
1158
- else {
1159
- model = modelChoice;
1160
- }
1161
- }
1162
- else {
1163
- model = await prompt(() => p.text({
1164
- message: "Model name:",
1165
- placeholder: currentLmsModel ?? "local-model",
1166
- ...(currentLmsModel ? { defaultValue: currentLmsModel } : {}),
1167
- validate: (v) => (!v?.trim() ? "Model name cannot be empty" : undefined),
1168
- }));
1169
- }
1170
- llm = {
1171
- provider: "lmstudio",
1172
- endpoint: endpoint.trim(),
1173
- model: model.trim(),
1174
- temperature: 0.3,
1175
- maxTokens: 1024,
1176
- };
1177
- }
1178
- else {
1179
- // custom
1180
- const currentCustomEndpoint = currentLlmSmall?.provider === "custom" ? currentLlmSmall.endpoint : undefined;
1181
- const currentCustomModel = currentLlmSmall?.provider === "custom" ? currentLlmSmall.model : undefined;
1182
- const endpoint = await prompt(() => p.text({
1183
- message: "OpenAI-compatible chat completions endpoint:",
1184
- placeholder: currentCustomEndpoint ?? "https://your-host/v1/chat/completions",
1185
- ...(currentCustomEndpoint ? { defaultValue: currentCustomEndpoint } : {}),
1186
- validate: (v) => {
1187
- if (!v?.trim())
1188
- return "Endpoint cannot be empty";
1189
- if (!v.startsWith("http://") && !v.startsWith("https://"))
1190
- return "Must start with http:// or https://";
1191
- },
1192
- }));
1193
- const model = await prompt(() => p.text({
1194
- message: "Model name:",
1195
- placeholder: currentCustomModel ?? "gpt-4o-mini",
1196
- ...(currentCustomModel ? { defaultValue: currentCustomModel } : {}),
1197
- validate: (v) => (!v?.trim() ? "Model name cannot be empty" : undefined),
1198
- }));
1199
- const apiKeyInput = await promptOrBack(() => p.text({
1200
- message: "API key (optional — press Enter to skip):",
1201
- placeholder: "",
1202
- }));
1203
- llm = {
1204
- provider: "custom",
1205
- endpoint: endpoint.trim(),
1206
- model: model.trim(),
1207
- temperature: 0.3,
1208
- maxTokens: 1024,
1209
- ...(apiKeyInput?.trim() ? { apiKey: apiKeyInput.trim() } : {}),
1210
- };
1211
- }
1212
- // Best-effort probe — never blocks setup.
1213
- const probeSpin = p.spinner();
1214
- probeSpin.start("Probing LLM (structured-output round-trip)...");
1215
- const probe = await probeLlmCapabilities(llm);
1216
- if (probe.reachable && probe.structuredOutput) {
1217
- probeSpin.stop("LLM reachable; structured output verified.");
1218
- llm.capabilities = { ...(llm.capabilities ?? {}), structuredOutput: true };
1219
- }
1220
- else if (probe.reachable) {
1221
- probeSpin.stop("LLM reachable but structured-output probe failed.");
1222
- llm.capabilities = { ...(llm.capabilities ?? {}), structuredOutput: false };
1223
- }
1224
- else {
1225
- probeSpin.stop("LLM not reachable.");
1226
- p.log.warn(`Could not reach the LLM endpoint${probe.error ? ` (${probe.error})` : ""}. Configuration was saved; verify your endpoint and API key, then retry.`);
1227
- }
1228
- return { llm, skipped: false, ollamaEndpoint };
1229
- }
1230
- /**
1231
- * Step 2/2: Configure the agent connection used for agentic features.
1232
- *
1233
- * Options depend on whether Step 1 was completed or skipped.
1234
- */
1235
- export async function stepAgentConnection(current, smallModel) {
1236
- p.log.step("Step 2/2: Configure your agent connection");
1237
- p.note([
1238
- "This connection is used for agentic commands:",
1239
- " • akm propose (generate improvement proposals)",
1240
- " • akm improve (run the reflect/distill/consolidate self-improvement pipeline)",
1241
- " • akm tasks run (run automated task prompts)",
1242
- ].join("\n"));
1243
- // Detect available CLI agents.
1244
- const detections = detectAgentCliProfiles(current);
1245
- const currentAgentBlock = getCurrentAgentBlock(current);
1246
- const availableClis = detections.filter((d) => d.available);
1247
- const agentOptions = [];
1248
- if (!smallModel.skipped && smallModel.llm) {
1249
- agentOptions.push({
1250
- value: "same-connection",
1251
- label: "Same connection, select model",
1252
- hint: `uses ${smallModel.llm.endpoint.replace("/v1/chat/completions", "")}`,
1253
- });
1254
- }
1255
- agentOptions.push({ value: "new-connection", label: "New connection (different endpoint)" });
1256
- if (availableClis.length > 0) {
1257
- agentOptions.push({
1258
- value: "cli-agent",
1259
- label: "Installed CLI agent",
1260
- hint: `${availableClis.map((d) => d.name).join(", ")} detected`,
1261
- });
1262
- }
1263
- agentOptions.push({ value: "none", label: "None — disable agentic features" });
1264
- if (currentAgentBlock) {
1265
- const currentDesc = currentAgentBlock.default
1266
- ? `CLI: ${currentAgentBlock.default}`
1267
- : currentAgentBlock.profiles?.default?.model
1268
- ? `SDK: ${currentAgentBlock.profiles.default.model}`
1269
- : "configured";
1270
- agentOptions.push({ value: "keep", label: `Keep current: ${currentDesc}` });
1271
- }
1272
- const initialAgentValue = currentAgentBlock
1273
- ? "keep"
1274
- : availableClis.length > 0 && smallModel.skipped
1275
- ? "cli-agent"
1276
- : !smallModel.skipped && smallModel.llm
1277
- ? "same-connection"
1278
- : availableClis.length > 0
1279
- ? "cli-agent"
1280
- : "none";
1281
- const agentChoice = await prompt(() => p.select({
1282
- message: "How do you want to run agent commands?",
1283
- options: agentOptions,
1284
- initialValue: initialAgentValue,
1285
- }));
1286
- if (agentChoice === "keep") {
1287
- return currentAgentBlock;
1288
- }
1289
- if (agentChoice === "none") {
1290
- p.note([
1291
- "Agentic features disabled:",
1292
- ' • akm propose — will show "no agent configured" error',
1293
- ' • akm improve — will show "no agent configured" error',
1294
- ' • akm tasks run — will show "no agent configured" error',
1295
- "",
1296
- "You can configure this later with `akm setup`.",
1297
- ].join("\n"), "Warning");
1298
- return undefined;
1299
- }
1300
- if (agentChoice === "same-connection") {
1301
- if (smallModel.skipped || !smallModel.llm) {
1302
- p.log.warn("You skipped the small model connection. Configure one to use the same connection. Falling back to 'new connection'.");
1303
- // Fall through to new-connection flow
1304
- }
1305
- else {
1306
- const baseEndpoint = smallModel.llm.endpoint.replace("/v1/chat/completions", "");
1307
- p.log.info(`Endpoint: ${baseEndpoint} (from Step 1)`);
1308
- const profileName = smallModel.llm.provider ?? "default";
1309
- // Pre-populate from existing agent profile for this provider, if any.
1310
- const existingAgentModel = currentAgentBlock?.profiles?.[profileName]?.model ?? smallModel.llm.model ?? undefined;
1311
- const agentModel = await prompt(() => p.text({
1312
- message: "Model to use for agent tasks (same model is fine, larger models work better):",
1313
- placeholder: existingAgentModel ?? "qwen2.5-coder:32b",
1314
- ...(existingAgentModel ? { defaultValue: existingAgentModel } : {}),
1315
- validate: (v) => (!v?.trim() ? "Model name cannot be empty" : undefined),
1316
- }));
1317
- return {
1318
- ...(currentAgentBlock ?? {}),
1319
- profiles: {
1320
- ...(currentAgentBlock?.profiles ?? {}),
1321
- [profileName]: {
1322
- ...(currentAgentBlock?.profiles?.[profileName] ?? {}),
1323
- sdkMode: true,
1324
- model: agentModel.trim(),
1325
- endpoint: smallModel.llm.endpoint,
1326
- },
1327
- },
1328
- default: profileName,
1329
- };
1330
- }
1331
- }
1332
- if (agentChoice === "cli-agent") {
1333
- if (availableClis.length === 0) {
1334
- p.log.warn("No agent CLIs detected on PATH.");
1335
- return currentAgentBlock;
1336
- }
1337
- const initialCli = pickDefaultAgentProfile(detections, currentAgentBlock?.default) ?? availableClis[0]?.name;
1338
- const selectedCli = await prompt(() => p.select({
1339
- message: "Which CLI agent?",
1340
- options: availableClis.map((d) => ({
1341
- value: d.name,
1342
- label: d.name,
1343
- hint: d.resolvedPath ?? d.bin,
1344
- })),
1345
- initialValue: initialCli,
1346
- }));
1347
- return {
1348
- ...(currentAgentBlock ?? {}),
1349
- default: selectedCli,
1350
- };
1351
- }
1352
- // "new-connection" (also fall-through from "same-provider" when Step 1 was skipped)
1353
- // Pre-populate from current "custom" agent profile if available.
1354
- const currentCustomAgentProfile = currentAgentBlock?.profiles?.custom;
1355
- const currentNewEndpoint = currentCustomAgentProfile?.endpoint ?? undefined;
1356
- const currentNewModel = currentCustomAgentProfile?.model ?? undefined;
1357
- const newEndpoint = await prompt(() => p.text({
1358
- message: "OpenAI-compatible chat completions endpoint:",
1359
- placeholder: currentNewEndpoint ?? "https://your-host/v1/chat/completions",
1360
- ...(currentNewEndpoint ? { defaultValue: currentNewEndpoint } : {}),
1361
- validate: (v) => {
1362
- if (!v?.trim())
1363
- return "Endpoint cannot be empty";
1364
- if (!v.startsWith("http://") && !v.startsWith("https://"))
1365
- return "Must start with http:// or https://";
1366
- },
1367
- }));
1368
- const newApiKeyInput = await promptOrBack(() => p.text({
1369
- message: "API key (optional — press Enter to skip):",
1370
- placeholder: "",
1371
- }));
1372
- const newModel = await prompt(() => p.text({
1373
- message: "Model name (larger is better, e.g. gpt-4o):",
1374
- placeholder: currentNewModel ?? "gpt-4o",
1375
- ...(currentNewModel ? { defaultValue: currentNewModel } : {}),
1376
- validate: (v) => (!v?.trim() ? "Model name cannot be empty" : undefined),
1377
- }));
1378
- const customProfile = {
1379
- sdkMode: true,
1380
- endpoint: newEndpoint.trim(),
1381
- model: newModel.trim(),
1382
- ...(newApiKeyInput?.trim() ? { apiKey: newApiKeyInput.trim() } : {}),
1383
- };
1384
- return {
1385
- ...(currentAgentBlock ?? {}),
1386
- profiles: {
1387
- ...(currentAgentBlock?.profiles ?? {}),
1388
- custom: customProfile,
1389
- },
1390
- default: "custom",
1391
- };
1392
- }
1393
- /**
1394
- * Print a feature capability summary after both connection steps are complete.
1395
- */
1396
- function printCapabilitySummary(smallModelSkipped, agentConfigured) {
1397
- const lines = ["Setup complete. Here's what's enabled:", ""];
1398
- lines.push(" ✓ akm search, akm curate, akm show — always available");
1399
- if (!smallModelSkipped) {
1400
- lines.push(" ✓ akm index, akm distill, akm remember — small model configured");
1401
- }
1402
- else {
1403
- lines.push(" ✗ akm index, akm distill, akm remember — run `akm setup` to enable");
1404
- }
1405
- if (agentConfigured) {
1406
- lines.push(" ✓ akm propose, akm improve, akm tasks — agent configured");
1407
- }
1408
- else {
1409
- lines.push(" ✗ akm propose, akm improve, akm tasks — run `akm setup` to enable");
1410
- }
1411
- p.note(lines.join("\n"), "Feature Summary");
1412
- }
1413
- export async function stepAgentSelection(current, detections) {
1414
- const currentAgentBlock = getCurrentAgentBlock(current);
1415
- const available = detections.filter((d) => d.available);
1416
- if (available.length === 0) {
1417
- return currentAgentBlock;
1418
- }
1419
- const initialValue = pickDefaultAgentProfile(detections, currentAgentBlock?.default) ?? available[0]?.name;
1420
- const selectedDefault = await prompt(() => p.select({
1421
- message: "Which detected agent CLI should be the default?",
1422
- options: [
1423
- ...available.map((d) => ({
1424
- value: d.name,
1425
- label: d.name,
1426
- hint: d.resolvedPath ?? d.bin,
1427
- })),
1428
- { value: "disabled", label: "Disabled", hint: "do not configure a default agent CLI" },
1429
- ],
1430
- initialValue,
1431
- }));
1432
- if (selectedDefault === "disabled") {
1433
- if (!currentAgentBlock?.profiles && !currentAgentBlock?.timeoutMs) {
1434
- return undefined;
1435
- }
1436
- return {
1437
- ...(currentAgentBlock ?? {}),
1438
- default: undefined,
1439
- };
1440
- }
1441
- return {
1442
- ...(currentAgentBlock ?? {}),
1443
- default: selectedDefault,
1444
- };
1445
- }
1446
- export async function stepOutputConfig(current) {
1447
- const defaultOutput = current.output ?? DEFAULT_CONFIG.output ?? { format: "json", detail: "brief" };
1448
- const format = await prompt(() => p.select({
1449
- message: "Default output format?",
1450
- options: [
1451
- { value: "json", label: "json", hint: "structured default" },
1452
- { value: "text", label: "text", hint: "human-readable CLI output" },
1453
- { value: "yaml", label: "yaml", hint: "structured text" },
1454
- ],
1455
- initialValue: defaultOutput.format ?? "json",
1456
- }));
1457
- const detail = await prompt(() => p.select({
1458
- message: "Default output detail level?",
1459
- options: [
1460
- { value: "brief", label: "brief", hint: "compact summaries" },
1461
- { value: "normal", label: "normal", hint: "balanced detail" },
1462
- { value: "full", label: "full", hint: "max available detail" },
1463
- ],
1464
- initialValue: defaultOutput.detail ?? "brief",
1465
- }));
1466
- return { format: format, detail: detail };
1467
- }
1468
- /**
1469
- * Detect installed agent CLIs and produce an updated `agent` config block
1470
- * with a sensible `default` (the first detected profile that the user has
1471
- * not already overridden).
1472
- *
1473
- * Pure-ish: file system / PATH probes are routed through `detectFn` so
1474
- * tests can drive the branches without touching the real PATH.
1475
- *
1476
- * @internal Exported for testing only.
1477
- */
1478
- export function stepAgentCliDetection(current, detectFn = detectAgentCliProfiles) {
1479
- const detections = detectFn(current);
1480
- const currentAgentBlock = getCurrentAgentBlock(current);
1481
- const defaultName = pickDefaultAgentProfile(detections, currentAgentBlock?.default);
1482
- // No installed agents found and no existing config → leave block absent.
1483
- if (!defaultName && !currentAgentBlock) {
1484
- return { detections };
1485
- }
1486
- const agent = {
1487
- ...(currentAgentBlock ?? {}),
1488
- ...(defaultName ? { default: defaultName } : {}),
1489
- };
1490
- return { agent, detections };
1491
- }
1492
126
  // ── Main Wizard ─────────────────────────────────────────────────────────────
1493
- /**
1494
- * Normalise a task id the same way `akm tasks` does (strip a trailing `.yml`
1495
- * / `.md` suffix, trim) so the wizard can match embedded template ids against
1496
- * the ids reported by `akmTasksList()`.
1497
- */
1498
- function normaliseTaskIdForMatch(raw) {
1499
- return raw.trim().replace(/\.(yml|md)$/, "");
1500
- }
1501
- /**
1502
- * Interactive-only setup step: enable/disable embedded core tasks.
1503
- *
1504
- * Presents a multi-select of the bundled core task templates pre-checked
1505
- * against the user's currently-enabled tasks. On confirm:
1506
- * - newly-checked & absent → copy template (with edited schedule) into the
1507
- * primary stash via `akmTasksAdd`, then `akmTasksSync`, then `akm sync`
1508
- * (a no-op for non-git stashes).
1509
- * - newly-checked & present-but-disabled → `akmTasksSetEnabled(id, true)`.
1510
- * - previously-enabled & now unchecked → `akmTasksSetEnabled(id, false)`
1511
- * (keeps the stash file, removes the scheduler entry).
1512
- * - unchanged → no action.
1513
- *
1514
- * Exported for testing. Not registered as `nonInteractive`, so `akm init` /
1515
- * `--yes` never reach it.
1516
- *
1517
- * The task primitives + git-sync helper are injected via `deps` (defaulting
1518
- * to the real implementations) so tests can supply fakes without
1519
- * `mock.module`-ing the shared `commands/tasks` / `sources/providers/git`
1520
- * modules — which would leak into unrelated test files (Bun's `mock.module`
1521
- * is process-global and not reverted by `mock.restore()`).
1522
- */
1523
- /**
1524
- * Setup sub-step (issue #552): idempotently register the default improve task
1525
- * set. Asks a single "Is this a server install?" question (defaulting per
1526
- * platform) to decide whether the nightly sweep is enabled, then delegates to
1527
- * {@link registerDefaultTasks}, which is CI-aware and never duplicates an
1528
- * existing task. Skipped entirely under CI (the registration helper short-
1529
- * circuits, and we never even prompt).
1530
- *
1531
- * Exported for testing.
1532
- */
1533
- export async function stepDefaultImproveTasks(register = registerDefaultTasks) {
1534
- // CI: register nothing and don't prompt.
1535
- if (isCiEnvironment()) {
1536
- p.log.info("CI detected — skipping default improve task registration.");
1537
- return;
1538
- }
1539
- const platformDefault = detectServerDefault();
1540
- const serverInstall = await prompt(() => p.confirm({
1541
- message: "Is this a server install? (enables the nightly quality sweep at 2am)",
1542
- initialValue: platformDefault,
1543
- }));
1544
- const result = await register({ serverInstall: serverInstall === true });
1545
- if (result.skipped)
1546
- return;
1547
- const total = result.created.length + result.existing.length;
1548
- p.log.success(`Default improve tasks registered (${result.created.length} new, ${result.existing.length} already present, ${total} total).`);
1549
- }
1550
- const DEFAULT_SCHEDULED_TASKS_DEPS = {
1551
- list: akmTasksList,
1552
- add: akmTasksAdd,
1553
- setEnabled: akmTasksSetEnabled,
1554
- sync: akmTasksSync,
1555
- gitSync: saveGitStash,
1556
- };
1557
- export async function stepScheduledTasks(deps = DEFAULT_SCHEDULED_TASKS_DEPS) {
1558
- const embedded = listEmbeddedTasks();
1559
- if (embedded.length === 0)
1560
- return;
1561
- // Snapshot current state so we can diff against the user's selection.
1562
- let installed = [];
1563
- try {
1564
- installed = (await deps.list()).tasks;
1565
- }
1566
- catch {
1567
- // A missing/empty tasks dir is fine — treat as nothing installed.
1568
- installed = [];
1569
- }
1570
- const byId = new Map();
1571
- for (const t of installed)
1572
- byId.set(normaliseTaskIdForMatch(t.id), t);
1573
- // Pre-check tasks that are installed AND enabled.
1574
- const preChecked = embedded.filter((e) => byId.get(e.id)?.enabled === true).map((e) => e.id);
1575
- const stateLabel = (e) => {
1576
- const cur = byId.get(e.id);
1577
- if (!cur)
1578
- return "not installed";
1579
- return cur.enabled ? "enabled" : "disabled";
1580
- };
1581
- const selected = await prompt(() => p.multiselect({
1582
- message: "Enable scheduled core tasks? (space to toggle, enter to confirm)",
1583
- required: false,
1584
- initialValues: preChecked,
1585
- options: embedded.map((e) => ({
1586
- value: e.id,
1587
- label: e.label,
1588
- hint: `${e.description} — ${e.schedule} [${stateLabel(e)}]`,
1589
- })),
1590
- }));
1591
- const selectedSet = new Set(selected);
1592
- // Resolve per-task schedule edits for newly-checked, not-yet-installed tasks.
1593
- const scheduleFor = new Map();
1594
- for (const e of embedded) {
1595
- const cur = byId.get(e.id);
1596
- if (selectedSet.has(e.id) && !cur) {
1597
- const edited = await prompt(() => p.text({
1598
- message: `Schedule for ${e.label}?`,
1599
- initialValue: e.schedule,
1600
- validate(value) {
1601
- const candidate = (value ?? "").trim() || e.schedule;
1602
- try {
1603
- parseSchedule(candidate, backendNameForPlatform());
1604
- }
1605
- catch (err) {
1606
- return err instanceof Error ? err.message : "Invalid schedule.";
1607
- }
1608
- return undefined;
1609
- },
1610
- }));
1611
- const sched = (edited ?? "").trim() || e.schedule;
1612
- scheduleFor.set(e.id, sched);
1613
- }
1614
- }
1615
- let syncNeeded = false;
1616
- for (const e of embedded) {
1617
- const cur = byId.get(e.id);
1618
- const checked = selectedSet.has(e.id);
1619
- if (checked && !cur) {
1620
- // New task: copy template into the primary stash + install scheduler entry.
1621
- const schedule = scheduleFor.get(e.id) ?? e.schedule;
1622
- await deps.add({
1623
- id: e.id,
1624
- schedule,
1625
- command: e.command,
1626
- description: e.description,
1627
- });
1628
- syncNeeded = true;
1629
- }
1630
- else if (checked && cur && !cur.enabled) {
1631
- // Present but disabled → re-enable.
1632
- await deps.setEnabled(e.id, true);
1633
- }
1634
- else if (!checked && cur?.enabled) {
1635
- // Previously enabled, now unchecked → disable (keep the stash file).
1636
- await deps.setEnabled(e.id, false);
1637
- }
1638
- // No state change → no action.
1639
- }
1640
- if (syncNeeded) {
1641
- // Reconcile scheduler entries with on-disk YAML, then commit the new file
1642
- // to git (a no-op for non-git stashes).
1643
- await deps.sync();
1644
- try {
1645
- deps.gitSync(undefined, "akm setup: enable scheduled tasks");
1646
- }
1647
- catch {
1648
- // Non-fatal — the task is installed regardless of git sync outcome.
1649
- }
1650
- }
1651
- }
1652
127
  /**
1653
128
  * Build the canonical list of `SetupStep`s for the interactive wizard.
1654
129
  * Exposed (and exported) so tests and `akm init` can compose subsets.
@@ -2065,35 +540,6 @@ export async function runSetupWithDefaults(opts) {
2065
540
  ripgrep: initResult?.ripgrep,
2066
541
  };
2067
542
  }
2068
- /**
2069
- * Recursively merge `incoming` into `base`: plain objects merge key-by-key,
2070
- * while arrays and scalars replace wholesale. A partial input therefore only
2071
- * updates the keys it carries and never drops sibling subkeys (e.g. a file
2072
- * containing `{ output: { format: "text" } }` leaves `output.detail` intact).
2073
- *
2074
- * `base` is treated as immutable — a fresh object graph is returned.
2075
- */
2076
- function deepMergeConfig(base, incoming) {
2077
- if (!isPlainObject(incoming))
2078
- return incoming;
2079
- const baseObj = isPlainObject(base) ? base : {};
2080
- const out = { ...baseObj };
2081
- for (const [key, value] of Object.entries(incoming)) {
2082
- if (value === undefined)
2083
- continue;
2084
- if (isPlainObject(value) && isPlainObject(baseObj[key])) {
2085
- out[key] = deepMergeConfig(baseObj[key], value);
2086
- }
2087
- else {
2088
- out[key] = value;
2089
- }
2090
- }
2091
- return out;
2092
- }
2093
- /** True for non-null, non-array plain objects. */
2094
- function isPlainObject(value) {
2095
- return typeof value === "object" && value !== null && !Array.isArray(value);
2096
- }
2097
543
  /**
2098
544
  * Run ONLY environment detection and return the typed result. Performs no
2099
545
  * config writes and shows no prompts. Backs `akm setup --detect-only`.
@@ -2141,44 +587,15 @@ export function deriveRecommendedConfig(env) {
2141
587
  // the value lives in the env var the user already set; we never read it.
2142
588
  const cloud = env.providers.find((pr) => pr.kind === "apiKey");
2143
589
  if (cloud) {
2144
- const endpoint = cloudEndpointForProvider(cloud.provider);
2145
- const model = cloudDefaultModelForProvider(cloud.provider);
2146
- if (endpoint && model) {
2147
- result.llm = { provider: cloud.provider, endpoint, model };
590
+ const defaults = PROVIDER_DEFAULTS[cloud.provider];
591
+ if (defaults) {
592
+ result.llm = { provider: cloud.provider, endpoint: defaults.endpoint, model: defaults.model };
2148
593
  }
2149
594
  }
2150
595
  }
2151
596
  result.taskSchedules = { improve: "0 2 * * *", index: "0 4 * * *" };
2152
597
  return result;
2153
598
  }
2154
- function cloudEndpointForProvider(provider) {
2155
- switch (provider) {
2156
- case "anthropic":
2157
- return "https://api.anthropic.com/v1";
2158
- case "openai":
2159
- return "https://api.openai.com/v1";
2160
- case "gemini":
2161
- return "https://generativelanguage.googleapis.com/v1beta/openai";
2162
- case "groq":
2163
- return "https://api.groq.com/openai/v1";
2164
- default:
2165
- return undefined;
2166
- }
2167
- }
2168
- function cloudDefaultModelForProvider(provider) {
2169
- switch (provider) {
2170
- case "anthropic":
2171
- return "claude-sonnet-4-5";
2172
- case "openai":
2173
- return "gpt-4o-mini";
2174
- case "gemini":
2175
- return "gemini-1.5-flash";
2176
- case "groq":
2177
- return "llama-3.3-70b-versatile";
2178
- default:
2179
- return undefined;
2180
- }
2181
- }
2182
599
  /**
2183
600
  * `akm setup --reset-recommended`: merge opinionated, detection-derived
2184
601
  * defaults into the existing config WITHOUT removing pre-existing custom keys.