akm-cli 0.9.16-alpha.1 → 0.9.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/CHANGELOG.md +56 -132
  2. package/dist/assets/hints/cli-hints-full.md +13 -6
  3. package/dist/assets/tasks/core/index-refresh.yml +1 -1
  4. package/dist/assets/tasks/improve/akm-improve-catchup.yml +3 -6
  5. package/dist/cli/retired-commands.js +0 -4
  6. package/dist/cli/unknown-flags.js +3 -36
  7. package/dist/commands/env/env-binding.js +4 -4
  8. package/dist/commands/env/env-cli.js +3 -3
  9. package/dist/commands/improve/collapse-detector.js +2 -2
  10. package/dist/commands/improve/consolidate.js +4 -6
  11. package/dist/commands/improve/improve-cli.js +20 -15
  12. package/dist/commands/improve/reflect.js +23 -2
  13. package/dist/commands/lint/base-linter.js +9 -0
  14. package/dist/commands/lint/env-key-rules.js +2 -2
  15. package/dist/commands/proposal/propose.js +15 -1
  16. package/dist/commands/proposal/repository.js +3 -12
  17. package/dist/commands/proposal/validators/proposal-quality-validators.js +40 -3
  18. package/dist/commands/proposal/validators/proposal-validators.js +5 -4
  19. package/dist/commands/read/curate.js +44 -34
  20. package/dist/commands/read/search.js +35 -54
  21. package/dist/commands/read/show.js +21 -2
  22. package/dist/commands/registry-cli.js +5 -5
  23. package/dist/commands/sources/add-cli.js +59 -16
  24. package/dist/commands/sources/bundle-cli.js +35 -11
  25. package/dist/commands/sources/bundle-config-ops.js +30 -0
  26. package/dist/commands/sources/dangerous-env-audit.js +4 -4
  27. package/dist/commands/sources/info.js +8 -8
  28. package/dist/commands/sources/installed-stashes.js +55 -61
  29. package/dist/commands/sources/source-add.js +39 -38
  30. package/dist/commands/sources/source-manage.js +34 -12
  31. package/dist/commands/sources/stash-cli.js +111 -119
  32. package/dist/commands/sources/stash-skeleton.js +6 -3
  33. package/dist/commands/tasks/explain.js +4 -1
  34. package/dist/commands/tasks/tasks-cli.js +31 -9
  35. package/dist/commands/tasks/tasks.js +239 -194
  36. package/dist/commands/tasks/validate.js +20 -32
  37. package/dist/core/activation-policy.js +4 -4
  38. package/dist/core/adapter/adapters/akm-adapter.js +8 -35
  39. package/dist/core/adapter/adapters/akm-metadata.js +1 -11
  40. package/dist/core/adapter/execution-source.js +10 -29
  41. package/dist/core/asset/asset-placement.js +0 -35
  42. package/dist/core/config/config-schema.js +64 -8
  43. package/dist/core/config/config-sources.js +96 -2
  44. package/dist/core/config/config.js +190 -24
  45. package/dist/core/config/legacy-source-shape-shim.js +9 -0
  46. package/dist/core/config/schema/embedding.js +30 -7
  47. package/dist/core/config/schema/execution.js +23 -0
  48. package/dist/core/config/schema/experimental.js +1 -1
  49. package/dist/core/config/schema/scheduler.js +20 -0
  50. package/dist/core/config/schema/search.js +10 -12
  51. package/dist/core/config/schema/sources-bundles.js +32 -1
  52. package/dist/core/content-safety.js +52 -0
  53. package/dist/core/errors.js +2 -5
  54. package/dist/core/maintenance-barrier.js +11 -13
  55. package/dist/core/paths.js +11 -0
  56. package/dist/core/run-lock.js +2 -5
  57. package/dist/core/state/migrations.js +1 -26
  58. package/dist/core/state-db.js +27 -63
  59. package/dist/core/type-presentation.js +1 -1
  60. package/dist/core/write-source.js +13 -8
  61. package/dist/indexer/bundle-identity-guard.js +45 -8
  62. package/dist/indexer/ensure-index.js +0 -5
  63. package/dist/indexer/index-db-contention.js +56 -0
  64. package/dist/indexer/index-rebuild-lock.js +73 -0
  65. package/dist/indexer/index-written-assets.js +171 -133
  66. package/dist/indexer/indexer.js +1621 -458
  67. package/dist/indexer/lookup/adapter-concept-owner.js +5 -19
  68. package/dist/indexer/materialize-embeddings.js +785 -0
  69. package/dist/indexer/passes/dir-staleness.js +161 -0
  70. package/dist/indexer/passes/metadata.js +1 -18
  71. package/dist/indexer/scan/drain-dir.js +70 -27
  72. package/dist/indexer/search/db-search.js +89 -373
  73. package/dist/indexer/search/ranking-contributors.js +16 -21
  74. package/dist/indexer/search/ranking.js +57 -135
  75. package/dist/indexer/search/search-source.js +29 -11
  76. package/dist/integrations/agent/execution-lowering.js +3 -2
  77. package/dist/integrations/agent/execution-preparation.js +32 -1
  78. package/dist/integrations/agent/prompts.js +1 -1
  79. package/dist/integrations/agent/request-lowering.js +3 -2
  80. package/dist/llm/client.js +3 -11
  81. package/dist/llm/embedder.js +3 -10
  82. package/dist/llm/embedders/remote.js +104 -133
  83. package/dist/llm/feature-gate.js +2 -4
  84. package/dist/llm/rerank-client.js +3 -3
  85. package/dist/output/html-render.js +2 -1
  86. package/dist/output/shapes/passthrough.js +2 -1
  87. package/dist/output/stdout.js +24 -0
  88. package/dist/output/text/command-format.js +13 -19
  89. package/dist/output/text/helpers.js +1 -1
  90. package/dist/output/text/index.js +2 -5
  91. package/dist/output/text.js +4 -3
  92. package/dist/registry/resolve.js +37 -10
  93. package/dist/scripts/akm-migrate-node.js +15197 -11351
  94. package/dist/scripts/akm-migrate.js +15514 -11668
  95. package/dist/setup/semantic-assets.js +2 -2
  96. package/dist/setup/setup.js +3 -3
  97. package/dist/setup/steps/connection.js +2 -3
  98. package/dist/setup/steps/tasks.js +29 -36
  99. package/dist/sources/providers/git-install.js +17 -11
  100. package/dist/sources/providers/git-provider.js +12 -5
  101. package/dist/sources/providers/git-stash.js +38 -16
  102. package/dist/sources/snapshot-fetchers/website-ingest.js +3 -3
  103. package/dist/storage/repositories/embedding-salvage-repository.js +184 -0
  104. package/dist/storage/repositories/index-connection.js +3 -1
  105. package/dist/storage/repositories/index-entries-repository.js +68 -77
  106. package/dist/storage/repositories/index-entry-schema.js +25 -16
  107. package/dist/storage/repositories/index-fts-repository.js +263 -29
  108. package/dist/storage/repositories/index-meta-repository.js +29 -0
  109. package/dist/storage/repositories/index-schema.js +122 -115
  110. package/dist/storage/repositories/index-utility-repository.js +1 -1
  111. package/dist/storage/repositories/index-vec-repository.js +435 -22
  112. package/dist/tasks/activation-config.js +90 -0
  113. package/dist/tasks/backends/cron.js +9 -0
  114. package/dist/tasks/backends/launchd.js +1 -0
  115. package/dist/tasks/backends/schtasks.js +2 -0
  116. package/dist/tasks/embedded.js +4 -5
  117. package/dist/tasks/scheduler-binding.js +2 -2
  118. package/dist/tasks/scheduler-sync-preview.js +8 -1
  119. package/dist/tasks/scheduler-sync.js +19 -10
  120. package/dist/tasks/source/parse-task-source.js +10 -113
  121. package/dist/tasks/source/project-v4.js +2 -2
  122. package/dist/tasks/source/task-source-v4.js +4 -12
  123. package/dist/tasks/source/task-to-v3.js +4 -12
  124. package/dist/tasks/source/task-to-v4.js +40 -7
  125. package/docs/migration/README.md +1 -0
  126. package/docs/migration/release-notes/0.9.15.md +36 -34
  127. package/docs/migration/release-notes/0.9.16.md +60 -98
  128. package/docs/migration/release-notes/README.md +0 -5
  129. package/docs/migration/v0.9.1-to-v0.9.2.md +6 -9
  130. package/docs/reference/cli.md +124 -122
  131. package/docs/reference/configuration.md +137 -133
  132. package/docs/reference/data-and-telemetry.md +1 -2
  133. package/docs/reference/tasks.md +34 -29
  134. package/package.json +1 -1
  135. package/schemas/akm-config.json +170 -6
  136. package/schemas/akm-task.json +1 -2
  137. package/dist/commands/sources/index-status.js +0 -99
  138. package/dist/core/hash.js +0 -18
  139. package/dist/indexer/drain.js +0 -306
  140. package/dist/indexer/embedding-identity.js +0 -20
  141. package/dist/indexer/enrich.js +0 -260
  142. package/dist/indexer/reconcile.js +0 -890
  143. package/dist/indexer/scan/parse-file.js +0 -66
  144. package/dist/indexer/units/unit.js +0 -159
  145. package/dist/llm/embedders/provider-limits.js +0 -288
  146. package/dist/storage/repositories/files-repository.js +0 -181
  147. package/dist/storage/repositories/units-repository.js +0 -510
@@ -2,162 +2,84 @@
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  import { stableFtsScore } from "../../core/lexical-score.js";
5
- import { getEntryById } from "../../storage/repositories/index-entries-repository.js";
6
5
  import { getUtilityScoresByIds } from "../../storage/repositories/index-utility-repository.js";
7
- import { groupUnitHitsByEntry } from "../../storage/repositories/units-repository.js";
8
6
  import { buildLexicalQueryPlan } from "./fts-query.js";
9
7
  import { lexicalNameTokens, structuralNameTokenMatch } from "./name-match.js";
10
8
  import { applyBeliefStateScoreCeiling, applyScoreContributors, applyUtilityContributors, defaultRankingContributors, defaultUtilityRankingContributors, } from "./ranking-contributors.js";
11
9
  /**
12
- * The pre-redesign lexical/semantic split (`combineSearchScores`, retired
13
- * with the units path but restored here — measured against `curate-golden`,
14
- * see docs/plans/index-redesign.md's Search section): with both present,
15
- * lexical evidence dominates and semantic refines it; nothing here is a new
16
- * tuned value.
10
+ * Lower bounds keep a lexical hit competitive with a vector-only neighbour;
11
+ * the upper bound deliberately leaves room for the ranking contributors that
12
+ * run after retrieval (notably the bounded graph boost). This is a
13
+ * calibration for the one search pipeline, not a claim that BM25 is
14
+ * comparable across different queries or FTS tables.
17
15
  */
18
- const LEXICAL_WEIGHT = 0.7;
19
- const SEMANTIC_WEIGHT = 0.3;
20
16
  /**
21
- * `unit_texts.kind` is redundant with `fragmentId` nullity by construction
22
- * (A1's `deriveUnits`: ordinal 0 is always the one structured-fields "card"
23
- * unit; every fragment-derived unit carries a non-null `fragmentId`), so
24
- * `matchedUnit.kind` is derived here instead of a second table read.
25
- */
26
- function unitKindFromFragmentId(fragmentId) {
27
- return fragmentId === null ? "card" : "fragment";
28
- }
29
- /**
30
- * Group lexical unit hits to entries via `entry_units`, keeping the
31
- * strongest (most negative bm25 — see `stableFtsScore`) unit per entry, not
32
- * merely the best-RANKED one: magnitude, not rank, is what `fuseByEntry`
33
- * scores on.
17
+ * Convert FTS5's negative BM25 value into the lexical contribution used by
18
+ * this pipeline. The transform is fixed and monotone: it depends only on a
19
+ * row's own BM25 value, so appending weaker candidates cannot rewrite an
20
+ * existing row's score. FTS5 commonly emits relevance near 1e-6 for broad
21
+ * queries, so first put relevance on a log scale around that observed value.
22
+ * The shape constant intentionally makes the curve approach its ceiling
23
+ * slowly: rare-term scores retain separation instead of all reading as 0.8.
24
+ *
25
+ * FTS5 produces finite non-positive values in normal operation. Keeping the
26
+ * defensive cases here finite makes this boundary safe if a driver or fixture
27
+ * hands us an invalid value: `-Infinity` is the strongest possible match,
28
+ * while NaN, +Infinity, and positive scores contribute no lexical evidence.
34
29
  */
35
- function groupLexicalHitsByEntry(db, hits) {
36
- const best = new Map();
37
- if (hits.length === 0)
38
- return best;
39
- const hashes = [...new Set(hits.map((hit) => hit.unitHash))];
40
- const placeholders = hashes.map(() => "?").join(",");
41
- const rows = db
42
- .prepare(`SELECT entry_id AS entryId, fragment_id AS fragmentId, unit_hash AS unitHash FROM entry_units WHERE unit_hash IN (${placeholders})`)
43
- .all(...hashes);
44
- const ownersByHash = new Map();
45
- for (const row of rows) {
46
- const owners = ownersByHash.get(row.unitHash) ?? [];
47
- owners.push({ entryId: row.entryId, fragmentId: row.fragmentId });
48
- ownersByHash.set(row.unitHash, owners);
49
- }
50
- for (const hit of hits) {
51
- for (const owner of ownersByHash.get(hit.unitHash) ?? []) {
52
- const existing = best.get(owner.entryId);
53
- if (!existing || hit.bm25 < existing.bm25) {
54
- best.set(owner.entryId, {
55
- bm25: hit.bm25,
56
- unitHash: hit.unitHash,
57
- fragmentId: owner.fragmentId,
58
- lexicalMatch: hit.lexicalMatch,
59
- });
60
- }
61
- }
30
+ export function normalizeFtsScores(results) {
31
+ const ftsScoreMap = new Map();
32
+ for (const result of results) {
33
+ ftsScoreMap.set(result.id, { score: result.lexicalScore ?? stableFtsScore(result.bm25Score), result });
62
34
  }
63
- return best;
64
- }
65
- /**
66
- * Which unit is reported as `matchedUnit`/`fragmentId`: a fixed priority
67
- * order by evidence strength — card (name/description/tags/hints, the
68
- * strongest, most structured signal), then fragment, then a vector
69
- * neighbor — never a magnitude comparison across the three. At least one of
70
- * the three is defined for every `entryId` this is called with.
71
- */
72
- function pickFusionWinner(cardHit, fragmentHit, semanticHit) {
73
- if (cardHit)
74
- return { unitHash: cardHit.unitHash, fragmentId: cardHit.fragmentId };
75
- if (fragmentHit)
76
- return { unitHash: fragmentHit.unitHash, fragmentId: fragmentHit.fragmentId };
77
- const winner = semanticHit;
78
- return { unitHash: winner.hash, fragmentId: winner.fragmentId };
79
- }
80
- /**
81
- * Cosine similarity from a `units_vec` L2 distance over normalized vectors —
82
- * exactly the retired `tryVecScores`' own conversion, guarded the same way:
83
- * `1 - distance²/2`, clamped at 0, non-finite results treated as no evidence.
84
- */
85
- function semanticCosine(distance) {
86
- const raw = 1 - (distance * distance) / 2;
87
- return Number.isFinite(raw) ? Math.max(0, raw) : 0;
35
+ return ftsScoreMap;
88
36
  }
89
- /**
90
- * Fuse the card-lexical, fragment-lexical (both `units_fts`) and semantic
91
- * (`units_vec`) unit-level result lists into one ranked entry list
92
- * (index-redesign-contract.md B3, restructured for field emphasis by B5f
93
- * item 2). Splitting lexical into two kind-scoped lists — rather than one
94
- * pool mixing card and fragment units — is what replaces the old per-column
95
- * BM25 weights (name 10x, description 5x, tags 3x, hints 2x, content 1x): a
96
- * card (name/description/tags/hints) match ranks within its own small pool
97
- * instead of racing every fragment's body text on raw BM25, so field
98
- * emphasis falls out of the units' structure with no weight tuned.
99
- *
100
- * Each list is first grouped to entries via `entry_units`, keeping that
101
- * list's own strongest unit per entry — `groupUnitHitsByEntry` from the
102
- * stage-1 unit store does this (by distance) for the semantic side;
103
- * `groupLexicalHitsByEntry` mirrors it (by bm25) for each lexical side.
104
- *
105
- * The fused score is MAGNITUDE, not reciprocal-rank fusion: rank-only fusion
106
- * was measured against the `curate-golden` gate fixture and lost to this —
107
- * see docs/plans/index-redesign.md's Search section for the table. Lexical
108
- * evidence is `stableFtsScore(bm25, population)` (the calibrated, monotone,
109
- * per-row transform — floor 0.3, ceiling 0.8), taken as the BEST of the
110
- * entry's card ("parent" population) and fragment ("fragment" population)
111
- * magnitudes; semantic evidence is cosine similarity. Combined the way the
112
- * pre-redesign `combineSearchScores` combined FTS and vector scores: with
113
- * both present, `lexical * 0.7 + semantic * 0.3`; lexical alone is itself;
114
- * semantic alone is `semantic * 0.3` (never enough alone to outrank a real
115
- * lexical hit's 0.3 floor). `matchedUnit` reports the unit by evidence
116
- * priority, not magnitude (see `pickFusionWinner`).
117
- */
118
- export function fuseByEntry(db, cardLexical, fragmentLexical, semantic, opts = {}) {
119
- const cardByEntry = groupLexicalHitsByEntry(db, cardLexical);
120
- const fragmentByEntry = groupLexicalHitsByEntry(db, fragmentLexical);
121
- const semanticByEntry = groupUnitHitsByEntry(db, semantic);
122
- const includeTypes = opts.typeFilter && opts.typeFilter.length > 0 ? new Set(opts.typeFilter) : null;
123
- const excludeTypes = opts.excludeTypes && opts.excludeTypes.length > 0 ? new Set(opts.excludeTypes) : null;
124
- const entryIds = new Set([...cardByEntry.keys(), ...fragmentByEntry.keys(), ...semanticByEntry.keys()]);
125
- const results = [];
126
- for (const entryId of entryIds) {
127
- const cardHit = cardByEntry.get(entryId);
128
- const fragmentHit = fragmentByEntry.get(entryId);
129
- const semanticHit = semanticByEntry.get(entryId);
130
- const lexicalHit = cardHit ?? fragmentHit;
131
- const lexicalScore = Math.max(cardHit ? stableFtsScore(cardHit.bm25, "parent") : 0, fragmentHit ? stableFtsScore(fragmentHit.bm25, "fragment") : 0);
132
- const semanticScore = semanticHit ? semanticCosine(semanticHit.distance) : undefined;
133
- const score = lexicalHit
134
- ? semanticScore === undefined
135
- ? lexicalScore
136
- : lexicalScore * LEXICAL_WEIGHT + semanticScore * SEMANTIC_WEIGHT
137
- : (semanticScore ?? 0) * SEMANTIC_WEIGHT;
138
- const { unitHash, fragmentId } = pickFusionWinner(cardHit, fragmentHit, semanticHit);
139
- const found = getEntryById(db, entryId);
37
+ export function combineSearchScores(options) {
38
+ const FTS_WEIGHT = 0.7;
39
+ const VEC_WEIGHT = 0.3;
40
+ const excludeTypeSet = options.excludeTypes && options.excludeTypes.length > 0 ? new Set(options.excludeTypes) : null;
41
+ const scored = [];
42
+ const seenIds = new Set();
43
+ for (const [id, { score: ftsScore, result }] of options.ftsScoreMap) {
44
+ seenIds.add(id);
45
+ const embedScore = options.embedScoreMap.get(id);
46
+ const combinedScore = embedScore !== undefined ? ftsScore * FTS_WEIGHT + embedScore * VEC_WEIGHT : ftsScore;
47
+ scored.push({
48
+ id,
49
+ entry: result.entry,
50
+ filePath: result.filePath,
51
+ score: combinedScore,
52
+ rankingMode: embedScore !== undefined ? "hybrid" : "fts",
53
+ lexicalMatch: result.lexicalMatch,
54
+ itemRef: result.itemRef,
55
+ bundleId: result.bundleId,
56
+ conceptId: result.conceptId,
57
+ fragmentId: result.fragmentId,
58
+ });
59
+ }
60
+ for (const [id, cosine] of options.embedScoreMap) {
61
+ if (seenIds.has(id))
62
+ continue;
63
+ const found = options.getEntryById(id);
140
64
  if (!found)
141
65
  continue;
142
- if (includeTypes && !includeTypes.has(found.entry.type))
66
+ if (options.typeFilter && found.entry.type !== options.typeFilter)
143
67
  continue;
144
- if (excludeTypes?.has(found.entry.type))
68
+ // #627 — drop vector-only neighbors whose type is excluded on the default path.
69
+ if (excludeTypeSet?.has(found.entry.type))
145
70
  continue;
146
- results.push({
147
- id: entryId,
71
+ scored.push({
72
+ id,
148
73
  entry: found.entry,
149
74
  filePath: found.filePath,
150
- score,
151
- rankingMode: lexicalHit && semanticHit ? "hybrid" : lexicalHit ? "fts" : "semantic",
75
+ score: cosine * VEC_WEIGHT,
76
+ rankingMode: "semantic",
152
77
  itemRef: found.itemRef,
153
78
  bundleId: found.bundleId,
154
79
  conceptId: found.conceptId,
155
- ...(lexicalHit ? { lexicalMatch: lexicalHit.lexicalMatch } : {}),
156
- ...(fragmentId ? { fragmentId } : {}),
157
- matchedUnit: { unitHash, fragmentId, kind: unitKindFromFragmentId(fragmentId) },
158
80
  });
159
81
  }
160
- return results;
82
+ return scored;
161
83
  }
162
84
  export function applyRankingRules(options) {
163
85
  const queryTokens = buildLexicalQueryPlan(options.query).tokens.map((token) => token.toLowerCase());
@@ -4,7 +4,8 @@
4
4
  import fs from "node:fs";
5
5
  import path from "node:path";
6
6
  import { isWithin, resolveStashDir } from "../../core/common.js";
7
- import { bundleComponentConfig, bundlesToSourceEntries, getSources, loadConfig } from "../../core/config/config.js";
7
+ import { bundleComponentConfig, bundleKeyForContentRoot, bundlesToSourceEntries, getSources, isBundleEnabled, loadConfig, } from "../../core/config/config.js";
8
+ import { ConfigError } from "../../core/errors.js";
8
9
  import { getUnresolvedSourcesDir } from "../../core/paths.js";
9
10
  import { resolveGitContentRoot, resolveWritable } from "../../core/write-source.js";
10
11
  import { lockContentRootFor } from "../../integrations/lockfile.js";
@@ -23,7 +24,7 @@ import { warn } from "../../core/warn.js";
23
24
  * 2. The configured `defaultBundle`, after component-root validation.
24
25
  * 3. Remaining configured bundles in installation-priority order.
25
26
  *
26
- * Disabled entries (`enabled: false`) are filtered after deduplication.
27
+ * Disabled entries (`enabled: false`) are excluded before materialization.
27
28
  * Missing configured roots remain in the result so the
28
29
  * indexer can classify their scan as incomplete instead of mistaking them for
29
30
  * removed sources.
@@ -39,23 +40,39 @@ export function resolveSourceEntries(overrideStashDir, existingConfig) {
39
40
  : config.defaultBundle
40
41
  ? undefined
41
42
  : resolveStashDir();
43
+ const implicitConfiguredBundle = implicitStashDir ? bundleKeyForContentRoot(config, implicitStashDir) : undefined;
44
+ if (implicitConfiguredBundle && !isBundleEnabled(config, implicitConfiguredBundle)) {
45
+ throw new ConfigError(`The requested working source is disabled as bundle ${JSON.stringify(implicitConfiguredBundle)}.`, "INVALID_CONFIG_FILE");
46
+ }
42
47
  // Explicit and environment overrides stay first. Without either override,
43
48
  // the configured default enters through the validated loop below.
44
- const sources = implicitStashDir ? [{ path: implicitStashDir, writable: true }] : [];
45
- const seen = new Set(implicitStashDir ? [implicitStashDir] : []);
46
- const addSource = (dir, registryId, writable, type, adapterId, unresolved = false) => {
49
+ const sources = implicitStashDir ? [{ path: implicitStashDir, writable: true, isDefault: true }] : [];
50
+ const sourceIdentity = (dir) => {
51
+ const resolved = path.resolve(dir);
52
+ try {
53
+ return fs.realpathSync.native(resolved);
54
+ }
55
+ catch {
56
+ return resolved;
57
+ }
58
+ };
59
+ const seen = new Set(implicitStashDir ? [sourceIdentity(implicitStashDir)] : []);
60
+ const addSource = (dir, registryId, writable, type, adapterId, unresolved = false, isDefault = false) => {
47
61
  const resolved = path.resolve(dir);
48
- if (seen.has(resolved)) {
62
+ const identity = sourceIdentity(resolved);
63
+ if (seen.has(identity)) {
49
64
  // Already in the source list — typically the primary stash injected at
50
65
  // sources[0] before this loop. Enrich that entry with whatever metadata
51
66
  // the matching config source carries so `--from <config-name>` can
52
67
  // find it via registryId. Without this, the primary stash entry stays
53
68
  // identity-less and a user-named primary source ("name": "my-stash")
54
69
  // would validate but match zero entries when filtering.
55
- const existing = sources.find((s) => s.path === resolved);
70
+ const existing = sources.find((source) => sourceIdentity(source.path) === identity);
56
71
  if (existing && existing.type === undefined) {
57
72
  if (registryId)
58
73
  existing.registryId = registryId;
74
+ if (isDefault)
75
+ existing.isDefault = true;
59
76
  existing.type = type;
60
77
  existing.writable = writable;
61
78
  existing.adapterId = adapterId;
@@ -64,13 +81,14 @@ export function resolveSourceEntries(overrideStashDir, existingConfig) {
64
81
  existing.unresolved = true;
65
82
  return;
66
83
  }
67
- seen.add(resolved);
84
+ seen.add(identity);
68
85
  if (isSuspiciousStashRoot(dir)) {
69
86
  warn(`Warning: stash root "${dir}" appears to be a system directory. This may be unintentional.`);
70
87
  }
71
88
  sources.push({
72
89
  path: resolved,
73
90
  ...(registryId ? { registryId } : {}),
91
+ ...(isDefault ? { isDefault: true } : {}),
74
92
  writable,
75
93
  type,
76
94
  ...(adapterId ? { adapterId } : {}),
@@ -88,17 +106,17 @@ export function resolveSourceEntries(overrideStashDir, existingConfig) {
88
106
  const contentRoot = resolveEntryContentDir(entry);
89
107
  if (contentRoot == null) {
90
108
  const unresolvedPath = path.join(getUnresolvedSourcesDir(implicitStashDir ?? process.cwd()), entry.name ?? entry.type);
91
- addSource(unresolvedPath, entry.name, component?.writable ?? resolveWritable(entry), entry.type, component?.adapter, true);
109
+ addSource(unresolvedPath, entry.name, component?.writable ?? resolveWritable(entry), entry.type, component?.adapter, true, entry.name === config.defaultBundle);
92
110
  continue;
93
111
  }
94
112
  const dir = path.resolve(contentRoot, component?.root ?? ".");
95
113
  if (!isWithin(dir, contentRoot)) {
96
114
  warn(`Warning: component root "${component?.root}" escapes bundle "${entry.name}"; skipping source.`);
97
115
  const unresolvedPath = path.join(getUnresolvedSourcesDir(contentRoot), entry.name ?? entry.type);
98
- addSource(unresolvedPath, entry.name, component?.writable ?? resolveWritable(entry), entry.type, component?.adapter, true);
116
+ addSource(unresolvedPath, entry.name, component?.writable ?? resolveWritable(entry), entry.type, component?.adapter, true, entry.name === config.defaultBundle);
99
117
  continue;
100
118
  }
101
- addSource(dir, entry.name, component?.writable ?? resolveWritable(entry), entry.type, component?.adapter);
119
+ addSource(dir, entry.name, component?.writable ?? resolveWritable(entry), entry.type, component?.adapter, false, entry.name === config.defaultBundle);
102
120
  }
103
121
  return sources;
104
122
  }
@@ -581,8 +581,9 @@ function lowerLlm(request, base) {
581
581
  (typeof tools === "object" && tools !== null && !Array.isArray(tools) && Object.keys(tools).length === 0);
582
582
  if (empty)
583
583
  translated.add("tools");
584
- else
585
- reject("tools");
584
+ else {
585
+ throw new ConfigError("The direct LLM transport cannot enforce the resolved tool policy.", "INVALID_CONFIG_FILE");
586
+ }
586
587
  }
587
588
  if (own(request.runtime, "workspace")) {
588
589
  if (request.runtime.workspace === null || request.runtime.workspace === "")
@@ -9,6 +9,37 @@ import { loadModelMap } from "./model-map.js";
9
9
  function own(value, key) {
10
10
  return Object.hasOwn(value, key);
11
11
  }
12
+ function requestedToolNames(tools) {
13
+ if (tools === null)
14
+ return Object.freeze([]);
15
+ if (typeof tools === "string") {
16
+ return Object.freeze(tools
17
+ .split(",")
18
+ .map((tool) => tool.trim())
19
+ .filter(Boolean));
20
+ }
21
+ if (Array.isArray(tools))
22
+ return Object.freeze(tools.map((tool) => tool.trim()).filter(Boolean));
23
+ const structured = tools;
24
+ // The portable structured spelling is a boolean allow/deny map. Opaque or
25
+ // nested provider policy cannot be reduced to tool names without guessing,
26
+ // so a host name allowlist must deny it (an explicit "*" may still allow it).
27
+ if (Object.values(structured).some((value) => typeof value !== "boolean"))
28
+ return undefined;
29
+ return Object.freeze(Object.keys(structured).filter((tool) => structured[tool] === true));
30
+ }
31
+ /** The production authorizer: executable assets may only narrow a host-owned ceiling. */
32
+ export function toolAuthorizerFromConfig(config) {
33
+ const allowed = new Set(config.execution?.allowedTools ?? []);
34
+ return (input) => {
35
+ const requested = requestedToolNames(input.tools);
36
+ const permitted = allowed.has("*") || requested?.every((tool) => allowed.has(tool)) === true;
37
+ return Object.freeze({
38
+ status: permitted ? "allowed" : "denied",
39
+ policy: "config-execution-allowed-tools",
40
+ });
41
+ };
42
+ }
12
43
  /** Pure common cascade composition once caller-specific sources are rendered. */
13
44
  export function planPreparedExecution(options) {
14
45
  return planExecutionCascade({
@@ -56,7 +87,7 @@ export function prepareResolvedExecution(options) {
56
87
  engines: executionEngineDefinitionsFromConfig(config),
57
88
  modelMap: options.modelMap ?? loadModelMap({ engines: config.engines }).map,
58
89
  invocationKind: options.invocationKind,
59
- ...(options.authorizeTools ? { authorizeTools: options.authorizeTools } : {}),
90
+ authorizeTools: options.authorizeTools ?? toolAuthorizerFromConfig(config),
60
91
  });
61
92
  return Object.freeze({
62
93
  plan,
@@ -324,7 +324,7 @@ export function buildReflectPrompt(input) {
324
324
  sections.push(lines.join("\n"));
325
325
  }
326
326
  if (input.avoidPatterns && input.avoidPatterns.length > 0) {
327
- sections.push(`## Avoid These Patterns\nPrevious assets in this run produced these errors — do not repeat them:\n${input.avoidPatterns.map((e) => `- ${e}`).join("\n")}`);
327
+ sections.push(`## Avoid These Patterns\nRun-only guidance: do not copy this heading, explanation, or any diagnostic below into the proposed asset.\nPrevious assets in this run produced these errors — do not repeat them:\n${input.avoidPatterns.map((e) => `- ${e}`).join("\n")}`);
328
328
  }
329
329
  // R-1 / #372: Self-Refine (arXiv:2303.17651) — inject prior draft as critique target.
330
330
  // On refinement iterations (iter > 0), the agent is shown its previous proposal
@@ -162,8 +162,9 @@ export function createAgentRequestLowerer(options) {
162
162
  const tools = request.tools;
163
163
  if (translatesTools(options.tools, tools))
164
164
  translated.add("tools");
165
- else
166
- reject("tools");
165
+ else {
166
+ throw new ConfigError(`The ${options.adapter} transport cannot enforce the resolved tool policy.`, "INVALID_CONFIG_FILE");
167
+ }
167
168
  dispatch.tools = tools;
168
169
  }
169
170
  if (own(request.runtime, "settings")) {
@@ -444,11 +444,12 @@ async function chatCompletionAttemptOnce(config, messages, options, timeoutMs, i
444
444
  * attempts the schema request fresh every time and falls back once per call
445
445
  * on a 4xx — see the in-memory tracker below.
446
446
  */
447
- export async function probeLlmReachable(config) {
447
+ export async function probeLlmReachable(config, timeoutMs) {
448
448
  try {
449
449
  const raw = await chatCompletion(config, [{ role: "user", content: "Respond with just the word: ok" }], {
450
450
  maxTokens: 16,
451
451
  temperature: 0,
452
+ ...(timeoutMs !== undefined ? { timeoutMs } : {}),
452
453
  });
453
454
  return raw.length > 0 ? { reachable: true } : { reachable: false, error: "empty response" };
454
455
  }
@@ -456,15 +457,6 @@ export async function probeLlmReachable(config) {
456
457
  return { reachable: false, error: err instanceof Error ? err.message : String(err) };
457
458
  }
458
459
  }
459
- /**
460
- * Default bound for a best-effort capability/reachability probe (#914):
461
- * generous enough for a cold local model server to answer a route-existence
462
- * check, short enough that `akm health --probe` and the provider-limits
463
- * probe (`src/llm/embedders/provider-limits.ts`, index-units) do not stall a
464
- * run on a dead endpoint. Shared so both probes bound themselves to the same
465
- * value instead of drifting apart.
466
- */
467
- export const HEALTH_PROBE_TIMEOUT_MS = 3_000;
468
460
  /**
469
461
  * Reachability probe for `akm health` (#914): one GET against the
470
462
  * OpenAI-compatible `/models` route, bounded by `timeoutMs`. Any HTTP
@@ -472,7 +464,7 @@ export const HEALTH_PROBE_TIMEOUT_MS = 3_000;
472
464
  * answers, not whether the route exists or the credential is right — so a
473
465
  * cold local server is never asked to load a model just to be checked.
474
466
  */
475
- export async function probeLlmEndpoint(config, timeoutMs = HEALTH_PROBE_TIMEOUT_MS) {
467
+ export async function probeLlmEndpoint(config, timeoutMs = 3_000) {
476
468
  try {
477
469
  await fetch(`${config.endpoint.replace(/\/+$/, "")}/models`, { signal: AbortSignal.timeout(timeoutMs) });
478
470
  return { reachable: true };
@@ -104,17 +104,10 @@ async function embedOnce(text, embeddingConfig, signal) {
104
104
  * `onBatch`, when given, fires once per provider/local batch as it completes
105
105
  * (#954) so a caller can commit each batch's rows durably as they land
106
106
  * rather than buffering the whole call — see `EmbeddingBatchCommit`.
107
- *
108
- * `packing`, when given, threads a remote request's window/exact-token-
109
- * counter/Ollama `num_ctx` in from the provider's own probed limits
110
- * (`probeProviderLimits`, `src/llm/embedders/provider-limits.ts`) instead of
111
- * the retired `embedding.maxTokens`/`batchSize`/`contextLength` config keys
112
- * (index redesign, B5) — see `EmbeddingRequestPacking`. Only the remote
113
- * branch below consumes it; local/deterministic embedding ignores it.
114
107
  */
115
- export async function embedBatch(texts, embeddingConfig, signal, onSkip, onBatch, packing) {
108
+ export async function embedBatch(texts, embeddingConfig, signal, onSkip, onBatch) {
116
109
  if (embedderOverrides?.embedBatch) {
117
- return embedderOverrides.embedBatch(texts, embeddingConfig, signal, onSkip, onBatch, packing);
110
+ return embedderOverrides.embedBatch(texts, embeddingConfig, signal, onSkip, onBatch);
118
111
  }
119
112
  if (texts.length === 0)
120
113
  return [];
@@ -128,7 +121,7 @@ export async function embedBatch(texts, embeddingConfig, signal, onSkip, onBatch
128
121
  return embeddings;
129
122
  }
130
123
  if (embeddingConfig && hasRemoteEndpoint(embeddingConfig)) {
131
- return new RemoteEmbedder(embeddingConfig).embedBatch(texts, signal, onSkip, onBatch, packing);
124
+ return new RemoteEmbedder(embeddingConfig).embedBatch(texts, signal, onSkip, onBatch);
132
125
  }
133
126
  // Local transformer: use the batched path (chunks of 32 via LocalEmbedder).
134
127
  // When a localModel override is set we cannot share the singleton (which uses