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.
- package/{.github/CHANGELOG.md → CHANGELOG.md} +113 -2
- package/README.md +20 -4
- package/SECURITY.md +93 -0
- package/dist/cli/config-migrate.js +144 -0
- package/dist/cli/config-validate.js +39 -0
- package/dist/cli/confirm.js +73 -0
- package/dist/cli/parse-args.js +133 -0
- package/dist/cli.js +1995 -551
- package/dist/commands/agent-dispatch.js +110 -0
- package/dist/commands/agent-support.js +68 -0
- package/dist/commands/completions.js +3 -0
- package/dist/commands/config-cli.js +130 -534
- package/dist/commands/consolidate.js +1531 -0
- package/dist/commands/curate.js +44 -3
- package/dist/commands/db-cli.js +23 -0
- package/dist/commands/distill-promotion-policy.js +660 -0
- package/dist/commands/distill.js +990 -75
- package/dist/commands/eval-cases.js +43 -0
- package/dist/commands/events.js +5 -23
- package/dist/commands/graph.js +477 -0
- package/dist/commands/health.js +400 -0
- package/dist/commands/help/help-accept.md +9 -0
- package/dist/commands/help/help-improve.md +77 -0
- package/dist/commands/help/help-proposals.md +15 -0
- package/dist/commands/help/help-propose.md +17 -0
- package/dist/commands/help/help-reject.md +8 -0
- package/dist/commands/history.js +54 -46
- package/dist/commands/improve-profiles.js +146 -0
- package/dist/commands/improve-result-file.js +103 -0
- package/dist/commands/improve.js +2175 -0
- package/dist/commands/info.js +5 -2
- package/dist/commands/init.js +50 -2
- package/dist/commands/installed-stashes.js +102 -139
- package/dist/commands/knowledge.js +136 -0
- package/dist/commands/lint/agent-linter.js +49 -0
- package/dist/commands/lint/base-linter.js +479 -0
- package/dist/commands/lint/command-linter.js +49 -0
- package/dist/commands/lint/default-linter.js +16 -0
- package/dist/commands/lint/index.js +183 -0
- package/dist/commands/lint/knowledge-linter.js +16 -0
- package/dist/commands/lint/markdown-insertion.js +343 -0
- package/dist/commands/lint/memory-linter.js +61 -0
- package/dist/commands/lint/registry.js +36 -0
- package/dist/commands/lint/skill-linter.js +45 -0
- package/dist/commands/lint/task-linter.js +50 -0
- package/dist/commands/lint/types.js +4 -0
- package/dist/commands/lint/vault-key-rules.js +139 -0
- package/dist/commands/lint/workflow-linter.js +56 -0
- package/dist/commands/lint.js +4 -0
- package/dist/commands/migration-help.js +5 -2
- package/dist/commands/proposal.js +66 -12
- package/dist/commands/propose.js +86 -31
- package/dist/commands/reflect.js +1119 -73
- package/dist/commands/registry-search.js +5 -2
- package/dist/commands/remember.js +69 -6
- package/dist/commands/schema-repair.js +203 -0
- package/dist/commands/search.js +115 -14
- package/dist/commands/self-update.js +3 -0
- package/dist/commands/show.js +144 -25
- package/dist/commands/source-add.js +17 -45
- package/dist/commands/source-clone.js +3 -0
- package/dist/commands/source-manage.js +14 -19
- package/dist/commands/tasks.js +438 -0
- package/dist/commands/url-checker.js +42 -0
- package/dist/commands/vault.js +130 -77
- package/dist/core/action-contributors.js +28 -0
- package/dist/core/asset-ref.js +7 -0
- package/dist/core/asset-registry.js +7 -16
- package/dist/core/asset-serialize.js +88 -0
- package/dist/core/asset-spec.js +22 -0
- package/dist/core/common.js +157 -0
- package/dist/core/concurrent.js +25 -0
- package/dist/core/config-io.js +347 -0
- package/dist/core/config-migration.js +625 -0
- package/dist/core/config-schema.js +501 -0
- package/dist/core/config-sources.js +108 -0
- package/dist/core/config-types.js +4 -0
- package/dist/core/config-walker.js +337 -0
- package/dist/core/config.js +327 -987
- package/dist/core/errors.js +40 -19
- package/dist/core/events.js +91 -138
- package/dist/core/file-lock.js +104 -0
- package/dist/core/frontmatter.js +3 -6
- package/dist/core/lesson-lint.js +3 -0
- package/dist/core/markdown.js +20 -0
- package/dist/core/memory-belief.js +62 -0
- package/dist/core/memory-contradiction-detect.js +274 -0
- package/dist/core/memory-improve.js +806 -0
- package/dist/core/parse.js +158 -0
- package/dist/core/paths.js +326 -14
- package/dist/core/proposal-quality-validators.js +364 -0
- package/dist/core/proposal-validators.js +69 -0
- package/dist/core/proposals.js +498 -42
- package/dist/core/state-db.js +927 -0
- package/dist/core/text-truncation.js +107 -0
- package/dist/core/time.js +54 -0
- package/dist/core/warn.js +62 -1
- package/dist/core/write-source.js +3 -0
- package/dist/indexer/db-backup.js +391 -0
- package/dist/indexer/db-search.js +152 -253
- package/dist/indexer/db.js +933 -103
- package/dist/indexer/ensure-index.js +64 -0
- package/dist/indexer/file-context.js +3 -0
- package/dist/indexer/graph-boost.js +376 -101
- package/dist/indexer/graph-db.js +391 -0
- package/dist/indexer/graph-dedup.js +95 -0
- package/dist/indexer/graph-extraction.js +550 -124
- package/dist/indexer/index-context.js +4 -0
- package/dist/indexer/indexer.js +506 -291
- package/dist/indexer/llm-cache.js +47 -0
- package/dist/indexer/manifest.js +3 -0
- package/dist/indexer/matchers.js +148 -160
- package/dist/indexer/memory-inference.js +99 -74
- package/dist/indexer/metadata-contributors.js +29 -0
- package/dist/indexer/metadata.js +255 -196
- package/dist/indexer/path-resolver.js +92 -0
- package/dist/indexer/project-context.js +192 -0
- package/dist/indexer/ranking-contributors.js +331 -0
- package/dist/indexer/ranking.js +81 -0
- package/dist/indexer/search-fields.js +5 -9
- package/dist/indexer/search-hit-enrichers.js +111 -0
- package/dist/indexer/search-source.js +44 -10
- package/dist/indexer/semantic-status.js +5 -16
- package/dist/indexer/staleness-detect.js +447 -0
- package/dist/indexer/usage-events.js +12 -9
- package/dist/indexer/walker.js +28 -0
- package/dist/integrations/agent/builders.js +135 -0
- package/dist/integrations/agent/config.js +122 -230
- package/dist/integrations/agent/detect.js +3 -0
- package/dist/integrations/agent/index.js +7 -13
- package/dist/integrations/agent/model-aliases.js +55 -0
- package/dist/integrations/agent/profiles.js +70 -5
- package/dist/integrations/agent/prompts.js +150 -74
- package/dist/integrations/agent/runner.js +151 -0
- package/dist/integrations/agent/sdk-runner.js +126 -0
- package/dist/integrations/agent/spawn.js +118 -23
- package/dist/integrations/github.js +3 -0
- package/dist/integrations/lockfile.js +32 -69
- package/dist/integrations/session-logs/index.js +68 -0
- package/dist/integrations/session-logs/providers/claude-code.js +59 -0
- package/dist/integrations/session-logs/providers/opencode.js +55 -0
- package/dist/integrations/session-logs/types.js +4 -0
- package/dist/llm/call-ai.js +62 -0
- package/dist/llm/client.js +72 -124
- package/dist/llm/embedder.js +3 -19
- package/dist/llm/embedders/cache.js +3 -7
- package/dist/llm/embedders/local.js +3 -0
- package/dist/llm/embedders/remote.js +20 -8
- package/dist/llm/embedders/types.js +3 -7
- package/dist/llm/feature-gate.js +89 -48
- package/dist/llm/graph-extract.js +676 -70
- package/dist/llm/index-passes.js +9 -23
- package/dist/llm/memory-infer.js +52 -71
- package/dist/llm/metadata-enhance.js +42 -29
- package/dist/llm/prompts/graph-extract-user-prompt.md +35 -0
- package/dist/output/cli-hints-full.md +281 -0
- package/dist/output/cli-hints-short.md +65 -0
- package/dist/output/cli-hints.js +5 -318
- package/dist/output/context.js +3 -0
- package/dist/output/renderers.js +223 -256
- package/dist/output/shapes.js +150 -105
- package/dist/output/text.js +318 -30
- package/dist/registry/build-index.js +3 -0
- package/dist/registry/create-provider-registry.js +3 -0
- package/dist/registry/factory.js +3 -0
- package/dist/registry/origin-resolve.js +3 -0
- package/dist/registry/providers/index.js +3 -0
- package/dist/registry/providers/skills-sh.js +70 -49
- package/dist/registry/providers/static-index.js +53 -48
- package/dist/registry/providers/types.js +3 -24
- package/dist/registry/resolve.js +11 -16
- package/dist/registry/types.js +3 -0
- package/dist/scripts/migrate-storage.js +17307 -0
- package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +8900 -0
- package/dist/scripts/migrations/v16-to-v17.js +141 -0
- package/dist/setup/detect.js +3 -0
- package/dist/setup/ripgrep-install.js +3 -0
- package/dist/setup/ripgrep-resolve.js +3 -0
- package/dist/setup/setup.js +775 -37
- package/dist/setup/steps.js +3 -15
- package/dist/sources/include.js +3 -0
- package/dist/sources/provider-factory.js +5 -12
- package/dist/sources/provider.js +3 -20
- package/dist/sources/providers/filesystem.js +19 -23
- package/dist/sources/providers/git.js +7 -5
- package/dist/sources/providers/index.js +3 -0
- package/dist/sources/providers/install-types.js +3 -13
- package/dist/sources/providers/npm.js +3 -4
- package/dist/sources/providers/provider-utils.js +3 -0
- package/dist/sources/providers/sync-from-ref.js +3 -11
- package/dist/sources/providers/tar-utils.js +3 -0
- package/dist/sources/providers/website.js +18 -22
- package/dist/sources/resolve.js +3 -0
- package/dist/sources/types.js +3 -0
- package/dist/sources/website-ingest.js +7 -0
- package/dist/tasks/backends/cron.js +203 -0
- package/dist/tasks/backends/exec-utils.js +28 -0
- package/dist/tasks/backends/index.js +24 -0
- package/dist/tasks/backends/launchd-template.xml +19 -0
- package/dist/tasks/backends/launchd.js +187 -0
- package/dist/tasks/backends/schtasks-template.xml +29 -0
- package/dist/tasks/backends/schtasks.js +215 -0
- package/dist/tasks/parser.js +211 -0
- package/dist/tasks/resolveAkmBin.js +87 -0
- package/dist/tasks/runner.js +458 -0
- package/dist/tasks/schedule.js +211 -0
- package/dist/tasks/schema.js +15 -0
- package/dist/tasks/validator.js +62 -0
- package/dist/version.js +3 -0
- package/dist/wiki/index-template.md +12 -0
- package/dist/wiki/ingest-workflow-template.md +54 -0
- package/dist/wiki/log-template.md +8 -0
- package/dist/wiki/schema-template.md +61 -0
- package/dist/wiki/wiki-templates.js +15 -0
- package/dist/wiki/wiki.js +13 -61
- package/dist/workflows/authoring.js +8 -25
- package/dist/workflows/cli.js +3 -0
- package/dist/workflows/db.js +140 -10
- package/dist/workflows/document-cache.js +3 -10
- package/dist/workflows/parser.js +3 -0
- package/dist/workflows/renderer.js +11 -3
- package/dist/workflows/runs.js +62 -91
- package/dist/workflows/schema.js +3 -0
- package/dist/workflows/scope-key.js +3 -0
- package/dist/workflows/validator.js +4 -8
- package/dist/workflows/workflow-template.md +24 -0
- package/docs/README.md +9 -2
- package/docs/data-and-telemetry.md +225 -0
- package/docs/migration/release-notes/0.7.0.md +1 -1
- package/docs/migration/release-notes/0.7.5.md +2 -2
- package/docs/migration/release-notes/0.8.0.md +48 -0
- package/docs/migration/v0.7-to-v0.8.md +1307 -0
- package/package.json +20 -8
- package/.github/LICENSE +0 -374
- package/dist/commands/install-audit.js +0 -381
- package/dist/templates/wiki-templates.js +0 -100
package/dist/core/config.js
CHANGED
|
@@ -1,11 +1,60 @@
|
|
|
1
|
-
|
|
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/.
|
|
2
4
|
import fs from "node:fs";
|
|
3
5
|
import path from "node:path";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
+
import { backupExistingConfig, parseConfigText, withConfigLock, writeConfigAtomic } from "./config-io";
|
|
7
|
+
import { CURRENT_CONFIG_VERSION, compareConfigVersion, migrateConfigShape } from "./config-migration";
|
|
8
|
+
import { AkmConfigSchema } from "./config-schema";
|
|
6
9
|
import { ConfigError } from "./errors";
|
|
7
|
-
|
|
10
|
+
export { stripJsonComments } from "./config-io";
|
|
11
|
+
import { getCacheDir, getConfigPath } from "./paths";
|
|
8
12
|
import { warn } from "./warn";
|
|
13
|
+
// ── Feedback failure-mode constants (F-3 / #384) ────────────────────────────
|
|
14
|
+
/**
|
|
15
|
+
* Curated taxonomy of failure modes for negative feedback.
|
|
16
|
+
*
|
|
17
|
+
* Structured failure modes enable aggregation across feedback events so the
|
|
18
|
+
* distill pipeline can detect that "5 assets failed for the same reason" and
|
|
19
|
+
* act on it — free-text strings about the same issue are not aggregatable.
|
|
20
|
+
*/
|
|
21
|
+
export const FEEDBACK_FAILURE_MODES = [
|
|
22
|
+
"incorrect", // Factually wrong or logically flawed content
|
|
23
|
+
"outdated", // Correct at some point but now stale
|
|
24
|
+
"dangerous", // Could cause harm if followed (security, safety)
|
|
25
|
+
"incomplete", // Missing key steps, context, or caveats
|
|
26
|
+
"redundant", // Duplicates another asset without adding value
|
|
27
|
+
];
|
|
28
|
+
/**
|
|
29
|
+
* Default value for {@link IndexPassConfig.graphExtractionBatchSize}. Chosen
|
|
30
|
+
* empirically: 4 amortises the per-call HTTP overhead 4× while keeping the
|
|
31
|
+
* combined prompt size well under common 8K/16K context windows (each body is
|
|
32
|
+
* sliced to ~500 chars in the graph-extract prompt builder).
|
|
33
|
+
*/
|
|
34
|
+
export const DEFAULT_GRAPH_EXTRACTION_BATCH_SIZE = 4;
|
|
35
|
+
/**
|
|
36
|
+
* Approximate character budget per asset body inside a batched
|
|
37
|
+
* graph-extraction prompt — used by {@link resolveBatchSize} to derive a
|
|
38
|
+
* context-window ceiling when `llm.contextLength` is configured. This accounts
|
|
39
|
+
* for the actual `MAX_BODY_CHARS` (500) in graph-extract.ts plus the system
|
|
40
|
+
* prompt, user prompt wrapper, and expected JSON response overhead.
|
|
41
|
+
*/
|
|
42
|
+
const GRAPH_EXTRACTION_CHARS_PER_BODY = 1500;
|
|
43
|
+
/**
|
|
44
|
+
* Clamp a configured batch size against the model's known context window.
|
|
45
|
+
*
|
|
46
|
+
* `configured` defaults to {@link DEFAULT_GRAPH_EXTRACTION_BATCH_SIZE} when
|
|
47
|
+
* `undefined`. When `contextLength` is provided, the result is the smaller of
|
|
48
|
+
* `configured` and `floor(contextLength / GRAPH_EXTRACTION_CHARS_PER_BODY)`,
|
|
49
|
+
* with a floor of 1 so the batched path always processes at least one body.
|
|
50
|
+
*/
|
|
51
|
+
export function resolveBatchSize(configured, contextLength) {
|
|
52
|
+
const base = configured && configured > 0 ? configured : DEFAULT_GRAPH_EXTRACTION_BATCH_SIZE;
|
|
53
|
+
if (!contextLength || contextLength <= 0)
|
|
54
|
+
return base;
|
|
55
|
+
const ceiling = Math.max(1, Math.floor(contextLength / GRAPH_EXTRACTION_CHARS_PER_BODY));
|
|
56
|
+
return Math.max(1, Math.min(base, ceiling));
|
|
57
|
+
}
|
|
9
58
|
// ── Defaults ────────────────────────────────────────────────────────────────
|
|
10
59
|
export const DEFAULT_CONFIG = {
|
|
11
60
|
semanticSearchMode: "auto",
|
|
@@ -18,31 +67,11 @@ export const DEFAULT_CONFIG = {
|
|
|
18
67
|
detail: "brief",
|
|
19
68
|
},
|
|
20
69
|
};
|
|
21
|
-
// ── Paths ───────────────────────────────────────────────────────────────────
|
|
22
|
-
export function getConfigDir(env, platform) {
|
|
23
|
-
return _getConfigDir(env, platform);
|
|
24
|
-
}
|
|
25
|
-
export function getConfigPath() {
|
|
26
|
-
return _getConfigPath();
|
|
27
|
-
}
|
|
28
70
|
// ── Load / Save / Update ────────────────────────────────────────────────────
|
|
29
71
|
const PROJECT_CONFIG_RELATIVE_PATH = path.join(".akm", "config.json");
|
|
30
72
|
let cachedConfig;
|
|
31
|
-
let cachedUserConfig;
|
|
32
73
|
export function resetConfigCache() {
|
|
33
74
|
cachedConfig = undefined;
|
|
34
|
-
cachedUserConfig = undefined;
|
|
35
|
-
}
|
|
36
|
-
function hashString(text) {
|
|
37
|
-
// Simple, fast non-cryptographic hash (FNV-1a 32-bit) — sufficient to detect
|
|
38
|
-
// content changes between config writes when filesystem mtime resolution is
|
|
39
|
-
// too coarse to reflect rapid back-to-back writes (common in tests).
|
|
40
|
-
let hash = 0x811c9dc5;
|
|
41
|
-
for (let i = 0; i < text.length; i++) {
|
|
42
|
-
hash ^= text.charCodeAt(i);
|
|
43
|
-
hash = Math.imul(hash, 0x01000193);
|
|
44
|
-
}
|
|
45
|
-
return (hash >>> 0).toString(16);
|
|
46
75
|
}
|
|
47
76
|
export function loadUserConfig() {
|
|
48
77
|
const configPath = getConfigPath();
|
|
@@ -51,988 +80,319 @@ export function loadUserConfig() {
|
|
|
51
80
|
stat = fs.statSync(configPath);
|
|
52
81
|
}
|
|
53
82
|
catch {
|
|
54
|
-
|
|
83
|
+
cachedConfig = undefined;
|
|
55
84
|
return applyRuntimeEnvApiKeys({ ...DEFAULT_CONFIG });
|
|
56
85
|
}
|
|
57
|
-
// Cache key
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
|
|
86
|
+
// Cache key: mtimeMs + size. Tests that write rapidly back-to-back inside
|
|
87
|
+
// the mtime resolution window MUST call resetConfigCache() between writes —
|
|
88
|
+
// every public test helper already does.
|
|
89
|
+
if (cachedConfig &&
|
|
90
|
+
cachedConfig.path === configPath &&
|
|
91
|
+
cachedConfig.mtime === stat.mtimeMs &&
|
|
92
|
+
cachedConfig.size === stat.size) {
|
|
93
|
+
return cachedConfig.config;
|
|
94
|
+
}
|
|
61
95
|
let text;
|
|
62
96
|
try {
|
|
63
97
|
text = fs.readFileSync(configPath, "utf8");
|
|
64
98
|
}
|
|
65
99
|
catch {
|
|
66
|
-
|
|
100
|
+
cachedConfig = undefined;
|
|
67
101
|
return applyRuntimeEnvApiKeys({ ...DEFAULT_CONFIG });
|
|
68
102
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
103
|
+
// Auto-migration: rewrite legacy shapes to disk on cache miss so the schema
|
|
104
|
+
// sees canonical input. AKM_NO_AUTO_MIGRATE=1 skips the disk rewrite (still
|
|
105
|
+
// applies in-memory).
|
|
106
|
+
text = maybeAutoMigrateConfigFile(configPath, text);
|
|
107
|
+
const finalConfig = applyRuntimeEnvApiKeys(parseAndValidate(text, configPath));
|
|
108
|
+
// Re-stat after potential write-back so the cache key reflects the new mtime.
|
|
109
|
+
let finalStat = stat;
|
|
110
|
+
try {
|
|
111
|
+
finalStat = fs.statSync(configPath);
|
|
76
112
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
113
|
+
catch {
|
|
114
|
+
// Stat failed — use original stat for cache; no harm done.
|
|
115
|
+
}
|
|
116
|
+
cachedConfig = {
|
|
80
117
|
config: finalConfig,
|
|
81
118
|
path: configPath,
|
|
82
|
-
mtime:
|
|
83
|
-
size:
|
|
84
|
-
contentHash,
|
|
119
|
+
mtime: finalStat.mtimeMs,
|
|
120
|
+
size: finalStat.size,
|
|
85
121
|
};
|
|
86
122
|
return finalConfig;
|
|
87
123
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
124
|
+
/**
|
|
125
|
+
* Parse raw config text, run the legacy-shape migration
|
|
126
|
+
* ({@link migrateConfigShape}), then validate via Zod
|
|
127
|
+
* ({@link AkmConfigSchema}). Returns the merged-with-defaults AkmConfig.
|
|
128
|
+
*
|
|
129
|
+
* The migration handles all one-time 0.7→0.8 transforms (legacy keys,
|
|
130
|
+
* boolean→string coercions, openviking rename); the schema then validates
|
|
131
|
+
* the canonical shape and throws on anything it doesn't recognise.
|
|
132
|
+
*/
|
|
133
|
+
function parseAndValidate(text, sourcePath) {
|
|
134
|
+
// Migration absorbs 0.7→0.8 input transforms (semanticSearchMode bool→string,
|
|
135
|
+
// stashes[] → sources[], openviking removal); the schema then sees a
|
|
136
|
+
// canonical shape. Migration is idempotent on already-migrated input.
|
|
137
|
+
const migrated = migrateConfigShape(parseConfigText(text, sourcePath)).result;
|
|
138
|
+
const parsed = AkmConfigSchema.safeParse(migrated);
|
|
139
|
+
if (!parsed.success) {
|
|
140
|
+
const lines = parsed.error.issues.map((i) => ` - ${i.path.join(".") || "(root)"}: ${i.message}`).join("\n");
|
|
141
|
+
const where = sourcePath ? ` at ${sourcePath}` : "";
|
|
142
|
+
throw new ConfigError(`Invalid config${where}:\n${lines}`, "INVALID_CONFIG_FILE");
|
|
100
143
|
}
|
|
101
|
-
|
|
102
|
-
cachedConfig = { config: finalConfig, signature };
|
|
103
|
-
return finalConfig;
|
|
144
|
+
return mergeLoadedConfig(DEFAULT_CONFIG, parsed.data);
|
|
104
145
|
}
|
|
105
|
-
export function
|
|
106
|
-
|
|
107
|
-
cachedUserConfig = undefined;
|
|
108
|
-
const configPath = getConfigPath();
|
|
109
|
-
const dir = path.dirname(configPath);
|
|
110
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
111
|
-
backupExistingConfig(configPath);
|
|
112
|
-
const sanitized = sanitizeConfigForWrite(config);
|
|
113
|
-
writeConfigObject(configPath, sanitized);
|
|
146
|
+
export function getSources(config) {
|
|
147
|
+
return config.sources ?? [];
|
|
114
148
|
}
|
|
115
|
-
function
|
|
116
|
-
|
|
117
|
-
return;
|
|
118
|
-
const backupDir = path.join(getCacheDir(), "config-backups");
|
|
119
|
-
fs.mkdirSync(backupDir, { recursive: true });
|
|
120
|
-
const timestamp = new Date().toISOString().replace(/[.:]/g, "-");
|
|
121
|
-
const backupPath = path.join(backupDir, `config-${timestamp}.json`);
|
|
122
|
-
fs.copyFileSync(configPath, backupPath);
|
|
123
|
-
const latestPath = path.join(backupDir, "config.latest.json");
|
|
124
|
-
fs.copyFileSync(configPath, latestPath);
|
|
149
|
+
export function getEffectiveRegistries(config) {
|
|
150
|
+
return config.registries ?? DEFAULT_CONFIG.registries ?? [];
|
|
125
151
|
}
|
|
126
152
|
/**
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
153
|
+
* Resolve the name of the default LLM profile.
|
|
154
|
+
*
|
|
155
|
+
* Resolution order:
|
|
156
|
+
* 1. `defaults.llm` — explicit pointer set by the user.
|
|
157
|
+
* 2. A profile literally named `default` under `profiles.llm` — implicit
|
|
158
|
+
* fallback. The convention "name your default profile `default`" is
|
|
159
|
+
* what `akm setup` produces, so an unset `defaults.llm` next to a
|
|
160
|
+
* `profiles.llm.default` block is overwhelmingly a config-rewrite
|
|
161
|
+
* casualty (see [[project_akm_config_clobber_trap]]) rather than
|
|
162
|
+
* intent. Treating that shape as configured avoids the silent
|
|
163
|
+
* `getDefaultLlmConfig → undefined → pass-returns-zero` failure mode
|
|
164
|
+
* that produced 18h of no-op memory-inference runs on 2026-05-23.
|
|
165
|
+
*
|
|
166
|
+
* Returns `undefined` when neither path resolves to a profile name.
|
|
130
167
|
*/
|
|
131
|
-
function
|
|
132
|
-
const
|
|
133
|
-
if (
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const { apiKey, ...rest } = config.llm;
|
|
139
|
-
sanitized.llm = rest;
|
|
140
|
-
}
|
|
141
|
-
// Drop empty keys to keep config clean
|
|
142
|
-
return sanitized;
|
|
143
|
-
}
|
|
144
|
-
export function updateConfig(partial) {
|
|
145
|
-
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
|
-
}
|
|
171
|
-
saveConfig(merged);
|
|
172
|
-
return merged;
|
|
168
|
+
function resolveDefaultLlmProfileName(config) {
|
|
169
|
+
const explicit = config.defaults?.llm;
|
|
170
|
+
if (explicit)
|
|
171
|
+
return explicit;
|
|
172
|
+
if (config.profiles?.llm?.default)
|
|
173
|
+
return "default";
|
|
174
|
+
return undefined;
|
|
173
175
|
}
|
|
174
|
-
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
175
176
|
/**
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
177
|
+
* Resolve the default LLM connection from `profiles.llm[defaults.llm]`.
|
|
178
|
+
*
|
|
179
|
+
* Throws {@link ConfigError} when no default profile can be resolved (neither
|
|
180
|
+
* `defaults.llm` nor an implicit `profiles.llm.default`) or when the named
|
|
181
|
+
* profile does not exist under `profiles.llm`. Use this in code paths that
|
|
182
|
+
* must have an LLM configured (per-pass index calls, distill, consolidate,
|
|
183
|
+
* etc).
|
|
181
184
|
*/
|
|
182
|
-
function
|
|
183
|
-
const
|
|
184
|
-
if (
|
|
185
|
-
throw new ConfigError("
|
|
186
|
-
}
|
|
187
|
-
if (typeof raw.stashDir === "string" && raw.stashDir.trim()) {
|
|
188
|
-
config.stashDir = raw.stashDir.trim();
|
|
185
|
+
export function requireLlmConfig(config) {
|
|
186
|
+
const defaultName = resolveDefaultLlmProfileName(config);
|
|
187
|
+
if (!defaultName) {
|
|
188
|
+
throw new ConfigError("LLM is not configured. Run `akm setup` or set `defaults.llm` to a profile defined in `profiles.llm`.", "LLM_NOT_CONFIGURED");
|
|
189
189
|
}
|
|
190
|
-
|
|
191
|
-
if (
|
|
192
|
-
|
|
193
|
-
}
|
|
194
|
-
else if (raw.semanticSearchMode === "off" || raw.semanticSearchMode === "auto") {
|
|
195
|
-
config.semanticSearchMode = raw.semanticSearchMode;
|
|
196
|
-
}
|
|
197
|
-
const embedding = parseEmbeddingConfig(raw.embedding);
|
|
198
|
-
if (embedding)
|
|
199
|
-
config.embedding = embedding;
|
|
200
|
-
const llm = parseLlmConfig(raw.llm);
|
|
201
|
-
if (llm)
|
|
202
|
-
config.llm = llm;
|
|
203
|
-
const index = parseIndexConfig(raw.index);
|
|
204
|
-
if (index)
|
|
205
|
-
config.index = index;
|
|
206
|
-
const installed = parseInstalledEntries(raw.installed);
|
|
207
|
-
if (installed)
|
|
208
|
-
config.installed = installed;
|
|
209
|
-
const registries = parseRegistriesConfig(raw.registries);
|
|
210
|
-
if (registries)
|
|
211
|
-
config.registries = registries;
|
|
212
|
-
if (raw.stashInheritance === "replace" || raw.stashInheritance === "merge") {
|
|
213
|
-
config.stashInheritance = raw.stashInheritance;
|
|
214
|
-
}
|
|
215
|
-
const sources = parseSourcesConfig(raw.sources);
|
|
216
|
-
if (sources) {
|
|
217
|
-
config.sources = sources;
|
|
218
|
-
}
|
|
219
|
-
const security = parseSecurityConfig(raw.security);
|
|
220
|
-
if (security)
|
|
221
|
-
config.security = security;
|
|
222
|
-
const output = parseOutputConfig(raw.output);
|
|
223
|
-
if (output)
|
|
224
|
-
config.output = output;
|
|
225
|
-
if (typeof raw.writable === "boolean") {
|
|
226
|
-
config.writable = raw.writable;
|
|
227
|
-
}
|
|
228
|
-
if (typeof raw.defaultWriteTarget === "string" && raw.defaultWriteTarget.trim()) {
|
|
229
|
-
config.defaultWriteTarget = raw.defaultWriteTarget.trim();
|
|
230
|
-
}
|
|
231
|
-
if ("agent" in raw) {
|
|
232
|
-
const agent = parseAgentConfig(raw.agent);
|
|
233
|
-
if (agent)
|
|
234
|
-
config.agent = agent;
|
|
235
|
-
}
|
|
236
|
-
if (typeof raw.search === "object" && raw.search !== null && !Array.isArray(raw.search)) {
|
|
237
|
-
const searchRaw = raw.search;
|
|
238
|
-
const searchConfig = {};
|
|
239
|
-
if (typeof searchRaw.minScore === "number" && Number.isFinite(searchRaw.minScore) && searchRaw.minScore >= 0) {
|
|
240
|
-
searchConfig.minScore = searchRaw.minScore;
|
|
241
|
-
}
|
|
242
|
-
if (Object.keys(searchConfig).length > 0)
|
|
243
|
-
config.search = searchConfig;
|
|
190
|
+
const profile = config.profiles?.llm?.[defaultName];
|
|
191
|
+
if (!profile) {
|
|
192
|
+
throw new ConfigError(`LLM default profile "${defaultName}" not found in profiles.llm.`, "LLM_NOT_CONFIGURED", `Available profiles: ${Object.keys(config.profiles?.llm ?? {}).join(", ") || "none"}. Run \`akm setup\` to configure.`);
|
|
244
193
|
}
|
|
245
|
-
return
|
|
246
|
-
}
|
|
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) {
|
|
253
|
-
const raw = parseConfigObjectFromText(text);
|
|
254
|
-
if (!raw)
|
|
255
|
-
return undefined;
|
|
256
|
-
const expanded = expandEnvVars(raw);
|
|
257
|
-
return pickKnownKeys(expanded);
|
|
258
|
-
}
|
|
259
|
-
function parseOutputConfig(value) {
|
|
260
|
-
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
261
|
-
return undefined;
|
|
262
|
-
const obj = value;
|
|
263
|
-
const output = {};
|
|
264
|
-
if (obj.format === "json" || obj.format === "yaml" || obj.format === "text") {
|
|
265
|
-
output.format = obj.format;
|
|
266
|
-
}
|
|
267
|
-
if (obj.detail === "brief" || obj.detail === "normal" || obj.detail === "full") {
|
|
268
|
-
output.detail = obj.detail;
|
|
269
|
-
}
|
|
270
|
-
return Object.keys(output).length > 0 ? output : undefined;
|
|
194
|
+
return profile;
|
|
271
195
|
}
|
|
272
196
|
/**
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
* an attacker-controlled server if the config file is world-readable.
|
|
197
|
+
* Like {@link requireLlmConfig} but returns `undefined` instead of throwing
|
|
198
|
+
* when no LLM is configured. Use in code paths where the LLM is optional.
|
|
276
199
|
*/
|
|
277
|
-
|
|
200
|
+
export function getDefaultLlmConfig(config) {
|
|
201
|
+
const defaultName = resolveDefaultLlmProfileName(config);
|
|
202
|
+
if (!defaultName)
|
|
203
|
+
return undefined;
|
|
204
|
+
return config.profiles?.llm?.[defaultName];
|
|
205
|
+
}
|
|
278
206
|
/**
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
207
|
+
* Run `migrateConfigShape` on the raw text and — unless `AKM_NO_AUTO_MIGRATE=1`
|
|
208
|
+
* is set — persist the migrated result. Returns the (possibly migrated) text
|
|
209
|
+
* for the caller to feed into `parseAndValidate`.
|
|
282
210
|
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
211
|
+
* If the on-disk config is newer than this binary's known version, the bytes
|
|
212
|
+
* are left untouched (we won't silently strip fields on downgrade).
|
|
285
213
|
*/
|
|
286
|
-
function
|
|
287
|
-
|
|
288
|
-
// Skip URL-type fields by name or by value prefix, unless they contain ${VAR} syntax
|
|
289
|
-
if (!value.includes("${") &&
|
|
290
|
-
((fieldName !== undefined && URL_FIELD_NAMES.has(fieldName)) ||
|
|
291
|
-
value.startsWith("http://") ||
|
|
292
|
-
value.startsWith("https://"))) {
|
|
293
|
-
return value;
|
|
294
|
-
}
|
|
295
|
-
return value.replace(/\$\{([^}]+)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (_match, braced, bare) => {
|
|
296
|
-
if (braced) {
|
|
297
|
-
const [name, ...rest] = braced.split(":-");
|
|
298
|
-
const fallback = rest.join(":-");
|
|
299
|
-
return process.env[name] ?? fallback ?? "";
|
|
300
|
-
}
|
|
301
|
-
return process.env[bare] ?? "";
|
|
302
|
-
});
|
|
303
|
-
}
|
|
304
|
-
if (Array.isArray(value)) {
|
|
305
|
-
return value.map((item) => expandEnvVars(item));
|
|
306
|
-
}
|
|
307
|
-
if (value !== null && typeof value === "object") {
|
|
308
|
-
const out = {};
|
|
309
|
-
for (const [k, v] of Object.entries(value)) {
|
|
310
|
-
out[k] = expandEnvVars(v, k);
|
|
311
|
-
}
|
|
312
|
-
return out;
|
|
313
|
-
}
|
|
314
|
-
return value;
|
|
315
|
-
}
|
|
316
|
-
function readConfigObject(configPath) {
|
|
214
|
+
function maybeAutoMigrateConfigFile(configPath, text) {
|
|
215
|
+
let obj;
|
|
317
216
|
try {
|
|
318
|
-
|
|
319
|
-
return parseConfigObjectFromText(text);
|
|
217
|
+
obj = parseConfigText(text);
|
|
320
218
|
}
|
|
321
219
|
catch {
|
|
322
|
-
return
|
|
220
|
+
return text; // Malformed JSON — let parseAndValidate surface the error.
|
|
323
221
|
}
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
try {
|
|
327
|
-
const raw = JSON.parse(stripJsonComments(text));
|
|
328
|
-
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
|
329
|
-
return undefined;
|
|
330
|
-
return raw;
|
|
331
|
-
}
|
|
332
|
-
catch {
|
|
333
|
-
return undefined;
|
|
222
|
+
if (compareConfigVersion(obj.configVersion, CURRENT_CONFIG_VERSION) === 1) {
|
|
223
|
+
return text;
|
|
334
224
|
}
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
|
|
225
|
+
const { changed, result } = migrateConfigShape(obj);
|
|
226
|
+
if (!changed)
|
|
227
|
+
return text;
|
|
228
|
+
const migratedText = `${JSON.stringify(result, null, 2)}\n`;
|
|
229
|
+
if (process.env.AKM_NO_AUTO_MIGRATE === "1")
|
|
230
|
+
return migratedText;
|
|
338
231
|
try {
|
|
339
|
-
|
|
340
|
-
|
|
232
|
+
withConfigLock(() => {
|
|
233
|
+
backupExistingConfig(configPath);
|
|
234
|
+
writeConfigAtomic(configPath, result);
|
|
235
|
+
});
|
|
236
|
+
const newVersion = typeof result.configVersion === "string" ? result.configVersion : "0.8.0";
|
|
237
|
+
const backupDir = `${getCacheDir()}/config-backups`;
|
|
238
|
+
// WS-2: emit a loud banner to BOTH stderr and stdout so pipelines and
|
|
239
|
+
// interactive terminals both see it. Include the backup path (resolved,
|
|
240
|
+
// not ~/...), opt-out env var, and preview diff command.
|
|
241
|
+
const banner = [
|
|
242
|
+
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
|
|
243
|
+
` akm: auto-migrated config → ${newVersion} format`,
|
|
244
|
+
` file: ${configPath}`,
|
|
245
|
+
` backup: ${backupDir}/config-<timestamp>.json`,
|
|
246
|
+
" to opt out of future auto-migration: AKM_NO_AUTO_MIGRATE=1",
|
|
247
|
+
" to preview a dry-run diff: akm config migrate --dry-run --print-diff",
|
|
248
|
+
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
|
|
249
|
+
].join("\n");
|
|
250
|
+
process.stderr.write(`${banner}\n`);
|
|
251
|
+
process.stdout.write(`${banner}\n`);
|
|
341
252
|
}
|
|
342
253
|
catch (err) {
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
/* ignore cleanup failure */
|
|
348
|
-
}
|
|
349
|
-
throw err;
|
|
254
|
+
// #461: never return migrated bytes when disk write fails — that triggers
|
|
255
|
+
// an infinite re-migrate loop on every load. Hard-error so the user
|
|
256
|
+
// notices and either fixes the disk issue or sets AKM_NO_AUTO_MIGRATE=1.
|
|
257
|
+
throw new ConfigError(`Failed to write migrated config to ${configPath}: ${err instanceof Error ? err.message : String(err)}`, "INVALID_CONFIG_FILE", "Check filesystem permissions, free space, and disk health. To skip auto-migration, set AKM_NO_AUTO_MIGRATE=1.");
|
|
350
258
|
}
|
|
259
|
+
return migratedText;
|
|
351
260
|
}
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
let result = "";
|
|
359
|
-
let i = 0;
|
|
360
|
-
let inString = false;
|
|
361
|
-
while (i < text.length) {
|
|
362
|
-
if (inString) {
|
|
363
|
-
if (text[i] === "\\") {
|
|
364
|
-
result += text[i] + (text[i + 1] ?? "");
|
|
365
|
-
i += 2;
|
|
366
|
-
continue;
|
|
367
|
-
}
|
|
368
|
-
if (text[i] === '"') {
|
|
369
|
-
inString = false;
|
|
370
|
-
}
|
|
371
|
-
result += text[i];
|
|
372
|
-
i++;
|
|
373
|
-
continue;
|
|
374
|
-
}
|
|
375
|
-
// JSON only uses double-quoted strings; single quotes are not valid JSON
|
|
376
|
-
if (text[i] === '"') {
|
|
377
|
-
inString = true;
|
|
378
|
-
result += text[i];
|
|
379
|
-
i++;
|
|
380
|
-
continue;
|
|
381
|
-
}
|
|
382
|
-
if (text[i] === "/" && text[i + 1] === "/") {
|
|
383
|
-
while (i < text.length && text[i] !== "\n")
|
|
384
|
-
i++;
|
|
385
|
-
continue;
|
|
386
|
-
}
|
|
387
|
-
if (text[i] === "/" && text[i + 1] === "*") {
|
|
388
|
-
i += 2;
|
|
389
|
-
while (i < text.length && !(text[i] === "*" && text[i + 1] === "/"))
|
|
390
|
-
i++;
|
|
391
|
-
i += 2;
|
|
392
|
-
continue;
|
|
393
|
-
}
|
|
394
|
-
result += text[i];
|
|
395
|
-
i++;
|
|
396
|
-
}
|
|
397
|
-
return result;
|
|
398
|
-
}
|
|
399
|
-
function parseEmbeddingConfig(value) {
|
|
400
|
-
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
401
|
-
return undefined;
|
|
402
|
-
const obj = value;
|
|
403
|
-
// Extract localModel early — it's valid even without a remote endpoint
|
|
404
|
-
const localModel = typeof obj.localModel === "string" && obj.localModel ? obj.localModel : undefined;
|
|
405
|
-
// If no endpoint is provided, the config is only valid when localModel is set
|
|
406
|
-
// (local-only embedding configuration).
|
|
407
|
-
// Sentinel: { endpoint: "", model: "" } means "local-only" — use hasRemoteEndpoint()
|
|
408
|
-
// (in embedder.ts) to distinguish from a real remote config. Do NOT check
|
|
409
|
-
// endpoint/model directly in consuming code.
|
|
410
|
-
if (typeof obj.endpoint !== "string" || !obj.endpoint) {
|
|
411
|
-
if (localModel) {
|
|
412
|
-
return { endpoint: "", model: "", localModel };
|
|
413
|
-
}
|
|
414
|
-
return undefined;
|
|
415
|
-
}
|
|
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}"`);
|
|
418
|
-
// Still return localModel-only config if localModel was set
|
|
419
|
-
if (localModel) {
|
|
420
|
-
return { endpoint: "", model: "", localModel };
|
|
421
|
-
}
|
|
422
|
-
return undefined;
|
|
423
|
-
}
|
|
424
|
-
if (typeof obj.model !== "string" || !obj.model) {
|
|
425
|
-
// No remote model, but localModel may still be valid
|
|
426
|
-
if (localModel) {
|
|
427
|
-
warn(`[akm] Embedding endpoint "${obj.endpoint}" ignored: model is required for remote embeddings. Using local model only.`);
|
|
428
|
-
return { endpoint: "", model: "", localModel };
|
|
429
|
-
}
|
|
430
|
-
return undefined;
|
|
431
|
-
}
|
|
432
|
-
const result = {
|
|
433
|
-
endpoint: obj.endpoint,
|
|
434
|
-
model: obj.model,
|
|
435
|
-
};
|
|
436
|
-
if (typeof obj.provider === "string" && obj.provider) {
|
|
437
|
-
result.provider = obj.provider;
|
|
438
|
-
}
|
|
439
|
-
if ("dimension" in obj) {
|
|
440
|
-
if (typeof obj.dimension !== "number" ||
|
|
441
|
-
!Number.isFinite(obj.dimension) ||
|
|
442
|
-
!Number.isInteger(obj.dimension) ||
|
|
443
|
-
obj.dimension <= 0) {
|
|
444
|
-
return undefined;
|
|
445
|
-
}
|
|
446
|
-
result.dimension = obj.dimension;
|
|
447
|
-
}
|
|
448
|
-
if (typeof obj.apiKey === "string" && obj.apiKey) {
|
|
449
|
-
result.apiKey = obj.apiKey;
|
|
450
|
-
}
|
|
451
|
-
if (localModel) {
|
|
452
|
-
result.localModel = localModel;
|
|
453
|
-
}
|
|
454
|
-
if ("contextLength" in obj) {
|
|
455
|
-
if (typeof obj.contextLength !== "number" ||
|
|
456
|
-
!Number.isFinite(obj.contextLength) ||
|
|
457
|
-
!Number.isInteger(obj.contextLength) ||
|
|
458
|
-
obj.contextLength <= 0) {
|
|
459
|
-
return undefined;
|
|
460
|
-
}
|
|
461
|
-
result.contextLength = obj.contextLength;
|
|
462
|
-
}
|
|
463
|
-
if (typeof obj.ollamaOptions === "object" && obj.ollamaOptions !== null && !Array.isArray(obj.ollamaOptions)) {
|
|
464
|
-
const opts = obj.ollamaOptions;
|
|
465
|
-
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;
|
|
471
|
-
}
|
|
472
|
-
if (Object.keys(parsed).length > 0) {
|
|
473
|
-
result.ollamaOptions = parsed;
|
|
474
|
-
}
|
|
475
|
-
}
|
|
476
|
-
return result;
|
|
477
|
-
}
|
|
478
|
-
function parseLlmConfig(value) {
|
|
479
|
-
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
480
|
-
return undefined;
|
|
481
|
-
const obj = value;
|
|
482
|
-
if (typeof obj.endpoint !== "string" || !obj.endpoint)
|
|
483
|
-
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}"`);
|
|
486
|
-
return undefined;
|
|
487
|
-
}
|
|
488
|
-
if (!obj.endpoint.endsWith("/chat/completions")) {
|
|
489
|
-
warn(`[akm] llm.endpoint "${obj.endpoint}" does not end in /chat/completions. ` +
|
|
490
|
-
`Did you mean "${obj.endpoint.replace(/\/+$/, "")}/chat/completions"?`);
|
|
491
|
-
}
|
|
492
|
-
const model = typeof obj.model === "string" ? obj.model : "";
|
|
493
|
-
const result = {
|
|
494
|
-
endpoint: obj.endpoint,
|
|
495
|
-
model,
|
|
496
|
-
};
|
|
497
|
-
if (typeof obj.provider === "string" && obj.provider) {
|
|
498
|
-
result.provider = obj.provider;
|
|
499
|
-
}
|
|
500
|
-
if (typeof obj.temperature === "number" && Number.isFinite(obj.temperature)) {
|
|
501
|
-
result.temperature = obj.temperature;
|
|
502
|
-
}
|
|
503
|
-
if ("timeoutMs" in obj) {
|
|
504
|
-
if (typeof obj.timeoutMs !== "number" ||
|
|
505
|
-
!Number.isFinite(obj.timeoutMs) ||
|
|
506
|
-
!Number.isInteger(obj.timeoutMs) ||
|
|
507
|
-
obj.timeoutMs <= 0) {
|
|
508
|
-
return undefined;
|
|
509
|
-
}
|
|
510
|
-
result.timeoutMs = obj.timeoutMs;
|
|
511
|
-
}
|
|
512
|
-
if ("maxTokens" in obj) {
|
|
513
|
-
if (typeof obj.maxTokens !== "number" ||
|
|
514
|
-
!Number.isFinite(obj.maxTokens) ||
|
|
515
|
-
!Number.isInteger(obj.maxTokens) ||
|
|
516
|
-
obj.maxTokens <= 0) {
|
|
517
|
-
return undefined;
|
|
518
|
-
}
|
|
519
|
-
result.maxTokens = obj.maxTokens;
|
|
520
|
-
}
|
|
521
|
-
if (typeof obj.apiKey === "string" && obj.apiKey) {
|
|
522
|
-
result.apiKey = obj.apiKey;
|
|
523
|
-
}
|
|
524
|
-
if (typeof obj.capabilities === "object" && obj.capabilities !== null && !Array.isArray(obj.capabilities)) {
|
|
525
|
-
const capsRaw = obj.capabilities;
|
|
526
|
-
const caps = {};
|
|
527
|
-
if (typeof capsRaw.structuredOutput === "boolean")
|
|
528
|
-
caps.structuredOutput = capsRaw.structuredOutput;
|
|
529
|
-
if (Object.keys(caps).length > 0)
|
|
530
|
-
result.capabilities = caps;
|
|
531
|
-
}
|
|
532
|
-
if (typeof obj.features === "object" && obj.features !== null && !Array.isArray(obj.features)) {
|
|
533
|
-
const features = parseLlmFeatures(obj.features);
|
|
534
|
-
if (Object.keys(features).length > 0)
|
|
535
|
-
result.features = features;
|
|
536
|
-
}
|
|
537
|
-
if (typeof obj.extraParams === "object" && obj.extraParams !== null && !Array.isArray(obj.extraParams)) {
|
|
538
|
-
result.extraParams = obj.extraParams;
|
|
539
|
-
}
|
|
540
|
-
return result;
|
|
261
|
+
export function loadConfig() {
|
|
262
|
+
// Single-layer load: only the user-level config file is read. Project-level
|
|
263
|
+
// .akm/config.json files discovered under cwd-ancestors emit a one-time
|
|
264
|
+
// deprecation warning (#457) but are NOT merged.
|
|
265
|
+
warnIfProjectConfigPresent(process.cwd());
|
|
266
|
+
return loadUserConfig();
|
|
541
267
|
}
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
warn(`[akm] Ignoring llm.features.${key}: expected boolean, got ${typeof value}.`);
|
|
564
|
-
continue;
|
|
565
|
-
}
|
|
566
|
-
switch (key) {
|
|
567
|
-
case "memory_inference":
|
|
568
|
-
out.memory_inference = value;
|
|
569
|
-
break;
|
|
570
|
-
case "graph_extraction":
|
|
571
|
-
out.graph_extraction = value;
|
|
572
|
-
break;
|
|
573
|
-
case "curate_rerank":
|
|
574
|
-
out.curate_rerank = value;
|
|
575
|
-
break;
|
|
576
|
-
case "feedback_distillation":
|
|
577
|
-
out.feedback_distillation = value;
|
|
578
|
-
break;
|
|
579
|
-
// No default: LOCKED_LLM_FEATURE_KEYS is the source of truth for which
|
|
580
|
-
// keys are accepted. Adding a new locked key requires an arm here AND a
|
|
581
|
-
// field on LlmFeatureFlags above.
|
|
582
|
-
}
|
|
583
|
-
}
|
|
584
|
-
return out;
|
|
268
|
+
export function saveConfig(config) {
|
|
269
|
+
cachedConfig = undefined;
|
|
270
|
+
const configPath = getConfigPath();
|
|
271
|
+
const dir = path.dirname(configPath);
|
|
272
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
273
|
+
const sanitized = sanitizeConfigForWrite(config);
|
|
274
|
+
// Final validation gate before bytes hit disk. Catches schema violations
|
|
275
|
+
// (unknown keys in registries[] / sources[] / profiles.*; out-of-range
|
|
276
|
+
// numbers; etc. — closes #462) before we corrupt the user's config.
|
|
277
|
+
const parseResult = AkmConfigSchema.safeParse(sanitized);
|
|
278
|
+
if (!parseResult.success) {
|
|
279
|
+
const lines = parseResult.error.issues.map((i) => ` - ${i.path.join(".") || "(root)"}: ${i.message}`).join("\n");
|
|
280
|
+
throw new ConfigError(`Refusing to save invalid config:\n${lines}`, "INVALID_CONFIG_FILE", "Fix the listed fields, or undo the offending `akm config set`. " +
|
|
281
|
+
"If this looks like an akm bug, re-run with --debug to attach the traceback.");
|
|
282
|
+
}
|
|
283
|
+
// WS-3: acquire the config write lock so concurrent `akm config set`
|
|
284
|
+
// invocations do not interleave their backup+atomic-write cycles.
|
|
285
|
+
withConfigLock(() => {
|
|
286
|
+
backupExistingConfig(configPath);
|
|
287
|
+
writeConfigAtomic(configPath, sanitized);
|
|
288
|
+
});
|
|
585
289
|
}
|
|
586
290
|
/**
|
|
587
|
-
*
|
|
588
|
-
*
|
|
589
|
-
*
|
|
590
|
-
* configure the LLM (`akm.llm`).
|
|
591
|
-
*/
|
|
592
|
-
const PROVIDER_CONFIG_KEYS = new Set([
|
|
593
|
-
"endpoint",
|
|
594
|
-
"model",
|
|
595
|
-
"provider",
|
|
596
|
-
"apiKey",
|
|
597
|
-
"baseUrl",
|
|
598
|
-
"temperature",
|
|
599
|
-
"maxTokens",
|
|
600
|
-
"capabilities",
|
|
601
|
-
]);
|
|
602
|
-
/**
|
|
603
|
-
* Parse the `index` config block. Each entry is a pass name → small object
|
|
604
|
-
* `{ llm?: boolean }`. Anything richer (a parallel provider config, unknown
|
|
605
|
-
* keys, non-boolean `llm`) throws `ConfigError("INVALID_CONFIG_FILE")` at
|
|
606
|
-
* load time so the failure is visible at startup, not on the next index run.
|
|
291
|
+
* Strip apiKey fields before writing config to disk.
|
|
292
|
+
* API keys should be provided via environment variables
|
|
293
|
+
* AKM_EMBED_API_KEY and AKM_LLM_API_KEY.
|
|
607
294
|
*/
|
|
608
|
-
function
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
295
|
+
function sanitizeConfigForWrite(config) {
|
|
296
|
+
const sanitized = { ...config };
|
|
297
|
+
if (config.embedding) {
|
|
298
|
+
const { apiKey, ...rest } = config.embedding;
|
|
299
|
+
sanitized.embedding = rest;
|
|
613
300
|
}
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
const passRaw = raw;
|
|
620
|
-
// Reject any provider-shaped key — there must be exactly one place to
|
|
621
|
-
// configure the LLM (#208). This is the duplicate-provider guard.
|
|
622
|
-
for (const key of Object.keys(passRaw)) {
|
|
623
|
-
if (PROVIDER_CONFIG_KEYS.has(key)) {
|
|
624
|
-
throw new ConfigError(`Duplicate LLM provider configuration: \`index.${passName}.${key}\` is not allowed. ` +
|
|
625
|
-
"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.');
|
|
626
|
-
}
|
|
627
|
-
if (key !== "llm") {
|
|
628
|
-
throw new ConfigError(`Unknown key \`index.${passName}.${key}\`. Per-pass entries only support \`llm\` (boolean opt-out).`, "INVALID_CONFIG_FILE");
|
|
629
|
-
}
|
|
301
|
+
if (config.profiles?.llm) {
|
|
302
|
+
const llmProfiles = {};
|
|
303
|
+
for (const [name, profile] of Object.entries(config.profiles.llm)) {
|
|
304
|
+
const { apiKey: _apiKey, ...rest } = profile;
|
|
305
|
+
llmProfiles[name] = rest;
|
|
630
306
|
}
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
throw new ConfigError(`Invalid \`index.${passName}.llm\`: expected a boolean (true to use \`akm.llm\`, false to opt out). Got ${typeof llmFlag}.`, "INVALID_CONFIG_FILE", "Per-pass alternative provider config is intentionally unsupported in v1 (#208). Use `false` to disable LLM for this pass.");
|
|
636
|
-
}
|
|
637
|
-
passConfig.llm = llmFlag;
|
|
638
|
-
}
|
|
639
|
-
out[passName] = passConfig;
|
|
640
|
-
}
|
|
641
|
-
return out;
|
|
642
|
-
}
|
|
643
|
-
function parseInstalledEntries(value) {
|
|
644
|
-
if (!Array.isArray(value))
|
|
645
|
-
return undefined;
|
|
646
|
-
const entries = value
|
|
647
|
-
.map((entry) => parseInstalledStashEntry(entry))
|
|
648
|
-
.filter((entry) => entry !== undefined);
|
|
649
|
-
return entries.length > 0 ? entries : undefined;
|
|
650
|
-
}
|
|
651
|
-
function parseInstalledStashEntry(value) {
|
|
652
|
-
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
653
|
-
return undefined;
|
|
654
|
-
const obj = value;
|
|
655
|
-
const id = asNonEmptyString(obj.id);
|
|
656
|
-
const source = asKitSource(obj.source);
|
|
657
|
-
const ref = asNonEmptyString(obj.ref);
|
|
658
|
-
const artifactUrl = asNonEmptyString(obj.artifactUrl);
|
|
659
|
-
const stashRoot = asNonEmptyString(obj.stashRoot);
|
|
660
|
-
const cacheDir = asNonEmptyString(obj.cacheDir);
|
|
661
|
-
const installedAt = asNonEmptyString(obj.installedAt);
|
|
662
|
-
if (!id || !source || !ref || !artifactUrl || !stashRoot || !cacheDir || !installedAt)
|
|
663
|
-
return undefined;
|
|
664
|
-
const entry = {
|
|
665
|
-
id,
|
|
666
|
-
source,
|
|
667
|
-
ref,
|
|
668
|
-
artifactUrl,
|
|
669
|
-
stashRoot,
|
|
670
|
-
cacheDir,
|
|
671
|
-
installedAt,
|
|
672
|
-
};
|
|
673
|
-
if (typeof obj.writable === "boolean")
|
|
674
|
-
entry.writable = obj.writable;
|
|
675
|
-
if (entry.writable === true && entry.source !== "git") {
|
|
676
|
-
throw new ConfigError(`writable: true is only supported on filesystem and git sources (got "${entry.source}" on installed entry "${entry.id}").`, "INVALID_CONFIG_FILE", "Remove `writable: true` from the installed entry or re-add it as a git source instead.");
|
|
307
|
+
sanitized.profiles = {
|
|
308
|
+
...(sanitized.profiles ?? {}),
|
|
309
|
+
llm: llmProfiles,
|
|
310
|
+
};
|
|
677
311
|
}
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
entry.resolvedVersion = resolvedVersion;
|
|
681
|
-
const resolvedRevision = asNonEmptyString(obj.resolvedRevision);
|
|
682
|
-
if (resolvedRevision)
|
|
683
|
-
entry.resolvedRevision = resolvedRevision;
|
|
684
|
-
const wikiName = asNonEmptyString(obj.wikiName);
|
|
685
|
-
if (wikiName)
|
|
686
|
-
entry.wikiName = wikiName;
|
|
687
|
-
return entry;
|
|
312
|
+
// Drop empty keys to keep config clean
|
|
313
|
+
return sanitized;
|
|
688
314
|
}
|
|
689
|
-
function
|
|
690
|
-
|
|
315
|
+
export function updateConfig(partial) {
|
|
316
|
+
const current = loadUserConfig();
|
|
317
|
+
const merged = mergeLoadedConfig(current, partial);
|
|
318
|
+
saveConfig(merged);
|
|
319
|
+
return merged;
|
|
691
320
|
}
|
|
321
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
692
322
|
/**
|
|
693
|
-
*
|
|
323
|
+
* Resolve a single secret value by expanding `${VAR}` / `$VAR` /
|
|
324
|
+
* `${VAR:-default}` references against `process.env`. Use this at apiKey /
|
|
325
|
+
* authorization-header consumption sites (LLM client, embedder, agent SDK
|
|
326
|
+
* runner) — NOT on the load path. Non-string inputs pass through unchanged.
|
|
694
327
|
*
|
|
695
|
-
*
|
|
696
|
-
*
|
|
697
|
-
*
|
|
698
|
-
*
|
|
328
|
+
* Returns the input unchanged when no substitution markers are present, so
|
|
329
|
+
* literal API key strings (already-resolved secrets) are zero-cost.
|
|
330
|
+
*
|
|
331
|
+
* Other config string values (URLs, endpoints, model names, prompts) are
|
|
332
|
+
* preserved verbatim on read — only fields explicitly routed through this
|
|
333
|
+
* helper are expanded.
|
|
699
334
|
*/
|
|
700
|
-
function
|
|
701
|
-
if (value ===
|
|
702
|
-
return value;
|
|
703
|
-
return undefined;
|
|
704
|
-
}
|
|
705
|
-
function parseRegistriesConfig(value) {
|
|
706
|
-
if (!Array.isArray(value))
|
|
707
|
-
return undefined;
|
|
708
|
-
const entries = value
|
|
709
|
-
.map((entry) => parseRegistryConfigEntry(entry))
|
|
710
|
-
.filter((entry) => entry !== undefined);
|
|
711
|
-
// Return the array even if empty — an explicit empty array means "no registries"
|
|
712
|
-
// which overrides the default. Only return undefined if the field was not an array.
|
|
713
|
-
return entries;
|
|
714
|
-
}
|
|
715
|
-
function parseSourcesConfig(value) {
|
|
716
|
-
if (!Array.isArray(value))
|
|
717
|
-
return undefined;
|
|
718
|
-
const entries = value
|
|
719
|
-
.map((entry) => parseSourceConfigEntry(entry))
|
|
720
|
-
.filter((entry) => entry !== undefined);
|
|
721
|
-
return entries;
|
|
722
|
-
}
|
|
723
|
-
function parseSecurityConfig(value) {
|
|
724
|
-
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
725
|
-
return undefined;
|
|
726
|
-
const obj = value;
|
|
727
|
-
const installAudit = parseInstallAuditConfig(obj.installAudit);
|
|
728
|
-
if (!installAudit)
|
|
729
|
-
return undefined;
|
|
730
|
-
return { installAudit };
|
|
731
|
-
}
|
|
732
|
-
function parseInstallAuditConfig(value) {
|
|
733
|
-
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
734
|
-
return undefined;
|
|
735
|
-
const obj = value;
|
|
736
|
-
const config = {};
|
|
737
|
-
if (typeof obj.enabled === "boolean")
|
|
738
|
-
config.enabled = obj.enabled;
|
|
739
|
-
if (typeof obj.blockOnCritical === "boolean")
|
|
740
|
-
config.blockOnCritical = obj.blockOnCritical;
|
|
741
|
-
if (typeof obj.blockUnlistedRegistries === "boolean")
|
|
742
|
-
config.blockUnlistedRegistries = obj.blockUnlistedRegistries;
|
|
743
|
-
const rawAllowlist = filterNonEmptyStrings(obj.registryAllowlist) ?? filterNonEmptyStrings(obj.registryWhitelist);
|
|
744
|
-
if (rawAllowlist) {
|
|
745
|
-
config.registryAllowlist = rawAllowlist;
|
|
746
|
-
}
|
|
747
|
-
const allowedFindings = parseInstallAuditAllowedFindings(obj.allowedFindings);
|
|
748
|
-
if (allowedFindings) {
|
|
749
|
-
config.allowedFindings = allowedFindings;
|
|
750
|
-
}
|
|
751
|
-
return Object.keys(config).length > 0 ? config : undefined;
|
|
752
|
-
}
|
|
753
|
-
function parseInstallAuditAllowedFindings(value) {
|
|
754
|
-
if (!Array.isArray(value))
|
|
755
|
-
return undefined;
|
|
756
|
-
const findings = value
|
|
757
|
-
.map((entry) => parseInstallAuditAllowedFinding(entry))
|
|
758
|
-
.filter((entry) => entry !== undefined);
|
|
759
|
-
return findings.length > 0 ? findings : undefined;
|
|
760
|
-
}
|
|
761
|
-
function parseInstallAuditAllowedFinding(value) {
|
|
762
|
-
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
335
|
+
export function resolveSecret(value) {
|
|
336
|
+
if (value === undefined)
|
|
763
337
|
return undefined;
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
if (!
|
|
767
|
-
return
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
const reason = asNonEmptyString(obj.reason);
|
|
776
|
-
if (reason)
|
|
777
|
-
finding.reason = reason;
|
|
778
|
-
return finding;
|
|
779
|
-
}
|
|
780
|
-
function parseSourceConfigEntry(value) {
|
|
781
|
-
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
782
|
-
return undefined;
|
|
783
|
-
const obj = value;
|
|
784
|
-
const type = asNonEmptyString(obj.type);
|
|
785
|
-
if (!type)
|
|
786
|
-
return undefined;
|
|
787
|
-
if (type === "openviking") {
|
|
788
|
-
const name = asNonEmptyString(obj.name) ?? "unnamed";
|
|
789
|
-
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.`);
|
|
790
|
-
}
|
|
791
|
-
const entry = { type };
|
|
792
|
-
const entryPath = asNonEmptyString(obj.path);
|
|
793
|
-
if (entryPath)
|
|
794
|
-
entry.path = entryPath;
|
|
795
|
-
const url = asNonEmptyString(obj.url);
|
|
796
|
-
if (url)
|
|
797
|
-
entry.url = url;
|
|
798
|
-
const name = asNonEmptyString(obj.name);
|
|
799
|
-
if (name)
|
|
800
|
-
entry.name = name;
|
|
801
|
-
if (typeof obj.enabled === "boolean")
|
|
802
|
-
entry.enabled = obj.enabled;
|
|
803
|
-
if (typeof obj.writable === "boolean")
|
|
804
|
-
entry.writable = obj.writable;
|
|
805
|
-
if (typeof obj.primary === "boolean")
|
|
806
|
-
entry.primary = obj.primary;
|
|
807
|
-
// Locked decision 4 (§6 v1 implementation plan): reject writable: true on
|
|
808
|
-
// website / npm sources at config load. The next sync() would clobber
|
|
809
|
-
// writes — allowing this is a footgun, not a feature. Throw early so the
|
|
810
|
-
// user sees the problem at `akm` startup, not when they try to write.
|
|
811
|
-
if (entry.writable === true && (type === "website" || type === "npm")) {
|
|
812
|
-
const label = entry.name ? ` "${entry.name}"` : "";
|
|
813
|
-
throw new ConfigError(`writable: true is only supported on filesystem and git sources (got "${type}" on source${label}).`, "INVALID_CONFIG_FILE", "To author into a checked-out package, add the same path as a separate filesystem source.");
|
|
814
|
-
}
|
|
815
|
-
if (typeof obj.options === "object" && obj.options !== null && !Array.isArray(obj.options)) {
|
|
816
|
-
entry.options = obj.options;
|
|
817
|
-
}
|
|
818
|
-
const wikiName = asNonEmptyString(obj.wikiName);
|
|
819
|
-
if (wikiName)
|
|
820
|
-
entry.wikiName = wikiName;
|
|
821
|
-
return entry;
|
|
822
|
-
}
|
|
823
|
-
// ── ConfiguredSource runtime construction ─────────────────────────────────────────
|
|
824
|
-
/**
|
|
825
|
-
* Synthesize a stable identifier when a {@link SourceConfigEntry} omits its
|
|
826
|
-
* `name`. Uses a short hash of the discriminating fields so two equivalent
|
|
827
|
-
* entries collapse to the same generated name.
|
|
828
|
-
*/
|
|
829
|
-
function deriveStashEntryName(entry) {
|
|
830
|
-
if (entry.name)
|
|
831
|
-
return entry.name;
|
|
832
|
-
const seed = JSON.stringify({
|
|
833
|
-
type: entry.type,
|
|
834
|
-
path: entry.path ?? null,
|
|
835
|
-
url: entry.url ?? null,
|
|
338
|
+
if (typeof value !== "string")
|
|
339
|
+
return value;
|
|
340
|
+
if (!value.includes("$"))
|
|
341
|
+
return value;
|
|
342
|
+
return value.replace(/\$\{([^}]+)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (_match, braced, bare) => {
|
|
343
|
+
if (braced) {
|
|
344
|
+
const [name, ...rest] = braced.split(":-");
|
|
345
|
+
const fallback = rest.join(":-");
|
|
346
|
+
return process.env[name] ?? fallback ?? "";
|
|
347
|
+
}
|
|
348
|
+
return process.env[bare] ?? "";
|
|
836
349
|
});
|
|
837
|
-
const hash = createHash("sha256").update(seed).digest("hex").slice(0, 8);
|
|
838
|
-
return `${entry.type}-${hash}`;
|
|
839
350
|
}
|
|
840
351
|
/**
|
|
841
|
-
*
|
|
842
|
-
*
|
|
843
|
-
*
|
|
844
|
-
* `filesystem` entry with no `path`); callers should drop or warn for those.
|
|
845
|
-
*
|
|
846
|
-
* Unknown provider types fall back to `{ type: "filesystem", path: ... }` when
|
|
847
|
-
* a `path` is supplied, so future provider types still produce a usable
|
|
848
|
-
* runtime value.
|
|
352
|
+
* Read a per-pass {@link IndexPassConfig} entry from {@link IndexConfig},
|
|
353
|
+
* filtering out the reserved feature-section keys so callers don't mistake
|
|
354
|
+
* `metadataEnhance` / `stalenessDetection` for a pass.
|
|
849
355
|
*/
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
case "git":
|
|
855
|
-
return entry.url ? { type: "git", url: entry.url } : undefined;
|
|
856
|
-
case "website":
|
|
857
|
-
return entry.url
|
|
858
|
-
? {
|
|
859
|
-
type: "website",
|
|
860
|
-
url: entry.url,
|
|
861
|
-
...(typeof entry.options?.maxPages === "number" ? { maxPages: entry.options.maxPages } : {}),
|
|
862
|
-
}
|
|
863
|
-
: undefined;
|
|
864
|
-
case "npm":
|
|
865
|
-
// Persisted `npm` stash entries are unusual but supported for symmetry.
|
|
866
|
-
return entry.path ? { type: "npm", package: entry.path } : undefined;
|
|
867
|
-
default:
|
|
868
|
-
// Unknown provider — best-effort fallback so callers still get something.
|
|
869
|
-
return entry.path ? { type: "filesystem", path: entry.path } : undefined;
|
|
870
|
-
}
|
|
871
|
-
}
|
|
872
|
-
/**
|
|
873
|
-
* Build the full ordered list of runtime {@link ConfiguredSource} values from a
|
|
874
|
-
* loaded {@link AkmConfig}. Order is the canonical iteration order:
|
|
875
|
-
*
|
|
876
|
-
* 1. The entry marked `primary: true` (or, as a backwards-compat shim,
|
|
877
|
-
* a synthetic filesystem entry built from the top-level `stashDir`).
|
|
878
|
-
* 2. Remaining `sources[]` entries in declared order.
|
|
879
|
-
* 3. Legacy `installed[]` entries, mapped into runtime entries.
|
|
880
|
-
*
|
|
881
|
-
* Entries with `enabled: false` are still emitted — callers decide whether
|
|
882
|
-
* to honour the flag (mirrors how `installed[]` entries have always been
|
|
883
|
-
* unconditional). Entries that fail {@link parseSourceSpec} are
|
|
884
|
-
* dropped silently.
|
|
885
|
-
*/
|
|
886
|
-
export function resolveConfiguredSources(config) {
|
|
887
|
-
const entries = [];
|
|
888
|
-
const sources = config.sources ?? [];
|
|
889
|
-
// (1) Primary entry: explicit `primary: true` wins; fall back to top-level stashDir.
|
|
890
|
-
let primary = sources.find((entry) => entry.primary === true);
|
|
891
|
-
if (!primary && config.stashDir) {
|
|
892
|
-
primary = { type: "filesystem", path: config.stashDir, primary: true };
|
|
893
|
-
}
|
|
894
|
-
if (primary) {
|
|
895
|
-
const runtime = toConfiguredSource(primary, true);
|
|
896
|
-
if (runtime)
|
|
897
|
-
entries.push(runtime);
|
|
898
|
-
}
|
|
899
|
-
// (2) Declared sources (skip the primary entry — already added).
|
|
900
|
-
for (const entry of sources) {
|
|
901
|
-
if (entry === primary)
|
|
902
|
-
continue;
|
|
903
|
-
const runtime = toConfiguredSource(entry, false);
|
|
904
|
-
if (runtime)
|
|
905
|
-
entries.push(runtime);
|
|
906
|
-
}
|
|
907
|
-
// (3) Legacy installed[] entries.
|
|
908
|
-
for (const installed of config.installed ?? []) {
|
|
909
|
-
entries.push({
|
|
910
|
-
name: installed.id,
|
|
911
|
-
type: "filesystem",
|
|
912
|
-
source: { type: "filesystem", path: installed.stashRoot },
|
|
913
|
-
enabled: true,
|
|
914
|
-
writable: installed.writable,
|
|
915
|
-
...(installed.wikiName ? { wikiName: installed.wikiName } : {}),
|
|
916
|
-
});
|
|
917
|
-
}
|
|
918
|
-
return entries;
|
|
919
|
-
}
|
|
920
|
-
function toConfiguredSource(persisted, isPrimary) {
|
|
921
|
-
const source = parseSourceSpec(persisted);
|
|
922
|
-
if (!source)
|
|
356
|
+
/** Reserved well-known keys on IndexConfig that are NOT per-pass entries. */
|
|
357
|
+
const INDEX_RESERVED_KEYS = new Set(["metadataEnhance", "stalenessDetection"]);
|
|
358
|
+
export function getIndexPassConfig(config, passName) {
|
|
359
|
+
if (!config)
|
|
923
360
|
return undefined;
|
|
924
|
-
|
|
925
|
-
name: deriveStashEntryName(persisted),
|
|
926
|
-
type: persisted.type,
|
|
927
|
-
source,
|
|
928
|
-
...(persisted.enabled !== undefined ? { enabled: persisted.enabled } : {}),
|
|
929
|
-
...(persisted.writable !== undefined ? { writable: persisted.writable } : {}),
|
|
930
|
-
...(isPrimary || persisted.primary ? { primary: true } : {}),
|
|
931
|
-
...(persisted.options ? { options: persisted.options } : {}),
|
|
932
|
-
...(persisted.wikiName ? { wikiName: persisted.wikiName } : {}),
|
|
933
|
-
};
|
|
934
|
-
}
|
|
935
|
-
function parseRegistryConfigEntry(value) {
|
|
936
|
-
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
361
|
+
if (INDEX_RESERVED_KEYS.has(passName))
|
|
937
362
|
return undefined;
|
|
938
|
-
const
|
|
939
|
-
|
|
940
|
-
if (!url?.startsWith("http"))
|
|
363
|
+
const entry = config[passName];
|
|
364
|
+
if (!entry || typeof entry !== "object")
|
|
941
365
|
return undefined;
|
|
942
|
-
const entry = { url };
|
|
943
|
-
const name = asNonEmptyString(obj.name);
|
|
944
|
-
if (name)
|
|
945
|
-
entry.name = name;
|
|
946
|
-
if (typeof obj.enabled === "boolean")
|
|
947
|
-
entry.enabled = obj.enabled;
|
|
948
|
-
const provider = asNonEmptyString(obj.provider);
|
|
949
|
-
if (provider)
|
|
950
|
-
entry.provider = provider;
|
|
951
|
-
if (typeof obj.options === "object" && obj.options !== null && !Array.isArray(obj.options)) {
|
|
952
|
-
entry.options = obj.options;
|
|
953
|
-
}
|
|
954
366
|
return entry;
|
|
955
367
|
}
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
const baseProfiles = base.profiles;
|
|
959
|
-
const overrideProfiles = override.profiles;
|
|
960
|
-
if (baseProfiles && overrideProfiles) {
|
|
961
|
-
const profiles = { ...baseProfiles };
|
|
962
|
-
for (const [name, entry] of Object.entries(overrideProfiles)) {
|
|
963
|
-
const existing = baseProfiles[name];
|
|
964
|
-
profiles[name] = existing ? { ...existing, ...entry } : entry;
|
|
965
|
-
}
|
|
966
|
-
merged.profiles = profiles;
|
|
967
|
-
}
|
|
968
|
-
return merged;
|
|
969
|
-
}
|
|
970
|
-
function mergeSecurityConfig(base, override) {
|
|
971
|
-
if (!base && !override)
|
|
972
|
-
return undefined;
|
|
973
|
-
const installAudit = mergeInstallAuditConfig(base?.installAudit, override?.installAudit);
|
|
974
|
-
return installAudit ? { installAudit } : undefined;
|
|
975
|
-
}
|
|
976
|
-
function mergeInstallAuditConfig(base, override) {
|
|
977
|
-
if (!base && !override)
|
|
978
|
-
return undefined;
|
|
979
|
-
const merged = {
|
|
980
|
-
...(base ?? {}),
|
|
981
|
-
...(override ?? {}),
|
|
982
|
-
};
|
|
983
|
-
return Object.values(merged).some((value) => value !== undefined) ? merged : undefined;
|
|
984
|
-
}
|
|
368
|
+
// Re-export source runtime helpers — implementation lives in config-sources.ts.
|
|
369
|
+
export { parseSourceSpec, resolveConfiguredSources } from "./config-sources";
|
|
985
370
|
/**
|
|
986
|
-
* Merge a
|
|
987
|
-
*
|
|
988
|
-
*
|
|
989
|
-
*
|
|
990
|
-
* clobbering sibling settings. `sources` are additive by default, but a later
|
|
991
|
-
* layer can set `stashInheritance: "replace"` to drop inherited sources first.
|
|
371
|
+
* Merge a partial user-config override onto a base config. Used by
|
|
372
|
+
* {@link loadUserConfig} (DEFAULT_CONFIG + on-disk) and {@link updateConfig}
|
|
373
|
+
* (current config + partial patch). Sub-objects with named records (profiles,
|
|
374
|
+
* defaults, etc.) shallow-merge; arrays override wholesale.
|
|
992
375
|
*/
|
|
993
376
|
function mergeLoadedConfig(base, override) {
|
|
994
377
|
if (!override)
|
|
995
378
|
return { ...base };
|
|
996
|
-
const merged = {
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
if (base.embedding && override.embedding) {
|
|
1004
|
-
merged.embedding = { ...base.embedding, ...override.embedding };
|
|
1005
|
-
}
|
|
1006
|
-
if (base.llm && override.llm) {
|
|
1007
|
-
merged.llm = { ...base.llm, ...override.llm };
|
|
1008
|
-
}
|
|
1009
|
-
if (base.index || override.index) {
|
|
1010
|
-
// Deep-merge per-pass entries so a project layer can opt one pass out
|
|
1011
|
-
// without dropping siblings configured in user config.
|
|
1012
|
-
const mergedIndex = { ...(base.index ?? {}) };
|
|
1013
|
-
for (const [passName, passOverride] of Object.entries(override.index ?? {})) {
|
|
1014
|
-
mergedIndex[passName] = { ...(mergedIndex[passName] ?? {}), ...passOverride };
|
|
379
|
+
const merged = { ...base, ...override };
|
|
380
|
+
// Shallow-merge sub-objects so a partial update to e.g. `output.format`
|
|
381
|
+
// doesn't drop the existing `output.detail`.
|
|
382
|
+
for (const key of ["output", "embedding", "index", "defaults"]) {
|
|
383
|
+
if (base[key] && override[key]) {
|
|
384
|
+
// biome-ignore lint/suspicious/noExplicitAny: heterogeneous structural merge
|
|
385
|
+
merged[key] = { ...base[key], ...override[key] };
|
|
1015
386
|
}
|
|
1016
|
-
if (Object.keys(mergedIndex).length > 0)
|
|
1017
|
-
merged.index = mergedIndex;
|
|
1018
|
-
}
|
|
1019
|
-
if (base.security && override.security) {
|
|
1020
|
-
merged.security = mergeSecurityConfig(base.security, override.security);
|
|
1021
|
-
}
|
|
1022
|
-
if (base.agent && override.agent) {
|
|
1023
|
-
merged.agent = mergeAgentConfig(base.agent, override.agent);
|
|
1024
387
|
}
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
merged.
|
|
1033
|
-
}
|
|
1034
|
-
else if (baseSources.length > 0) {
|
|
1035
|
-
merged.sources = [...baseSources];
|
|
388
|
+
if (base.profiles && override.profiles) {
|
|
389
|
+
const next = { ...base.profiles };
|
|
390
|
+
for (const k of ["llm", "agent", "improve"]) {
|
|
391
|
+
const ovr = override.profiles[k];
|
|
392
|
+
if (ovr)
|
|
393
|
+
next[k] = { ...(next[k] ?? {}), ...ovr };
|
|
394
|
+
}
|
|
395
|
+
merged.profiles = next;
|
|
1036
396
|
}
|
|
1037
397
|
return merged;
|
|
1038
398
|
}
|
|
@@ -1043,51 +403,51 @@ function applyRuntimeEnvApiKeys(config) {
|
|
|
1043
403
|
if (envKey)
|
|
1044
404
|
next.embedding = { ...next.embedding, apiKey: envKey };
|
|
1045
405
|
}
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
406
|
+
// LLM profile keys: AKM_LLM_API_KEY for the default profile, then
|
|
407
|
+
// AKM_PROFILE_<UPPER>_API_KEY for any profile (per-profile wins).
|
|
408
|
+
const defaultProfile = next.defaults?.llm;
|
|
409
|
+
if (next.profiles?.llm) {
|
|
410
|
+
const updated = { ...next.profiles.llm };
|
|
411
|
+
let changed = false;
|
|
412
|
+
for (const [name, profile] of Object.entries(updated)) {
|
|
413
|
+
if (profile.apiKey)
|
|
414
|
+
continue;
|
|
415
|
+
const perProfile = process.env[`AKM_PROFILE_${name.toUpperCase().replace(/-/g, "_")}_API_KEY`]?.trim();
|
|
416
|
+
const fallback = name === defaultProfile ? process.env.AKM_LLM_API_KEY?.trim() : undefined;
|
|
417
|
+
const envKey = perProfile || fallback;
|
|
418
|
+
if (envKey) {
|
|
419
|
+
updated[name] = { ...profile, apiKey: envKey };
|
|
420
|
+
changed = true;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
if (changed)
|
|
424
|
+
next.profiles = { ...next.profiles, llm: updated };
|
|
1050
425
|
}
|
|
1051
426
|
return next;
|
|
1052
427
|
}
|
|
1053
428
|
/**
|
|
1054
|
-
*
|
|
1055
|
-
*
|
|
1056
|
-
*
|
|
429
|
+
* Walk cwd-ancestors looking for `.akm/config.json`. If one is found, emit a
|
|
430
|
+
* one-time deprecation warning per path. The file's contents are NOT read —
|
|
431
|
+
* multi-layer project config was removed in this release; the warning stays
|
|
432
|
+
* for one cycle so users notice they have a now-dead file on disk and can
|
|
433
|
+
* migrate its settings to the user-level config.
|
|
1057
434
|
*/
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
const paths = [];
|
|
1061
|
-
if (isFile(configPath)) {
|
|
1062
|
-
paths.push(configPath);
|
|
1063
|
-
}
|
|
1064
|
-
return [...paths, ...discoverProjectConfigPaths(process.cwd())];
|
|
1065
|
-
}
|
|
1066
|
-
/**
|
|
1067
|
-
* Walk from `startDir` up to the filesystem root and collect `.akm/config.json`
|
|
1068
|
-
* files. Paths are returned from outermost parent to innermost directory so
|
|
1069
|
-
* nearer project directories override broader project settings.
|
|
1070
|
-
*/
|
|
1071
|
-
function discoverProjectConfigPaths(startDir) {
|
|
1072
|
-
const paths = [];
|
|
435
|
+
const PROJECT_CONFIG_DEPRECATION_WARNED = new Set();
|
|
436
|
+
function warnIfProjectConfigPresent(startDir) {
|
|
1073
437
|
let currentDir = path.resolve(startDir);
|
|
1074
438
|
while (true) {
|
|
1075
439
|
const configPath = path.join(currentDir, PROJECT_CONFIG_RELATIVE_PATH);
|
|
1076
|
-
if (isFile(configPath)) {
|
|
1077
|
-
|
|
440
|
+
if (isFile(configPath) && !PROJECT_CONFIG_DEPRECATION_WARNED.has(configPath)) {
|
|
441
|
+
PROJECT_CONFIG_DEPRECATION_WARNED.add(configPath);
|
|
442
|
+
warn(`[akm] DEPRECATED: project-level config file found at ${configPath}. ` +
|
|
443
|
+
"Project-level config files are no longer merged (removed after 0.8.x deprecation). " +
|
|
444
|
+
"Move any needed settings to ~/.config/akm/config.json; this file is ignored.");
|
|
1078
445
|
}
|
|
1079
446
|
const parentDir = path.dirname(currentDir);
|
|
1080
|
-
if (parentDir === currentDir)
|
|
447
|
+
if (parentDir === currentDir)
|
|
1081
448
|
break;
|
|
1082
|
-
}
|
|
1083
449
|
currentDir = parentDir;
|
|
1084
450
|
}
|
|
1085
|
-
return paths;
|
|
1086
|
-
}
|
|
1087
|
-
function getConfigSignature(configPaths) {
|
|
1088
|
-
if (configPaths.length === 0)
|
|
1089
|
-
return "defaults";
|
|
1090
|
-
return configPaths.map((configPath) => `${configPath}:${getFileSignatureToken(configPath)}`).join("|");
|
|
1091
451
|
}
|
|
1092
452
|
function isFile(filePath) {
|
|
1093
453
|
try {
|
|
@@ -1097,23 +457,3 @@ function isFile(filePath) {
|
|
|
1097
457
|
return false;
|
|
1098
458
|
}
|
|
1099
459
|
}
|
|
1100
|
-
function getFileSignatureToken(filePath) {
|
|
1101
|
-
try {
|
|
1102
|
-
const stat = fs.statSync(filePath);
|
|
1103
|
-
// mtimeMs alone is unreliable on filesystems with low-resolution mtime
|
|
1104
|
-
// (HFS+, some network FS, or very fast back-to-back writes in tests).
|
|
1105
|
-
// Combine mtime + size + content hash so the signature actually changes
|
|
1106
|
-
// when content does.
|
|
1107
|
-
let contentHash = "";
|
|
1108
|
-
try {
|
|
1109
|
-
contentHash = hashString(fs.readFileSync(filePath, "utf8"));
|
|
1110
|
-
}
|
|
1111
|
-
catch {
|
|
1112
|
-
// ignore — fall back to stat-only signature
|
|
1113
|
-
}
|
|
1114
|
-
return `${stat.mtimeMs}:${stat.size}:${contentHash}`;
|
|
1115
|
-
}
|
|
1116
|
-
catch {
|
|
1117
|
-
return "missing";
|
|
1118
|
-
}
|
|
1119
|
-
}
|