akm-cli 0.7.5 → 0.8.0-rc.6

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 (236) hide show
  1. package/{.github/CHANGELOG.md → CHANGELOG.md} +113 -2
  2. package/README.md +20 -4
  3. package/SECURITY.md +93 -0
  4. package/dist/cli/config-migrate.js +144 -0
  5. package/dist/cli/config-validate.js +39 -0
  6. package/dist/cli/confirm.js +73 -0
  7. package/dist/cli/parse-args.js +133 -0
  8. package/dist/cli.js +1995 -551
  9. package/dist/commands/agent-dispatch.js +110 -0
  10. package/dist/commands/agent-support.js +68 -0
  11. package/dist/commands/completions.js +3 -0
  12. package/dist/commands/config-cli.js +130 -534
  13. package/dist/commands/consolidate.js +1531 -0
  14. package/dist/commands/curate.js +44 -3
  15. package/dist/commands/db-cli.js +23 -0
  16. package/dist/commands/distill-promotion-policy.js +660 -0
  17. package/dist/commands/distill.js +990 -75
  18. package/dist/commands/eval-cases.js +43 -0
  19. package/dist/commands/events.js +5 -23
  20. package/dist/commands/graph.js +477 -0
  21. package/dist/commands/health.js +400 -0
  22. package/dist/commands/help/help-accept.md +9 -0
  23. package/dist/commands/help/help-improve.md +77 -0
  24. package/dist/commands/help/help-proposals.md +15 -0
  25. package/dist/commands/help/help-propose.md +17 -0
  26. package/dist/commands/help/help-reject.md +8 -0
  27. package/dist/commands/history.js +54 -46
  28. package/dist/commands/improve-profiles.js +146 -0
  29. package/dist/commands/improve-result-file.js +103 -0
  30. package/dist/commands/improve.js +2175 -0
  31. package/dist/commands/info.js +5 -2
  32. package/dist/commands/init.js +50 -2
  33. package/dist/commands/installed-stashes.js +102 -139
  34. package/dist/commands/knowledge.js +136 -0
  35. package/dist/commands/lint/agent-linter.js +49 -0
  36. package/dist/commands/lint/base-linter.js +479 -0
  37. package/dist/commands/lint/command-linter.js +49 -0
  38. package/dist/commands/lint/default-linter.js +16 -0
  39. package/dist/commands/lint/index.js +183 -0
  40. package/dist/commands/lint/knowledge-linter.js +16 -0
  41. package/dist/commands/lint/markdown-insertion.js +343 -0
  42. package/dist/commands/lint/memory-linter.js +61 -0
  43. package/dist/commands/lint/registry.js +36 -0
  44. package/dist/commands/lint/skill-linter.js +45 -0
  45. package/dist/commands/lint/task-linter.js +50 -0
  46. package/dist/commands/lint/types.js +4 -0
  47. package/dist/commands/lint/vault-key-rules.js +139 -0
  48. package/dist/commands/lint/workflow-linter.js +56 -0
  49. package/dist/commands/lint.js +4 -0
  50. package/dist/commands/migration-help.js +5 -2
  51. package/dist/commands/proposal.js +66 -12
  52. package/dist/commands/propose.js +86 -31
  53. package/dist/commands/reflect.js +1119 -73
  54. package/dist/commands/registry-search.js +5 -2
  55. package/dist/commands/remember.js +69 -6
  56. package/dist/commands/schema-repair.js +203 -0
  57. package/dist/commands/search.js +115 -14
  58. package/dist/commands/self-update.js +3 -0
  59. package/dist/commands/show.js +144 -25
  60. package/dist/commands/source-add.js +17 -45
  61. package/dist/commands/source-clone.js +3 -0
  62. package/dist/commands/source-manage.js +14 -19
  63. package/dist/commands/tasks.js +438 -0
  64. package/dist/commands/url-checker.js +42 -0
  65. package/dist/commands/vault.js +130 -77
  66. package/dist/core/action-contributors.js +28 -0
  67. package/dist/core/asset-ref.js +7 -0
  68. package/dist/core/asset-registry.js +7 -16
  69. package/dist/core/asset-serialize.js +88 -0
  70. package/dist/core/asset-spec.js +22 -0
  71. package/dist/core/common.js +157 -0
  72. package/dist/core/concurrent.js +25 -0
  73. package/dist/core/config-io.js +347 -0
  74. package/dist/core/config-migration.js +625 -0
  75. package/dist/core/config-schema.js +501 -0
  76. package/dist/core/config-sources.js +108 -0
  77. package/dist/core/config-types.js +4 -0
  78. package/dist/core/config-walker.js +337 -0
  79. package/dist/core/config.js +327 -987
  80. package/dist/core/errors.js +40 -19
  81. package/dist/core/events.js +91 -138
  82. package/dist/core/file-lock.js +104 -0
  83. package/dist/core/frontmatter.js +3 -6
  84. package/dist/core/lesson-lint.js +3 -0
  85. package/dist/core/markdown.js +20 -0
  86. package/dist/core/memory-belief.js +62 -0
  87. package/dist/core/memory-contradiction-detect.js +274 -0
  88. package/dist/core/memory-improve.js +806 -0
  89. package/dist/core/parse.js +158 -0
  90. package/dist/core/paths.js +326 -14
  91. package/dist/core/proposal-quality-validators.js +364 -0
  92. package/dist/core/proposal-validators.js +69 -0
  93. package/dist/core/proposals.js +498 -42
  94. package/dist/core/state-db.js +927 -0
  95. package/dist/core/text-truncation.js +107 -0
  96. package/dist/core/time.js +54 -0
  97. package/dist/core/warn.js +62 -1
  98. package/dist/core/write-source.js +3 -0
  99. package/dist/indexer/db-backup.js +391 -0
  100. package/dist/indexer/db-search.js +152 -253
  101. package/dist/indexer/db.js +933 -103
  102. package/dist/indexer/ensure-index.js +64 -0
  103. package/dist/indexer/file-context.js +3 -0
  104. package/dist/indexer/graph-boost.js +376 -101
  105. package/dist/indexer/graph-db.js +391 -0
  106. package/dist/indexer/graph-dedup.js +95 -0
  107. package/dist/indexer/graph-extraction.js +550 -124
  108. package/dist/indexer/index-context.js +4 -0
  109. package/dist/indexer/indexer.js +506 -291
  110. package/dist/indexer/llm-cache.js +47 -0
  111. package/dist/indexer/manifest.js +3 -0
  112. package/dist/indexer/matchers.js +148 -160
  113. package/dist/indexer/memory-inference.js +99 -74
  114. package/dist/indexer/metadata-contributors.js +29 -0
  115. package/dist/indexer/metadata.js +255 -196
  116. package/dist/indexer/path-resolver.js +92 -0
  117. package/dist/indexer/project-context.js +192 -0
  118. package/dist/indexer/ranking-contributors.js +331 -0
  119. package/dist/indexer/ranking.js +81 -0
  120. package/dist/indexer/search-fields.js +5 -9
  121. package/dist/indexer/search-hit-enrichers.js +111 -0
  122. package/dist/indexer/search-source.js +44 -10
  123. package/dist/indexer/semantic-status.js +5 -16
  124. package/dist/indexer/staleness-detect.js +447 -0
  125. package/dist/indexer/usage-events.js +12 -9
  126. package/dist/indexer/walker.js +28 -0
  127. package/dist/integrations/agent/builders.js +135 -0
  128. package/dist/integrations/agent/config.js +122 -230
  129. package/dist/integrations/agent/detect.js +3 -0
  130. package/dist/integrations/agent/index.js +7 -13
  131. package/dist/integrations/agent/model-aliases.js +55 -0
  132. package/dist/integrations/agent/profiles.js +70 -5
  133. package/dist/integrations/agent/prompts.js +150 -74
  134. package/dist/integrations/agent/runner.js +151 -0
  135. package/dist/integrations/agent/sdk-runner.js +126 -0
  136. package/dist/integrations/agent/spawn.js +118 -23
  137. package/dist/integrations/github.js +3 -0
  138. package/dist/integrations/lockfile.js +32 -69
  139. package/dist/integrations/session-logs/index.js +68 -0
  140. package/dist/integrations/session-logs/providers/claude-code.js +59 -0
  141. package/dist/integrations/session-logs/providers/opencode.js +55 -0
  142. package/dist/integrations/session-logs/types.js +4 -0
  143. package/dist/llm/call-ai.js +62 -0
  144. package/dist/llm/client.js +72 -124
  145. package/dist/llm/embedder.js +3 -19
  146. package/dist/llm/embedders/cache.js +3 -7
  147. package/dist/llm/embedders/local.js +3 -0
  148. package/dist/llm/embedders/remote.js +20 -8
  149. package/dist/llm/embedders/types.js +3 -7
  150. package/dist/llm/feature-gate.js +89 -48
  151. package/dist/llm/graph-extract.js +676 -70
  152. package/dist/llm/index-passes.js +9 -23
  153. package/dist/llm/memory-infer.js +52 -71
  154. package/dist/llm/metadata-enhance.js +42 -29
  155. package/dist/llm/prompts/graph-extract-user-prompt.md +35 -0
  156. package/dist/output/cli-hints-full.md +281 -0
  157. package/dist/output/cli-hints-short.md +65 -0
  158. package/dist/output/cli-hints.js +5 -318
  159. package/dist/output/context.js +3 -0
  160. package/dist/output/renderers.js +223 -256
  161. package/dist/output/shapes.js +150 -105
  162. package/dist/output/text.js +318 -30
  163. package/dist/registry/build-index.js +3 -0
  164. package/dist/registry/create-provider-registry.js +3 -0
  165. package/dist/registry/factory.js +3 -0
  166. package/dist/registry/origin-resolve.js +3 -0
  167. package/dist/registry/providers/index.js +3 -0
  168. package/dist/registry/providers/skills-sh.js +70 -49
  169. package/dist/registry/providers/static-index.js +53 -48
  170. package/dist/registry/providers/types.js +3 -24
  171. package/dist/registry/resolve.js +11 -16
  172. package/dist/registry/types.js +3 -0
  173. package/dist/scripts/migrate-storage.js +17307 -0
  174. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +8900 -0
  175. package/dist/scripts/migrations/v16-to-v17.js +141 -0
  176. package/dist/setup/detect.js +3 -0
  177. package/dist/setup/ripgrep-install.js +3 -0
  178. package/dist/setup/ripgrep-resolve.js +3 -0
  179. package/dist/setup/setup.js +775 -37
  180. package/dist/setup/steps.js +3 -15
  181. package/dist/sources/include.js +3 -0
  182. package/dist/sources/provider-factory.js +5 -12
  183. package/dist/sources/provider.js +3 -20
  184. package/dist/sources/providers/filesystem.js +19 -23
  185. package/dist/sources/providers/git.js +7 -5
  186. package/dist/sources/providers/index.js +3 -0
  187. package/dist/sources/providers/install-types.js +3 -13
  188. package/dist/sources/providers/npm.js +3 -4
  189. package/dist/sources/providers/provider-utils.js +3 -0
  190. package/dist/sources/providers/sync-from-ref.js +3 -11
  191. package/dist/sources/providers/tar-utils.js +3 -0
  192. package/dist/sources/providers/website.js +18 -22
  193. package/dist/sources/resolve.js +3 -0
  194. package/dist/sources/types.js +3 -0
  195. package/dist/sources/website-ingest.js +7 -0
  196. package/dist/tasks/backends/cron.js +203 -0
  197. package/dist/tasks/backends/exec-utils.js +28 -0
  198. package/dist/tasks/backends/index.js +24 -0
  199. package/dist/tasks/backends/launchd-template.xml +19 -0
  200. package/dist/tasks/backends/launchd.js +187 -0
  201. package/dist/tasks/backends/schtasks-template.xml +29 -0
  202. package/dist/tasks/backends/schtasks.js +215 -0
  203. package/dist/tasks/parser.js +211 -0
  204. package/dist/tasks/resolveAkmBin.js +87 -0
  205. package/dist/tasks/runner.js +458 -0
  206. package/dist/tasks/schedule.js +211 -0
  207. package/dist/tasks/schema.js +15 -0
  208. package/dist/tasks/validator.js +62 -0
  209. package/dist/version.js +3 -0
  210. package/dist/wiki/index-template.md +12 -0
  211. package/dist/wiki/ingest-workflow-template.md +54 -0
  212. package/dist/wiki/log-template.md +8 -0
  213. package/dist/wiki/schema-template.md +61 -0
  214. package/dist/wiki/wiki-templates.js +15 -0
  215. package/dist/wiki/wiki.js +13 -61
  216. package/dist/workflows/authoring.js +8 -25
  217. package/dist/workflows/cli.js +3 -0
  218. package/dist/workflows/db.js +140 -10
  219. package/dist/workflows/document-cache.js +3 -10
  220. package/dist/workflows/parser.js +3 -0
  221. package/dist/workflows/renderer.js +11 -3
  222. package/dist/workflows/runs.js +62 -91
  223. package/dist/workflows/schema.js +3 -0
  224. package/dist/workflows/scope-key.js +3 -0
  225. package/dist/workflows/validator.js +4 -8
  226. package/dist/workflows/workflow-template.md +24 -0
  227. package/docs/README.md +9 -2
  228. package/docs/data-and-telemetry.md +225 -0
  229. package/docs/migration/release-notes/0.7.0.md +1 -1
  230. package/docs/migration/release-notes/0.7.5.md +2 -2
  231. package/docs/migration/release-notes/0.8.0.md +48 -0
  232. package/docs/migration/v0.7-to-v0.8.md +1307 -0
  233. package/package.json +20 -8
  234. package/.github/LICENSE +0 -374
  235. package/dist/commands/install-audit.js +0 -381
  236. package/dist/templates/wiki-templates.js +0 -100
@@ -1,5 +1,8 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
1
4
  import { toErrorMessage } from "../core/common";
2
- import { DEFAULT_CONFIG, loadConfig } from "../core/config";
5
+ import { DEFAULT_CONFIG } from "../core/config";
3
6
  import { warn } from "../core/warn";
4
7
  import { resolveProviderFactory } from "../registry/factory";
5
8
  // ── Eagerly import providers to trigger self-registration ───────────────────
@@ -133,7 +136,7 @@ export function resolveRegistries(configRegistries) {
133
136
  }
134
137
  return entries;
135
138
  }
136
- const registries = configRegistries ?? loadConfig().registries ?? DEFAULT_CONFIG.registries ?? [];
139
+ const registries = configRegistries ?? DEFAULT_CONFIG.registries ?? [];
137
140
  return registries.filter((r) => r.enabled !== false);
138
141
  }
139
142
  // ── Provider resolution ─────────────────────────────────────────────────────
@@ -1,3 +1,6 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
1
4
  /**
2
5
  * Memory-specific helpers for `akm remember`.
3
6
  *
@@ -5,12 +8,13 @@
5
8
  * heuristic derivation, LLM enrichment) is testable in isolation and the
6
9
  * CLI entry point stays focused on argument parsing + output routing.
7
10
  */
8
- import { stringify as yamlStringify } from "yaml";
11
+ import { serializeFrontmatter } from "../core/asset-serialize";
9
12
  import { toErrorMessage, tryReadStdinText } from "../core/common";
10
- import { loadConfig } from "../core/config";
13
+ import { getDefaultLlmConfig, loadConfig } from "../core/config";
11
14
  import { UsageError } from "../core/errors";
12
15
  import { warn } from "../core/warn";
13
16
  import { SCOPE_KEYS } from "../indexer/metadata";
17
+ import { parseFlagValue } from "../output/context";
14
18
  /**
15
19
  * Parse a shorthand duration string to a number of milliseconds.
16
20
  * Supports: `30d` (days), `12h` (hours), `6m` (months, approximated as 30d).
@@ -53,6 +57,12 @@ export function buildMemoryFrontmatter(fields) {
53
57
  obj.expires = fields.expires;
54
58
  if (fields.subjective)
55
59
  obj.subjective = true;
60
+ if (fields.captureMode === "hot" || fields.captureMode === "background") {
61
+ obj.captureMode = fields.captureMode;
62
+ }
63
+ if (typeof fields.beliefState === "string" && fields.beliefState.trim()) {
64
+ obj.beliefState = fields.beliefState.trim();
65
+ }
56
66
  // Scope keys are emitted as flat top-level keys (`scope_user`, …) so the
57
67
  // existing one-level frontmatter parser can read them without nesting.
58
68
  // A scope object with no populated values is dropped.
@@ -68,7 +78,7 @@ export function buildMemoryFrontmatter(fields) {
68
78
  // produce `---\n{}\n---` (the YAML serializer's empty-object form).
69
79
  if (Object.keys(obj).length === 0)
70
80
  return "---\n---";
71
- const serialized = yamlStringify(obj).trimEnd();
81
+ const serialized = serializeFrontmatter(obj);
72
82
  return `---\n${serialized}\n---`;
73
83
  }
74
84
  /**
@@ -133,11 +143,11 @@ const LLM_ENRICH_TIMEOUT_MS = 10_000;
133
143
  */
134
144
  export async function runLlmEnrich(body) {
135
145
  const config = loadConfig();
136
- if (!config.llm) {
137
- warn("Warning: --enrich requires an LLM to be configured. Run `akm config set llm` to configure one.");
146
+ const llmConfig = getDefaultLlmConfig(config);
147
+ if (!llmConfig) {
148
+ warn("Warning: --enrich requires an LLM to be configured. Run `akm setup` to configure one.");
138
149
  return { tags: [] };
139
150
  }
140
- const llmConfig = config.llm;
141
151
  const { chatCompletion, parseEmbeddedJsonResponse: parseJsonResponse } = await import("../llm/client");
142
152
  const prompt = `You are a memory tagger for a developer knowledge base.
143
153
  Given the memory text below, return ONLY a JSON object with these fields:
@@ -188,3 +198,56 @@ Return ONLY the JSON object, no prose, no markdown fences.`;
188
198
  return { tags: [] };
189
199
  }
190
200
  }
201
+ // ── Content-arg disambiguation ───────────────────────────────────────────────
202
+ /**
203
+ * Guard against citty consuming a global flag value as the `content` positional.
204
+ *
205
+ * When the user runs `akm remember --format json` without a content argument,
206
+ * citty may assign `"json"` to the `content` positional because of how it
207
+ * handles flag order. This helper detects that case and returns `undefined`
208
+ * so `readMemoryContent` falls through to stdin.
209
+ */
210
+ export function resolveRememberContentArg(content) {
211
+ if (content === undefined)
212
+ return undefined;
213
+ const parsedFormat = parseFlagValue(process.argv, "--format");
214
+ if (parsedFormat !== undefined &&
215
+ content === parsedFormat &&
216
+ wasRememberFlagValueConsumedAsContent(content, parsedFormat, "--format")) {
217
+ return undefined;
218
+ }
219
+ const parsedDetail = parseFlagValue(process.argv, "--detail");
220
+ if (parsedDetail !== undefined &&
221
+ content === parsedDetail &&
222
+ wasRememberFlagValueConsumedAsContent(content, parsedDetail, "--detail")) {
223
+ return undefined;
224
+ }
225
+ return content;
226
+ }
227
+ function wasRememberFlagValueConsumedAsContent(content, flagValue, flagName) {
228
+ const argv = process.argv.slice(2);
229
+ const rememberIndex = argv.indexOf("remember");
230
+ const tokens = rememberIndex >= 0 ? argv.slice(rememberIndex + 1) : argv;
231
+ let flagIndex = -1;
232
+ let flagConsumesNextToken = false;
233
+ for (let i = 0; i < tokens.length; i += 1) {
234
+ const token = tokens[i];
235
+ if (token === flagName) {
236
+ flagIndex = i;
237
+ flagConsumesNextToken = true;
238
+ break;
239
+ }
240
+ if (token === `${flagName}=${flagValue}`) {
241
+ flagIndex = i;
242
+ break;
243
+ }
244
+ }
245
+ if (flagIndex === -1)
246
+ return false;
247
+ if (tokens.slice(0, flagIndex).includes(content))
248
+ return false;
249
+ const firstTokenAfterFlag = flagIndex + (flagConsumesNextToken ? 2 : 1);
250
+ if (tokens.slice(firstTokenAfterFlag).includes(content))
251
+ return false;
252
+ return true;
253
+ }
@@ -0,0 +1,203 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * Schema-repair pass for `akm improve`.
6
+ *
7
+ * Attempts to patch missing frontmatter fields (`description`, `when_to_use`)
8
+ * on assets that failed schema validation, using a single bounded in-tree LLM
9
+ * call per asset. Results are recorded as `schema_repair_invoked` events.
10
+ *
11
+ * This module is extracted from `improve.ts` to make the repair logic
12
+ * independently testable and to use the `tryLlmFeature` seam rather than raw
13
+ * `chatCompletion`.
14
+ */
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+ import { parseAssetRef } from "../core/asset-ref";
18
+ import { assembleAsset } from "../core/asset-serialize";
19
+ import { appendEvent, readEvents } from "../core/events";
20
+ import { parseFrontmatter } from "../core/frontmatter";
21
+ import { createProposal, isProposalSkipped } from "../core/proposals";
22
+ import { info, warn } from "../core/warn";
23
+ import { resolveAssetPath } from "../indexer/path-resolver";
24
+ import { chatCompletion, parseEmbeddedJsonResponse } from "../llm/client";
25
+ // ── Constants ────────────────────────────────────────────────────────────────
26
+ /** Minimum gap between schema-repair attempts on the same asset. */
27
+ const SCHEMA_REPAIR_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
28
+ /**
29
+ * Per-ref attempt cap (O-6 / #379): maximum number of schema-repair attempts
30
+ * allowed within SCHEMA_REPAIR_WINDOW_MS. Prevents indefinite nightly re-repair
31
+ * of assets whose source content is genuinely ambiguous or inconsistently
32
+ * structured. After cap, the asset is skipped until the window rolls over.
33
+ * Self-Refine arXiv:2303.17651 — iteration must be bounded.
34
+ */
35
+ const SCHEMA_REPAIR_MAX_ATTEMPTS = 3;
36
+ const SCHEMA_REPAIR_WINDOW_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
37
+ // ── Main ─────────────────────────────────────────────────────────────────────
38
+ /**
39
+ * Run the schema-repair loop for a batch of validation failures.
40
+ * Returns a list of per-asset outcome records and the set of refs that were
41
+ * successfully repaired (so the caller can exclude them from skip logic).
42
+ */
43
+ export async function runSchemaRepairPass(failures, options) {
44
+ const repairs = [];
45
+ const repairedRefs = new Set();
46
+ const { startMs, budgetMs, llmConfig, stashDir, findFilePath = defaultFindFilePath, isLessonCandidateFn = defaultIsLessonCandidate, chatFn = chatCompletion, } = options;
47
+ for (const failure of failures) {
48
+ if (Date.now() - startMs >= budgetMs)
49
+ break;
50
+ // Cooldown: skip repair if we ran it successfully recently.
51
+ const recentRepairs = readEvents({ type: "schema_repair_invoked", ref: failure.ref });
52
+ const lastRepair = recentRepairs.events
53
+ .filter((e) => e.metadata?.outcome === "written")
54
+ .sort((a, b) => new Date(b.ts ?? 0).getTime() - new Date(a.ts ?? 0).getTime())[0];
55
+ if (lastRepair?.ts && Date.now() - new Date(lastRepair.ts).getTime() < SCHEMA_REPAIR_COOLDOWN_MS) {
56
+ repairs.push({ ref: failure.ref, reason: failure.reason, outcome: "skipped" });
57
+ continue;
58
+ }
59
+ // O-6 / #379: Cap total attempts at SCHEMA_REPAIR_MAX_ATTEMPTS per SCHEMA_REPAIR_WINDOW_MS.
60
+ // Prevents indefinite nightly re-repair of assets whose source is genuinely ambiguous.
61
+ // After the cap is reached, the asset is skipped until the window rolls over.
62
+ const windowStart = Date.now() - SCHEMA_REPAIR_WINDOW_MS;
63
+ const attemptsInWindow = recentRepairs.events.filter((e) => e.ts !== undefined && new Date(e.ts).getTime() >= windowStart).length;
64
+ if (attemptsInWindow >= SCHEMA_REPAIR_MAX_ATTEMPTS) {
65
+ repairs.push({
66
+ ref: failure.ref,
67
+ reason: failure.reason,
68
+ outcome: "skipped",
69
+ error: `schema-repair attempt cap reached (${attemptsInWindow}/${SCHEMA_REPAIR_MAX_ATTEMPTS} in 30d window)`,
70
+ });
71
+ continue;
72
+ }
73
+ const filePath = await findFilePath(failure.ref, stashDir);
74
+ if (!filePath) {
75
+ repairs.push({ ref: failure.ref, reason: failure.reason, outcome: "skipped" });
76
+ continue;
77
+ }
78
+ if (path.extname(filePath).toLowerCase() !== ".md") {
79
+ repairs.push({ ref: failure.ref, reason: failure.reason, outcome: "skipped" });
80
+ continue;
81
+ }
82
+ try {
83
+ const raw = fs.readFileSync(filePath, "utf8");
84
+ const fm = parseFrontmatter(raw);
85
+ const missingFields = [];
86
+ if (!fm.data.description)
87
+ missingFields.push("description");
88
+ if (isLessonCandidateFn(failure.ref) && !fm.data.when_to_use)
89
+ missingFields.push("when_to_use");
90
+ if (missingFields.length === 0) {
91
+ repairs.push({ ref: failure.ref, reason: failure.reason, outcome: "skipped" });
92
+ continue;
93
+ }
94
+ const fieldList = missingFields.join(" and ");
95
+ info(`[improve] schema-repair ${failure.ref} (${fieldList})`);
96
+ const bodyPreview = (fm.content ?? raw).slice(0, 2000);
97
+ const llmResponse = await chatFn(llmConfig, [
98
+ {
99
+ role: "system",
100
+ content: `You generate concise asset frontmatter fields. Respond with a JSON object containing only the missing fields. No prose, no markdown fences.`,
101
+ },
102
+ {
103
+ role: "user",
104
+ content: `Generate the missing frontmatter fields (${fieldList}) for this ${parseAssetRef(failure.ref).type} asset. Return ONLY valid JSON like {"description": "...", "when_to_use": "..."}\n\n${bodyPreview}`,
105
+ },
106
+ ]);
107
+ const parsed = parseEmbeddedJsonResponse(llmResponse.trim());
108
+ if (!parsed) {
109
+ repairs.push({
110
+ ref: failure.ref,
111
+ reason: failure.reason,
112
+ outcome: "error",
113
+ error: "LLM returned unparseable JSON for schema repair",
114
+ });
115
+ continue;
116
+ }
117
+ const newFm = { ...fm.data };
118
+ if (parsed.description)
119
+ newFm.description = parsed.description;
120
+ if (parsed.when_to_use)
121
+ newFm.when_to_use = parsed.when_to_use;
122
+ const newContent = assembleAsset(newFm, fm.content);
123
+ // M-3 / #387: Route through proposal queue instead of writing directly to
124
+ // disk. This restores akm's safety invariant — the proposal queue is the
125
+ // only path to a committed asset write. LLM-generated `description` /
126
+ // `when_to_use` fields can be incorrect; routing through the queue makes
127
+ // them human-reviewable before they affect search ranking and curate hints.
128
+ // mem0 open gaps (arXiv:2504.19413) — any LLM write to a memory field
129
+ // should be human-reviewable.
130
+ if (stashDir) {
131
+ const proposalResult = createProposal(stashDir, {
132
+ ref: failure.ref,
133
+ source: "schema-repair",
134
+ payload: {
135
+ content: newContent,
136
+ ...(Object.keys(newFm).length > 0 ? { frontmatter: newFm } : {}),
137
+ },
138
+ });
139
+ if (isProposalSkipped(proposalResult)) {
140
+ info(`[improve] schema-repair proposal skipped for ${failure.ref}: ${proposalResult.message}`);
141
+ repairs.push({ ref: failure.ref, reason: failure.reason, outcome: "skipped" });
142
+ continue;
143
+ }
144
+ info(`[improve] schema-repair queued: ${failure.ref} (proposal id: ${proposalResult.id})`);
145
+ appendEvent({
146
+ eventType: "schema_repair_invoked",
147
+ ref: failure.ref,
148
+ metadata: { outcome: "queued", reason: failure.reason, proposalId: proposalResult.id },
149
+ });
150
+ repairs.push({
151
+ ref: failure.ref,
152
+ reason: failure.reason,
153
+ outcome: "queued",
154
+ proposalId: proposalResult.id,
155
+ });
156
+ // Mark as repaired so the caller removes it from the validation-failure set.
157
+ repairedRefs.add(failure.ref);
158
+ }
159
+ else {
160
+ // Fallback: no stash dir available — write directly (legacy path).
161
+ // This should not occur in production; stashDir is always provided by
162
+ // `runSchemaRepairPass` callers in improve.ts.
163
+ warn(`[improve] schema-repair: no stashDir available for ${failure.ref}, falling back to direct write`);
164
+ fs.writeFileSync(filePath, newContent, "utf8");
165
+ info(`[improve] schema-repair written: ${failure.ref}`);
166
+ appendEvent({
167
+ eventType: "schema_repair_invoked",
168
+ ref: failure.ref,
169
+ metadata: { outcome: "written", reason: failure.reason },
170
+ });
171
+ repairs.push({ ref: failure.ref, reason: failure.reason, outcome: "written" });
172
+ repairedRefs.add(failure.ref);
173
+ }
174
+ }
175
+ catch (e) {
176
+ appendEvent({
177
+ eventType: "schema_repair_invoked",
178
+ ref: failure.ref,
179
+ metadata: { outcome: "error", reason: failure.reason, error: String(e) },
180
+ });
181
+ repairs.push({ ref: failure.ref, reason: failure.reason, outcome: "error", error: String(e) });
182
+ }
183
+ }
184
+ return { repairs, repairedRefs };
185
+ }
186
+ // ── Default seam implementations ─────────────────────────────────────────────
187
+ function defaultIsLessonCandidate(ref) {
188
+ try {
189
+ const parsed = parseAssetRef(ref);
190
+ return parsed.type === "lesson";
191
+ }
192
+ catch {
193
+ return false;
194
+ }
195
+ }
196
+ async function defaultFindFilePath(ref, stashDir) {
197
+ return resolveAssetPath(ref, {
198
+ stashDir,
199
+ mode: "index-first",
200
+ directoryIndexNames: ["SKILL.md", "index.md", "README.md"],
201
+ preserveDirectNameFallback: true,
202
+ });
203
+ }
@@ -1,3 +1,6 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
1
4
  /**
2
5
  * `akm search` — entry point.
3
6
  *
@@ -9,11 +12,13 @@
9
12
  * Provider `search()` methods do not exist.
10
13
  */
11
14
  import { loadConfig } from "../core/config";
12
- import { UsageError } from "../core/errors";
15
+ import { rethrowIfTestIsolationError, UsageError } from "../core/errors";
13
16
  import { appendEvent } from "../core/events";
14
- import { closeDatabase, openExistingDatabase } from "../indexer/db";
17
+ import { isTransientStashPath } from "../core/paths";
18
+ import { bumpUtilityScoresBatch, closeDatabase, openExistingDatabase } from "../indexer/db";
15
19
  import { searchLocal } from "../indexer/db-search";
16
20
  import { resolveSourceEntries } from "../indexer/search-source";
21
+ import { getCurrentWorkflowScopeKey } from "../workflows/scope-key";
17
22
  // Eagerly import source providers to trigger self-registration before the
18
23
  // indexer or path-resolution code runs.
19
24
  import "../sources/providers/index";
@@ -26,10 +31,42 @@ export async function akmSearch(input) {
26
31
  const normalizedQuery = query.toLowerCase();
27
32
  const searchType = input.type ?? "any";
28
33
  const limit = normalizeLimit(input.limit);
29
- const source = parseSearchSource(input.source ?? "stash");
34
+ const parsedSource = parseSearchSource(input.source ?? "stash");
30
35
  const config = loadConfig();
31
- const sources = resolveSourceEntries(undefined, config);
32
- if (sources.length === 0) {
36
+ // Named-source filter: when --source is not a standard enum value, treat it
37
+ // as a named source from config.sources[].name. Validate early (before
38
+ // resolveSourceEntries, which can throw STASH_DIR_NOT_FOUND) so that a bad
39
+ // --source name always produces INVALID_SOURCE_VALUE regardless of stash state.
40
+ let namedSourceName;
41
+ let source;
42
+ if (parsedSource !== "stash" && parsedSource !== "registry" && parsedSource !== "both") {
43
+ namedSourceName = parsedSource;
44
+ // Check that the named source exists in the config before touching the stash.
45
+ const configSources = config.sources ?? [];
46
+ const foundInConfig = configSources.some((s) => s.name === namedSourceName) || configSources.some((s) => s.path === namedSourceName);
47
+ if (!foundInConfig) {
48
+ const validNames = configSources.map((s) => s.name).filter((n) => Boolean(n));
49
+ const hint = validNames.length > 0
50
+ ? `Known source names: ${validNames.join(", ")}`
51
+ : "No named sources are configured. Run `akm list` to see installed stashes.";
52
+ throw new UsageError(`Unknown source name: "${namedSourceName}". ${hint}`, "INVALID_SOURCE_VALUE");
53
+ }
54
+ source = "stash";
55
+ }
56
+ else {
57
+ source = parsedSource;
58
+ }
59
+ let allSources = resolveSourceEntries(undefined, config);
60
+ // When a named source was requested, narrow the sources list to just that entry.
61
+ // `resolveSourceEntries` sets `registryId` to `entry.name` for each config source.
62
+ if (namedSourceName !== undefined) {
63
+ const ns = namedSourceName;
64
+ allSources = allSources.filter((s) => s.registryId === ns || s.path === ns);
65
+ // allSources may still be empty if the configured source dir doesn't exist on
66
+ // disk (resolveSourceEntries skips non-existent dirs). Fall through to the
67
+ // zero-sources guard below which emits a friendly warning.
68
+ }
69
+ if (allSources.length === 0) {
33
70
  // stashDir: "" is a safe sentinel here — the response carries zero hits
34
71
  // and a warning, so no downstream code will try to use the empty path.
35
72
  const response = {
@@ -40,14 +77,18 @@ export async function akmSearch(input) {
40
77
  warnings: ["No stashes configured. Run `akm init` to create your working stash."],
41
78
  timing: { totalMs: Date.now() - t0 },
42
79
  };
43
- logSearchEvent(query, response);
80
+ if (!input.skipLogging)
81
+ logSearchEvent(query, response, undefined, undefined, input.eventSource);
44
82
  return response;
45
83
  }
46
84
  // Primary stash directory — used for DB path lookups and as the default
47
85
  // stash root. Safe because the empty-sources case is handled above.
48
- const stashDir = sources[0].path;
86
+ const stashDir = allSources[0].path;
87
+ // Expose the filtered source list to downstream search calls.
88
+ const sources = allSources;
49
89
  const filters = normalizeScopeFilters(input.filters);
50
90
  const includeProposed = input.includeProposed === true;
91
+ const belief = input.belief ?? "all";
51
92
  const localResult = source === "registry"
52
93
  ? undefined
53
94
  : await searchLocal({
@@ -59,6 +100,13 @@ export async function akmSearch(input) {
59
100
  config,
60
101
  filters,
61
102
  includeProposed,
103
+ beliefFilter: belief,
104
+ // When `--source <name>` narrowed the source list above, propagate
105
+ // that intent down to the database layer so FTS/vector hits from
106
+ // sources outside the narrowed set are filtered out post-ranking.
107
+ // Without this, the index (which spans every configured source)
108
+ // would leak hits from sources the caller did not request.
109
+ restrictToSources: namedSourceName !== undefined,
62
110
  });
63
111
  const registryResult = source === "stash" ? undefined : await searchRegistry(query, { limit, registries: config.registries });
64
112
  if (source === "stash") {
@@ -73,7 +121,8 @@ export async function akmSearch(input) {
73
121
  warnings: localResult?.warnings?.length ? localResult.warnings : undefined,
74
122
  timing: { totalMs: Date.now() - t0, rankMs: localResult?.rankMs, embedMs: localResult?.embedMs },
75
123
  };
76
- logSearchEvent(query, response);
124
+ if (!input.skipLogging)
125
+ logSearchEvent(query, response, undefined, localResult?.mode ?? "keyword", input.eventSource);
77
126
  return response;
78
127
  }
79
128
  const registryHits = (registryResult?.hits ?? []).map((hit) => {
@@ -107,7 +156,8 @@ export async function akmSearch(input) {
107
156
  warnings: registryResult?.warnings.length ? registryResult.warnings : undefined,
108
157
  timing: { totalMs: Date.now() - t0 },
109
158
  };
110
- logSearchEvent(query, response);
159
+ if (!input.skipLogging)
160
+ logSearchEvent(query, response, undefined, undefined, input.eventSource);
111
161
  return response;
112
162
  }
113
163
  // source === "both"
@@ -124,7 +174,8 @@ export async function akmSearch(input) {
124
174
  warnings: warnings.length ? warnings : undefined,
125
175
  timing: { totalMs: Date.now() - t0 },
126
176
  };
127
- logSearchEvent(query, response);
177
+ if (!input.skipLogging)
178
+ logSearchEvent(query, response, undefined, undefined, input.eventSource);
128
179
  return response;
129
180
  }
130
181
  /**
@@ -160,13 +211,16 @@ function resolveEntryIds(db, hits) {
160
211
  * Per-entry events are recorded only for stash hits because registry hits
161
212
  * have no local entry_id to reference.
162
213
  */
163
- function logSearchEvent(query, response, existingDb) {
214
+ function logSearchEvent(query, response, existingDb, mode = "keyword", eventSource = "user") {
164
215
  // Emit a structured event to events.jsonl so workflow-trace consumers
165
216
  // detect akm search invocations without relying on stdout scraping.
166
217
  const stashHits = response.hits.filter((h) => h.type !== "registry");
218
+ // D8: include registry hit refs so a show following a registry-only search generates a select event
219
+ const registryHitRefs = (response.registryHits ?? []).map((h) => `registry:${h.id}`);
220
+ const allResultRefs = [...stashHits.map((h) => h.ref), ...registryHitRefs];
167
221
  appendEvent({
168
222
  eventType: "search",
169
- metadata: { query, hitCount: stashHits.length, resultRefs: stashHits.map((h) => h.ref) },
223
+ metadata: { query, hitCount: stashHits.length, resultRefs: allResultRefs, mode },
170
224
  });
171
225
  try {
172
226
  const db = existingDb ?? openExistingDatabase();
@@ -178,8 +232,24 @@ function logSearchEvent(query, response, existingDb) {
178
232
  query,
179
233
  entry_id: entryId,
180
234
  entry_ref: ref,
235
+ source: eventSource,
181
236
  });
182
237
  }
238
+ // Bump utility scores for all resolved entries (MemRL retrieval signal).
239
+ // The indexer overwrites these at next reindex; bumps are temporary hints.
240
+ const resolvedIds = resolved.map((r) => r.entryId).filter((id) => id !== undefined);
241
+ if (resolvedIds.length > 0) {
242
+ let scopeKey;
243
+ try {
244
+ const stashPath = response.stashDir;
245
+ const disabled = process.env.AKM_DISABLE_SCOPED_UTILITY === "1" || (stashPath && isTransientStashPath(stashPath));
246
+ scopeKey = disabled ? undefined : getCurrentWorkflowScopeKey();
247
+ }
248
+ catch {
249
+ // Non-fatal — fall back to global-only bumps on any error.
250
+ }
251
+ bumpUtilityScoresBatch(db, resolvedIds, 1.0, 0.1, scopeKey);
252
+ }
183
253
  // Count registry hits separately so registry-only searches record a
184
254
  // non-zero resultCount. response.hits is always [] when source="registry".
185
255
  const stashHitCount = response.hits.length;
@@ -192,7 +262,9 @@ function logSearchEvent(query, response, existingDb) {
192
262
  stashHitCount,
193
263
  registryHitCount,
194
264
  resolvedCount: resolved.length,
265
+ mode,
195
266
  }),
267
+ source: eventSource,
196
268
  });
197
269
  }
198
270
  finally {
@@ -200,7 +272,8 @@ function logSearchEvent(query, response, existingDb) {
200
272
  closeDatabase(db);
201
273
  }
202
274
  }
203
- catch {
275
+ catch (err) {
276
+ rethrowIfTestIsolationError(err);
204
277
  /* fire-and-forget */
205
278
  }
206
279
  }
@@ -211,6 +284,24 @@ function normalizeLimit(limit) {
211
284
  }
212
285
  return Math.min(Math.floor(limit), 200);
213
286
  }
287
+ /**
288
+ * Parse the `--source` flag value.
289
+ *
290
+ * Accepts:
291
+ * - `stash` (default) — search the local stash index only
292
+ * - `registry` — search remote registries only
293
+ * - `both` — search stash and registries
294
+ * - `local` — alias for `stash`
295
+ * - Any named source from `config.sources[].name` — filters stash results to
296
+ * that single source only. The named-source path is detected and resolved
297
+ * inside `akmSearch`; this function returns the raw name so the caller can
298
+ * pass it through to `akmSearch` which accepts `SearchSource | string`.
299
+ *
300
+ * Unknown values that are not a known enum AND not a named source will still
301
+ * produce an error inside `akmSearch` when the config lookup finds nothing.
302
+ * This allows the CLI to accept named sources without requiring config access
303
+ * at parse time.
304
+ */
214
305
  export function parseSearchSource(source) {
215
306
  if (source === "stash" || source === "registry" || source === "both")
216
307
  return source;
@@ -219,7 +310,17 @@ export function parseSearchSource(source) {
219
310
  return "stash";
220
311
  if (typeof source === "undefined")
221
312
  return "stash";
222
- throw new UsageError(`Invalid value for --source: ${String(source)}. Expected one of: stash|registry|both`, "INVALID_SOURCE_VALUE");
313
+ // Pass through unknown strings they may be valid named sources.
314
+ // `akmSearch` will validate against config.sources and throw a UsageError
315
+ // with a helpful message if the name isn't found.
316
+ return source;
317
+ }
318
+ export function parseBeliefFilterMode(value) {
319
+ if (value === undefined || value === "all")
320
+ return "all";
321
+ if (value === "current" || value === "historical")
322
+ return value;
323
+ throw new UsageError(`Invalid value for --belief: ${String(value)}. Expected one of: all|current|historical`, "INVALID_FLAG_VALUE");
223
324
  }
224
325
  /**
225
326
  * Strip empty / non-string values from a scope filter object. Returns
@@ -1,3 +1,6 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
1
4
  import * as childProcess from "node:child_process";
2
5
  import { createHash } from "node:crypto";
3
6
  import fs from "node:fs";