akm-cli 0.9.0-beta.45 → 0.9.0-beta.46

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 (77) hide show
  1. package/dist/cli/shared.js +28 -0
  2. package/dist/cli.js +1 -2
  3. package/dist/commands/env/env-cli.js +16 -24
  4. package/dist/commands/env/secret-cli.js +12 -20
  5. package/dist/commands/graph/graph-cli.js +5 -13
  6. package/dist/commands/graph/graph.js +3 -3
  7. package/dist/commands/improve/consolidate/chunking.js +141 -0
  8. package/dist/commands/improve/consolidate/eligibility.js +64 -0
  9. package/dist/commands/improve/consolidate/merge.js +145 -0
  10. package/dist/commands/improve/consolidate/sanitize.js +231 -0
  11. package/dist/commands/improve/consolidate/types.js +4 -0
  12. package/dist/commands/improve/consolidate.js +20 -571
  13. package/dist/commands/improve/distill.js +5 -9
  14. package/dist/commands/improve/eligibility.js +434 -0
  15. package/dist/commands/improve/extract-cli.js +9 -1
  16. package/dist/commands/improve/extract.js +5 -19
  17. package/dist/commands/improve/improve-auto-accept.js +4 -8
  18. package/dist/commands/improve/improve-cli.js +35 -60
  19. package/dist/commands/improve/improve-result-file.js +5 -23
  20. package/dist/commands/improve/improve-session.js +58 -0
  21. package/dist/commands/improve/improve.js +107 -3606
  22. package/dist/commands/improve/locks.js +154 -0
  23. package/dist/commands/improve/loop-stages.js +1079 -0
  24. package/dist/commands/improve/preparation.js +1963 -0
  25. package/dist/commands/improve/recombine.js +6 -12
  26. package/dist/commands/improve/reflect.js +29 -34
  27. package/dist/commands/proposal/drain.js +25 -48
  28. package/dist/commands/proposal/proposal-cli.js +21 -31
  29. package/dist/commands/proposal/validators/proposals.js +3 -7
  30. package/dist/commands/read/curate.js +70 -14
  31. package/dist/commands/read/knowledge.js +2 -2
  32. package/dist/commands/sources/self-update.js +2 -2
  33. package/dist/commands/sources/stash-cli.js +9 -37
  34. package/dist/commands/tasks/tasks-cli.js +19 -27
  35. package/dist/commands/wiki-cli.js +21 -35
  36. package/dist/core/config/config.js +18 -2
  37. package/dist/core/events.js +3 -7
  38. package/dist/core/logs-db.js +6 -63
  39. package/dist/core/state/migrations.js +714 -0
  40. package/dist/core/state-db.js +28 -779
  41. package/dist/indexer/db/db.js +82 -216
  42. package/dist/indexer/indexer.js +11 -112
  43. package/dist/indexer/passes/dir-staleness.js +114 -0
  44. package/dist/indexer/search/search-source.js +10 -24
  45. package/dist/integrations/agent/runner-dispatch.js +59 -0
  46. package/dist/llm/client.js +22 -11
  47. package/dist/llm/graph-extract.js +28 -39
  48. package/dist/llm/memory-infer.js +34 -22
  49. package/dist/llm/metadata-enhance.js +35 -30
  50. package/dist/llm/structured-call.js +49 -0
  51. package/dist/output/shapes/passthrough.js +0 -1
  52. package/dist/registry/providers/skills-sh.js +21 -147
  53. package/dist/registry/providers/static-index.js +15 -157
  54. package/dist/registry/resolve.js +22 -9
  55. package/dist/scripts/migrate-storage.js +892 -1186
  56. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +214 -179
  57. package/dist/setup/setup.js +26 -5
  58. package/dist/sources/providers/filesystem.js +0 -1
  59. package/dist/sources/providers/git-install.js +206 -0
  60. package/dist/sources/providers/git-provider.js +234 -0
  61. package/dist/sources/providers/git-stash.js +248 -0
  62. package/dist/sources/providers/git.js +10 -671
  63. package/dist/sources/providers/npm.js +2 -6
  64. package/dist/sources/providers/sync-from-ref.js +9 -1
  65. package/dist/sources/providers/website.js +2 -3
  66. package/dist/sources/website-ingest.js +51 -9
  67. package/dist/sources/wiki-fetchers/registry.js +53 -0
  68. package/dist/sources/wiki-fetchers/youtube.js +181 -0
  69. package/dist/storage/database.js +45 -10
  70. package/dist/storage/managed-db.js +82 -0
  71. package/dist/storage/repositories/registry-cache.js +92 -0
  72. package/dist/tasks/runner.js +5 -13
  73. package/dist/workflows/runtime/runs.js +1 -117
  74. package/dist/workflows/runtime/workflow-asset-loader.js +125 -0
  75. package/package.json +5 -5
  76. package/dist/commands/db-cli.js +0 -23
  77. package/dist/indexer/db/db-backup.js +0 -376
@@ -46,7 +46,7 @@ import { getDefaultLlmConfig, loadConfig } from "../../core/config/config.js";
46
46
  import { appendEvent } from "../../core/events.js";
47
47
  import { parseEmbeddedJsonResponse } from "../../core/parse.js";
48
48
  import { resolveStashStandards } from "../../core/standards/resolve-stash-standards.js";
49
- import { decayUnseenRecombineHypotheses, findMatchingRecombineHypothesis, getRecombineHypothesis, getStateDbPath, markRecombineHypothesisPromoted, openStateDatabase, recordRecombineInduction, } from "../../core/state-db.js";
49
+ import { decayUnseenRecombineHypotheses, findMatchingRecombineHypothesis, getRecombineHypothesis, markRecombineHypothesisPromoted, recordRecombineInduction, withStateDbAsync, } from "../../core/state-db.js";
50
50
  import { warn } from "../../core/warn.js";
51
51
  import { closeDatabase, getAllEntries, getEntitiesByEntryIds, openExistingDatabase, } from "../../indexer/db/db.js";
52
52
  import { resolveImproveProcessRunnerFromProfile, runnerIsLlm } from "../../integrations/agent/runner.js";
@@ -586,18 +586,16 @@ export async function akmRecombine(opts) {
586
586
  warnings.push("recombine: no LLM configured — skipping");
587
587
  return finish({ clustersFormed: 0 });
588
588
  }
589
- // #625 — open the confirmation-count store once per run via the ctx seam,
590
- // reusing a long-lived ctx.db handle when the caller provided one (mirrors
591
- // proposals.ts). Only handles WE opened are closed in the finally below.
592
- const ownStateDb = opts.ctx?.db ? undefined : openStateDatabase(opts.ctx?.dbPath ?? getStateDbPath());
593
- const stateDb = opts.ctx?.db ?? ownStateDb;
594
589
  // Refs re-induced (defensible generalization passed the quality gate) THIS
595
590
  // run — everything else is decayed after the loop.
596
591
  const seenThisRun = new Set();
597
592
  // Recombine output is knowledge/lesson (non-wiki) → stash authoring
598
593
  // standards. Resolved ONCE per run and passed to each cluster prompt.
599
594
  const standardsContext = resolveStashStandards(stashDir);
600
- try {
595
+ // #625 — open the confirmation-count store once per run via the ctx seam,
596
+ // reusing a long-lived ctx.db handle when the caller provided one (mirrors
597
+ // proposals.ts). Only handles WE opened are closed by the seam.
598
+ await withStateDbAsync(async (stateDb) => {
601
599
  for (const cluster of clusters) {
602
600
  if (opts.signal?.aborted) {
603
601
  warnings.push("aborted-mid-run");
@@ -782,11 +780,7 @@ export async function akmRecombine(opts) {
782
780
  }, opts.ctx);
783
781
  }
784
782
  }
785
- }
786
- finally {
787
- if (ownStateDb)
788
- ownStateDb.close();
789
- }
783
+ }, { path: opts.ctx?.dbPath, borrowed: opts.ctx?.db });
790
784
  return finish({ clustersFormed, proposalsEmitted, lessonsPromoted, nullsReturned });
791
785
  }
792
786
  /** Serialize frontmatter + body into a markdown asset string. */
@@ -24,7 +24,6 @@
24
24
  import fs from "node:fs";
25
25
  import os from "node:os";
26
26
  import path from "node:path";
27
- import { assertNever } from "../../core/assert.js";
28
27
  import { parseAssetRef } from "../../core/asset/asset-ref.js";
29
28
  import { assembleAssetFromString, serializeFrontmatter } from "../../core/asset/asset-serialize.js";
30
29
  import { parseFrontmatter } from "../../core/asset/frontmatter.js";
@@ -41,6 +40,7 @@ import { runAgent, } from "../../integrations/agent/index.js";
41
40
  import { resolveProcessAgentProfile } from "../../integrations/agent/config.js";
42
41
  import { buildReflectPrompt, extractDraftConfidence, parseAgentProposalPayload, } from "../../integrations/agent/prompts.js";
43
42
  import { resolveImproveProcessRunnerFromProfile, runnerIsLlm, runnerSupportsFileWrite, } from "../../integrations/agent/runner.js";
43
+ import { executeRunner } from "../../integrations/agent/runner-dispatch.js";
44
44
  import { runOpencodeSdk } from "../../integrations/harnesses/opencode-sdk/index.js";
45
45
  import { chatCompletion } from "../../llm/client.js";
46
46
  import { isLlmFeatureEnabled } from "../../llm/feature-gate.js";
@@ -954,44 +954,39 @@ export async function akmReflect(options = {}) {
954
954
  iterResult = await runAgent(resolvedProfile, prompt, runOptions);
955
955
  }
956
956
  else if (runnerSpec) {
957
- // v2: dispatch through unified RunnerSpec
957
+ // v2: dispatch through the unified RunnerSpec seam (X3). The `agent` /
958
+ // `sdk` arms route to the default profile runners; the `llm` arm is
959
+ // reflect-specific (wraps `runReflectViaLlm` — its bespoke iteration
960
+ // shape) so it is supplied as the `llm` handler.
958
961
  const runOptions = {
959
962
  stdio: "captured",
960
963
  parseOutput: "text",
961
964
  ...(Object.keys(agentEnv).length > 0 ? { env: agentEnv } : {}),
962
965
  };
963
- switch (runnerSpec.kind) {
964
- case "llm":
965
- // LLM HTTP path — `draftFilePath` is accepted for type symmetry
966
- // (see `RunReflectViaLlmOptions.draftFilePath` docstring) but is
967
- // intentionally a no-op. The prompt builder above also did not
968
- // include the file-write contract for this kind, so the LLM is
969
- // still asked for JSON via stdout.
970
- iterResult = await runReflectViaLlm({
971
- prompt,
972
- connection: runnerSpec.connection,
973
- timeoutMs: runnerSpec.timeoutMs ?? (typeof resolvedTimeoutMs === "number" ? resolvedTimeoutMs : undefined),
974
- priorDraft,
975
- iteration: iter,
976
- responseSchema: REFLECT_JSON_SCHEMA,
977
- chat: options.chat,
978
- ...(maxTokensForLlm !== undefined ? { maxTokens: maxTokensForLlm } : {}),
979
- });
980
- break;
981
- case "sdk":
982
- iterResult = await runOpencodeSdk(runnerSpec.profile, prompt ?? "", runOptions);
983
- break;
984
- case "agent":
985
- iterResult = await runAgent(runnerSpec.profile, prompt, {
986
- ...runOptions,
987
- ...(runnerSpec.timeoutMs !== undefined ? { timeoutMs: runnerSpec.timeoutMs } : {}),
988
- });
989
- break;
990
- default:
991
- // Exhaustiveness arm (H1): a 4th RunnerSpec kind becomes a compile
992
- // error here instead of leaving `iterResult` unassigned at runtime.
993
- assertNever(runnerSpec);
994
- }
966
+ iterResult = await executeRunner(runnerSpec, prompt ?? "", runOptions, {
967
+ llm: async (spec) =>
968
+ // LLM HTTP path — `draftFilePath` is accepted for type symmetry
969
+ // (see `RunReflectViaLlmOptions.draftFilePath` docstring) but is
970
+ // intentionally a no-op. The prompt builder above also did not
971
+ // include the file-write contract for this kind, so the LLM is
972
+ // still asked for JSON via stdout.
973
+ runReflectViaLlm({
974
+ prompt,
975
+ connection: spec.connection,
976
+ timeoutMs: spec.timeoutMs ?? (typeof resolvedTimeoutMs === "number" ? resolvedTimeoutMs : undefined),
977
+ priorDraft,
978
+ iteration: iter,
979
+ responseSchema: REFLECT_JSON_SCHEMA,
980
+ chat: options.chat,
981
+ ...(maxTokensForLlm !== undefined ? { maxTokens: maxTokensForLlm } : {}),
982
+ }),
983
+ // The `agent` arm (and only the agent arm — preserving prior behavior)
984
+ // overlays `spec.timeoutMs` onto the base run options.
985
+ runAgent: (profile, p, opts) => runAgent(profile, p, {
986
+ ...opts,
987
+ ...(runnerSpec.timeoutMs !== undefined ? { timeoutMs: runnerSpec.timeoutMs } : {}),
988
+ }),
989
+ });
995
990
  }
996
991
  else {
997
992
  // Production path (v1): dispatch directly to the appropriate runner.
@@ -36,14 +36,12 @@
36
36
  */
37
37
  import fs from "node:fs";
38
38
  import path from "node:path";
39
- import { assertNever } from "../../core/assert.js";
40
39
  import { parseAssetRef } from "../../core/asset/asset-ref.js";
41
40
  import { resolveAssetPathFromName, TYPE_DIRS } from "../../core/asset/asset-spec.js";
42
41
  import { parseFrontmatter } from "../../core/asset/frontmatter.js";
43
42
  import { appendEvent } from "../../core/events.js";
44
43
  import { info, warn } from "../../core/warn.js";
45
- import { runAgent } from "../../integrations/agent/index.js";
46
- import { runOpencodeSdk } from "../../integrations/harnesses/opencode-sdk/index.js";
44
+ import { executeRunner } from "../../integrations/agent/runner-dispatch.js";
47
45
  import { chatCompletion, stripJsonFences } from "../../llm/client.js";
48
46
  import { akmProposalAccept, akmProposalReject } from "./proposal.js";
49
47
  import { listProposals, recordGateDecision } from "./validators/proposals.js";
@@ -209,59 +207,38 @@ export function parseJudgmentVerdict(raw) {
209
207
  return { decision, reason: typeof reason === "string" ? reason : "" };
210
208
  }
211
209
  /**
212
- * Dispatch a single judgment prompt to the resolved runner. The switch mirrors
213
- * the canonical consumer at `reflect.ts:1060-1090`: llm `chatCompletion`
214
- * (no filesystem), agent `runAgent`, sdk `runOpencodeSdk`.
210
+ * Dispatch a single judgment prompt to the resolved runner via the unified
211
+ * {@link executeRunner} seam (X3). The `llm` arm is drain-specific (wraps
212
+ * `chatCompletion` — no filesystem) so it is supplied as the `llm` handler; the
213
+ * byte-identical `agent` / `sdk` arms route to the default profile runners (or
214
+ * the injected {@link JudgmentSeams} test fakes). A failed spawn warns and
215
+ * yields `null`, matching the prior per-arm behavior.
215
216
  */
216
217
  async function dispatchJudgment(runner, prompt, seams) {
217
- let raw;
218
- switch (runner.kind) {
219
- case "llm": {
220
- const messages = [{ role: "user", content: prompt }];
221
- raw = seams.chat
222
- ? await seams.chat(runner, messages)
223
- : await chatCompletion(runner.connection, messages, {
224
- ...(runner.timeoutMs !== undefined ? { timeoutMs: runner.timeoutMs } : {}),
225
- });
226
- break;
227
- }
228
- case "agent": {
229
- const stdout = await runProfileJudgment(seams.runAgentFn ?? runAgent, runner.profile, prompt, "agent", runner.timeoutMs);
230
- if (stdout === null)
231
- return null;
232
- raw = stdout;
233
- break;
234
- }
235
- case "sdk": {
236
- const stdout = await runProfileJudgment(seams.runSdkFn ?? runOpencodeSdk, runner.profile, prompt, "sdk", runner.timeoutMs);
237
- if (stdout === null)
238
- return null;
239
- raw = stdout;
240
- break;
241
- }
242
- default:
243
- // Exhaustiveness arm (H1): a 4th RunnerSpec kind becomes a compile error
244
- // here instead of leaving `raw` undefined at runtime.
245
- return assertNever(runner);
246
- }
247
- return parseJudgmentVerdict(raw);
248
- }
249
- /**
250
- * Shared spawn-runner dispatch for the byte-identical `agent` / `sdk` arms: run
251
- * the profile-based runner, log a labelled warning on failure, and return the
252
- * captured stdout (or `null` on failure). `label` only changes the warn prefix.
253
- */
254
- async function runProfileJudgment(run, profile, prompt, label, timeoutMs) {
255
- const result = await run(profile, prompt, {
218
+ const runOptions = {
256
219
  stdio: "captured",
257
220
  parseOutput: "text",
258
- ...(timeoutMs !== undefined ? { timeoutMs } : {}),
221
+ ...(runner.timeoutMs !== undefined ? { timeoutMs: runner.timeoutMs } : {}),
222
+ };
223
+ const result = await executeRunner(runner, prompt, runOptions, {
224
+ llm: async (spec, p) => {
225
+ const messages = [{ role: "user", content: p }];
226
+ const raw = seams.chat
227
+ ? await seams.chat(spec, messages)
228
+ : await chatCompletion(spec.connection, messages, {
229
+ ...(spec.timeoutMs !== undefined ? { timeoutMs: spec.timeoutMs } : {}),
230
+ });
231
+ // chatCompletion has no failure envelope — a returned string is success.
232
+ return { ok: true, exitCode: 0, stdout: raw, stderr: "", durationMs: 0 };
233
+ },
234
+ ...(seams.runAgentFn ? { runAgent: seams.runAgentFn } : {}),
235
+ ...(seams.runSdkFn ? { runSdk: seams.runSdkFn } : {}),
259
236
  });
260
237
  if (!result.ok) {
261
- warn(`[triage] judgment ${label} failed: ${result.error ?? result.reason ?? "unknown error"}`);
238
+ warn(`[triage] judgment ${runner.kind} failed: ${result.error ?? result.reason ?? "unknown error"}`);
262
239
  return null;
263
240
  }
264
- return result.stdout;
241
+ return parseJudgmentVerdict(result.stdout);
265
242
  }
266
243
  /**
267
244
  * Run the judgment tier over the deferred items. The runner only *judges*; the
@@ -9,9 +9,8 @@
9
9
  * and emits the same JSON envelope (stdout/stderr/exit-code) as the inline
10
10
  * `runWithJsonErrors` form it replaces.
11
11
  */
12
- import { defineCommand } from "citty";
13
- import { hasSubcommand, parsePositiveIntFlag } from "../../cli/parse-args.js";
14
- import { defineJsonCommand, output, runWithJsonErrors } from "../../cli/shared.js";
12
+ import { parsePositiveIntFlag } from "../../cli/parse-args.js";
13
+ import { defineGroupCommand, defineJsonCommand, output } from "../../cli/shared.js";
15
14
  import { resolveStashDir } from "../../core/common.js";
16
15
  import { loadConfig } from "../../core/config/config.js";
17
16
  import { UsageError } from "../../core/errors.js";
@@ -448,19 +447,7 @@ const proposalDrainCommand = defineJsonCommand({
448
447
  // (`proposals`/`accept`/`reject`/`diff`/`revert`) remain as deprecated aliases
449
448
  // that warn to stderr and delegate to the same command bodies; they are removed
450
449
  // in 0.9.0. Bare `akm proposal` behaves as `proposal list` (mirrors `akm env`).
451
- // Single source of truth: the routing set is derived from the subCommands keys
452
- // (M10) so adding a subcommand can never silently desync from `hasSubcommand`.
453
- const proposalSubCommands = {
454
- list: proposalListCommand,
455
- show: proposalShowCommand,
456
- diff: proposalDiffCommand,
457
- accept: proposalAcceptCommand,
458
- reject: proposalRejectCommand,
459
- revert: proposalRevertCommand,
460
- drain: proposalDrainCommand,
461
- };
462
- const PROPOSAL_SUBCOMMAND_SET = new Set(Object.keys(proposalSubCommands));
463
- export const proposalCommand = defineCommand({
450
+ export const proposalCommand = defineGroupCommand({
464
451
  meta: { name: "proposal", description: "Manage the proposal queue: list, show, diff, accept, reject, revert" },
465
452
  args: {
466
453
  status: {
@@ -470,21 +457,24 @@ export const proposalCommand = defineCommand({
470
457
  ref: { type: "string", description: "Filter by asset ref (type:name)" },
471
458
  type: { type: "string", description: "Filter by asset type" },
472
459
  },
473
- subCommands: proposalSubCommands,
474
- run({ args }) {
475
- return runWithJsonErrors(() => {
476
- // citty runs the group body even after a subcommand; short-circuit so the
477
- // default-to-list body only fires for bare `akm proposal [--status …]`.
478
- if (hasSubcommand(args, PROPOSAL_SUBCOMMAND_SET))
479
- return;
480
- const status = parseProposalStatus(args.status);
481
- const result = akmProposalList({
482
- status,
483
- ref: args.ref,
484
- type: args.type,
485
- includeArchive: status === "accepted" || status === "rejected" || status === "reverted",
486
- });
487
- output("proposal-list", result);
460
+ subCommands: {
461
+ list: proposalListCommand,
462
+ show: proposalShowCommand,
463
+ diff: proposalDiffCommand,
464
+ accept: proposalAcceptCommand,
465
+ reject: proposalRejectCommand,
466
+ revert: proposalRevertCommand,
467
+ drain: proposalDrainCommand,
468
+ },
469
+ // Default body fires only for bare `akm proposal [--status …]`.
470
+ defaultRun({ args }) {
471
+ const status = parseProposalStatus(args.status);
472
+ const result = akmProposalList({
473
+ status,
474
+ ref: args.ref,
475
+ type: args.type,
476
+ includeArchive: status === "accepted" || status === "rejected" || status === "reverted",
488
477
  });
478
+ output("proposal-list", result);
489
479
  },
490
480
  });
@@ -50,7 +50,7 @@ import { makeAssetRef, parseAssetRef } from "../../../core/asset/asset-ref.js";
50
50
  import { resolveAssetPathFromName, TYPE_DIRS } from "../../../core/asset/asset-spec.js";
51
51
  import { NotFoundError, UsageError } from "../../../core/errors.js";
52
52
  import { appendEvent } from "../../../core/events.js";
53
- import { getStateDbPath, getStateProposal, hasImportedFsProposals, insertProposalIfAbsent, listStateProposalIdsByPrefix, listStateProposals, openStateDatabase, recordFsProposalsImport, upsertProposal, withImmediateTransaction, } from "../../../core/state-db.js";
53
+ import { getStateProposal, hasImportedFsProposals, insertProposalIfAbsent, listStateProposalIdsByPrefix, listStateProposals, recordFsProposalsImport, upsertProposal, withImmediateTransaction, withStateDb, } from "../../../core/state-db.js";
54
54
  import { repairTruncatedDescription } from "../../../core/text-truncation.js";
55
55
  import { warn } from "../../../core/warn.js";
56
56
  import { commitWriteTargetBoundary, formatRefForMessage, resolveWriteTarget, writeAssetToSource, } from "../../../core/write-source.js";
@@ -161,14 +161,10 @@ function newId(ctx) {
161
161
  * guaranteed to have run before any read or write.
162
162
  */
163
163
  function withProposalsDb(stashDir, ctx, fn) {
164
- const db = openStateDatabase(ctx?.dbPath ?? getStateDbPath());
165
- try {
164
+ return withStateDb((db) => {
166
165
  importLegacyProposalFiles(db, stashDir);
167
166
  return fn(db);
168
- }
169
- finally {
170
- db.close();
171
- }
167
+ }, { path: ctx?.dbPath });
172
168
  }
173
169
  // ── Legacy filesystem import (#578) ─────────────────────────────────────────
174
170
  /** Legacy (pre-0.9.0) proposal directory: `<stashDir>/.akm/proposals[/archive]`. */
@@ -278,33 +278,89 @@ export function deriveCurateFallbackQueries(query) {
278
278
  return tokens;
279
279
  }
280
280
  export function mergeCurateSearchResponses(base, extras) {
281
- const hitsByRef = new Map();
281
+ // The base (full-query) ranking is the relevance signal — keyword fallback
282
+ // searches exist only to ADD recall when that ranking is thin, never to
283
+ // re-rank it. So we PRESERVE base order and APPEND fallback-only hits below
284
+ // it. A single-token fallback match on an exact title/path normalizes to a
285
+ // high FTS score, but those scores are not comparable to the full-query
286
+ // (hybrid) scores; re-sorting the union by raw score (the prior behaviour)
287
+ // let that keyword junk leapfrog the contextually-relevant full-query hits.
288
+ // Dup refs (present in both base and a fallback) keep their base POSITION but
289
+ // take the MAX score, since matching both the full query and a key term is a
290
+ // stronger relevance signal for the downstream score floor.
291
+ const bestExtraStashScore = new Map();
292
+ for (const result of extras) {
293
+ for (const hit of result.hits.filter((entry) => entry.type !== "registry")) {
294
+ const prev = bestExtraStashScore.get(hit.ref);
295
+ if (prev === undefined || (hit.score ?? 0) > prev)
296
+ bestExtraStashScore.set(hit.ref, hit.score ?? 0);
297
+ }
298
+ }
299
+ const baseRefs = new Set();
300
+ const baseStash = [];
282
301
  for (const hit of base.hits.filter((entry) => entry.type !== "registry")) {
283
- hitsByRef.set(hit.ref, hit);
302
+ baseRefs.add(hit.ref);
303
+ const extraScore = bestExtraStashScore.get(hit.ref);
304
+ baseStash.push(extraScore !== undefined && extraScore > (hit.score ?? 0) ? { ...hit, score: extraScore } : hit);
284
305
  }
306
+ const extraOnly = new Map();
285
307
  for (const result of extras) {
286
308
  for (const hit of result.hits.filter((entry) => entry.type !== "registry")) {
287
- const existing = hitsByRef.get(hit.ref);
288
- if (!existing || (hit.score ?? 0) > (existing.score ?? 0)) {
289
- hitsByRef.set(hit.ref, hit);
290
- }
309
+ if (baseRefs.has(hit.ref))
310
+ continue;
311
+ const existing = extraOnly.get(hit.ref);
312
+ if (!existing || (hit.score ?? 0) > (existing.score ?? 0))
313
+ extraOnly.set(hit.ref, hit);
291
314
  }
292
315
  }
293
- const registryById = new Map();
316
+ // Fallback-only hits must rank BELOW every full-query hit through the rest of
317
+ // the pipeline. The downstream selector (`selectCuratedStashHits`) RE-SORTS by
318
+ // score and derives its relevance floor from the top score, so preserving
319
+ // order here is not enough — a single-token FTS match (normalized ~0.9) would
320
+ // otherwise become the leader and evict the contextual full-query memories.
321
+ // We therefore restamp fallback-only scores into a band strictly below the
322
+ // minimum base score (keeping their own relative order). When there are no
323
+ // base hits, fallback IS the result, so scores are kept as-is.
324
+ const sortedExtra = [...extraOnly.values()].sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
325
+ const minBaseScore = baseStash.length
326
+ ? Math.min(...baseStash.map((hit) => hit.score ?? 0))
327
+ : Number.POSITIVE_INFINITY;
328
+ const cappedExtra = baseStash.length
329
+ ? sortedExtra.map((hit, i) => ({ ...hit, score: minBaseScore - 1e-6 * (i + 1) }))
330
+ : sortedExtra;
331
+ const mergedHits = [...baseStash, ...cappedExtra];
332
+ // Registry hits are supplemental fill — same rule: base first (max score on
333
+ // dups), then fallback-only registry hits appended by score.
334
+ const bestExtraRegScore = new Map();
335
+ for (const result of extras) {
336
+ for (const hit of result.registryHits ?? []) {
337
+ const prev = bestExtraRegScore.get(hit.id);
338
+ if (prev === undefined || (hit.score ?? 0) > prev)
339
+ bestExtraRegScore.set(hit.id, hit.score ?? 0);
340
+ }
341
+ }
342
+ const baseRegIds = new Set();
343
+ const baseReg = [];
294
344
  for (const hit of base.registryHits ?? []) {
295
- registryById.set(hit.id, hit);
345
+ baseRegIds.add(hit.id);
346
+ const extraScore = bestExtraRegScore.get(hit.id);
347
+ baseReg.push(extraScore !== undefined && extraScore > (hit.score ?? 0) ? { ...hit, score: extraScore } : hit);
296
348
  }
349
+ const extraRegOnly = new Map();
297
350
  for (const result of extras) {
298
351
  for (const hit of result.registryHits ?? []) {
299
- const existing = registryById.get(hit.id);
300
- if (!existing || (hit.score ?? 0) > (existing.score ?? 0)) {
301
- registryById.set(hit.id, hit);
302
- }
352
+ if (baseRegIds.has(hit.id))
353
+ continue;
354
+ const existing = extraRegOnly.get(hit.id);
355
+ if (!existing || (hit.score ?? 0) > (existing.score ?? 0))
356
+ extraRegOnly.set(hit.id, hit);
303
357
  }
304
358
  }
305
359
  const warnings = Array.from(new Set([...(base.warnings ?? []), ...extras.flatMap((result) => result.warnings ?? [])]));
306
- const mergedHits = [...hitsByRef.values()].sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
307
- const mergedRegistryHits = [...registryById.values()].sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
360
+ const mergedRegistryHits = [
361
+ ...baseReg,
362
+ ...[...extraRegOnly.values()].sort((a, b) => (b.score ?? 0) - (a.score ?? 0)),
363
+ ];
308
364
  return {
309
365
  ...base,
310
366
  hits: mergedHits,
@@ -101,10 +101,10 @@ export function readKnowledgeContent(source) {
101
101
  * URLs are fetched via `fetchWebsiteMarkdownSnapshot`; local sources delegate
102
102
  * to `readKnowledgeContent`.
103
103
  */
104
- export async function readKnowledgeInput(source) {
104
+ export async function readKnowledgeInput(source, options) {
105
105
  if (!isHttpUrl(source))
106
106
  return readKnowledgeContent(source);
107
- const snapshot = await fetchWebsiteMarkdownSnapshot(source);
107
+ const snapshot = await fetchWebsiteMarkdownSnapshot(source, { stashDir: options?.stashDir });
108
108
  return { content: snapshot.content, preferredName: snapshot.preferredName };
109
109
  }
110
110
  // ── Asset writing ────────────────────────────────────────────────────────────
@@ -306,8 +306,8 @@ export async function performUpgrade(check, opts) {
306
306
  * The new binary's `akm index` does the work for us:
307
307
  * 1. loadConfig() runs at startup — auto-migrates legacy `stashes` →
308
308
  * `sources` if the on-disk config still uses the old key.
309
- * 2. ensureSchema() detects DB_VERSION mismatch and rebuilds index.db
310
- * tables (preserving usage_events).
309
+ * 2. ensureSchema() converges index.db forward via its idempotent baseline
310
+ * schema + additive migrations (no destructive rebuild).
311
311
  * 3. The full reindex repopulates entries + workflow_documents + FTS.
312
312
  */
313
313
  function runPostUpgradeTasks(akmBin, opts) {
@@ -5,20 +5,20 @@
5
5
  * Stash-lifecycle command cluster — the create/index/ingest/inspect verbs for
6
6
  * the working stash and its index database: `akm init` (create the stash +
7
7
  * persist stashDir), `akm index` (build/refresh the search index), `akm import`
8
- * (ingest a knowledge doc/URL), `akm db` (+ nested `backups` — inspect the
9
- * SQLite data dir), and `akm info` (system capabilities + index stats).
8
+ * (ingest a knowledge doc/URL), and `akm info` (system capabilities + index
9
+ * stats).
10
10
  * Extracted verbatim from src/cli.ts (WS6) so the God Module shrinks; the
11
- * `main.subCommands.{init,index,import,db,info}` keys and every subcommand's
11
+ * `main.subCommands.{init,index,import,info}` keys and every subcommand's
12
12
  * args/output shape stay byte-identical.
13
13
  *
14
14
  * These share no private helper with any command still inline in cli.ts — every
15
15
  * dependency is already exported from a shared module (core/paths, core/warn,
16
16
  * core/errors, core/events, output/context, cli/shared, cli/parse-args, plus the
17
- * per-command implementations in ./init, ./indexer, ./info, ./db-cli, ./knowledge,
17
+ * per-command implementations in ./init, ./indexer, ./info, ./knowledge,
18
18
  * ./core/asset-create, ./core/common), so the cluster moves with zero hoisting.
19
19
  *
20
20
  * The leaf handlers whose body is a plain `runWithJsonErrors(...) + output(...)`
21
- * (`init`, `import`, `info`, `db`, `db backups`) are migrated onto
21
+ * (`init`, `import`, `info`) are migrated onto
22
22
  * `defineJsonCommand`, which emits the same JSON envelope (stdout/stderr/
23
23
  * exit-code) as the inline form. `index` keeps a plain `defineCommand` wrapping
24
24
  * `runWithJsonErrors` because its body owns a spinner, an AbortController, and
@@ -27,17 +27,17 @@
27
27
  import path from "node:path";
28
28
  import * as p from "@clack/prompts";
29
29
  import { defineCommand } from "citty";
30
- import { hasSubcommand } from "../../cli/parse-args.js";
31
30
  import { defineJsonCommand, output, runWithJsonErrors } from "../../cli/shared.js";
32
31
  import { assertFlatAssetName } from "../../core/asset/asset-create.js";
33
32
  import { isHttpUrl } from "../../core/common.js";
33
+ import { loadConfig } from "../../core/config/config.js";
34
34
  import { UsageError } from "../../core/errors.js";
35
35
  import { appendEvent } from "../../core/events.js";
36
36
  import { getCacheDir } from "../../core/paths.js";
37
37
  import { clearLogFile, info, isVerbose, setLogFile } from "../../core/warn.js";
38
+ import { resolveWriteTarget } from "../../core/write-source.js";
38
39
  import { akmIndex } from "../../indexer/indexer.js";
39
40
  import { getHyphenatedBoolean, getOutputMode, parseFlagValue } from "../../output/context.js";
40
- import { akmDbBackups } from "../db-cli.js";
41
41
  import { readKnowledgeInput, writeMarkdownAsset } from "../read/knowledge.js";
42
42
  import { assembleInfo } from "./info.js";
43
43
  import { akmInit } from "./init.js";
@@ -153,35 +153,6 @@ export const infoCommand = defineJsonCommand({
153
153
  output("info", result);
154
154
  },
155
155
  });
156
- // MVP DB administration. Currently only `akm db backups`; restore is manual —
157
- // stop akm and run `scripts/migrations/restore-data-dir.sh <backup>`.
158
- // Single source of truth: the routing set is derived from the subCommands keys
159
- // (M10) so adding a subcommand can never silently desync from `hasSubcommand`.
160
- const dbSubCommands = {
161
- backups: defineJsonCommand({
162
- meta: {
163
- name: "backups",
164
- description: "List pre-upgrade snapshots of the data directory (newest first). Backups are created automatically before destructive DB version upgrades unless AKM_DB_BACKUP=0.",
165
- },
166
- run() {
167
- output("db-backups", akmDbBackups());
168
- },
169
- }),
170
- };
171
- const DB_SUBCOMMAND_SET = new Set(Object.keys(dbSubCommands));
172
- export const dbCommand = defineJsonCommand({
173
- meta: {
174
- name: "db",
175
- description: "Inspect the AKM SQLite data directory. Currently exposes `backups`; to restore from a snapshot, stop akm and run scripts/migrations/restore-data-dir.sh against the chosen backup.",
176
- },
177
- subCommands: dbSubCommands,
178
- run({ args }) {
179
- if (hasSubcommand(args, DB_SUBCOMMAND_SET))
180
- return;
181
- // Default action: list backups.
182
- output("db-backups", akmDbBackups());
183
- },
184
- });
185
156
  export const importKnowledgeCommand = defineJsonCommand({
186
157
  meta: {
187
158
  name: "import",
@@ -214,7 +185,8 @@ export const importKnowledgeCommand = defineJsonCommand({
214
185
  async run({ args }) {
215
186
  // `--name` is a flat name; subdirectory placement is `--path`'s job.
216
187
  assertFlatAssetName(args.name);
217
- const { content, preferredName } = await readKnowledgeInput(args.source);
188
+ const stashDir = resolveWriteTarget(loadConfig(), args.target).source.path;
189
+ const { content, preferredName } = await readKnowledgeInput(args.source, { stashDir });
218
190
  const result = await writeMarkdownAsset({
219
191
  type: "knowledge",
220
192
  content,
@@ -13,8 +13,8 @@
13
13
  * with the family.
14
14
  */
15
15
  import { defineCommand } from "citty";
16
- import { hasSubcommand, parsePositiveIntFlag } from "../../cli/parse-args.js";
17
- import { defineJsonCommand, output, runWithJsonErrors } from "../../cli/shared.js";
16
+ import { parsePositiveIntFlag } from "../../cli/parse-args.js";
17
+ import { defineGroupCommand, defineJsonCommand, output, runWithJsonErrors } from "../../cli/shared.js";
18
18
  import { getHyphenatedArg } from "../../output/context.js";
19
19
  import { detectServerDefault, registerDefaultTasks } from "./default-tasks.js";
20
20
  import { akmTasksAdd, akmTasksDoctor, akmTasksHistory, akmTasksList, akmTasksRemove, akmTasksRun, akmTasksSetEnabled, akmTasksShow, akmTasksSync, parseTaskRef, } from "./tasks.js";
@@ -176,35 +176,27 @@ const tasksDoctorCommand = defineJsonCommand({
176
176
  output("tasks-doctor", result);
177
177
  },
178
178
  });
179
- // Single source of truth: the routing set is derived from the subCommands keys
180
- // (M10) so adding a subcommand can never silently desync from `hasSubcommand`.
181
- const tasksSubCommands = {
182
- add: tasksAddCommand,
183
- init: tasksInitCommand,
184
- list: tasksListCommand,
185
- show: tasksShowCommand,
186
- remove: tasksRemoveCommand,
187
- enable: tasksEnableCommand,
188
- disable: tasksDisableCommand,
189
- run: tasksRunCommand,
190
- history: tasksHistoryCommand,
191
- sync: tasksSyncCommand,
192
- doctor: tasksDoctorCommand,
193
- };
194
- const TASKS_SUBCOMMAND_SET = new Set(Object.keys(tasksSubCommands));
195
- export const tasksCommand = defineCommand({
179
+ export const tasksCommand = defineGroupCommand({
196
180
  meta: {
197
181
  name: "tasks",
198
182
  alias: "task",
199
183
  description: "Schedule workflows or prompts via the OS-native scheduler (cron / launchd / schtasks)",
200
184
  },
201
- subCommands: tasksSubCommands,
202
- run({ args }) {
203
- return runWithJsonErrors(async () => {
204
- if (hasSubcommand(args, TASKS_SUBCOMMAND_SET))
205
- return;
206
- const result = await akmTasksList();
207
- output("tasks-list", result);
208
- });
185
+ subCommands: {
186
+ add: tasksAddCommand,
187
+ init: tasksInitCommand,
188
+ list: tasksListCommand,
189
+ show: tasksShowCommand,
190
+ remove: tasksRemoveCommand,
191
+ enable: tasksEnableCommand,
192
+ disable: tasksDisableCommand,
193
+ run: tasksRunCommand,
194
+ history: tasksHistoryCommand,
195
+ sync: tasksSyncCommand,
196
+ doctor: tasksDoctorCommand,
197
+ },
198
+ async defaultRun() {
199
+ const result = await akmTasksList();
200
+ output("tasks-list", result);
209
201
  },
210
202
  });