akm-cli 0.7.4 → 0.8.0-rc.3

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 (162) hide show
  1. package/{CHANGELOG.md → .github/CHANGELOG.md} +34 -1
  2. package/.github/LICENSE +374 -0
  3. package/dist/cli/parse-args.js +86 -0
  4. package/dist/cli.js +1223 -650
  5. package/dist/commands/agent-dispatch.js +107 -0
  6. package/dist/commands/agent-support.js +62 -0
  7. package/dist/commands/config-cli.js +68 -84
  8. package/dist/commands/consolidate.js +812 -0
  9. package/dist/commands/curate.js +1 -0
  10. package/dist/commands/distill-promotion-policy.js +658 -0
  11. package/dist/commands/distill.js +224 -39
  12. package/dist/commands/eval-cases.js +40 -0
  13. package/dist/commands/events.js +12 -24
  14. package/dist/commands/graph.js +222 -0
  15. package/dist/commands/health.js +376 -0
  16. package/dist/commands/help/help-accept.md +9 -0
  17. package/dist/commands/help/help-improve.md +53 -0
  18. package/dist/commands/help/help-proposals.md +15 -0
  19. package/dist/commands/help/help-propose.md +17 -0
  20. package/dist/commands/help/help-reject.md +8 -0
  21. package/dist/commands/history.js +3 -30
  22. package/dist/commands/improve.js +1161 -0
  23. package/dist/commands/info.js +2 -2
  24. package/dist/commands/init.js +2 -2
  25. package/dist/commands/install-audit.js +5 -1
  26. package/dist/commands/installed-stashes.js +118 -138
  27. package/dist/commands/knowledge.js +133 -0
  28. package/dist/commands/lint/agent-linter.js +46 -0
  29. package/dist/commands/lint/base-linter.js +291 -0
  30. package/dist/commands/lint/command-linter.js +46 -0
  31. package/dist/commands/lint/default-linter.js +13 -0
  32. package/dist/commands/lint/index.js +145 -0
  33. package/dist/commands/lint/knowledge-linter.js +13 -0
  34. package/dist/commands/lint/memory-linter.js +58 -0
  35. package/dist/commands/lint/registry.js +33 -0
  36. package/dist/commands/lint/skill-linter.js +42 -0
  37. package/dist/commands/lint/task-linter.js +47 -0
  38. package/dist/commands/lint/types.js +1 -0
  39. package/dist/commands/lint/vault-key-rules.js +67 -0
  40. package/dist/commands/lint/workflow-linter.js +53 -0
  41. package/dist/commands/lint.js +1 -0
  42. package/dist/commands/migration-help.js +2 -2
  43. package/dist/commands/proposal.js +8 -7
  44. package/dist/commands/propose.js +106 -43
  45. package/dist/commands/reflect.js +167 -41
  46. package/dist/commands/registry-search.js +2 -2
  47. package/dist/commands/remember.js +55 -1
  48. package/dist/commands/schema-repair.js +130 -0
  49. package/dist/commands/search.js +21 -5
  50. package/dist/commands/show.js +135 -55
  51. package/dist/commands/source-add.js +10 -10
  52. package/dist/commands/source-manage.js +11 -19
  53. package/dist/commands/tasks.js +385 -0
  54. package/dist/commands/url-checker.js +39 -0
  55. package/dist/commands/vault.js +173 -87
  56. package/dist/core/action-contributors.js +25 -0
  57. package/dist/core/asset-ref.js +4 -0
  58. package/dist/core/asset-registry.js +5 -17
  59. package/dist/core/asset-spec.js +11 -1
  60. package/dist/core/common.js +100 -0
  61. package/dist/core/concurrent.js +22 -0
  62. package/dist/core/config.js +240 -127
  63. package/dist/core/events.js +87 -123
  64. package/dist/core/frontmatter.js +0 -6
  65. package/dist/core/markdown.js +17 -0
  66. package/dist/core/memory-improve.js +678 -0
  67. package/dist/core/parse.js +155 -0
  68. package/dist/core/paths.js +101 -3
  69. package/dist/core/proposal-validators.js +61 -0
  70. package/dist/core/proposals.js +49 -38
  71. package/dist/core/state-db.js +731 -0
  72. package/dist/core/time.js +51 -0
  73. package/dist/core/warn.js +59 -1
  74. package/dist/indexer/db-search.js +86 -472
  75. package/dist/indexer/db.js +418 -59
  76. package/dist/indexer/ensure-index.js +133 -0
  77. package/dist/indexer/graph-boost.js +247 -94
  78. package/dist/indexer/graph-db.js +201 -0
  79. package/dist/indexer/graph-dedup.js +99 -0
  80. package/dist/indexer/graph-extraction.js +417 -74
  81. package/dist/indexer/index-context.js +10 -0
  82. package/dist/indexer/indexer.js +480 -298
  83. package/dist/indexer/llm-cache.js +47 -0
  84. package/dist/indexer/matchers.js +124 -160
  85. package/dist/indexer/memory-inference.js +63 -29
  86. package/dist/indexer/metadata-contributors.js +26 -0
  87. package/dist/indexer/metadata.js +196 -197
  88. package/dist/indexer/path-resolver.js +89 -0
  89. package/dist/indexer/ranking-contributors.js +204 -0
  90. package/dist/indexer/ranking.js +74 -0
  91. package/dist/indexer/search-hit-enrichers.js +22 -0
  92. package/dist/indexer/search-source.js +24 -9
  93. package/dist/indexer/semantic-status.js +2 -16
  94. package/dist/indexer/walker.js +25 -0
  95. package/dist/integrations/agent/builders.js +109 -0
  96. package/dist/integrations/agent/config.js +203 -3
  97. package/dist/integrations/agent/index.js +5 -2
  98. package/dist/integrations/agent/model-aliases.js +63 -0
  99. package/dist/integrations/agent/profiles.js +67 -5
  100. package/dist/integrations/agent/prompts.js +114 -29
  101. package/dist/integrations/agent/sdk-runner.js +120 -0
  102. package/dist/integrations/agent/spawn.js +158 -34
  103. package/dist/integrations/lockfile.js +10 -18
  104. package/dist/integrations/session-logs/index.js +65 -0
  105. package/dist/integrations/session-logs/providers/claude-code.js +56 -0
  106. package/dist/integrations/session-logs/providers/opencode.js +52 -0
  107. package/dist/integrations/session-logs/types.js +1 -0
  108. package/dist/llm/call-ai.js +74 -0
  109. package/dist/llm/client.js +63 -86
  110. package/dist/llm/feature-gate.js +27 -16
  111. package/dist/llm/graph-extract.js +297 -64
  112. package/dist/llm/memory-infer.js +52 -71
  113. package/dist/llm/metadata-enhance.js +39 -22
  114. package/dist/llm/prompts/graph-extract-user-prompt.md +12 -0
  115. package/dist/output/cli-hints-full.md +277 -0
  116. package/dist/output/cli-hints-short.md +65 -0
  117. package/dist/output/cli-hints.js +2 -309
  118. package/dist/output/renderers.js +226 -257
  119. package/dist/output/shapes.js +109 -96
  120. package/dist/output/text.js +274 -36
  121. package/dist/registry/providers/skills-sh.js +61 -49
  122. package/dist/registry/providers/static-index.js +44 -48
  123. package/dist/registry/resolve.js +8 -16
  124. package/dist/setup/setup.js +510 -11
  125. package/dist/sources/provider-factory.js +2 -1
  126. package/dist/sources/providers/filesystem.js +16 -23
  127. package/dist/sources/providers/git.js +45 -4
  128. package/dist/sources/providers/website.js +15 -22
  129. package/dist/sources/website-ingest.js +4 -0
  130. package/dist/tasks/backends/cron.js +200 -0
  131. package/dist/tasks/backends/exec-utils.js +25 -0
  132. package/dist/tasks/backends/index.js +32 -0
  133. package/dist/tasks/backends/launchd-template.xml +19 -0
  134. package/dist/tasks/backends/launchd.js +184 -0
  135. package/dist/tasks/backends/schtasks-template.xml +29 -0
  136. package/dist/tasks/backends/schtasks.js +212 -0
  137. package/dist/tasks/parser.js +198 -0
  138. package/dist/tasks/resolveAkmBin.js +84 -0
  139. package/dist/tasks/runner.js +432 -0
  140. package/dist/tasks/schedule.js +208 -0
  141. package/dist/tasks/schema.js +13 -0
  142. package/dist/tasks/validator.js +59 -0
  143. package/dist/wiki/index-template.md +12 -0
  144. package/dist/wiki/ingest-workflow-template.md +54 -0
  145. package/dist/wiki/log-template.md +8 -0
  146. package/dist/wiki/schema-template.md +61 -0
  147. package/dist/wiki/wiki-templates.js +12 -0
  148. package/dist/wiki/wiki.js +10 -61
  149. package/dist/workflows/authoring.js +5 -25
  150. package/dist/workflows/db.js +9 -0
  151. package/dist/workflows/renderer.js +8 -3
  152. package/dist/workflows/runs.js +73 -88
  153. package/dist/workflows/scope-key.js +76 -0
  154. package/dist/workflows/validator.js +1 -1
  155. package/dist/workflows/workflow-template.md +24 -0
  156. package/docs/README.md +5 -2
  157. package/docs/migration/release-notes/0.7.0.md +1 -1
  158. package/docs/migration/release-notes/0.7.4.md +1 -1
  159. package/docs/migration/release-notes/0.7.5.md +20 -0
  160. package/docs/migration/release-notes/0.8.0.md +43 -0
  161. package/package.json +4 -3
  162. package/dist/templates/wiki-templates.js +0 -100
@@ -2,9 +2,9 @@ import { createHash } from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { parseAgentConfig } from "../integrations/agent/config";
5
- import { filterNonEmptyStrings } from "./common";
5
+ import { asNonEmptyString, filterNonEmptyStrings, writeFileAtomic } from "./common";
6
6
  import { ConfigError } from "./errors";
7
- import { getConfigDir as _getConfigDir, getConfigPath as _getConfigPath, getCacheDir } from "./paths";
7
+ import { getCacheDir, getConfigPath } from "./paths";
8
8
  import { warn } from "./warn";
9
9
  // ── Defaults ────────────────────────────────────────────────────────────────
10
10
  export const DEFAULT_CONFIG = {
@@ -19,19 +19,53 @@ export const DEFAULT_CONFIG = {
19
19
  },
20
20
  };
21
21
  // ── Paths ───────────────────────────────────────────────────────────────────
22
- export function getConfigDir(env, platform) {
23
- return _getConfigDir(env, platform);
22
+ // ── Private helpers ─────────────────────────────────────────────────────────
23
+ /**
24
+ * Returns `value` if it is a finite positive integer; otherwise `undefined`.
25
+ * Used to validate numeric config fields like `dimension`, `contextLength`,
26
+ * `timeoutMs`, `maxTokens`, and `ollamaOptions.num_ctx`.
27
+ */
28
+ function parsePositiveInteger(_fieldPath, value) {
29
+ if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value) || value <= 0) {
30
+ return undefined;
31
+ }
32
+ return value;
24
33
  }
25
- export function getConfigPath() {
26
- return _getConfigPath();
34
+ function parseNonNegativeNumber(value) {
35
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0)
36
+ return undefined;
37
+ return value;
38
+ }
39
+ /**
40
+ * Returns `value` if it is a string present in `allowed`; otherwise `undefined`.
41
+ */
42
+ function isOneOf(value, allowed) {
43
+ return typeof value === "string" && allowed.includes(value);
44
+ }
45
+ /**
46
+ * Validates that `url` starts with `http://` or `https://`. Returns `url` on
47
+ * success and warns+returns `undefined` on failure. `fieldName` is used only
48
+ * in the warning message.
49
+ */
50
+ function isValidHttpUrl(url, fieldName) {
51
+ if (typeof url !== "string" || !url)
52
+ return undefined;
53
+ if (!url.startsWith("http://") && !url.startsWith("https://")) {
54
+ warn(`[akm] Ignoring ${fieldName}: endpoint must start with http:// or https://, got "${url}"`);
55
+ return undefined;
56
+ }
57
+ return url;
58
+ }
59
+ function clearAllCaches() {
60
+ cachedConfig = undefined;
61
+ cachedUserConfig = undefined;
27
62
  }
28
63
  // ── Load / Save / Update ────────────────────────────────────────────────────
29
64
  const PROJECT_CONFIG_RELATIVE_PATH = path.join(".akm", "config.json");
30
65
  let cachedConfig;
31
66
  let cachedUserConfig;
32
67
  export function resetConfigCache() {
33
- cachedConfig = undefined;
34
- cachedUserConfig = undefined;
68
+ clearAllCaches();
35
69
  }
36
70
  function hashString(text) {
37
71
  // Simple, fast non-cryptographic hash (FNV-1a 32-bit) — sufficient to detect
@@ -85,6 +119,17 @@ export function loadUserConfig() {
85
119
  };
86
120
  return finalConfig;
87
121
  }
122
+ export function getSources(config) {
123
+ return config.sources ?? [];
124
+ }
125
+ export function getEffectiveRegistries(config) {
126
+ return config.registries ?? DEFAULT_CONFIG.registries ?? [];
127
+ }
128
+ export function requireLlmConfig(config) {
129
+ if (!config.llm)
130
+ throw new ConfigError("LLM is not configured. Run `akm config set llm` to configure one.", "LLM_NOT_CONFIGURED");
131
+ return config.llm;
132
+ }
88
133
  export function loadConfig() {
89
134
  const configPaths = getEffectiveConfigPaths();
90
135
  const signature = getConfigSignature(configPaths);
@@ -103,8 +148,7 @@ export function loadConfig() {
103
148
  return finalConfig;
104
149
  }
105
150
  export function saveConfig(config) {
106
- cachedConfig = undefined;
107
- cachedUserConfig = undefined;
151
+ clearAllCaches();
108
152
  const configPath = getConfigPath();
109
153
  const dir = path.dirname(configPath);
110
154
  fs.mkdirSync(dir, { recursive: true });
@@ -143,31 +187,7 @@ function sanitizeConfigForWrite(config) {
143
187
  }
144
188
  export function updateConfig(partial) {
145
189
  const current = loadUserConfig();
146
- // Shallow-merge for top-level scalar fields; deep-merge known object-type config keys.
147
- const merged = { ...current, ...partial };
148
- // Deep-merge output — partial update should not wipe sibling keys
149
- if (current.output && partial.output && partial.output !== current.output) {
150
- merged.output = { ...current.output, ...partial.output };
151
- }
152
- // Deep-merge embedding — only when both sides are objects and partial does not intend to clear
153
- if (current.embedding && partial.embedding && partial.embedding !== current.embedding) {
154
- merged.embedding = { ...current.embedding, ...partial.embedding };
155
- }
156
- // Deep-merge llm — same pattern
157
- if (current.llm && partial.llm && partial.llm !== current.llm) {
158
- merged.llm = { ...current.llm, ...partial.llm };
159
- }
160
- // Deep-merge index per-pass entries so partial updates don't wipe siblings.
161
- if (current.index && partial.index && partial.index !== current.index) {
162
- const mergedIndex = { ...current.index };
163
- for (const [passName, passOverride] of Object.entries(partial.index)) {
164
- mergedIndex[passName] = { ...(mergedIndex[passName] ?? {}), ...passOverride };
165
- }
166
- merged.index = mergedIndex;
167
- }
168
- if (current.security && partial.security && partial.security !== current.security) {
169
- merged.security = mergeSecurityConfig(current.security, partial.security);
170
- }
190
+ const merged = mergeLoadedConfig(current, partial);
171
191
  saveConfig(merged);
172
192
  return merged;
173
193
  }
@@ -179,11 +199,8 @@ export function updateConfig(partial) {
179
199
  * combining multiple config sources so project config files only override what
180
200
  * they set.
181
201
  */
182
- function pickKnownKeys(raw) {
202
+ function parseConfigLayer(raw) {
183
203
  const config = {};
184
- if (Array.isArray(raw.stashes)) {
185
- throw new ConfigError("The legacy `stashes[]` config key is no longer supported; rename it to `sources[]`.", "INVALID_CONFIG_FILE", `Edit ${_getConfigPath()} and replace \`stashes\` with \`sources\`.`);
186
- }
187
204
  if (typeof raw.stashDir === "string" && raw.stashDir.trim()) {
188
205
  config.stashDir = raw.stashDir.trim();
189
206
  }
@@ -191,7 +208,7 @@ function pickKnownKeys(raw) {
191
208
  if (typeof raw.semanticSearchMode === "boolean") {
192
209
  config.semanticSearchMode = raw.semanticSearchMode ? "auto" : "off";
193
210
  }
194
- else if (raw.semanticSearchMode === "off" || raw.semanticSearchMode === "auto") {
211
+ else if (isOneOf(raw.semanticSearchMode, ["off", "auto"])) {
195
212
  config.semanticSearchMode = raw.semanticSearchMode;
196
213
  }
197
214
  const embedding = parseEmbeddingConfig(raw.embedding);
@@ -209,9 +226,12 @@ function pickKnownKeys(raw) {
209
226
  const registries = parseRegistriesConfig(raw.registries);
210
227
  if (registries)
211
228
  config.registries = registries;
212
- if (raw.stashInheritance === "replace" || raw.stashInheritance === "merge") {
229
+ if (isOneOf(raw.stashInheritance, ["replace", "merge"])) {
213
230
  config.stashInheritance = raw.stashInheritance;
214
231
  }
232
+ if (Array.isArray(raw.stashes)) {
233
+ throw new ConfigError("The legacy `stashes[]` config key is no longer supported. Rename it to `sources`.", "INVALID_CONFIG_FILE");
234
+ }
215
235
  const sources = parseSourcesConfig(raw.sources);
216
236
  if (sources) {
217
237
  config.sources = sources;
@@ -236,35 +256,102 @@ function pickKnownKeys(raw) {
236
256
  if (typeof raw.search === "object" && raw.search !== null && !Array.isArray(raw.search)) {
237
257
  const searchRaw = raw.search;
238
258
  const searchConfig = {};
259
+ for (const key of Object.keys(searchRaw)) {
260
+ if (key !== "minScore" && key !== "graphBoost") {
261
+ warn(`[akm] Ignoring unknown search key "${key}".`);
262
+ }
263
+ }
239
264
  if (typeof searchRaw.minScore === "number" && Number.isFinite(searchRaw.minScore) && searchRaw.minScore >= 0) {
240
265
  searchConfig.minScore = searchRaw.minScore;
241
266
  }
267
+ if (typeof searchRaw.graphBoost === "object" &&
268
+ searchRaw.graphBoost !== null &&
269
+ !Array.isArray(searchRaw.graphBoost)) {
270
+ const graphBoostRaw = searchRaw.graphBoost;
271
+ const graphBoostConfig = {};
272
+ for (const key of Object.keys(graphBoostRaw)) {
273
+ if (key !== "directBoostPerEntity" &&
274
+ key !== "directBoostCap" &&
275
+ key !== "hopBoostPerEntity" &&
276
+ key !== "hopBoostCap" &&
277
+ key !== "maxHops" &&
278
+ key !== "confidenceMode" &&
279
+ key !== "confidenceWeight") {
280
+ warn(`[akm] Ignoring unknown search.graphBoost key "${key}".`);
281
+ }
282
+ }
283
+ const directBoostPerEntity = parseNonNegativeNumber(graphBoostRaw.directBoostPerEntity);
284
+ if (directBoostPerEntity !== undefined)
285
+ graphBoostConfig.directBoostPerEntity = directBoostPerEntity;
286
+ const directBoostCap = parseNonNegativeNumber(graphBoostRaw.directBoostCap);
287
+ if (directBoostCap !== undefined)
288
+ graphBoostConfig.directBoostCap = directBoostCap;
289
+ const hopBoostPerEntity = parseNonNegativeNumber(graphBoostRaw.hopBoostPerEntity);
290
+ if (hopBoostPerEntity !== undefined)
291
+ graphBoostConfig.hopBoostPerEntity = hopBoostPerEntity;
292
+ const hopBoostCap = parseNonNegativeNumber(graphBoostRaw.hopBoostCap);
293
+ if (hopBoostCap !== undefined)
294
+ graphBoostConfig.hopBoostCap = hopBoostCap;
295
+ const maxHops = parsePositiveInteger("search.graphBoost.maxHops", graphBoostRaw.maxHops);
296
+ if (maxHops !== undefined)
297
+ graphBoostConfig.maxHops = Math.min(maxHops, 3);
298
+ if (isOneOf(graphBoostRaw.confidenceMode, ["off", "blend", "multiply"])) {
299
+ graphBoostConfig.confidenceMode = graphBoostRaw.confidenceMode;
300
+ }
301
+ const confidenceWeight = parseNonNegativeNumber(graphBoostRaw.confidenceWeight);
302
+ if (confidenceWeight !== undefined)
303
+ graphBoostConfig.confidenceWeight = Math.min(confidenceWeight, 1);
304
+ if (Object.keys(graphBoostConfig).length > 0)
305
+ searchConfig.graphBoost = graphBoostConfig;
306
+ }
242
307
  if (Object.keys(searchConfig).length > 0)
243
308
  config.search = searchConfig;
244
309
  }
310
+ if (typeof raw.feedback === "object" && raw.feedback !== null && !Array.isArray(raw.feedback)) {
311
+ const feedbackRaw = raw.feedback;
312
+ const feedbackConfig = {};
313
+ if (typeof feedbackRaw.requireReason === "boolean") {
314
+ feedbackConfig.requireReason = feedbackRaw.requireReason;
315
+ }
316
+ if (Object.keys(feedbackConfig).length > 0)
317
+ config.feedback = feedbackConfig;
318
+ }
319
+ if (typeof raw.archiveRetentionDays === "number" &&
320
+ Number.isFinite(raw.archiveRetentionDays) &&
321
+ raw.archiveRetentionDays >= 0) {
322
+ config.archiveRetentionDays = raw.archiveRetentionDays;
323
+ }
245
324
  return config;
246
325
  }
247
- function readNormalizedConfig(configPath) {
248
- const raw = readConfigObject(configPath);
249
- const expanded = raw ? expandEnvVars(raw) : undefined;
250
- return expanded ? pickKnownKeys(expanded) : undefined;
251
- }
252
- function readNormalizedConfigFromText(text) {
326
+ function parseConfigText(text) {
253
327
  const raw = parseConfigObjectFromText(text);
254
328
  if (!raw)
255
329
  return undefined;
256
330
  const expanded = expandEnvVars(raw);
257
- return pickKnownKeys(expanded);
331
+ return parseConfigLayer(expanded);
332
+ }
333
+ function readNormalizedConfig(configPath) {
334
+ let text;
335
+ try {
336
+ text = fs.readFileSync(configPath, "utf8");
337
+ }
338
+ catch {
339
+ return undefined;
340
+ }
341
+ return parseConfigText(text);
342
+ }
343
+ function readNormalizedConfigFromText(text) {
344
+ return parseConfigText(text);
258
345
  }
259
346
  function parseOutputConfig(value) {
260
347
  if (typeof value !== "object" || value === null || Array.isArray(value))
261
348
  return undefined;
262
349
  const obj = value;
263
350
  const output = {};
264
- if (obj.format === "json" || obj.format === "yaml" || obj.format === "text") {
351
+ if (isOneOf(obj.format, ["json", "yaml", "text"])) {
265
352
  output.format = obj.format;
266
353
  }
267
- if (obj.detail === "brief" || obj.detail === "normal" || obj.detail === "full") {
354
+ if (isOneOf(obj.detail, ["brief", "normal", "full"])) {
268
355
  output.detail = obj.detail;
269
356
  }
270
357
  return Object.keys(output).length > 0 ? output : undefined;
@@ -313,15 +400,6 @@ function expandEnvVars(value, fieldName) {
313
400
  }
314
401
  return value;
315
402
  }
316
- function readConfigObject(configPath) {
317
- try {
318
- const text = fs.readFileSync(configPath, "utf8");
319
- return parseConfigObjectFromText(text);
320
- }
321
- catch {
322
- return undefined;
323
- }
324
- }
325
403
  function parseConfigObjectFromText(text) {
326
404
  try {
327
405
  const raw = JSON.parse(stripJsonComments(text));
@@ -334,20 +412,7 @@ function parseConfigObjectFromText(text) {
334
412
  }
335
413
  }
336
414
  function writeConfigObject(configPath, config) {
337
- const tmpPath = `${configPath}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
338
- try {
339
- fs.writeFileSync(tmpPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
340
- fs.renameSync(tmpPath, configPath);
341
- }
342
- catch (err) {
343
- try {
344
- fs.unlinkSync(tmpPath);
345
- }
346
- catch {
347
- /* ignore cleanup failure */
348
- }
349
- throw err;
350
- }
415
+ writeFileAtomic(configPath, `${JSON.stringify(config, null, 2)}\n`);
351
416
  }
352
417
  /**
353
418
  * Strip JavaScript-style comments from a JSON string (JSONC support).
@@ -413,8 +478,7 @@ function parseEmbeddingConfig(value) {
413
478
  }
414
479
  return undefined;
415
480
  }
416
- if (!obj.endpoint.startsWith("http://") && !obj.endpoint.startsWith("https://")) {
417
- warn(`[akm] Ignoring embedding config: endpoint must start with http:// or https://, got "${obj.endpoint}"`);
481
+ if (!isValidHttpUrl(obj.endpoint, "embedding config")) {
418
482
  // Still return localModel-only config if localModel was set
419
483
  if (localModel) {
420
484
  return { endpoint: "", model: "", localModel };
@@ -437,13 +501,10 @@ function parseEmbeddingConfig(value) {
437
501
  result.provider = obj.provider;
438
502
  }
439
503
  if ("dimension" in obj) {
440
- if (typeof obj.dimension !== "number" ||
441
- !Number.isFinite(obj.dimension) ||
442
- !Number.isInteger(obj.dimension) ||
443
- obj.dimension <= 0) {
504
+ const dim = parsePositiveInteger("embedding.dimension", obj.dimension);
505
+ if (dim === undefined)
444
506
  return undefined;
445
- }
446
- result.dimension = obj.dimension;
507
+ result.dimension = dim;
447
508
  }
448
509
  if (typeof obj.apiKey === "string" && obj.apiKey) {
449
510
  result.apiKey = obj.apiKey;
@@ -452,22 +513,17 @@ function parseEmbeddingConfig(value) {
452
513
  result.localModel = localModel;
453
514
  }
454
515
  if ("contextLength" in obj) {
455
- if (typeof obj.contextLength !== "number" ||
456
- !Number.isFinite(obj.contextLength) ||
457
- !Number.isInteger(obj.contextLength) ||
458
- obj.contextLength <= 0) {
516
+ const ctx = parsePositiveInteger("embedding.contextLength", obj.contextLength);
517
+ if (ctx === undefined)
459
518
  return undefined;
460
- }
461
- result.contextLength = obj.contextLength;
519
+ result.contextLength = ctx;
462
520
  }
463
521
  if (typeof obj.ollamaOptions === "object" && obj.ollamaOptions !== null && !Array.isArray(obj.ollamaOptions)) {
464
522
  const opts = obj.ollamaOptions;
465
523
  const parsed = {};
466
- if (typeof opts.num_ctx === "number" &&
467
- Number.isFinite(opts.num_ctx) &&
468
- Number.isInteger(opts.num_ctx) &&
469
- opts.num_ctx > 0) {
470
- parsed.num_ctx = opts.num_ctx;
524
+ const numCtx = parsePositiveInteger("embedding.ollamaOptions.num_ctx", opts.num_ctx);
525
+ if (numCtx !== undefined) {
526
+ parsed.num_ctx = numCtx;
471
527
  }
472
528
  if (Object.keys(parsed).length > 0) {
473
529
  result.ollamaOptions = parsed;
@@ -481,10 +537,13 @@ function parseLlmConfig(value) {
481
537
  const obj = value;
482
538
  if (typeof obj.endpoint !== "string" || !obj.endpoint)
483
539
  return undefined;
484
- if (!obj.endpoint.startsWith("http://") && !obj.endpoint.startsWith("https://")) {
485
- warn(`[akm] Ignoring llm config: endpoint must start with http:// or https://, got "${obj.endpoint}"`);
540
+ if (!isValidHttpUrl(obj.endpoint, "llm config")) {
486
541
  return undefined;
487
542
  }
543
+ if (!obj.endpoint.endsWith("/chat/completions")) {
544
+ warn(`[akm] llm.endpoint "${obj.endpoint}" does not end in /chat/completions. ` +
545
+ `Did you mean "${obj.endpoint.replace(/\/+$/, "")}/chat/completions"?`);
546
+ }
488
547
  const model = typeof obj.model === "string" ? obj.model : "";
489
548
  const result = {
490
549
  endpoint: obj.endpoint,
@@ -496,14 +555,28 @@ function parseLlmConfig(value) {
496
555
  if (typeof obj.temperature === "number" && Number.isFinite(obj.temperature)) {
497
556
  result.temperature = obj.temperature;
498
557
  }
558
+ if ("timeoutMs" in obj) {
559
+ const t = parsePositiveInteger("llm.timeoutMs", obj.timeoutMs);
560
+ if (t === undefined)
561
+ return undefined;
562
+ result.timeoutMs = t;
563
+ }
564
+ if ("concurrency" in obj) {
565
+ const c = parsePositiveInteger("llm.concurrency", obj.concurrency);
566
+ if (c === undefined)
567
+ return undefined;
568
+ result.concurrency = c;
569
+ }
499
570
  if ("maxTokens" in obj) {
500
- if (typeof obj.maxTokens !== "number" ||
501
- !Number.isFinite(obj.maxTokens) ||
502
- !Number.isInteger(obj.maxTokens) ||
503
- obj.maxTokens <= 0) {
571
+ const m = parsePositiveInteger("llm.maxTokens", obj.maxTokens);
572
+ if (m === undefined)
504
573
  return undefined;
505
- }
506
- result.maxTokens = obj.maxTokens;
574
+ result.maxTokens = m;
575
+ }
576
+ if ("contextLength" in obj) {
577
+ const ctx = parsePositiveInteger("llm.contextLength", obj.contextLength);
578
+ if (ctx !== undefined)
579
+ result.contextLength = ctx;
507
580
  }
508
581
  if (typeof obj.apiKey === "string" && obj.apiKey) {
509
582
  result.apiKey = obj.apiKey;
@@ -521,6 +594,9 @@ function parseLlmConfig(value) {
521
594
  if (Object.keys(features).length > 0)
522
595
  result.features = features;
523
596
  }
597
+ if (typeof obj.judgeModel === "string" && obj.judgeModel.trim()) {
598
+ result.judgeModel = obj.judgeModel.trim();
599
+ }
524
600
  if (typeof obj.extraParams === "object" && obj.extraParams !== null && !Array.isArray(obj.extraParams)) {
525
601
  result.extraParams = obj.extraParams;
526
602
  }
@@ -538,6 +614,9 @@ const LOCKED_LLM_FEATURE_KEYS = new Set([
538
614
  "feedback_distillation",
539
615
  "memory_inference",
540
616
  "graph_extraction",
617
+ "memory_consolidation",
618
+ "lesson_quality_gate",
619
+ "metadata_enhance",
541
620
  ]);
542
621
  function parseLlmFeatures(raw) {
543
622
  const out = {};
@@ -550,22 +629,8 @@ function parseLlmFeatures(raw) {
550
629
  warn(`[akm] Ignoring llm.features.${key}: expected boolean, got ${typeof value}.`);
551
630
  continue;
552
631
  }
553
- switch (key) {
554
- case "memory_inference":
555
- out.memory_inference = value;
556
- break;
557
- case "graph_extraction":
558
- out.graph_extraction = value;
559
- break;
560
- case "curate_rerank":
561
- out.curate_rerank = value;
562
- break;
563
- case "feedback_distillation":
564
- out.feedback_distillation = value;
565
- break;
566
- // No default: LOCKED_LLM_FEATURE_KEYS is the source of truth for which
567
- // keys are accepted. Adding a new locked key requires an arm here AND a
568
- // field on LlmFeatureFlags above.
632
+ if (LOCKED_LLM_FEATURE_KEYS.has(key)) {
633
+ out[key] = value;
569
634
  }
570
635
  }
571
636
  return out;
@@ -586,6 +651,17 @@ const PROVIDER_CONFIG_KEYS = new Set([
586
651
  "maxTokens",
587
652
  "capabilities",
588
653
  ]);
654
+ const GRAPH_EXTRACTION_INCLUDE_TYPES_ALLOWED = new Set([
655
+ "memory",
656
+ "knowledge",
657
+ "skill",
658
+ "command",
659
+ "agent",
660
+ "workflow",
661
+ "lesson",
662
+ "task",
663
+ "wiki",
664
+ ]);
589
665
  /**
590
666
  * Parse the `index` config block. Each entry is a pass name → small object
591
667
  * `{ llm?: boolean }`. Anything richer (a parallel provider config, unknown
@@ -611,8 +687,11 @@ function parseIndexConfig(value) {
611
687
  throw new ConfigError(`Duplicate LLM provider configuration: \`index.${passName}.${key}\` is not allowed. ` +
612
688
  "Configure provider/model/endpoint under top-level `llm` only; per-pass entries support `{ llm: false }` opt-out.", "INVALID_CONFIG_FILE", 'Move provider settings to the top-level "llm" block, then set `index.<pass>.llm = false` to opt a single pass out.');
613
689
  }
614
- if (key !== "llm") {
615
- throw new ConfigError(`Unknown key \`index.${passName}.${key}\`. Per-pass entries only support \`llm\` (boolean opt-out).`, "INVALID_CONFIG_FILE");
690
+ if (key !== "llm" &&
691
+ key !== "graphExtractionBatchSize" &&
692
+ key !== "graphExtractionIncludeTypes" &&
693
+ key !== "memoryInferenceBatchSize") {
694
+ throw new ConfigError(`Unknown key \`index.${passName}.${key}\`. Per-pass entries support \`llm\` (boolean opt-out), \`graphExtractionBatchSize\`, \`graphExtractionIncludeTypes\`, and \`memoryInferenceBatchSize\`.`, "INVALID_CONFIG_FILE");
616
695
  }
617
696
  }
618
697
  const passConfig = {};
@@ -623,17 +702,45 @@ function parseIndexConfig(value) {
623
702
  }
624
703
  passConfig.llm = llmFlag;
625
704
  }
705
+ if ("graphExtractionBatchSize" in passRaw) {
706
+ const n = parsePositiveInteger(`index.${passName}.graphExtractionBatchSize`, passRaw.graphExtractionBatchSize);
707
+ if (n !== undefined)
708
+ passConfig.graphExtractionBatchSize = n;
709
+ }
710
+ if ("graphExtractionIncludeTypes" in passRaw) {
711
+ const rawTypes = passRaw.graphExtractionIncludeTypes;
712
+ if (!Array.isArray(rawTypes) || !rawTypes.every((t) => typeof t === "string" && t.trim().length > 0)) {
713
+ throw new ConfigError(`Invalid \`index.${passName}.graphExtractionIncludeTypes\`: expected a non-empty string array of asset types.`, "INVALID_CONFIG_FILE");
714
+ }
715
+ const normalized = rawTypes.map((t) => t.trim().toLowerCase());
716
+ const invalid = normalized.filter((t) => !GRAPH_EXTRACTION_INCLUDE_TYPES_ALLOWED.has(t));
717
+ if (invalid.length > 0) {
718
+ throw new ConfigError(`Invalid \`index.${passName}.graphExtractionIncludeTypes\`: unsupported type(s): ${invalid.join(", ")}.`, "INVALID_CONFIG_FILE");
719
+ }
720
+ passConfig.graphExtractionIncludeTypes = normalized;
721
+ }
722
+ if ("memoryInferenceBatchSize" in passRaw) {
723
+ const n = parsePositiveInteger(`index.${passName}.memoryInferenceBatchSize`, passRaw.memoryInferenceBatchSize);
724
+ if (n !== undefined)
725
+ passConfig.memoryInferenceBatchSize = n;
726
+ }
626
727
  out[passName] = passConfig;
627
728
  }
628
729
  return out;
629
730
  }
630
- function parseInstalledEntries(value) {
731
+ /**
732
+ * Parse an array of values with a per-item parser, filtering out undefined
733
+ * results. Returns undefined when the input is not an array, or (unless
734
+ * `allowEmpty` is true) when all items parse to undefined.
735
+ */
736
+ function parseArray(value, parseOne, allowEmpty = false) {
631
737
  if (!Array.isArray(value))
632
738
  return undefined;
633
- const entries = value
634
- .map((entry) => parseInstalledStashEntry(entry))
635
- .filter((entry) => entry !== undefined);
636
- return entries.length > 0 ? entries : undefined;
739
+ const items = value.map(parseOne).filter((x) => x !== undefined);
740
+ return items.length > 0 || allowEmpty ? items : undefined;
741
+ }
742
+ function parseInstalledEntries(value) {
743
+ return parseArray(value, parseInstalledStashEntry);
637
744
  }
638
745
  function parseInstalledStashEntry(value) {
639
746
  if (typeof value !== "object" || value === null || Array.isArray(value))
@@ -673,9 +780,6 @@ function parseInstalledStashEntry(value) {
673
780
  entry.wikiName = wikiName;
674
781
  return entry;
675
782
  }
676
- function asNonEmptyString(value) {
677
- return typeof value === "string" && value ? value : undefined;
678
- }
679
783
  /**
680
784
  * Validate a legacy lockfile/installed-entry source string.
681
785
  *
@@ -728,6 +832,9 @@ function parseInstallAuditConfig(value) {
728
832
  if (typeof obj.blockUnlistedRegistries === "boolean")
729
833
  config.blockUnlistedRegistries = obj.blockUnlistedRegistries;
730
834
  const rawAllowlist = filterNonEmptyStrings(obj.registryAllowlist) ?? filterNonEmptyStrings(obj.registryWhitelist);
835
+ if (!obj.registryAllowlist && obj.registryWhitelist) {
836
+ warn("[akm] config: `registryWhitelist` is deprecated; rename it to `registryAllowlist`");
837
+ }
731
838
  if (rawAllowlist) {
732
839
  config.registryAllowlist = rawAllowlist;
733
840
  }
@@ -773,7 +880,7 @@ function parseSourceConfigEntry(value) {
773
880
  return undefined;
774
881
  if (type === "openviking") {
775
882
  const name = asNonEmptyString(obj.name) ?? "unnamed";
776
- throw new ConfigError(`openviking is not supported in akm v1. API-backed sources will return as a\nseparate QuerySource tier post-v1. Remove the source named "${name}" from your config file\nor downgrade to 0.6.x. See docs/migration/v1.md.`, "INVALID_CONFIG_FILE", `Run \`akm remove ${name}\` then re-run, or edit your config file directly at ${_getConfigPath()} to remove the openviking entry.`);
883
+ throw new ConfigError(`openviking is not supported in akm v1. API-backed sources will return as a\nseparate QuerySource tier post-v1. Remove the source named "${name}" from your config file\nor downgrade to 0.6.x. See docs/migration/v1.md.`, "INVALID_CONFIG_FILE", `Run \`akm remove ${name}\` then re-run, or edit your config file directly at ${getConfigPath()} to remove the openviking entry.`);
777
884
  }
778
885
  const entry = { type };
779
886
  const entryPath = asNonEmptyString(obj.path);
@@ -952,6 +1059,12 @@ function mergeAgentConfig(base, override) {
952
1059
  }
953
1060
  merged.profiles = profiles;
954
1061
  }
1062
+ // Shallow merge per-key: later layer wins per process name (same as profiles).
1063
+ const baseProcesses = base.processes;
1064
+ const overrideProcesses = override.processes;
1065
+ if (baseProcesses || overrideProcesses) {
1066
+ merged.processes = { ...(baseProcesses ?? {}), ...(overrideProcesses ?? {}) };
1067
+ }
955
1068
  return merged;
956
1069
  }
957
1070
  function mergeSecurityConfig(base, override) {