akm-cli 0.7.4 → 0.8.0-rc.10
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/CHANGELOG.md +224 -1
- 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 +133 -0
- package/dist/cli/shared.js +129 -0
- package/dist/cli.js +2631 -1440
- package/dist/commands/add-cli.js +279 -0
- 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 +2122 -0
- package/dist/commands/curate.js +45 -3
- package/dist/commands/db-cli.js +23 -0
- package/dist/commands/distill-promotion-policy.js +660 -0
- package/dist/commands/distill.js +1081 -73
- package/dist/commands/env.js +213 -0
- package/dist/commands/eval-cases.js +43 -0
- package/dist/commands/events.js +15 -24
- 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 +477 -0
- package/dist/commands/health.js +1302 -0
- package/dist/commands/help/help-accept.md +12 -0
- package/dist/commands/help/help-improve.md +69 -0
- package/dist/commands/help/help-proposals.md +18 -0
- package/dist/commands/help/help-propose.md +17 -0
- package/dist/commands/help/help-reject.md +11 -0
- package/dist/commands/history.js +54 -46
- package/dist/commands/improve-auto-accept.js +97 -0
- package/dist/commands/improve-cli.js +217 -0
- package/dist/commands/improve-profiles.js +166 -0
- package/dist/commands/improve-result-file.js +167 -0
- package/dist/commands/improve.js +2373 -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/env-key-rules.js +154 -0
- package/dist/commands/lint/index.js +196 -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/workflow-linter.js +56 -0
- package/dist/commands/lint.js +4 -0
- package/dist/commands/migration-help.js +3 -0
- package/dist/commands/proposal.js +67 -12
- package/dist/commands/propose.js +120 -45
- package/dist/commands/reflect.js +1104 -60
- package/dist/commands/registry-cli.js +150 -0
- package/dist/commands/registry-search.js +5 -2
- package/dist/commands/remember-cli.js +257 -0
- package/dist/commands/remember.js +70 -7
- package/dist/commands/schema-repair.js +203 -0
- package/dist/commands/search.js +115 -14
- package/dist/commands/secret.js +173 -0
- package/dist/commands/self-update.js +3 -0
- package/dist/commands/show.js +158 -60
- 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 +437 -0
- package/dist/commands/url-checker.js +42 -0
- package/dist/core/action-contributors.js +28 -0
- package/dist/core/asset-ref.js +17 -2
- package/dist/core/asset-registry.js +12 -17
- package/dist/core/asset-serialize.js +88 -0
- package/dist/core/asset-spec.js +67 -1
- package/dist/core/common.js +182 -0
- package/dist/core/concurrent.js +25 -0
- package/dist/core/config-io.js +347 -0
- package/dist/core/config-migration.js +622 -0
- package/dist/core/config-schema.js +534 -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 +364 -968
- package/dist/core/errors.js +42 -20
- package/dist/core/events.js +105 -135
- package/dist/core/file-lock.js +104 -0
- package/dist/core/frontmatter.js +75 -8
- 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 +280 -14
- package/dist/core/proposal-quality-validators.js +380 -0
- package/dist/core/proposal-validators.js +69 -0
- package/dist/core/proposals.js +512 -42
- package/dist/core/state-db.js +1068 -0
- package/dist/core/text-truncation.js +107 -0
- package/dist/core/time.js +54 -0
- package/dist/core/tty.js +59 -0
- package/dist/core/warn.js +64 -1
- package/dist/core/write-source.js +3 -0
- package/dist/indexer/db-backup.js +391 -0
- package/dist/indexer/db-search.js +198 -489
- package/dist/indexer/db.js +990 -108
- package/dist/indexer/ensure-index.js +136 -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 -114
- package/dist/indexer/index-context.js +4 -0
- package/dist/indexer/indexer.js +547 -309
- package/dist/indexer/llm-cache.js +52 -0
- package/dist/indexer/manifest.js +3 -0
- package/dist/indexer/matchers.js +167 -160
- package/dist/indexer/memory-inference.js +152 -74
- package/dist/indexer/metadata-contributors.js +29 -0
- package/dist/indexer/metadata.js +275 -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 +6 -17
- 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 +250 -36
- package/dist/integrations/agent/runner.js +151 -0
- package/dist/integrations/agent/sdk-runner.js +126 -0
- package/dist/integrations/agent/spawn.js +183 -35
- package/dist/integrations/github.js +3 -0
- package/dist/integrations/lockfile.js +32 -69
- package/dist/integrations/session-logs/index.js +69 -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 +282 -0
- package/dist/integrations/session-logs/providers/opencode.js +258 -0
- package/dist/integrations/session-logs/types.js +4 -0
- package/dist/llm/call-ai.js +62 -0
- package/dist/llm/client.js +79 -88
- 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 +95 -48
- package/dist/llm/graph-extract.js +676 -72
- package/dist/llm/index-passes.js +44 -29
- package/dist/llm/memory-infer.js +80 -71
- package/dist/llm/metadata-enhance.js +42 -29
- package/dist/llm/prompts/extract-session.md +80 -0
- package/dist/llm/prompts/graph-extract-user-prompt.md +35 -0
- package/dist/output/cli-hints-full.md +292 -0
- package/dist/output/cli-hints-short.md +66 -0
- package/dist/output/cli-hints.js +7 -311
- package/dist/output/context.js +60 -8
- package/dist/output/renderers.js +306 -258
- 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 +102 -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 -511
- 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 +1039 -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 +11 -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 -1093
- 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 +71 -50
- 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 +17750 -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 +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 +179 -20
- 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 +227 -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 +141 -2
- 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 +91 -89
- package/dist/workflows/schema.js +3 -0
- package/dist/workflows/scope-key.js +79 -0
- package/dist/workflows/validator.js +4 -8
- package/dist/workflows/workflow-template.md +24 -0
- package/docs/README.md +10 -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.4.md +1 -1
- package/docs/migration/release-notes/0.7.5.md +20 -0
- 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 +29 -11
- package/dist/commands/install-audit.js +0 -381
- package/dist/commands/vault.js +0 -333
- 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,975 +80,362 @@ 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);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// Stat failed — use original stat for cache; no harm done.
|
|
76
115
|
}
|
|
77
|
-
|
|
78
|
-
const finalConfig = applyRuntimeEnvApiKeys(config);
|
|
79
|
-
cachedUserConfig = {
|
|
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();
|
|
189
|
-
}
|
|
190
|
-
// Backward compatibility: coerce legacy boolean values to string
|
|
191
|
-
if (typeof raw.semanticSearchMode === "boolean") {
|
|
192
|
-
config.semanticSearchMode = raw.semanticSearchMode ? "auto" : "off";
|
|
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;
|
|
244
|
-
}
|
|
245
|
-
return config;
|
|
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;
|
|
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");
|
|
266
189
|
}
|
|
267
|
-
|
|
268
|
-
|
|
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.`);
|
|
269
193
|
}
|
|
270
|
-
return
|
|
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;
|
|
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();
|
|
477
267
|
}
|
|
478
|
-
function
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
const
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
}
|
|
499
|
-
if ("maxTokens" in obj) {
|
|
500
|
-
if (typeof obj.maxTokens !== "number" ||
|
|
501
|
-
!Number.isFinite(obj.maxTokens) ||
|
|
502
|
-
!Number.isInteger(obj.maxTokens) ||
|
|
503
|
-
obj.maxTokens <= 0) {
|
|
504
|
-
return undefined;
|
|
505
|
-
}
|
|
506
|
-
result.maxTokens = obj.maxTokens;
|
|
507
|
-
}
|
|
508
|
-
if (typeof obj.apiKey === "string" && obj.apiKey) {
|
|
509
|
-
result.apiKey = obj.apiKey;
|
|
510
|
-
}
|
|
511
|
-
if (typeof obj.capabilities === "object" && obj.capabilities !== null && !Array.isArray(obj.capabilities)) {
|
|
512
|
-
const capsRaw = obj.capabilities;
|
|
513
|
-
const caps = {};
|
|
514
|
-
if (typeof capsRaw.structuredOutput === "boolean")
|
|
515
|
-
caps.structuredOutput = capsRaw.structuredOutput;
|
|
516
|
-
if (Object.keys(caps).length > 0)
|
|
517
|
-
result.capabilities = caps;
|
|
518
|
-
}
|
|
519
|
-
if (typeof obj.features === "object" && obj.features !== null && !Array.isArray(obj.features)) {
|
|
520
|
-
const features = parseLlmFeatures(obj.features);
|
|
521
|
-
if (Object.keys(features).length > 0)
|
|
522
|
-
result.features = features;
|
|
523
|
-
}
|
|
524
|
-
if (typeof obj.extraParams === "object" && obj.extraParams !== null && !Array.isArray(obj.extraParams)) {
|
|
525
|
-
result.extraParams = obj.extraParams;
|
|
526
|
-
}
|
|
527
|
-
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
|
+
});
|
|
528
289
|
}
|
|
529
290
|
/**
|
|
530
|
-
*
|
|
531
|
-
*
|
|
532
|
-
*
|
|
533
|
-
*
|
|
534
|
-
*
|
|
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.
|
|
535
304
|
*/
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
for (const [key, value] of Object.entries(raw)) {
|
|
545
|
-
if (!LOCKED_LLM_FEATURE_KEYS.has(key)) {
|
|
546
|
-
warn(`[akm] Ignoring unknown llm.features key "${key}".`);
|
|
547
|
-
continue;
|
|
548
|
-
}
|
|
549
|
-
if (typeof value !== "boolean") {
|
|
550
|
-
warn(`[akm] Ignoring llm.features.${key}: expected boolean, got ${typeof value}.`);
|
|
551
|
-
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 };
|
|
552
313
|
}
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
out.graph_extraction = value;
|
|
559
|
-
break;
|
|
560
|
-
case "curate_rerank":
|
|
561
|
-
out.curate_rerank = value;
|
|
562
|
-
break;
|
|
563
|
-
case "feedback_distillation":
|
|
564
|
-
out.feedback_distillation = value;
|
|
565
|
-
break;
|
|
566
|
-
// No default: LOCKED_LLM_FEATURE_KEYS is the source of truth for which
|
|
567
|
-
// keys are accepted. Adding a new locked key requires an arm here AND a
|
|
568
|
-
// field on LlmFeatureFlags above.
|
|
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)");
|
|
569
319
|
}
|
|
570
320
|
}
|
|
571
|
-
|
|
572
|
-
}
|
|
573
|
-
/**
|
|
574
|
-
* Keys that, if present anywhere under `index.<pass>`, indicate the user is
|
|
575
|
-
* trying to supply a parallel LLM provider configuration. Per #208 this is
|
|
576
|
-
* deliberately rejected at load time so there is exactly one place to
|
|
577
|
-
* configure the LLM (`akm.llm`).
|
|
578
|
-
*/
|
|
579
|
-
const PROVIDER_CONFIG_KEYS = new Set([
|
|
580
|
-
"endpoint",
|
|
581
|
-
"model",
|
|
582
|
-
"provider",
|
|
583
|
-
"apiKey",
|
|
584
|
-
"baseUrl",
|
|
585
|
-
"temperature",
|
|
586
|
-
"maxTokens",
|
|
587
|
-
"capabilities",
|
|
588
|
-
]);
|
|
589
|
-
/**
|
|
590
|
-
* Parse the `index` config block. Each entry is a pass name → small object
|
|
591
|
-
* `{ llm?: boolean }`. Anything richer (a parallel provider config, unknown
|
|
592
|
-
* keys, non-boolean `llm`) throws `ConfigError("INVALID_CONFIG_FILE")` at
|
|
593
|
-
* load time so the failure is visible at startup, not on the next index run.
|
|
594
|
-
*/
|
|
595
|
-
function parseIndexConfig(value) {
|
|
596
|
-
if (value === undefined || value === null)
|
|
597
|
-
return undefined;
|
|
598
|
-
if (typeof value !== "object" || Array.isArray(value)) {
|
|
599
|
-
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 };
|
|
600
323
|
}
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
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
|
+
}
|
|
616
339
|
}
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
if ("llm" in passRaw) {
|
|
620
|
-
const llmFlag = passRaw.llm;
|
|
621
|
-
if (typeof llmFlag !== "boolean") {
|
|
622
|
-
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.");
|
|
340
|
+
else {
|
|
341
|
+
llmProfiles[name] = { ...profile };
|
|
623
342
|
}
|
|
624
|
-
passConfig.llm = llmFlag;
|
|
625
343
|
}
|
|
626
|
-
|
|
344
|
+
sanitized.profiles = {
|
|
345
|
+
...(sanitized.profiles ?? {}),
|
|
346
|
+
llm: llmProfiles,
|
|
347
|
+
};
|
|
627
348
|
}
|
|
628
|
-
|
|
629
|
-
}
|
|
630
|
-
function parseInstalledEntries(value) {
|
|
631
|
-
if (!Array.isArray(value))
|
|
632
|
-
return undefined;
|
|
633
|
-
const entries = value
|
|
634
|
-
.map((entry) => parseInstalledStashEntry(entry))
|
|
635
|
-
.filter((entry) => entry !== undefined);
|
|
636
|
-
return entries.length > 0 ? entries : undefined;
|
|
637
|
-
}
|
|
638
|
-
function parseInstalledStashEntry(value) {
|
|
639
|
-
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
640
|
-
return undefined;
|
|
641
|
-
const obj = value;
|
|
642
|
-
const id = asNonEmptyString(obj.id);
|
|
643
|
-
const source = asKitSource(obj.source);
|
|
644
|
-
const ref = asNonEmptyString(obj.ref);
|
|
645
|
-
const artifactUrl = asNonEmptyString(obj.artifactUrl);
|
|
646
|
-
const stashRoot = asNonEmptyString(obj.stashRoot);
|
|
647
|
-
const cacheDir = asNonEmptyString(obj.cacheDir);
|
|
648
|
-
const installedAt = asNonEmptyString(obj.installedAt);
|
|
649
|
-
if (!id || !source || !ref || !artifactUrl || !stashRoot || !cacheDir || !installedAt)
|
|
650
|
-
return undefined;
|
|
651
|
-
const entry = {
|
|
652
|
-
id,
|
|
653
|
-
source,
|
|
654
|
-
ref,
|
|
655
|
-
artifactUrl,
|
|
656
|
-
stashRoot,
|
|
657
|
-
cacheDir,
|
|
658
|
-
installedAt,
|
|
659
|
-
};
|
|
660
|
-
if (typeof obj.writable === "boolean")
|
|
661
|
-
entry.writable = obj.writable;
|
|
662
|
-
if (entry.writable === true && entry.source !== "git") {
|
|
663
|
-
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.");
|
|
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.`);
|
|
664
351
|
}
|
|
665
|
-
|
|
666
|
-
if (resolvedVersion)
|
|
667
|
-
entry.resolvedVersion = resolvedVersion;
|
|
668
|
-
const resolvedRevision = asNonEmptyString(obj.resolvedRevision);
|
|
669
|
-
if (resolvedRevision)
|
|
670
|
-
entry.resolvedRevision = resolvedRevision;
|
|
671
|
-
const wikiName = asNonEmptyString(obj.wikiName);
|
|
672
|
-
if (wikiName)
|
|
673
|
-
entry.wikiName = wikiName;
|
|
674
|
-
return entry;
|
|
352
|
+
return sanitized;
|
|
675
353
|
}
|
|
676
|
-
|
|
677
|
-
|
|
354
|
+
/** Matches `${VAR}`, `${VAR:-default}`, or `$VAR`. */
|
|
355
|
+
function isEnvReference(value) {
|
|
356
|
+
return /^\$\{[^}]+\}$|^\$[A-Za-z_][A-Za-z0-9_]*$/.test(value);
|
|
357
|
+
}
|
|
358
|
+
export function updateConfig(partial) {
|
|
359
|
+
const current = loadUserConfig();
|
|
360
|
+
const merged = mergeLoadedConfig(current, partial);
|
|
361
|
+
saveConfig(merged);
|
|
362
|
+
return merged;
|
|
678
363
|
}
|
|
364
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
679
365
|
/**
|
|
680
|
-
*
|
|
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.
|
|
681
370
|
*
|
|
682
|
-
*
|
|
683
|
-
*
|
|
684
|
-
*
|
|
685
|
-
*
|
|
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.
|
|
686
377
|
*/
|
|
687
|
-
function
|
|
688
|
-
if (value ===
|
|
689
|
-
return value;
|
|
690
|
-
return undefined;
|
|
691
|
-
}
|
|
692
|
-
function parseRegistriesConfig(value) {
|
|
693
|
-
if (!Array.isArray(value))
|
|
694
|
-
return undefined;
|
|
695
|
-
const entries = value
|
|
696
|
-
.map((entry) => parseRegistryConfigEntry(entry))
|
|
697
|
-
.filter((entry) => entry !== undefined);
|
|
698
|
-
// Return the array even if empty — an explicit empty array means "no registries"
|
|
699
|
-
// which overrides the default. Only return undefined if the field was not an array.
|
|
700
|
-
return entries;
|
|
701
|
-
}
|
|
702
|
-
function parseSourcesConfig(value) {
|
|
703
|
-
if (!Array.isArray(value))
|
|
378
|
+
export function resolveSecret(value) {
|
|
379
|
+
if (value === undefined)
|
|
704
380
|
return undefined;
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
return undefined;
|
|
717
|
-
return { installAudit };
|
|
718
|
-
}
|
|
719
|
-
function parseInstallAuditConfig(value) {
|
|
720
|
-
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
721
|
-
return undefined;
|
|
722
|
-
const obj = value;
|
|
723
|
-
const config = {};
|
|
724
|
-
if (typeof obj.enabled === "boolean")
|
|
725
|
-
config.enabled = obj.enabled;
|
|
726
|
-
if (typeof obj.blockOnCritical === "boolean")
|
|
727
|
-
config.blockOnCritical = obj.blockOnCritical;
|
|
728
|
-
if (typeof obj.blockUnlistedRegistries === "boolean")
|
|
729
|
-
config.blockUnlistedRegistries = obj.blockUnlistedRegistries;
|
|
730
|
-
const rawAllowlist = filterNonEmptyStrings(obj.registryAllowlist) ?? filterNonEmptyStrings(obj.registryWhitelist);
|
|
731
|
-
if (rawAllowlist) {
|
|
732
|
-
config.registryAllowlist = rawAllowlist;
|
|
733
|
-
}
|
|
734
|
-
const allowedFindings = parseInstallAuditAllowedFindings(obj.allowedFindings);
|
|
735
|
-
if (allowedFindings) {
|
|
736
|
-
config.allowedFindings = allowedFindings;
|
|
737
|
-
}
|
|
738
|
-
return Object.keys(config).length > 0 ? config : undefined;
|
|
739
|
-
}
|
|
740
|
-
function parseInstallAuditAllowedFindings(value) {
|
|
741
|
-
if (!Array.isArray(value))
|
|
742
|
-
return undefined;
|
|
743
|
-
const findings = value
|
|
744
|
-
.map((entry) => parseInstallAuditAllowedFinding(entry))
|
|
745
|
-
.filter((entry) => entry !== undefined);
|
|
746
|
-
return findings.length > 0 ? findings : undefined;
|
|
747
|
-
}
|
|
748
|
-
function parseInstallAuditAllowedFinding(value) {
|
|
749
|
-
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
750
|
-
return undefined;
|
|
751
|
-
const obj = value;
|
|
752
|
-
const id = asNonEmptyString(obj.id);
|
|
753
|
-
if (!id)
|
|
754
|
-
return undefined;
|
|
755
|
-
const finding = { id };
|
|
756
|
-
const ref = asNonEmptyString(obj.ref);
|
|
757
|
-
if (ref)
|
|
758
|
-
finding.ref = ref;
|
|
759
|
-
const entryPath = asNonEmptyString(obj.path);
|
|
760
|
-
if (entryPath)
|
|
761
|
-
finding.path = entryPath;
|
|
762
|
-
const reason = asNonEmptyString(obj.reason);
|
|
763
|
-
if (reason)
|
|
764
|
-
finding.reason = reason;
|
|
765
|
-
return finding;
|
|
766
|
-
}
|
|
767
|
-
function parseSourceConfigEntry(value) {
|
|
768
|
-
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
769
|
-
return undefined;
|
|
770
|
-
const obj = value;
|
|
771
|
-
const type = asNonEmptyString(obj.type);
|
|
772
|
-
if (!type)
|
|
773
|
-
return undefined;
|
|
774
|
-
if (type === "openviking") {
|
|
775
|
-
const name = asNonEmptyString(obj.name) ?? "unnamed";
|
|
776
|
-
throw new ConfigError(`openviking is not supported in akm v1. API-backed sources will return as a\nseparate QuerySource tier post-v1. Remove the source named "${name}" from your config file\nor downgrade to 0.6.x. See docs/migration/v1.md.`, "INVALID_CONFIG_FILE", `Run \`akm remove ${name}\` then re-run, or edit your config file directly at ${_getConfigPath()} to remove the openviking entry.`);
|
|
777
|
-
}
|
|
778
|
-
const entry = { type };
|
|
779
|
-
const entryPath = asNonEmptyString(obj.path);
|
|
780
|
-
if (entryPath)
|
|
781
|
-
entry.path = entryPath;
|
|
782
|
-
const url = asNonEmptyString(obj.url);
|
|
783
|
-
if (url)
|
|
784
|
-
entry.url = url;
|
|
785
|
-
const name = asNonEmptyString(obj.name);
|
|
786
|
-
if (name)
|
|
787
|
-
entry.name = name;
|
|
788
|
-
if (typeof obj.enabled === "boolean")
|
|
789
|
-
entry.enabled = obj.enabled;
|
|
790
|
-
if (typeof obj.writable === "boolean")
|
|
791
|
-
entry.writable = obj.writable;
|
|
792
|
-
if (typeof obj.primary === "boolean")
|
|
793
|
-
entry.primary = obj.primary;
|
|
794
|
-
// Locked decision 4 (§6 v1 implementation plan): reject writable: true on
|
|
795
|
-
// website / npm sources at config load. The next sync() would clobber
|
|
796
|
-
// writes — allowing this is a footgun, not a feature. Throw early so the
|
|
797
|
-
// user sees the problem at `akm` startup, not when they try to write.
|
|
798
|
-
if (entry.writable === true && (type === "website" || type === "npm")) {
|
|
799
|
-
const label = entry.name ? ` "${entry.name}"` : "";
|
|
800
|
-
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.");
|
|
801
|
-
}
|
|
802
|
-
if (typeof obj.options === "object" && obj.options !== null && !Array.isArray(obj.options)) {
|
|
803
|
-
entry.options = obj.options;
|
|
804
|
-
}
|
|
805
|
-
const wikiName = asNonEmptyString(obj.wikiName);
|
|
806
|
-
if (wikiName)
|
|
807
|
-
entry.wikiName = wikiName;
|
|
808
|
-
return entry;
|
|
809
|
-
}
|
|
810
|
-
// ── ConfiguredSource runtime construction ─────────────────────────────────────────
|
|
811
|
-
/**
|
|
812
|
-
* Synthesize a stable identifier when a {@link SourceConfigEntry} omits its
|
|
813
|
-
* `name`. Uses a short hash of the discriminating fields so two equivalent
|
|
814
|
-
* entries collapse to the same generated name.
|
|
815
|
-
*/
|
|
816
|
-
function deriveStashEntryName(entry) {
|
|
817
|
-
if (entry.name)
|
|
818
|
-
return entry.name;
|
|
819
|
-
const seed = JSON.stringify({
|
|
820
|
-
type: entry.type,
|
|
821
|
-
path: entry.path ?? null,
|
|
822
|
-
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] ?? "";
|
|
823
392
|
});
|
|
824
|
-
const hash = createHash("sha256").update(seed).digest("hex").slice(0, 8);
|
|
825
|
-
return `${entry.type}-${hash}`;
|
|
826
393
|
}
|
|
827
394
|
/**
|
|
828
|
-
*
|
|
829
|
-
*
|
|
830
|
-
*
|
|
831
|
-
* `filesystem` entry with no `path`); callers should drop or warn for those.
|
|
832
|
-
*
|
|
833
|
-
* Unknown provider types fall back to `{ type: "filesystem", path: ... }` when
|
|
834
|
-
* a `path` is supplied, so future provider types still produce a usable
|
|
835
|
-
* runtime value.
|
|
836
|
-
*/
|
|
837
|
-
export function parseSourceSpec(entry) {
|
|
838
|
-
switch (entry.type) {
|
|
839
|
-
case "filesystem":
|
|
840
|
-
return entry.path ? { type: "filesystem", path: entry.path } : undefined;
|
|
841
|
-
case "git":
|
|
842
|
-
return entry.url ? { type: "git", url: entry.url } : undefined;
|
|
843
|
-
case "website":
|
|
844
|
-
return entry.url
|
|
845
|
-
? {
|
|
846
|
-
type: "website",
|
|
847
|
-
url: entry.url,
|
|
848
|
-
...(typeof entry.options?.maxPages === "number" ? { maxPages: entry.options.maxPages } : {}),
|
|
849
|
-
}
|
|
850
|
-
: undefined;
|
|
851
|
-
case "npm":
|
|
852
|
-
// Persisted `npm` stash entries are unusual but supported for symmetry.
|
|
853
|
-
return entry.path ? { type: "npm", package: entry.path } : undefined;
|
|
854
|
-
default:
|
|
855
|
-
// Unknown provider — best-effort fallback so callers still get something.
|
|
856
|
-
return entry.path ? { type: "filesystem", path: entry.path } : undefined;
|
|
857
|
-
}
|
|
858
|
-
}
|
|
859
|
-
/**
|
|
860
|
-
* Build the full ordered list of runtime {@link ConfiguredSource} values from a
|
|
861
|
-
* loaded {@link AkmConfig}. Order is the canonical iteration order:
|
|
862
|
-
*
|
|
863
|
-
* 1. The entry marked `primary: true` (or, as a backwards-compat shim,
|
|
864
|
-
* a synthetic filesystem entry built from the top-level `stashDir`).
|
|
865
|
-
* 2. Remaining `sources[]` entries in declared order.
|
|
866
|
-
* 3. Legacy `installed[]` entries, mapped into runtime entries.
|
|
867
|
-
*
|
|
868
|
-
* Entries with `enabled: false` are still emitted — callers decide whether
|
|
869
|
-
* to honour the flag (mirrors how `installed[]` entries have always been
|
|
870
|
-
* unconditional). Entries that fail {@link parseSourceSpec} are
|
|
871
|
-
* dropped silently.
|
|
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.
|
|
872
398
|
*/
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
let primary = sources.find((entry) => entry.primary === true);
|
|
878
|
-
if (!primary && config.stashDir) {
|
|
879
|
-
primary = { type: "filesystem", path: config.stashDir, primary: true };
|
|
880
|
-
}
|
|
881
|
-
if (primary) {
|
|
882
|
-
const runtime = toConfiguredSource(primary, true);
|
|
883
|
-
if (runtime)
|
|
884
|
-
entries.push(runtime);
|
|
885
|
-
}
|
|
886
|
-
// (2) Declared sources (skip the primary entry — already added).
|
|
887
|
-
for (const entry of sources) {
|
|
888
|
-
if (entry === primary)
|
|
889
|
-
continue;
|
|
890
|
-
const runtime = toConfiguredSource(entry, false);
|
|
891
|
-
if (runtime)
|
|
892
|
-
entries.push(runtime);
|
|
893
|
-
}
|
|
894
|
-
// (3) Legacy installed[] entries.
|
|
895
|
-
for (const installed of config.installed ?? []) {
|
|
896
|
-
entries.push({
|
|
897
|
-
name: installed.id,
|
|
898
|
-
type: "filesystem",
|
|
899
|
-
source: { type: "filesystem", path: installed.stashRoot },
|
|
900
|
-
enabled: true,
|
|
901
|
-
writable: installed.writable,
|
|
902
|
-
...(installed.wikiName ? { wikiName: installed.wikiName } : {}),
|
|
903
|
-
});
|
|
904
|
-
}
|
|
905
|
-
return entries;
|
|
906
|
-
}
|
|
907
|
-
function toConfiguredSource(persisted, isPrimary) {
|
|
908
|
-
const source = parseSourceSpec(persisted);
|
|
909
|
-
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)
|
|
910
403
|
return undefined;
|
|
911
|
-
|
|
912
|
-
name: deriveStashEntryName(persisted),
|
|
913
|
-
type: persisted.type,
|
|
914
|
-
source,
|
|
915
|
-
...(persisted.enabled !== undefined ? { enabled: persisted.enabled } : {}),
|
|
916
|
-
...(persisted.writable !== undefined ? { writable: persisted.writable } : {}),
|
|
917
|
-
...(isPrimary || persisted.primary ? { primary: true } : {}),
|
|
918
|
-
...(persisted.options ? { options: persisted.options } : {}),
|
|
919
|
-
...(persisted.wikiName ? { wikiName: persisted.wikiName } : {}),
|
|
920
|
-
};
|
|
921
|
-
}
|
|
922
|
-
function parseRegistryConfigEntry(value) {
|
|
923
|
-
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
404
|
+
if (INDEX_RESERVED_KEYS.has(passName))
|
|
924
405
|
return undefined;
|
|
925
|
-
const
|
|
926
|
-
|
|
927
|
-
if (!url?.startsWith("http"))
|
|
406
|
+
const entry = config[passName];
|
|
407
|
+
if (!entry || typeof entry !== "object")
|
|
928
408
|
return undefined;
|
|
929
|
-
const entry = { url };
|
|
930
|
-
const name = asNonEmptyString(obj.name);
|
|
931
|
-
if (name)
|
|
932
|
-
entry.name = name;
|
|
933
|
-
if (typeof obj.enabled === "boolean")
|
|
934
|
-
entry.enabled = obj.enabled;
|
|
935
|
-
const provider = asNonEmptyString(obj.provider);
|
|
936
|
-
if (provider)
|
|
937
|
-
entry.provider = provider;
|
|
938
|
-
if (typeof obj.options === "object" && obj.options !== null && !Array.isArray(obj.options)) {
|
|
939
|
-
entry.options = obj.options;
|
|
940
|
-
}
|
|
941
409
|
return entry;
|
|
942
410
|
}
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
const baseProfiles = base.profiles;
|
|
946
|
-
const overrideProfiles = override.profiles;
|
|
947
|
-
if (baseProfiles && overrideProfiles) {
|
|
948
|
-
const profiles = { ...baseProfiles };
|
|
949
|
-
for (const [name, entry] of Object.entries(overrideProfiles)) {
|
|
950
|
-
const existing = baseProfiles[name];
|
|
951
|
-
profiles[name] = existing ? { ...existing, ...entry } : entry;
|
|
952
|
-
}
|
|
953
|
-
merged.profiles = profiles;
|
|
954
|
-
}
|
|
955
|
-
return merged;
|
|
956
|
-
}
|
|
957
|
-
function mergeSecurityConfig(base, override) {
|
|
958
|
-
if (!base && !override)
|
|
959
|
-
return undefined;
|
|
960
|
-
const installAudit = mergeInstallAuditConfig(base?.installAudit, override?.installAudit);
|
|
961
|
-
return installAudit ? { installAudit } : undefined;
|
|
962
|
-
}
|
|
963
|
-
function mergeInstallAuditConfig(base, override) {
|
|
964
|
-
if (!base && !override)
|
|
965
|
-
return undefined;
|
|
966
|
-
const merged = {
|
|
967
|
-
...(base ?? {}),
|
|
968
|
-
...(override ?? {}),
|
|
969
|
-
};
|
|
970
|
-
return Object.values(merged).some((value) => value !== undefined) ? merged : undefined;
|
|
971
|
-
}
|
|
411
|
+
// Re-export source runtime helpers — implementation lives in config-sources.ts.
|
|
412
|
+
export { parseSourceSpec, resolveConfiguredSources } from "./config-sources";
|
|
972
413
|
/**
|
|
973
|
-
* Merge a
|
|
974
|
-
*
|
|
975
|
-
*
|
|
976
|
-
*
|
|
977
|
-
* clobbering sibling settings. `sources` are additive by default, but a later
|
|
978
|
-
* 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.
|
|
979
418
|
*/
|
|
980
419
|
function mergeLoadedConfig(base, override) {
|
|
981
420
|
if (!override)
|
|
982
421
|
return { ...base };
|
|
983
|
-
const merged = {
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
if (base.embedding && override.embedding) {
|
|
991
|
-
merged.embedding = { ...base.embedding, ...override.embedding };
|
|
992
|
-
}
|
|
993
|
-
if (base.llm && override.llm) {
|
|
994
|
-
merged.llm = { ...base.llm, ...override.llm };
|
|
995
|
-
}
|
|
996
|
-
if (base.index || override.index) {
|
|
997
|
-
// Deep-merge per-pass entries so a project layer can opt one pass out
|
|
998
|
-
// without dropping siblings configured in user config.
|
|
999
|
-
const mergedIndex = { ...(base.index ?? {}) };
|
|
1000
|
-
for (const [passName, passOverride] of Object.entries(override.index ?? {})) {
|
|
1001
|
-
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] };
|
|
1002
429
|
}
|
|
1003
|
-
if (Object.keys(mergedIndex).length > 0)
|
|
1004
|
-
merged.index = mergedIndex;
|
|
1005
|
-
}
|
|
1006
|
-
if (base.security && override.security) {
|
|
1007
|
-
merged.security = mergeSecurityConfig(base.security, override.security);
|
|
1008
|
-
}
|
|
1009
|
-
if (base.agent && override.agent) {
|
|
1010
|
-
merged.agent = mergeAgentConfig(base.agent, override.agent);
|
|
1011
|
-
}
|
|
1012
|
-
const replaceSources = override.stashInheritance === "replace";
|
|
1013
|
-
const overrideSources = override.sources ?? [];
|
|
1014
|
-
const baseSources = base.sources ?? [];
|
|
1015
|
-
if (replaceSources) {
|
|
1016
|
-
merged.sources = [...overrideSources];
|
|
1017
430
|
}
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
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;
|
|
1023
439
|
}
|
|
1024
440
|
return merged;
|
|
1025
441
|
}
|
|
@@ -1030,51 +446,51 @@ function applyRuntimeEnvApiKeys(config) {
|
|
|
1030
446
|
if (envKey)
|
|
1031
447
|
next.embedding = { ...next.embedding, apiKey: envKey };
|
|
1032
448
|
}
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
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 };
|
|
1037
468
|
}
|
|
1038
469
|
return next;
|
|
1039
470
|
}
|
|
1040
471
|
/**
|
|
1041
|
-
*
|
|
1042
|
-
*
|
|
1043
|
-
*
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
const configPath = getConfigPath();
|
|
1047
|
-
const paths = [];
|
|
1048
|
-
if (isFile(configPath)) {
|
|
1049
|
-
paths.push(configPath);
|
|
1050
|
-
}
|
|
1051
|
-
return [...paths, ...discoverProjectConfigPaths(process.cwd())];
|
|
1052
|
-
}
|
|
1053
|
-
/**
|
|
1054
|
-
* Walk from `startDir` up to the filesystem root and collect `.akm/config.json`
|
|
1055
|
-
* files. Paths are returned from outermost parent to innermost directory so
|
|
1056
|
-
* 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.
|
|
1057
477
|
*/
|
|
1058
|
-
|
|
1059
|
-
|
|
478
|
+
const PROJECT_CONFIG_DEPRECATION_WARNED = new Set();
|
|
479
|
+
function warnIfProjectConfigPresent(startDir) {
|
|
1060
480
|
let currentDir = path.resolve(startDir);
|
|
1061
481
|
while (true) {
|
|
1062
482
|
const configPath = path.join(currentDir, PROJECT_CONFIG_RELATIVE_PATH);
|
|
1063
|
-
if (isFile(configPath)) {
|
|
1064
|
-
|
|
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.");
|
|
1065
488
|
}
|
|
1066
489
|
const parentDir = path.dirname(currentDir);
|
|
1067
|
-
if (parentDir === currentDir)
|
|
490
|
+
if (parentDir === currentDir)
|
|
1068
491
|
break;
|
|
1069
|
-
}
|
|
1070
492
|
currentDir = parentDir;
|
|
1071
493
|
}
|
|
1072
|
-
return paths;
|
|
1073
|
-
}
|
|
1074
|
-
function getConfigSignature(configPaths) {
|
|
1075
|
-
if (configPaths.length === 0)
|
|
1076
|
-
return "defaults";
|
|
1077
|
-
return configPaths.map((configPath) => `${configPath}:${getFileSignatureToken(configPath)}`).join("|");
|
|
1078
494
|
}
|
|
1079
495
|
function isFile(filePath) {
|
|
1080
496
|
try {
|
|
@@ -1084,23 +500,3 @@ function isFile(filePath) {
|
|
|
1084
500
|
return false;
|
|
1085
501
|
}
|
|
1086
502
|
}
|
|
1087
|
-
function getFileSignatureToken(filePath) {
|
|
1088
|
-
try {
|
|
1089
|
-
const stat = fs.statSync(filePath);
|
|
1090
|
-
// mtimeMs alone is unreliable on filesystems with low-resolution mtime
|
|
1091
|
-
// (HFS+, some network FS, or very fast back-to-back writes in tests).
|
|
1092
|
-
// Combine mtime + size + content hash so the signature actually changes
|
|
1093
|
-
// when content does.
|
|
1094
|
-
let contentHash = "";
|
|
1095
|
-
try {
|
|
1096
|
-
contentHash = hashString(fs.readFileSync(filePath, "utf8"));
|
|
1097
|
-
}
|
|
1098
|
-
catch {
|
|
1099
|
-
// ignore — fall back to stat-only signature
|
|
1100
|
-
}
|
|
1101
|
-
return `${stat.mtimeMs}:${stat.size}:${contentHash}`;
|
|
1102
|
-
}
|
|
1103
|
-
catch {
|
|
1104
|
-
return "missing";
|
|
1105
|
-
}
|
|
1106
|
-
}
|