akm-cli 0.8.0-rc1 → 0.8.0
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} +191 -3
- package/README.md +22 -6
- 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 +93 -3
- package/dist/cli/shared.js +129 -0
- package/dist/cli.js +2162 -1258
- package/dist/commands/add-cli.js +279 -0
- package/dist/commands/agent-dispatch.js +20 -12
- package/dist/commands/agent-support.js +11 -5
- package/dist/commands/completions.js +3 -0
- package/dist/commands/config-cli.js +129 -517
- package/dist/commands/consolidate.js +1533 -144
- package/dist/commands/curate.js +44 -3
- package/dist/commands/db-cli.js +23 -0
- package/dist/commands/distill-promotion-policy.js +5 -3
- package/dist/commands/distill.js +906 -100
- package/dist/commands/env.js +213 -0
- package/dist/commands/eval-cases.js +3 -0
- package/dist/commands/events.js +3 -0
- package/dist/commands/extract-cli.js +127 -0
- package/dist/commands/extract-prompt.js +204 -0
- package/dist/commands/extract.js +477 -0
- package/dist/commands/feedback-cli.js +331 -0
- package/dist/commands/graph.js +260 -5
- package/dist/commands/health.js +977 -51
- package/dist/commands/help/help-accept.md +6 -3
- package/dist/commands/help/help-improve.md +36 -8
- package/dist/commands/help/help-proposals.md +7 -4
- package/dist/commands/help/help-reject.md +5 -2
- package/dist/commands/history.js +51 -16
- package/dist/commands/improve-auto-accept.js +97 -0
- package/dist/commands/improve-cli.js +236 -0
- package/dist/commands/improve-profiles.js +184 -0
- package/dist/commands/improve-result-file.js +167 -0
- package/dist/commands/improve.js +1725 -332
- package/dist/commands/info.js +3 -0
- package/dist/commands/init.js +49 -1
- package/dist/commands/installed-stashes.js +6 -23
- package/dist/commands/knowledge.js +3 -0
- package/dist/commands/lint/agent-linter.js +3 -0
- package/dist/commands/lint/base-linter.js +233 -5
- package/dist/commands/lint/command-linter.js +3 -0
- package/dist/commands/lint/default-linter.js +3 -0
- package/dist/commands/lint/env-key-rules.js +154 -0
- package/dist/commands/lint/index.js +92 -3
- package/dist/commands/lint/knowledge-linter.js +3 -0
- package/dist/commands/lint/markdown-insertion.js +343 -0
- package/dist/commands/lint/memory-linter.js +3 -0
- package/dist/commands/lint/registry.js +3 -0
- package/dist/commands/lint/skill-linter.js +3 -0
- package/dist/commands/lint/task-linter.js +15 -12
- package/dist/commands/lint/types.js +3 -0
- package/dist/commands/lint/workflow-linter.js +3 -0
- package/dist/commands/lint.js +3 -0
- package/dist/commands/migration-help.js +5 -2
- package/dist/commands/proposal-drain-policies.js +128 -0
- package/dist/commands/proposal-drain.js +477 -0
- package/dist/commands/proposal.js +60 -6
- package/dist/commands/propose.js +24 -19
- package/dist/commands/reflect.js +1004 -94
- package/dist/commands/registry-cli.js +150 -0
- package/dist/commands/registry-search.js +3 -0
- package/dist/commands/remember-cli.js +257 -0
- package/dist/commands/remember.js +15 -6
- package/dist/commands/schema-repair.js +88 -15
- package/dist/commands/search.js +99 -14
- package/dist/commands/secret.js +173 -0
- package/dist/commands/self-update.js +3 -0
- package/dist/commands/show.js +32 -13
- package/dist/commands/source-add.js +7 -35
- package/dist/commands/source-clone.js +3 -0
- package/dist/commands/source-manage.js +3 -0
- package/dist/commands/tasks.js +161 -95
- package/dist/commands/url-checker.js +3 -0
- package/dist/core/action-contributors.js +3 -0
- package/dist/core/asset-ref.js +17 -2
- package/dist/core/asset-registry.js +9 -2
- package/dist/core/asset-serialize.js +88 -0
- package/dist/core/asset-spec.js +61 -5
- package/dist/core/common.js +93 -5
- package/dist/core/concurrent.js +3 -0
- package/dist/core/config-io.js +347 -0
- package/dist/core/config-migration.js +622 -0
- package/dist/core/config-schema.js +558 -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 +366 -1077
- package/dist/core/errors.js +42 -20
- package/dist/core/events.js +31 -25
- package/dist/core/file-lock.js +104 -0
- package/dist/core/frontmatter.js +75 -10
- package/dist/core/lesson-lint.js +3 -0
- package/dist/core/markdown.js +3 -0
- package/dist/core/memory-belief.js +62 -0
- package/dist/core/memory-contradiction-detect.js +274 -0
- package/dist/core/memory-improve.js +142 -14
- package/dist/core/parse.js +3 -0
- package/dist/core/paths.js +218 -50
- package/dist/core/proposal-quality-validators.js +380 -0
- package/dist/core/proposal-validators.js +11 -3
- package/dist/core/proposals.js +464 -5
- package/dist/core/state-db.js +349 -56
- package/dist/core/text-truncation.js +107 -0
- package/dist/core/time.js +3 -0
- package/dist/core/tty.js +59 -0
- package/dist/core/warn.js +7 -2
- package/dist/core/write-source.js +12 -0
- package/dist/indexer/db-backup.js +391 -0
- package/dist/indexer/db-search.js +136 -28
- package/dist/indexer/db.js +662 -166
- package/dist/indexer/ensure-index.js +3 -0
- package/dist/indexer/file-context.js +3 -0
- package/dist/indexer/graph-boost.js +162 -40
- package/dist/indexer/graph-db.js +241 -51
- package/dist/indexer/graph-dedup.js +3 -7
- package/dist/indexer/graph-extraction.js +242 -149
- package/dist/indexer/index-context.js +3 -9
- package/dist/indexer/indexer.js +84 -14
- package/dist/indexer/llm-cache.js +24 -19
- package/dist/indexer/manifest.js +3 -0
- package/dist/indexer/matchers.js +184 -11
- package/dist/indexer/memory-inference.js +94 -50
- package/dist/indexer/metadata-contributors.js +3 -0
- package/dist/indexer/metadata.js +114 -48
- package/dist/indexer/path-resolver.js +3 -0
- package/dist/indexer/project-context.js +192 -0
- package/dist/indexer/ranking-contributors.js +134 -7
- package/dist/indexer/ranking.js +8 -1
- package/dist/indexer/search-fields.js +5 -9
- package/dist/indexer/search-hit-enrichers.js +91 -2
- package/dist/indexer/search-source.js +20 -1
- package/dist/indexer/semantic-status.js +4 -1
- package/dist/indexer/staleness-detect.js +447 -0
- package/dist/indexer/usage-events.js +12 -9
- package/dist/indexer/walker.js +3 -0
- package/dist/integrations/agent/builders.js +135 -0
- package/dist/integrations/agent/config.js +121 -401
- package/dist/integrations/agent/detect.js +3 -0
- package/dist/integrations/agent/index.js +6 -14
- package/dist/integrations/agent/model-aliases.js +55 -0
- package/dist/integrations/agent/profiles.js +3 -0
- package/dist/integrations/agent/prompts.js +137 -8
- package/dist/integrations/agent/runner.js +208 -0
- package/dist/integrations/agent/sdk-runner.js +8 -2
- package/dist/integrations/agent/spawn.js +54 -14
- package/dist/integrations/github.js +3 -0
- package/dist/integrations/lockfile.js +22 -51
- package/dist/integrations/session-logs/index.js +4 -0
- package/dist/integrations/session-logs/inline-refs.js +35 -0
- package/dist/integrations/session-logs/pre-filter.js +152 -0
- package/dist/integrations/session-logs/providers/claude-code.js +226 -0
- package/dist/integrations/session-logs/providers/opencode.js +231 -25
- package/dist/integrations/session-logs/types.js +3 -0
- package/dist/llm/call-ai.js +14 -26
- package/dist/llm/client.js +16 -2
- package/dist/llm/embedder.js +20 -29
- package/dist/llm/embedders/cache.js +3 -7
- package/dist/llm/embedders/local.js +42 -1
- package/dist/llm/embedders/remote.js +20 -8
- package/dist/llm/embedders/types.js +3 -7
- package/dist/llm/feature-gate.js +92 -56
- package/dist/llm/graph-extract.js +401 -30
- package/dist/llm/index-passes.js +44 -29
- package/dist/llm/memory-infer.js +30 -2
- package/dist/llm/metadata-enhance.js +3 -7
- package/dist/llm/prompts/extract-session.md +80 -0
- package/dist/llm/prompts/graph-extract-user-prompt.md +24 -1
- package/dist/output/cli-hints-full.md +60 -32
- package/dist/output/cli-hints-short.md +10 -7
- package/dist/output/cli-hints.js +5 -2
- package/dist/output/context.js +60 -8
- package/dist/output/renderers.js +170 -194
- package/dist/output/shapes/curate.js +56 -0
- package/dist/output/shapes/distill.js +10 -0
- package/dist/output/shapes/env-list.js +19 -0
- package/dist/output/shapes/events.js +11 -0
- package/dist/output/shapes/helpers.js +424 -0
- package/dist/output/shapes/history.js +7 -0
- package/dist/output/shapes/passthrough.js +105 -0
- package/dist/output/shapes/proposal-accept.js +7 -0
- package/dist/output/shapes/proposal-diff.js +7 -0
- package/dist/output/shapes/proposal-list.js +7 -0
- package/dist/output/shapes/proposal-producer.js +11 -0
- package/dist/output/shapes/proposal-reject.js +7 -0
- package/dist/output/shapes/proposal-show.js +7 -0
- package/dist/output/shapes/registry-search.js +6 -0
- package/dist/output/shapes/registry.js +30 -0
- package/dist/output/shapes/search.js +6 -0
- package/dist/output/shapes/secret-list.js +19 -0
- package/dist/output/shapes/show.js +6 -0
- package/dist/output/shapes/vault-list.js +19 -0
- package/dist/output/shapes.js +51 -549
- package/dist/output/text/add.js +6 -0
- package/dist/output/text/clone.js +6 -0
- package/dist/output/text/config.js +6 -0
- package/dist/output/text/curate.js +6 -0
- package/dist/output/text/distill.js +7 -0
- package/dist/output/text/enable-disable.js +7 -0
- package/dist/output/text/events.js +10 -0
- package/dist/output/text/feedback.js +6 -0
- package/dist/output/text/helpers.js +1059 -0
- package/dist/output/text/history.js +7 -0
- package/dist/output/text/import.js +6 -0
- package/dist/output/text/index.js +6 -0
- package/dist/output/text/info.js +6 -0
- package/dist/output/text/init.js +6 -0
- package/dist/output/text/list.js +6 -0
- package/dist/output/text/proposal-producer.js +8 -0
- package/dist/output/text/proposal.js +12 -0
- package/dist/output/text/registry-commands.js +11 -0
- package/dist/output/text/registry.js +30 -0
- package/dist/output/text/remember.js +6 -0
- package/dist/output/text/remove.js +6 -0
- package/dist/output/text/save.js +6 -0
- package/dist/output/text/search.js +6 -0
- package/dist/output/text/show.js +6 -0
- package/dist/output/text/update.js +6 -0
- package/dist/output/text/upgrade.js +6 -0
- package/dist/output/text/vault.js +16 -0
- package/dist/output/text/wiki.js +15 -0
- package/dist/output/text/workflow.js +14 -0
- package/dist/output/text.js +44 -1329
- package/dist/registry/build-index.js +3 -0
- package/dist/registry/create-provider-registry.js +3 -0
- package/dist/registry/factory.js +4 -1
- package/dist/registry/origin-resolve.js +3 -0
- package/dist/registry/providers/index.js +3 -0
- package/dist/registry/providers/skills-sh.js +11 -2
- package/dist/registry/providers/static-index.js +10 -1
- 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 +17767 -0
- package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +9031 -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 +306 -67
- package/dist/setup/steps.js +3 -15
- package/dist/sources/include.js +3 -0
- package/dist/sources/provider-factory.js +3 -11
- package/dist/sources/provider.js +3 -20
- package/dist/sources/providers/filesystem.js +19 -23
- package/dist/sources/providers/git.js +171 -21
- 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 +3 -0
- package/dist/tasks/backends/cron.js +3 -0
- package/dist/tasks/backends/exec-utils.js +3 -0
- package/dist/tasks/backends/index.js +3 -11
- package/dist/tasks/backends/launchd.js +3 -0
- package/dist/tasks/backends/schtasks.js +3 -0
- package/dist/tasks/parser.js +51 -38
- package/dist/tasks/resolveAkmBin.js +3 -0
- package/dist/tasks/runner.js +35 -9
- package/dist/tasks/schedule.js +20 -1
- package/dist/tasks/schema.js +5 -3
- package/dist/tasks/validator.js +6 -3
- package/dist/version.js +3 -0
- package/dist/wiki/wiki-templates.js +3 -0
- package/dist/wiki/wiki.js +3 -0
- package/dist/workflows/authoring.js +3 -0
- 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 +3 -0
- package/dist/workflows/runs.js +18 -1
- package/dist/workflows/schema.js +3 -0
- package/dist/workflows/scope-key.js +3 -0
- package/dist/workflows/validator.js +5 -9
- package/docs/README.md +7 -2
- package/docs/data-and-telemetry.md +225 -0
- package/docs/migration/release-notes/0.7.5.md +2 -2
- package/docs/migration/release-notes/0.8.0.md +57 -5
- package/docs/migration/v0.7-to-v0.8.md +1378 -0
- package/package.json +28 -11
- package/.github/LICENSE +0 -374
- package/dist/commands/install-audit.js +0 -385
- package/dist/commands/vault.js +0 -307
- package/dist/indexer/match-contributors.js +0 -141
- package/dist/integrations/agent/pipeline.js +0 -39
- package/dist/integrations/agent/runners.js +0 -31
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";
|
|
10
|
+
export { stripJsonComments } from "./config-io";
|
|
7
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,65 +67,11 @@ export const DEFAULT_CONFIG = {
|
|
|
18
67
|
detail: "brief",
|
|
19
68
|
},
|
|
20
69
|
};
|
|
21
|
-
// ── Paths ───────────────────────────────────────────────────────────────────
|
|
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;
|
|
33
|
-
}
|
|
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;
|
|
62
|
-
}
|
|
63
70
|
// ── Load / Save / Update ────────────────────────────────────────────────────
|
|
64
71
|
const PROJECT_CONFIG_RELATIVE_PATH = path.join(".akm", "config.json");
|
|
65
72
|
let cachedConfig;
|
|
66
|
-
let cachedUserConfig;
|
|
67
73
|
export function resetConfigCache() {
|
|
68
|
-
|
|
69
|
-
}
|
|
70
|
-
function hashString(text) {
|
|
71
|
-
// Simple, fast non-cryptographic hash (FNV-1a 32-bit) — sufficient to detect
|
|
72
|
-
// content changes between config writes when filesystem mtime resolution is
|
|
73
|
-
// too coarse to reflect rapid back-to-back writes (common in tests).
|
|
74
|
-
let hash = 0x811c9dc5;
|
|
75
|
-
for (let i = 0; i < text.length; i++) {
|
|
76
|
-
hash ^= text.charCodeAt(i);
|
|
77
|
-
hash = Math.imul(hash, 0x01000193);
|
|
78
|
-
}
|
|
79
|
-
return (hash >>> 0).toString(16);
|
|
74
|
+
cachedConfig = undefined;
|
|
80
75
|
}
|
|
81
76
|
export function loadUserConfig() {
|
|
82
77
|
const configPath = getConfigPath();
|
|
@@ -85,1048 +80,362 @@ export function loadUserConfig() {
|
|
|
85
80
|
stat = fs.statSync(configPath);
|
|
86
81
|
}
|
|
87
82
|
catch {
|
|
88
|
-
|
|
83
|
+
cachedConfig = undefined;
|
|
89
84
|
return applyRuntimeEnvApiKeys({ ...DEFAULT_CONFIG });
|
|
90
85
|
}
|
|
91
|
-
// Cache key
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
|
|
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
|
+
}
|
|
95
95
|
let text;
|
|
96
96
|
try {
|
|
97
97
|
text = fs.readFileSync(configPath, "utf8");
|
|
98
98
|
}
|
|
99
99
|
catch {
|
|
100
|
-
|
|
100
|
+
cachedConfig = undefined;
|
|
101
101
|
return applyRuntimeEnvApiKeys({ ...DEFAULT_CONFIG });
|
|
102
102
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// Stat failed — use original stat for cache; no harm done.
|
|
110
115
|
}
|
|
111
|
-
|
|
112
|
-
const finalConfig = applyRuntimeEnvApiKeys(config);
|
|
113
|
-
cachedUserConfig = {
|
|
116
|
+
cachedConfig = {
|
|
114
117
|
config: finalConfig,
|
|
115
118
|
path: configPath,
|
|
116
|
-
mtime:
|
|
117
|
-
size:
|
|
118
|
-
contentHash,
|
|
119
|
+
mtime: finalStat.mtimeMs,
|
|
120
|
+
size: finalStat.size,
|
|
119
121
|
};
|
|
120
122
|
return finalConfig;
|
|
121
123
|
}
|
|
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");
|
|
143
|
+
}
|
|
144
|
+
return mergeLoadedConfig(DEFAULT_CONFIG, parsed.data);
|
|
145
|
+
}
|
|
122
146
|
export function getSources(config) {
|
|
123
147
|
return config.sources ?? [];
|
|
124
148
|
}
|
|
125
149
|
export function getEffectiveRegistries(config) {
|
|
126
150
|
return config.registries ?? DEFAULT_CONFIG.registries ?? [];
|
|
127
151
|
}
|
|
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
|
-
}
|
|
133
|
-
export function loadConfig() {
|
|
134
|
-
const configPaths = getEffectiveConfigPaths();
|
|
135
|
-
const signature = getConfigSignature(configPaths);
|
|
136
|
-
if (cachedConfig && cachedConfig.signature === signature) {
|
|
137
|
-
return cachedConfig.config;
|
|
138
|
-
}
|
|
139
|
-
let config = loadUserConfig();
|
|
140
|
-
const userConfigPath = getConfigPath();
|
|
141
|
-
for (const configPath of configPaths) {
|
|
142
|
-
if (configPath === userConfigPath)
|
|
143
|
-
continue;
|
|
144
|
-
config = mergeLoadedConfig(config, readNormalizedConfig(configPath));
|
|
145
|
-
}
|
|
146
|
-
const finalConfig = applyRuntimeEnvApiKeys(config);
|
|
147
|
-
cachedConfig = { config: finalConfig, signature };
|
|
148
|
-
return finalConfig;
|
|
149
|
-
}
|
|
150
|
-
export function saveConfig(config) {
|
|
151
|
-
clearAllCaches();
|
|
152
|
-
const configPath = getConfigPath();
|
|
153
|
-
const dir = path.dirname(configPath);
|
|
154
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
155
|
-
backupExistingConfig(configPath);
|
|
156
|
-
const sanitized = sanitizeConfigForWrite(config);
|
|
157
|
-
writeConfigObject(configPath, sanitized);
|
|
158
|
-
}
|
|
159
|
-
function backupExistingConfig(configPath) {
|
|
160
|
-
if (!fs.existsSync(configPath))
|
|
161
|
-
return;
|
|
162
|
-
const backupDir = path.join(getCacheDir(), "config-backups");
|
|
163
|
-
fs.mkdirSync(backupDir, { recursive: true });
|
|
164
|
-
const timestamp = new Date().toISOString().replace(/[.:]/g, "-");
|
|
165
|
-
const backupPath = path.join(backupDir, `config-${timestamp}.json`);
|
|
166
|
-
fs.copyFileSync(configPath, backupPath);
|
|
167
|
-
const latestPath = path.join(backupDir, "config.latest.json");
|
|
168
|
-
fs.copyFileSync(configPath, latestPath);
|
|
169
|
-
}
|
|
170
152
|
/**
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
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.
|
|
174
167
|
*/
|
|
175
|
-
function
|
|
176
|
-
const
|
|
177
|
-
if (
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
const { apiKey, ...rest } = config.llm;
|
|
183
|
-
sanitized.llm = rest;
|
|
184
|
-
}
|
|
185
|
-
// Drop empty keys to keep config clean
|
|
186
|
-
return sanitized;
|
|
187
|
-
}
|
|
188
|
-
export function updateConfig(partial) {
|
|
189
|
-
const current = loadUserConfig();
|
|
190
|
-
const merged = mergeLoadedConfig(current, partial);
|
|
191
|
-
saveConfig(merged);
|
|
192
|
-
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;
|
|
193
175
|
}
|
|
194
|
-
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
195
176
|
/**
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
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).
|
|
201
184
|
*/
|
|
202
|
-
function
|
|
203
|
-
const
|
|
204
|
-
if (
|
|
205
|
-
|
|
206
|
-
}
|
|
207
|
-
// Backward compatibility: coerce legacy boolean values to string
|
|
208
|
-
if (typeof raw.semanticSearchMode === "boolean") {
|
|
209
|
-
config.semanticSearchMode = raw.semanticSearchMode ? "auto" : "off";
|
|
210
|
-
}
|
|
211
|
-
else if (isOneOf(raw.semanticSearchMode, ["off", "auto"])) {
|
|
212
|
-
config.semanticSearchMode = raw.semanticSearchMode;
|
|
213
|
-
}
|
|
214
|
-
const embedding = parseEmbeddingConfig(raw.embedding);
|
|
215
|
-
if (embedding)
|
|
216
|
-
config.embedding = embedding;
|
|
217
|
-
const llm = parseLlmConfig(raw.llm);
|
|
218
|
-
if (llm)
|
|
219
|
-
config.llm = llm;
|
|
220
|
-
const index = parseIndexConfig(raw.index);
|
|
221
|
-
if (index)
|
|
222
|
-
config.index = index;
|
|
223
|
-
const installed = parseInstalledEntries(raw.installed);
|
|
224
|
-
if (installed)
|
|
225
|
-
config.installed = installed;
|
|
226
|
-
const registries = parseRegistriesConfig(raw.registries);
|
|
227
|
-
if (registries)
|
|
228
|
-
config.registries = registries;
|
|
229
|
-
if (isOneOf(raw.stashInheritance, ["replace", "merge"])) {
|
|
230
|
-
config.stashInheritance = raw.stashInheritance;
|
|
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
|
-
}
|
|
235
|
-
const sources = parseSourcesConfig(raw.sources);
|
|
236
|
-
if (sources) {
|
|
237
|
-
config.sources = sources;
|
|
238
|
-
}
|
|
239
|
-
const security = parseSecurityConfig(raw.security);
|
|
240
|
-
if (security)
|
|
241
|
-
config.security = security;
|
|
242
|
-
const output = parseOutputConfig(raw.output);
|
|
243
|
-
if (output)
|
|
244
|
-
config.output = output;
|
|
245
|
-
if (typeof raw.writable === "boolean") {
|
|
246
|
-
config.writable = raw.writable;
|
|
247
|
-
}
|
|
248
|
-
if (typeof raw.defaultWriteTarget === "string" && raw.defaultWriteTarget.trim()) {
|
|
249
|
-
config.defaultWriteTarget = raw.defaultWriteTarget.trim();
|
|
250
|
-
}
|
|
251
|
-
if ("agent" in raw) {
|
|
252
|
-
const agent = parseAgentConfig(raw.agent);
|
|
253
|
-
if (agent)
|
|
254
|
-
config.agent = agent;
|
|
255
|
-
}
|
|
256
|
-
if (typeof raw.search === "object" && raw.search !== null && !Array.isArray(raw.search)) {
|
|
257
|
-
const searchRaw = raw.search;
|
|
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
|
-
}
|
|
264
|
-
if (typeof searchRaw.minScore === "number" && Number.isFinite(searchRaw.minScore) && searchRaw.minScore >= 0) {
|
|
265
|
-
searchConfig.minScore = searchRaw.minScore;
|
|
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
|
-
}
|
|
307
|
-
if (Object.keys(searchConfig).length > 0)
|
|
308
|
-
config.search = searchConfig;
|
|
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
|
-
}
|
|
324
|
-
return config;
|
|
325
|
-
}
|
|
326
|
-
function parseConfigText(text) {
|
|
327
|
-
const raw = parseConfigObjectFromText(text);
|
|
328
|
-
if (!raw)
|
|
329
|
-
return undefined;
|
|
330
|
-
const expanded = expandEnvVars(raw);
|
|
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);
|
|
345
|
-
}
|
|
346
|
-
function parseOutputConfig(value) {
|
|
347
|
-
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
348
|
-
return undefined;
|
|
349
|
-
const obj = value;
|
|
350
|
-
const output = {};
|
|
351
|
-
if (isOneOf(obj.format, ["json", "yaml", "text"])) {
|
|
352
|
-
output.format = obj.format;
|
|
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");
|
|
353
189
|
}
|
|
354
|
-
|
|
355
|
-
|
|
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.`);
|
|
356
193
|
}
|
|
357
|
-
return
|
|
194
|
+
return profile;
|
|
358
195
|
}
|
|
359
196
|
/**
|
|
360
|
-
*
|
|
361
|
-
*
|
|
362
|
-
* 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.
|
|
363
199
|
*/
|
|
364
|
-
|
|
200
|
+
export function getDefaultLlmConfig(config) {
|
|
201
|
+
const defaultName = resolveDefaultLlmProfileName(config);
|
|
202
|
+
if (!defaultName)
|
|
203
|
+
return undefined;
|
|
204
|
+
return config.profiles?.llm?.[defaultName];
|
|
205
|
+
}
|
|
365
206
|
/**
|
|
366
|
-
*
|
|
367
|
-
*
|
|
368
|
-
*
|
|
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`.
|
|
369
210
|
*
|
|
370
|
-
*
|
|
371
|
-
*
|
|
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).
|
|
372
213
|
*/
|
|
373
|
-
function
|
|
374
|
-
|
|
375
|
-
// Skip URL-type fields by name or by value prefix, unless they contain ${VAR} syntax
|
|
376
|
-
if (!value.includes("${") &&
|
|
377
|
-
((fieldName !== undefined && URL_FIELD_NAMES.has(fieldName)) ||
|
|
378
|
-
value.startsWith("http://") ||
|
|
379
|
-
value.startsWith("https://"))) {
|
|
380
|
-
return value;
|
|
381
|
-
}
|
|
382
|
-
return value.replace(/\$\{([^}]+)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (_match, braced, bare) => {
|
|
383
|
-
if (braced) {
|
|
384
|
-
const [name, ...rest] = braced.split(":-");
|
|
385
|
-
const fallback = rest.join(":-");
|
|
386
|
-
return process.env[name] ?? fallback ?? "";
|
|
387
|
-
}
|
|
388
|
-
return process.env[bare] ?? "";
|
|
389
|
-
});
|
|
390
|
-
}
|
|
391
|
-
if (Array.isArray(value)) {
|
|
392
|
-
return value.map((item) => expandEnvVars(item));
|
|
393
|
-
}
|
|
394
|
-
if (value !== null && typeof value === "object") {
|
|
395
|
-
const out = {};
|
|
396
|
-
for (const [k, v] of Object.entries(value)) {
|
|
397
|
-
out[k] = expandEnvVars(v, k);
|
|
398
|
-
}
|
|
399
|
-
return out;
|
|
400
|
-
}
|
|
401
|
-
return value;
|
|
402
|
-
}
|
|
403
|
-
function parseConfigObjectFromText(text) {
|
|
214
|
+
function maybeAutoMigrateConfigFile(configPath, text) {
|
|
215
|
+
let obj;
|
|
404
216
|
try {
|
|
405
|
-
|
|
406
|
-
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
|
407
|
-
return undefined;
|
|
408
|
-
return raw;
|
|
217
|
+
obj = parseConfigText(text);
|
|
409
218
|
}
|
|
410
219
|
catch {
|
|
411
|
-
return
|
|
220
|
+
return text; // Malformed JSON — let parseAndValidate surface the error.
|
|
412
221
|
}
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
writeFileAtomic(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
|
416
|
-
}
|
|
417
|
-
/**
|
|
418
|
-
* Strip JavaScript-style comments from a JSON string (JSONC support).
|
|
419
|
-
* Handles // line comments and /* block comments while preserving
|
|
420
|
-
* comment-like sequences inside quoted strings.
|
|
421
|
-
*/
|
|
422
|
-
export function stripJsonComments(text) {
|
|
423
|
-
let result = "";
|
|
424
|
-
let i = 0;
|
|
425
|
-
let inString = false;
|
|
426
|
-
while (i < text.length) {
|
|
427
|
-
if (inString) {
|
|
428
|
-
if (text[i] === "\\") {
|
|
429
|
-
result += text[i] + (text[i + 1] ?? "");
|
|
430
|
-
i += 2;
|
|
431
|
-
continue;
|
|
432
|
-
}
|
|
433
|
-
if (text[i] === '"') {
|
|
434
|
-
inString = false;
|
|
435
|
-
}
|
|
436
|
-
result += text[i];
|
|
437
|
-
i++;
|
|
438
|
-
continue;
|
|
439
|
-
}
|
|
440
|
-
// JSON only uses double-quoted strings; single quotes are not valid JSON
|
|
441
|
-
if (text[i] === '"') {
|
|
442
|
-
inString = true;
|
|
443
|
-
result += text[i];
|
|
444
|
-
i++;
|
|
445
|
-
continue;
|
|
446
|
-
}
|
|
447
|
-
if (text[i] === "/" && text[i + 1] === "/") {
|
|
448
|
-
while (i < text.length && text[i] !== "\n")
|
|
449
|
-
i++;
|
|
450
|
-
continue;
|
|
451
|
-
}
|
|
452
|
-
if (text[i] === "/" && text[i + 1] === "*") {
|
|
453
|
-
i += 2;
|
|
454
|
-
while (i < text.length && !(text[i] === "*" && text[i + 1] === "/"))
|
|
455
|
-
i++;
|
|
456
|
-
i += 2;
|
|
457
|
-
continue;
|
|
458
|
-
}
|
|
459
|
-
result += text[i];
|
|
460
|
-
i++;
|
|
222
|
+
if (compareConfigVersion(obj.configVersion, CURRENT_CONFIG_VERSION) === 1) {
|
|
223
|
+
return text;
|
|
461
224
|
}
|
|
462
|
-
|
|
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;
|
|
231
|
+
try {
|
|
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`);
|
|
252
|
+
}
|
|
253
|
+
catch (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.");
|
|
258
|
+
}
|
|
259
|
+
return migratedText;
|
|
463
260
|
}
|
|
464
|
-
function
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
// If no endpoint is provided, the config is only valid when localModel is set
|
|
471
|
-
// (local-only embedding configuration).
|
|
472
|
-
// Sentinel: { endpoint: "", model: "" } means "local-only" — use hasRemoteEndpoint()
|
|
473
|
-
// (in embedder.ts) to distinguish from a real remote config. Do NOT check
|
|
474
|
-
// endpoint/model directly in consuming code.
|
|
475
|
-
if (typeof obj.endpoint !== "string" || !obj.endpoint) {
|
|
476
|
-
if (localModel) {
|
|
477
|
-
return { endpoint: "", model: "", localModel };
|
|
478
|
-
}
|
|
479
|
-
return undefined;
|
|
480
|
-
}
|
|
481
|
-
if (!isValidHttpUrl(obj.endpoint, "embedding config")) {
|
|
482
|
-
// Still return localModel-only config if localModel was set
|
|
483
|
-
if (localModel) {
|
|
484
|
-
return { endpoint: "", model: "", localModel };
|
|
485
|
-
}
|
|
486
|
-
return undefined;
|
|
487
|
-
}
|
|
488
|
-
if (typeof obj.model !== "string" || !obj.model) {
|
|
489
|
-
// No remote model, but localModel may still be valid
|
|
490
|
-
if (localModel) {
|
|
491
|
-
warn(`[akm] Embedding endpoint "${obj.endpoint}" ignored: model is required for remote embeddings. Using local model only.`);
|
|
492
|
-
return { endpoint: "", model: "", localModel };
|
|
493
|
-
}
|
|
494
|
-
return undefined;
|
|
495
|
-
}
|
|
496
|
-
const result = {
|
|
497
|
-
endpoint: obj.endpoint,
|
|
498
|
-
model: obj.model,
|
|
499
|
-
};
|
|
500
|
-
if (typeof obj.provider === "string" && obj.provider) {
|
|
501
|
-
result.provider = obj.provider;
|
|
502
|
-
}
|
|
503
|
-
if ("dimension" in obj) {
|
|
504
|
-
const dim = parsePositiveInteger("embedding.dimension", obj.dimension);
|
|
505
|
-
if (dim === undefined)
|
|
506
|
-
return undefined;
|
|
507
|
-
result.dimension = dim;
|
|
508
|
-
}
|
|
509
|
-
if (typeof obj.apiKey === "string" && obj.apiKey) {
|
|
510
|
-
result.apiKey = obj.apiKey;
|
|
511
|
-
}
|
|
512
|
-
if (localModel) {
|
|
513
|
-
result.localModel = localModel;
|
|
514
|
-
}
|
|
515
|
-
if ("contextLength" in obj) {
|
|
516
|
-
const ctx = parsePositiveInteger("embedding.contextLength", obj.contextLength);
|
|
517
|
-
if (ctx === undefined)
|
|
518
|
-
return undefined;
|
|
519
|
-
result.contextLength = ctx;
|
|
520
|
-
}
|
|
521
|
-
if (typeof obj.ollamaOptions === "object" && obj.ollamaOptions !== null && !Array.isArray(obj.ollamaOptions)) {
|
|
522
|
-
const opts = obj.ollamaOptions;
|
|
523
|
-
const parsed = {};
|
|
524
|
-
const numCtx = parsePositiveInteger("embedding.ollamaOptions.num_ctx", opts.num_ctx);
|
|
525
|
-
if (numCtx !== undefined) {
|
|
526
|
-
parsed.num_ctx = numCtx;
|
|
527
|
-
}
|
|
528
|
-
if (Object.keys(parsed).length > 0) {
|
|
529
|
-
result.ollamaOptions = parsed;
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
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();
|
|
533
267
|
}
|
|
534
|
-
function
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
const
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
}
|
|
555
|
-
if (typeof obj.temperature === "number" && Number.isFinite(obj.temperature)) {
|
|
556
|
-
result.temperature = obj.temperature;
|
|
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
|
-
}
|
|
570
|
-
if ("maxTokens" in obj) {
|
|
571
|
-
const m = parsePositiveInteger("llm.maxTokens", obj.maxTokens);
|
|
572
|
-
if (m === undefined)
|
|
573
|
-
return undefined;
|
|
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;
|
|
580
|
-
}
|
|
581
|
-
if (typeof obj.apiKey === "string" && obj.apiKey) {
|
|
582
|
-
result.apiKey = obj.apiKey;
|
|
583
|
-
}
|
|
584
|
-
if (typeof obj.capabilities === "object" && obj.capabilities !== null && !Array.isArray(obj.capabilities)) {
|
|
585
|
-
const capsRaw = obj.capabilities;
|
|
586
|
-
const caps = {};
|
|
587
|
-
if (typeof capsRaw.structuredOutput === "boolean")
|
|
588
|
-
caps.structuredOutput = capsRaw.structuredOutput;
|
|
589
|
-
if (Object.keys(caps).length > 0)
|
|
590
|
-
result.capabilities = caps;
|
|
591
|
-
}
|
|
592
|
-
if (typeof obj.features === "object" && obj.features !== null && !Array.isArray(obj.features)) {
|
|
593
|
-
const features = parseLlmFeatures(obj.features);
|
|
594
|
-
if (Object.keys(features).length > 0)
|
|
595
|
-
result.features = features;
|
|
596
|
-
}
|
|
597
|
-
if (typeof obj.judgeModel === "string" && obj.judgeModel.trim()) {
|
|
598
|
-
result.judgeModel = obj.judgeModel.trim();
|
|
599
|
-
}
|
|
600
|
-
if (typeof obj.extraParams === "object" && obj.extraParams !== null && !Array.isArray(obj.extraParams)) {
|
|
601
|
-
result.extraParams = obj.extraParams;
|
|
602
|
-
}
|
|
603
|
-
return result;
|
|
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
|
+
});
|
|
604
289
|
}
|
|
605
290
|
/**
|
|
606
|
-
*
|
|
607
|
-
*
|
|
608
|
-
*
|
|
609
|
-
*
|
|
610
|
-
*
|
|
291
|
+
* Strip literal apiKey fields before writing config to disk.
|
|
292
|
+
* API keys are expected to come from environment variables
|
|
293
|
+
* (AKM_EMBED_API_KEY, AKM_LLM_API_KEY, AKM_PROFILE_<NAME>_API_KEY).
|
|
294
|
+
*
|
|
295
|
+
* `${VAR}` / `$VAR` references are preserved — they are not secrets, they
|
|
296
|
+
* are deferred lookups resolved at consumption by `resolveSecret`. Dropping
|
|
297
|
+
* them would break the documented config-on-disk pattern.
|
|
298
|
+
*
|
|
299
|
+
* When a non-reference literal value is stripped, emit a `warn()` so the
|
|
300
|
+
* user knows their key was dropped and how to provide it at runtime (#474).
|
|
301
|
+
* Previously the strip was silent — a user invoking `akm setup --from <file>
|
|
302
|
+
* --yes` with an `apiKey` field expected persistence and got a wiped config
|
|
303
|
+
* with no feedback.
|
|
611
304
|
*/
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
]);
|
|
621
|
-
function parseLlmFeatures(raw) {
|
|
622
|
-
const out = {};
|
|
623
|
-
for (const [key, value] of Object.entries(raw)) {
|
|
624
|
-
if (!LOCKED_LLM_FEATURE_KEYS.has(key)) {
|
|
625
|
-
warn(`[akm] Ignoring unknown llm.features key "${key}".`);
|
|
626
|
-
continue;
|
|
627
|
-
}
|
|
628
|
-
if (typeof value !== "boolean") {
|
|
629
|
-
warn(`[akm] Ignoring llm.features.${key}: expected boolean, got ${typeof value}.`);
|
|
630
|
-
continue;
|
|
305
|
+
function sanitizeConfigForWrite(config) {
|
|
306
|
+
const sanitized = { ...config };
|
|
307
|
+
const stripped = [];
|
|
308
|
+
if (config.embedding?.apiKey !== undefined) {
|
|
309
|
+
const apiKey = config.embedding.apiKey;
|
|
310
|
+
if (isEnvReference(apiKey)) {
|
|
311
|
+
// Preserve reference verbatim — not a secret.
|
|
312
|
+
sanitized.embedding = { ...config.embedding };
|
|
631
313
|
}
|
|
632
|
-
|
|
633
|
-
|
|
314
|
+
else {
|
|
315
|
+
const { apiKey: _drop, ...rest } = config.embedding;
|
|
316
|
+
sanitized.embedding = rest;
|
|
317
|
+
if (apiKey)
|
|
318
|
+
stripped.push("embedding.apiKey (set AKM_EMBED_API_KEY to provide at runtime)");
|
|
634
319
|
}
|
|
635
320
|
}
|
|
636
|
-
|
|
637
|
-
}
|
|
638
|
-
/**
|
|
639
|
-
* Keys that, if present anywhere under `index.<pass>`, indicate the user is
|
|
640
|
-
* trying to supply a parallel LLM provider configuration. Per #208 this is
|
|
641
|
-
* deliberately rejected at load time so there is exactly one place to
|
|
642
|
-
* configure the LLM (`akm.llm`).
|
|
643
|
-
*/
|
|
644
|
-
const PROVIDER_CONFIG_KEYS = new Set([
|
|
645
|
-
"endpoint",
|
|
646
|
-
"model",
|
|
647
|
-
"provider",
|
|
648
|
-
"apiKey",
|
|
649
|
-
"baseUrl",
|
|
650
|
-
"temperature",
|
|
651
|
-
"maxTokens",
|
|
652
|
-
"capabilities",
|
|
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
|
-
]);
|
|
665
|
-
/**
|
|
666
|
-
* Parse the `index` config block. Each entry is a pass name → small object
|
|
667
|
-
* `{ llm?: boolean }`. Anything richer (a parallel provider config, unknown
|
|
668
|
-
* keys, non-boolean `llm`) throws `ConfigError("INVALID_CONFIG_FILE")` at
|
|
669
|
-
* load time so the failure is visible at startup, not on the next index run.
|
|
670
|
-
*/
|
|
671
|
-
function parseIndexConfig(value) {
|
|
672
|
-
if (value === undefined || value === null)
|
|
673
|
-
return undefined;
|
|
674
|
-
if (typeof value !== "object" || Array.isArray(value)) {
|
|
675
|
-
throw new ConfigError('Invalid `index` config: expected an object keyed by pass name (e.g. `{ "enrichment": { "llm": false } }`).', "INVALID_CONFIG_FILE");
|
|
321
|
+
else if (config.embedding) {
|
|
322
|
+
sanitized.embedding = { ...config.embedding };
|
|
676
323
|
}
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
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");
|
|
695
|
-
}
|
|
696
|
-
}
|
|
697
|
-
const passConfig = {};
|
|
698
|
-
if ("llm" in passRaw) {
|
|
699
|
-
const llmFlag = passRaw.llm;
|
|
700
|
-
if (typeof llmFlag !== "boolean") {
|
|
701
|
-
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.");
|
|
702
|
-
}
|
|
703
|
-
passConfig.llm = llmFlag;
|
|
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");
|
|
324
|
+
if (config.profiles?.llm) {
|
|
325
|
+
const llmProfiles = {};
|
|
326
|
+
for (const [name, profile] of Object.entries(config.profiles.llm)) {
|
|
327
|
+
if (profile.apiKey !== undefined) {
|
|
328
|
+
if (isEnvReference(profile.apiKey)) {
|
|
329
|
+
llmProfiles[name] = { ...profile };
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
const { apiKey: _drop, ...rest } = profile;
|
|
333
|
+
llmProfiles[name] = rest;
|
|
334
|
+
if (profile.apiKey) {
|
|
335
|
+
const envVar = `AKM_PROFILE_${name.toUpperCase().replace(/-/g, "_")}_API_KEY`;
|
|
336
|
+
stripped.push(`profiles.llm.${name}.apiKey (set ${envVar} to provide at runtime)`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
714
339
|
}
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
if (invalid.length > 0) {
|
|
718
|
-
throw new ConfigError(`Invalid \`index.${passName}.graphExtractionIncludeTypes\`: unsupported type(s): ${invalid.join(", ")}.`, "INVALID_CONFIG_FILE");
|
|
340
|
+
else {
|
|
341
|
+
llmProfiles[name] = { ...profile };
|
|
719
342
|
}
|
|
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
343
|
}
|
|
727
|
-
|
|
344
|
+
sanitized.profiles = {
|
|
345
|
+
...(sanitized.profiles ?? {}),
|
|
346
|
+
llm: llmProfiles,
|
|
347
|
+
};
|
|
728
348
|
}
|
|
729
|
-
|
|
349
|
+
if (stripped.length > 0) {
|
|
350
|
+
warn(`Config sanitizer dropped API key(s) before writing to disk:\n - ${stripped.join("\n - ")}\n\nakm does not persist API keys to config.json. Set the listed environment variables to provide them at runtime, or use \`\${VAR}\` references in your config to defer lookup. See docs/data-and-telemetry.md.`);
|
|
351
|
+
}
|
|
352
|
+
return sanitized;
|
|
730
353
|
}
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
const entries = value
|
|
735
|
-
.map((entry) => parseInstalledStashEntry(entry))
|
|
736
|
-
.filter((entry) => entry !== undefined);
|
|
737
|
-
return entries.length > 0 ? entries : undefined;
|
|
354
|
+
/** Matches `${VAR}`, `${VAR:-default}`, or `$VAR`. */
|
|
355
|
+
function isEnvReference(value) {
|
|
356
|
+
return /^\$\{[^}]+\}$|^\$[A-Za-z_][A-Za-z0-9_]*$/.test(value);
|
|
738
357
|
}
|
|
739
|
-
function
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
const source = asKitSource(obj.source);
|
|
745
|
-
const ref = asNonEmptyString(obj.ref);
|
|
746
|
-
const artifactUrl = asNonEmptyString(obj.artifactUrl);
|
|
747
|
-
const stashRoot = asNonEmptyString(obj.stashRoot);
|
|
748
|
-
const cacheDir = asNonEmptyString(obj.cacheDir);
|
|
749
|
-
const installedAt = asNonEmptyString(obj.installedAt);
|
|
750
|
-
if (!id || !source || !ref || !artifactUrl || !stashRoot || !cacheDir || !installedAt)
|
|
751
|
-
return undefined;
|
|
752
|
-
const entry = {
|
|
753
|
-
id,
|
|
754
|
-
source,
|
|
755
|
-
ref,
|
|
756
|
-
artifactUrl,
|
|
757
|
-
stashRoot,
|
|
758
|
-
cacheDir,
|
|
759
|
-
installedAt,
|
|
760
|
-
};
|
|
761
|
-
if (typeof obj.writable === "boolean")
|
|
762
|
-
entry.writable = obj.writable;
|
|
763
|
-
if (entry.writable === true && entry.source !== "git") {
|
|
764
|
-
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.");
|
|
765
|
-
}
|
|
766
|
-
const resolvedVersion = asNonEmptyString(obj.resolvedVersion);
|
|
767
|
-
if (resolvedVersion)
|
|
768
|
-
entry.resolvedVersion = resolvedVersion;
|
|
769
|
-
const resolvedRevision = asNonEmptyString(obj.resolvedRevision);
|
|
770
|
-
if (resolvedRevision)
|
|
771
|
-
entry.resolvedRevision = resolvedRevision;
|
|
772
|
-
const wikiName = asNonEmptyString(obj.wikiName);
|
|
773
|
-
if (wikiName)
|
|
774
|
-
entry.wikiName = wikiName;
|
|
775
|
-
return entry;
|
|
358
|
+
export function updateConfig(partial) {
|
|
359
|
+
const current = loadUserConfig();
|
|
360
|
+
const merged = mergeLoadedConfig(current, partial);
|
|
361
|
+
saveConfig(merged);
|
|
362
|
+
return merged;
|
|
776
363
|
}
|
|
364
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
777
365
|
/**
|
|
778
|
-
*
|
|
366
|
+
* Resolve a single secret value by expanding `${VAR}` / `$VAR` /
|
|
367
|
+
* `${VAR:-default}` references against `process.env`. Use this at apiKey /
|
|
368
|
+
* authorization-header consumption sites (LLM client, embedder, agent SDK
|
|
369
|
+
* runner) — NOT on the load path. Non-string inputs pass through unchanged.
|
|
779
370
|
*
|
|
780
|
-
*
|
|
781
|
-
*
|
|
782
|
-
*
|
|
783
|
-
*
|
|
371
|
+
* Returns the input unchanged when no substitution markers are present, so
|
|
372
|
+
* literal API key strings (already-resolved secrets) are zero-cost.
|
|
373
|
+
*
|
|
374
|
+
* Other config string values (URLs, endpoints, model names, prompts) are
|
|
375
|
+
* preserved verbatim on read — only fields explicitly routed through this
|
|
376
|
+
* helper are expanded.
|
|
784
377
|
*/
|
|
785
|
-
function
|
|
786
|
-
if (value ===
|
|
787
|
-
return value;
|
|
788
|
-
return undefined;
|
|
789
|
-
}
|
|
790
|
-
function parseRegistriesConfig(value) {
|
|
791
|
-
if (!Array.isArray(value))
|
|
792
|
-
return undefined;
|
|
793
|
-
const entries = value
|
|
794
|
-
.map((entry) => parseRegistryConfigEntry(entry))
|
|
795
|
-
.filter((entry) => entry !== undefined);
|
|
796
|
-
// Return the array even if empty — an explicit empty array means "no registries"
|
|
797
|
-
// which overrides the default. Only return undefined if the field was not an array.
|
|
798
|
-
return entries;
|
|
799
|
-
}
|
|
800
|
-
function parseSourcesConfig(value) {
|
|
801
|
-
if (!Array.isArray(value))
|
|
378
|
+
export function resolveSecret(value) {
|
|
379
|
+
if (value === undefined)
|
|
802
380
|
return undefined;
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
return undefined;
|
|
815
|
-
return { installAudit };
|
|
816
|
-
}
|
|
817
|
-
function parseInstallAuditConfig(value) {
|
|
818
|
-
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
819
|
-
return undefined;
|
|
820
|
-
const obj = value;
|
|
821
|
-
const config = {};
|
|
822
|
-
if (typeof obj.enabled === "boolean")
|
|
823
|
-
config.enabled = obj.enabled;
|
|
824
|
-
if (typeof obj.blockOnCritical === "boolean")
|
|
825
|
-
config.blockOnCritical = obj.blockOnCritical;
|
|
826
|
-
if (typeof obj.blockUnlistedRegistries === "boolean")
|
|
827
|
-
config.blockUnlistedRegistries = obj.blockUnlistedRegistries;
|
|
828
|
-
const rawAllowlist = filterNonEmptyStrings(obj.registryAllowlist) ?? filterNonEmptyStrings(obj.registryWhitelist);
|
|
829
|
-
if (!obj.registryAllowlist && obj.registryWhitelist) {
|
|
830
|
-
warn("[akm] config: `registryWhitelist` is deprecated; rename it to `registryAllowlist`");
|
|
831
|
-
}
|
|
832
|
-
if (rawAllowlist) {
|
|
833
|
-
config.registryAllowlist = rawAllowlist;
|
|
834
|
-
}
|
|
835
|
-
const allowedFindings = parseInstallAuditAllowedFindings(obj.allowedFindings);
|
|
836
|
-
if (allowedFindings) {
|
|
837
|
-
config.allowedFindings = allowedFindings;
|
|
838
|
-
}
|
|
839
|
-
return Object.keys(config).length > 0 ? config : undefined;
|
|
840
|
-
}
|
|
841
|
-
function parseInstallAuditAllowedFindings(value) {
|
|
842
|
-
if (!Array.isArray(value))
|
|
843
|
-
return undefined;
|
|
844
|
-
const findings = value
|
|
845
|
-
.map((entry) => parseInstallAuditAllowedFinding(entry))
|
|
846
|
-
.filter((entry) => entry !== undefined);
|
|
847
|
-
return findings.length > 0 ? findings : undefined;
|
|
848
|
-
}
|
|
849
|
-
function parseInstallAuditAllowedFinding(value) {
|
|
850
|
-
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
851
|
-
return undefined;
|
|
852
|
-
const obj = value;
|
|
853
|
-
const id = asNonEmptyString(obj.id);
|
|
854
|
-
if (!id)
|
|
855
|
-
return undefined;
|
|
856
|
-
const finding = { id };
|
|
857
|
-
const ref = asNonEmptyString(obj.ref);
|
|
858
|
-
if (ref)
|
|
859
|
-
finding.ref = ref;
|
|
860
|
-
const entryPath = asNonEmptyString(obj.path);
|
|
861
|
-
if (entryPath)
|
|
862
|
-
finding.path = entryPath;
|
|
863
|
-
const reason = asNonEmptyString(obj.reason);
|
|
864
|
-
if (reason)
|
|
865
|
-
finding.reason = reason;
|
|
866
|
-
return finding;
|
|
867
|
-
}
|
|
868
|
-
function parseSourceConfigEntry(value) {
|
|
869
|
-
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
870
|
-
return undefined;
|
|
871
|
-
const obj = value;
|
|
872
|
-
const type = asNonEmptyString(obj.type);
|
|
873
|
-
if (!type)
|
|
874
|
-
return undefined;
|
|
875
|
-
if (type === "openviking") {
|
|
876
|
-
const name = asNonEmptyString(obj.name) ?? "unnamed";
|
|
877
|
-
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.`);
|
|
878
|
-
}
|
|
879
|
-
const entry = { type };
|
|
880
|
-
const entryPath = asNonEmptyString(obj.path);
|
|
881
|
-
if (entryPath)
|
|
882
|
-
entry.path = entryPath;
|
|
883
|
-
const url = asNonEmptyString(obj.url);
|
|
884
|
-
if (url)
|
|
885
|
-
entry.url = url;
|
|
886
|
-
const name = asNonEmptyString(obj.name);
|
|
887
|
-
if (name)
|
|
888
|
-
entry.name = name;
|
|
889
|
-
if (typeof obj.enabled === "boolean")
|
|
890
|
-
entry.enabled = obj.enabled;
|
|
891
|
-
if (typeof obj.writable === "boolean")
|
|
892
|
-
entry.writable = obj.writable;
|
|
893
|
-
if (typeof obj.primary === "boolean")
|
|
894
|
-
entry.primary = obj.primary;
|
|
895
|
-
// Locked decision 4 (§6 v1 implementation plan): reject writable: true on
|
|
896
|
-
// website / npm sources at config load. The next sync() would clobber
|
|
897
|
-
// writes — allowing this is a footgun, not a feature. Throw early so the
|
|
898
|
-
// user sees the problem at `akm` startup, not when they try to write.
|
|
899
|
-
if (entry.writable === true && (type === "website" || type === "npm")) {
|
|
900
|
-
const label = entry.name ? ` "${entry.name}"` : "";
|
|
901
|
-
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.");
|
|
902
|
-
}
|
|
903
|
-
if (typeof obj.options === "object" && obj.options !== null && !Array.isArray(obj.options)) {
|
|
904
|
-
entry.options = obj.options;
|
|
905
|
-
}
|
|
906
|
-
const wikiName = asNonEmptyString(obj.wikiName);
|
|
907
|
-
if (wikiName)
|
|
908
|
-
entry.wikiName = wikiName;
|
|
909
|
-
return entry;
|
|
910
|
-
}
|
|
911
|
-
// ── ConfiguredSource runtime construction ─────────────────────────────────────────
|
|
912
|
-
/**
|
|
913
|
-
* Synthesize a stable identifier when a {@link SourceConfigEntry} omits its
|
|
914
|
-
* `name`. Uses a short hash of the discriminating fields so two equivalent
|
|
915
|
-
* entries collapse to the same generated name.
|
|
916
|
-
*/
|
|
917
|
-
function deriveStashEntryName(entry) {
|
|
918
|
-
if (entry.name)
|
|
919
|
-
return entry.name;
|
|
920
|
-
const seed = JSON.stringify({
|
|
921
|
-
type: entry.type,
|
|
922
|
-
path: entry.path ?? null,
|
|
923
|
-
url: entry.url ?? null,
|
|
381
|
+
if (typeof value !== "string")
|
|
382
|
+
return value;
|
|
383
|
+
if (!value.includes("$"))
|
|
384
|
+
return value;
|
|
385
|
+
return value.replace(/\$\{([^}]+)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (_match, braced, bare) => {
|
|
386
|
+
if (braced) {
|
|
387
|
+
const [name, ...rest] = braced.split(":-");
|
|
388
|
+
const fallback = rest.join(":-");
|
|
389
|
+
return process.env[name] ?? fallback ?? "";
|
|
390
|
+
}
|
|
391
|
+
return process.env[bare] ?? "";
|
|
924
392
|
});
|
|
925
|
-
const hash = createHash("sha256").update(seed).digest("hex").slice(0, 8);
|
|
926
|
-
return `${entry.type}-${hash}`;
|
|
927
393
|
}
|
|
928
394
|
/**
|
|
929
|
-
*
|
|
930
|
-
*
|
|
931
|
-
*
|
|
932
|
-
* `filesystem` entry with no `path`); callers should drop or warn for those.
|
|
933
|
-
*
|
|
934
|
-
* Unknown provider types fall back to `{ type: "filesystem", path: ... }` when
|
|
935
|
-
* a `path` is supplied, so future provider types still produce a usable
|
|
936
|
-
* runtime value.
|
|
395
|
+
* Read a per-pass {@link IndexPassConfig} entry from {@link IndexConfig},
|
|
396
|
+
* filtering out the reserved feature-section keys so callers don't mistake
|
|
397
|
+
* `metadataEnhance` / `stalenessDetection` for a pass.
|
|
937
398
|
*/
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
case "git":
|
|
943
|
-
return entry.url ? { type: "git", url: entry.url } : undefined;
|
|
944
|
-
case "website":
|
|
945
|
-
return entry.url
|
|
946
|
-
? {
|
|
947
|
-
type: "website",
|
|
948
|
-
url: entry.url,
|
|
949
|
-
...(typeof entry.options?.maxPages === "number" ? { maxPages: entry.options.maxPages } : {}),
|
|
950
|
-
}
|
|
951
|
-
: undefined;
|
|
952
|
-
case "npm":
|
|
953
|
-
// Persisted `npm` stash entries are unusual but supported for symmetry.
|
|
954
|
-
return entry.path ? { type: "npm", package: entry.path } : undefined;
|
|
955
|
-
default:
|
|
956
|
-
// Unknown provider — best-effort fallback so callers still get something.
|
|
957
|
-
return entry.path ? { type: "filesystem", path: entry.path } : undefined;
|
|
958
|
-
}
|
|
959
|
-
}
|
|
960
|
-
/**
|
|
961
|
-
* Build the full ordered list of runtime {@link ConfiguredSource} values from a
|
|
962
|
-
* loaded {@link AkmConfig}. Order is the canonical iteration order:
|
|
963
|
-
*
|
|
964
|
-
* 1. The entry marked `primary: true` (or, as a backwards-compat shim,
|
|
965
|
-
* a synthetic filesystem entry built from the top-level `stashDir`).
|
|
966
|
-
* 2. Remaining `sources[]` entries in declared order.
|
|
967
|
-
* 3. Legacy `installed[]` entries, mapped into runtime entries.
|
|
968
|
-
*
|
|
969
|
-
* Entries with `enabled: false` are still emitted — callers decide whether
|
|
970
|
-
* to honour the flag (mirrors how `installed[]` entries have always been
|
|
971
|
-
* unconditional). Entries that fail {@link parseSourceSpec} are
|
|
972
|
-
* dropped silently.
|
|
973
|
-
*/
|
|
974
|
-
export function resolveConfiguredSources(config) {
|
|
975
|
-
const entries = [];
|
|
976
|
-
const sources = config.sources ?? [];
|
|
977
|
-
// (1) Primary entry: explicit `primary: true` wins; fall back to top-level stashDir.
|
|
978
|
-
let primary = sources.find((entry) => entry.primary === true);
|
|
979
|
-
if (!primary && config.stashDir) {
|
|
980
|
-
primary = { type: "filesystem", path: config.stashDir, primary: true };
|
|
981
|
-
}
|
|
982
|
-
if (primary) {
|
|
983
|
-
const runtime = toConfiguredSource(primary, true);
|
|
984
|
-
if (runtime)
|
|
985
|
-
entries.push(runtime);
|
|
986
|
-
}
|
|
987
|
-
// (2) Declared sources (skip the primary entry — already added).
|
|
988
|
-
for (const entry of sources) {
|
|
989
|
-
if (entry === primary)
|
|
990
|
-
continue;
|
|
991
|
-
const runtime = toConfiguredSource(entry, false);
|
|
992
|
-
if (runtime)
|
|
993
|
-
entries.push(runtime);
|
|
994
|
-
}
|
|
995
|
-
// (3) Legacy installed[] entries.
|
|
996
|
-
for (const installed of config.installed ?? []) {
|
|
997
|
-
entries.push({
|
|
998
|
-
name: installed.id,
|
|
999
|
-
type: "filesystem",
|
|
1000
|
-
source: { type: "filesystem", path: installed.stashRoot },
|
|
1001
|
-
enabled: true,
|
|
1002
|
-
writable: installed.writable,
|
|
1003
|
-
...(installed.wikiName ? { wikiName: installed.wikiName } : {}),
|
|
1004
|
-
});
|
|
1005
|
-
}
|
|
1006
|
-
return entries;
|
|
1007
|
-
}
|
|
1008
|
-
function toConfiguredSource(persisted, isPrimary) {
|
|
1009
|
-
const source = parseSourceSpec(persisted);
|
|
1010
|
-
if (!source)
|
|
399
|
+
/** Reserved well-known keys on IndexConfig that are NOT per-pass entries. */
|
|
400
|
+
const INDEX_RESERVED_KEYS = new Set(["metadataEnhance", "stalenessDetection"]);
|
|
401
|
+
export function getIndexPassConfig(config, passName) {
|
|
402
|
+
if (!config)
|
|
1011
403
|
return undefined;
|
|
1012
|
-
|
|
1013
|
-
name: deriveStashEntryName(persisted),
|
|
1014
|
-
type: persisted.type,
|
|
1015
|
-
source,
|
|
1016
|
-
...(persisted.enabled !== undefined ? { enabled: persisted.enabled } : {}),
|
|
1017
|
-
...(persisted.writable !== undefined ? { writable: persisted.writable } : {}),
|
|
1018
|
-
...(isPrimary || persisted.primary ? { primary: true } : {}),
|
|
1019
|
-
...(persisted.options ? { options: persisted.options } : {}),
|
|
1020
|
-
...(persisted.wikiName ? { wikiName: persisted.wikiName } : {}),
|
|
1021
|
-
};
|
|
1022
|
-
}
|
|
1023
|
-
function parseRegistryConfigEntry(value) {
|
|
1024
|
-
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
404
|
+
if (INDEX_RESERVED_KEYS.has(passName))
|
|
1025
405
|
return undefined;
|
|
1026
|
-
const
|
|
1027
|
-
|
|
1028
|
-
if (!url?.startsWith("http"))
|
|
406
|
+
const entry = config[passName];
|
|
407
|
+
if (!entry || typeof entry !== "object")
|
|
1029
408
|
return undefined;
|
|
1030
|
-
const entry = { url };
|
|
1031
|
-
const name = asNonEmptyString(obj.name);
|
|
1032
|
-
if (name)
|
|
1033
|
-
entry.name = name;
|
|
1034
|
-
if (typeof obj.enabled === "boolean")
|
|
1035
|
-
entry.enabled = obj.enabled;
|
|
1036
|
-
const provider = asNonEmptyString(obj.provider);
|
|
1037
|
-
if (provider)
|
|
1038
|
-
entry.provider = provider;
|
|
1039
|
-
if (typeof obj.options === "object" && obj.options !== null && !Array.isArray(obj.options)) {
|
|
1040
|
-
entry.options = obj.options;
|
|
1041
|
-
}
|
|
1042
409
|
return entry;
|
|
1043
410
|
}
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
const baseProfiles = base.profiles;
|
|
1047
|
-
const overrideProfiles = override.profiles;
|
|
1048
|
-
if (baseProfiles && overrideProfiles) {
|
|
1049
|
-
const profiles = { ...baseProfiles };
|
|
1050
|
-
for (const [name, entry] of Object.entries(overrideProfiles)) {
|
|
1051
|
-
const existing = baseProfiles[name];
|
|
1052
|
-
profiles[name] = existing ? { ...existing, ...entry } : entry;
|
|
1053
|
-
}
|
|
1054
|
-
merged.profiles = profiles;
|
|
1055
|
-
}
|
|
1056
|
-
// Shallow merge per-key: later layer wins per process name (same as profiles).
|
|
1057
|
-
const baseProcesses = base.processes;
|
|
1058
|
-
const overrideProcesses = override.processes;
|
|
1059
|
-
if (baseProcesses || overrideProcesses) {
|
|
1060
|
-
merged.processes = { ...(baseProcesses ?? {}), ...(overrideProcesses ?? {}) };
|
|
1061
|
-
}
|
|
1062
|
-
return merged;
|
|
1063
|
-
}
|
|
1064
|
-
function mergeSecurityConfig(base, override) {
|
|
1065
|
-
if (!base && !override)
|
|
1066
|
-
return undefined;
|
|
1067
|
-
const installAudit = mergeInstallAuditConfig(base?.installAudit, override?.installAudit);
|
|
1068
|
-
return installAudit ? { installAudit } : undefined;
|
|
1069
|
-
}
|
|
1070
|
-
function mergeInstallAuditConfig(base, override) {
|
|
1071
|
-
if (!base && !override)
|
|
1072
|
-
return undefined;
|
|
1073
|
-
const merged = {
|
|
1074
|
-
...(base ?? {}),
|
|
1075
|
-
...(override ?? {}),
|
|
1076
|
-
};
|
|
1077
|
-
return Object.values(merged).some((value) => value !== undefined) ? merged : undefined;
|
|
1078
|
-
}
|
|
411
|
+
// Re-export source runtime helpers — implementation lives in config-sources.ts.
|
|
412
|
+
export { parseSourceSpec, resolveConfiguredSources } from "./config-sources";
|
|
1079
413
|
/**
|
|
1080
|
-
* Merge a
|
|
1081
|
-
*
|
|
1082
|
-
*
|
|
1083
|
-
*
|
|
1084
|
-
* clobbering sibling settings. `sources` are additive by default, but a later
|
|
1085
|
-
* layer can set `stashInheritance: "replace"` to drop inherited sources first.
|
|
414
|
+
* Merge a partial user-config override onto a base config. Used by
|
|
415
|
+
* {@link loadUserConfig} (DEFAULT_CONFIG + on-disk) and {@link updateConfig}
|
|
416
|
+
* (current config + partial patch). Sub-objects with named records (profiles,
|
|
417
|
+
* defaults, etc.) shallow-merge; arrays override wholesale.
|
|
1086
418
|
*/
|
|
1087
419
|
function mergeLoadedConfig(base, override) {
|
|
1088
420
|
if (!override)
|
|
1089
421
|
return { ...base };
|
|
1090
|
-
const merged = {
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
if (base.embedding && override.embedding) {
|
|
1098
|
-
merged.embedding = { ...base.embedding, ...override.embedding };
|
|
1099
|
-
}
|
|
1100
|
-
if (base.llm && override.llm) {
|
|
1101
|
-
merged.llm = { ...base.llm, ...override.llm };
|
|
1102
|
-
}
|
|
1103
|
-
if (base.index || override.index) {
|
|
1104
|
-
// Deep-merge per-pass entries so a project layer can opt one pass out
|
|
1105
|
-
// without dropping siblings configured in user config.
|
|
1106
|
-
const mergedIndex = { ...(base.index ?? {}) };
|
|
1107
|
-
for (const [passName, passOverride] of Object.entries(override.index ?? {})) {
|
|
1108
|
-
mergedIndex[passName] = { ...(mergedIndex[passName] ?? {}), ...passOverride };
|
|
422
|
+
const merged = { ...base, ...override };
|
|
423
|
+
// Shallow-merge sub-objects so a partial update to e.g. `output.format`
|
|
424
|
+
// doesn't drop the existing `output.detail`.
|
|
425
|
+
for (const key of ["output", "embedding", "index", "defaults"]) {
|
|
426
|
+
if (base[key] && override[key]) {
|
|
427
|
+
// biome-ignore lint/suspicious/noExplicitAny: heterogeneous structural merge
|
|
428
|
+
merged[key] = { ...base[key], ...override[key] };
|
|
1109
429
|
}
|
|
1110
|
-
if (Object.keys(mergedIndex).length > 0)
|
|
1111
|
-
merged.index = mergedIndex;
|
|
1112
|
-
}
|
|
1113
|
-
if (base.security && override.security) {
|
|
1114
|
-
merged.security = mergeSecurityConfig(base.security, override.security);
|
|
1115
430
|
}
|
|
1116
|
-
if (base.
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
merged.
|
|
1124
|
-
}
|
|
1125
|
-
else if (overrideSources.length > 0) {
|
|
1126
|
-
merged.sources = [...baseSources, ...overrideSources];
|
|
1127
|
-
}
|
|
1128
|
-
else if (baseSources.length > 0) {
|
|
1129
|
-
merged.sources = [...baseSources];
|
|
431
|
+
if (base.profiles && override.profiles) {
|
|
432
|
+
const next = { ...base.profiles };
|
|
433
|
+
for (const k of ["llm", "agent", "improve"]) {
|
|
434
|
+
const ovr = override.profiles[k];
|
|
435
|
+
if (ovr)
|
|
436
|
+
next[k] = { ...(next[k] ?? {}), ...ovr };
|
|
437
|
+
}
|
|
438
|
+
merged.profiles = next;
|
|
1130
439
|
}
|
|
1131
440
|
return merged;
|
|
1132
441
|
}
|
|
@@ -1137,51 +446,51 @@ function applyRuntimeEnvApiKeys(config) {
|
|
|
1137
446
|
if (envKey)
|
|
1138
447
|
next.embedding = { ...next.embedding, apiKey: envKey };
|
|
1139
448
|
}
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
449
|
+
// LLM profile keys: AKM_LLM_API_KEY for the default profile, then
|
|
450
|
+
// AKM_PROFILE_<UPPER>_API_KEY for any profile (per-profile wins).
|
|
451
|
+
const defaultProfile = next.defaults?.llm;
|
|
452
|
+
if (next.profiles?.llm) {
|
|
453
|
+
const updated = { ...next.profiles.llm };
|
|
454
|
+
let changed = false;
|
|
455
|
+
for (const [name, profile] of Object.entries(updated)) {
|
|
456
|
+
if (profile.apiKey)
|
|
457
|
+
continue;
|
|
458
|
+
const perProfile = process.env[`AKM_PROFILE_${name.toUpperCase().replace(/-/g, "_")}_API_KEY`]?.trim();
|
|
459
|
+
const fallback = name === defaultProfile ? process.env.AKM_LLM_API_KEY?.trim() : undefined;
|
|
460
|
+
const envKey = perProfile || fallback;
|
|
461
|
+
if (envKey) {
|
|
462
|
+
updated[name] = { ...profile, apiKey: envKey };
|
|
463
|
+
changed = true;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
if (changed)
|
|
467
|
+
next.profiles = { ...next.profiles, llm: updated };
|
|
1144
468
|
}
|
|
1145
469
|
return next;
|
|
1146
470
|
}
|
|
1147
471
|
/**
|
|
1148
|
-
*
|
|
1149
|
-
*
|
|
1150
|
-
*
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
const configPath = getConfigPath();
|
|
1154
|
-
const paths = [];
|
|
1155
|
-
if (isFile(configPath)) {
|
|
1156
|
-
paths.push(configPath);
|
|
1157
|
-
}
|
|
1158
|
-
return [...paths, ...discoverProjectConfigPaths(process.cwd())];
|
|
1159
|
-
}
|
|
1160
|
-
/**
|
|
1161
|
-
* Walk from `startDir` up to the filesystem root and collect `.akm/config.json`
|
|
1162
|
-
* files. Paths are returned from outermost parent to innermost directory so
|
|
1163
|
-
* nearer project directories override broader project settings.
|
|
472
|
+
* Walk cwd-ancestors looking for `.akm/config.json`. If one is found, emit a
|
|
473
|
+
* one-time deprecation warning per path. The file's contents are NOT read —
|
|
474
|
+
* multi-layer project config was removed in this release; the warning stays
|
|
475
|
+
* for one cycle so users notice they have a now-dead file on disk and can
|
|
476
|
+
* migrate its settings to the user-level config.
|
|
1164
477
|
*/
|
|
1165
|
-
|
|
1166
|
-
|
|
478
|
+
const PROJECT_CONFIG_DEPRECATION_WARNED = new Set();
|
|
479
|
+
function warnIfProjectConfigPresent(startDir) {
|
|
1167
480
|
let currentDir = path.resolve(startDir);
|
|
1168
481
|
while (true) {
|
|
1169
482
|
const configPath = path.join(currentDir, PROJECT_CONFIG_RELATIVE_PATH);
|
|
1170
|
-
if (isFile(configPath)) {
|
|
1171
|
-
|
|
483
|
+
if (isFile(configPath) && !PROJECT_CONFIG_DEPRECATION_WARNED.has(configPath)) {
|
|
484
|
+
PROJECT_CONFIG_DEPRECATION_WARNED.add(configPath);
|
|
485
|
+
warn(`[akm] DEPRECATED: project-level config file found at ${configPath}. ` +
|
|
486
|
+
"Project-level config files are no longer merged (removed after 0.8.x deprecation). " +
|
|
487
|
+
"Move any needed settings to ~/.config/akm/config.json; this file is ignored.");
|
|
1172
488
|
}
|
|
1173
489
|
const parentDir = path.dirname(currentDir);
|
|
1174
|
-
if (parentDir === currentDir)
|
|
490
|
+
if (parentDir === currentDir)
|
|
1175
491
|
break;
|
|
1176
|
-
}
|
|
1177
492
|
currentDir = parentDir;
|
|
1178
493
|
}
|
|
1179
|
-
return paths;
|
|
1180
|
-
}
|
|
1181
|
-
function getConfigSignature(configPaths) {
|
|
1182
|
-
if (configPaths.length === 0)
|
|
1183
|
-
return "defaults";
|
|
1184
|
-
return configPaths.map((configPath) => `${configPath}:${getFileSignatureToken(configPath)}`).join("|");
|
|
1185
494
|
}
|
|
1186
495
|
function isFile(filePath) {
|
|
1187
496
|
try {
|
|
@@ -1191,23 +500,3 @@ function isFile(filePath) {
|
|
|
1191
500
|
return false;
|
|
1192
501
|
}
|
|
1193
502
|
}
|
|
1194
|
-
function getFileSignatureToken(filePath) {
|
|
1195
|
-
try {
|
|
1196
|
-
const stat = fs.statSync(filePath);
|
|
1197
|
-
// mtimeMs alone is unreliable on filesystems with low-resolution mtime
|
|
1198
|
-
// (HFS+, some network FS, or very fast back-to-back writes in tests).
|
|
1199
|
-
// Combine mtime + size + content hash so the signature actually changes
|
|
1200
|
-
// when content does.
|
|
1201
|
-
let contentHash = "";
|
|
1202
|
-
try {
|
|
1203
|
-
contentHash = hashString(fs.readFileSync(filePath, "utf8"));
|
|
1204
|
-
}
|
|
1205
|
-
catch {
|
|
1206
|
-
// ignore — fall back to stat-only signature
|
|
1207
|
-
}
|
|
1208
|
-
return `${stat.mtimeMs}:${stat.size}:${contentHash}`;
|
|
1209
|
-
}
|
|
1210
|
-
catch {
|
|
1211
|
-
return "missing";
|
|
1212
|
-
}
|
|
1213
|
-
}
|