akm-cli 0.9.15 → 0.9.16-alpha.1

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 (79) hide show
  1. package/CHANGELOG.md +144 -0
  2. package/dist/assets/tasks/core/index-refresh.yml +1 -1
  3. package/dist/cli/retired-commands.js +2 -0
  4. package/dist/cli/unknown-flags.js +36 -3
  5. package/dist/commands/improve/collapse-detector.js +2 -2
  6. package/dist/commands/improve/consolidate.js +6 -4
  7. package/dist/commands/improve/improve-cli.js +1 -1
  8. package/dist/commands/proposal/repository.js +12 -3
  9. package/dist/commands/read/curate.js +34 -44
  10. package/dist/commands/read/search.js +50 -2
  11. package/dist/commands/sources/index-status.js +99 -0
  12. package/dist/commands/sources/info.js +8 -8
  13. package/dist/commands/sources/installed-stashes.js +33 -12
  14. package/dist/commands/sources/source-add.js +21 -6
  15. package/dist/commands/sources/stash-cli.js +119 -111
  16. package/dist/core/adapter/adapters/akm-adapter.js +35 -3
  17. package/dist/core/adapter/adapters/akm-metadata.js +11 -1
  18. package/dist/core/asset/asset-placement.js +35 -0
  19. package/dist/core/config/schema/embedding.js +7 -30
  20. package/dist/core/config/schema/search.js +11 -9
  21. package/dist/core/errors.js +5 -2
  22. package/dist/core/hash.js +18 -0
  23. package/dist/core/maintenance-barrier.js +8 -6
  24. package/dist/core/paths.js +0 -11
  25. package/dist/core/run-lock.js +5 -2
  26. package/dist/core/state/migrations.js +26 -1
  27. package/dist/core/state-db.js +63 -27
  28. package/dist/indexer/drain.js +306 -0
  29. package/dist/indexer/embedding-identity.js +20 -0
  30. package/dist/indexer/enrich.js +260 -0
  31. package/dist/indexer/ensure-index.js +5 -0
  32. package/dist/indexer/index-written-assets.js +133 -171
  33. package/dist/indexer/indexer.js +458 -1621
  34. package/dist/indexer/lookup/adapter-concept-owner.js +19 -5
  35. package/dist/indexer/passes/metadata.js +18 -1
  36. package/dist/indexer/reconcile.js +890 -0
  37. package/dist/indexer/scan/drain-dir.js +27 -70
  38. package/dist/indexer/scan/parse-file.js +66 -0
  39. package/dist/indexer/search/db-search.js +373 -89
  40. package/dist/indexer/search/ranking-contributors.js +21 -16
  41. package/dist/indexer/search/ranking.js +135 -57
  42. package/dist/indexer/units/unit.js +159 -0
  43. package/dist/llm/client.js +10 -1
  44. package/dist/llm/embedder.js +10 -3
  45. package/dist/llm/embedders/provider-limits.js +288 -0
  46. package/dist/llm/embedders/remote.js +133 -104
  47. package/dist/llm/feature-gate.js +4 -2
  48. package/dist/llm/rerank-client.js +3 -3
  49. package/dist/output/shapes/passthrough.js +1 -0
  50. package/dist/output/text/command-format.js +19 -13
  51. package/dist/output/text/helpers.js +1 -1
  52. package/dist/output/text/index.js +5 -2
  53. package/dist/scripts/akm-migrate-node.js +1141 -1237
  54. package/dist/scripts/akm-migrate.js +1141 -1237
  55. package/dist/setup/semantic-assets.js +2 -2
  56. package/dist/setup/steps/connection.js +3 -2
  57. package/dist/storage/repositories/files-repository.js +181 -0
  58. package/dist/storage/repositories/index-connection.js +1 -3
  59. package/dist/storage/repositories/index-entries-repository.js +77 -68
  60. package/dist/storage/repositories/index-entry-schema.js +16 -25
  61. package/dist/storage/repositories/index-fts-repository.js +29 -263
  62. package/dist/storage/repositories/index-meta-repository.js +0 -29
  63. package/dist/storage/repositories/index-schema.js +115 -122
  64. package/dist/storage/repositories/index-utility-repository.js +1 -1
  65. package/dist/storage/repositories/index-vec-repository.js +21 -334
  66. package/dist/storage/repositories/units-repository.js +510 -0
  67. package/docs/migration/release-notes/0.9.15.md +34 -36
  68. package/docs/migration/release-notes/0.9.16.md +110 -0
  69. package/docs/migration/release-notes/README.md +5 -0
  70. package/docs/reference/cli.md +93 -87
  71. package/docs/reference/configuration.md +128 -89
  72. package/docs/reference/data-and-telemetry.md +2 -1
  73. package/package.json +1 -1
  74. package/schemas/akm-config.json +2 -58
  75. package/dist/indexer/index-db-contention.js +0 -56
  76. package/dist/indexer/index-rebuild-lock.js +0 -73
  77. package/dist/indexer/materialize-embeddings.js +0 -771
  78. package/dist/indexer/passes/dir-staleness.js +0 -161
  79. package/dist/storage/repositories/embedding-salvage-repository.js +0 -184
@@ -2,84 +2,162 @@
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";
5
6
  import { getUtilityScoresByIds } from "../../storage/repositories/index-utility-repository.js";
7
+ import { groupUnitHitsByEntry } from "../../storage/repositories/units-repository.js";
6
8
  import { buildLexicalQueryPlan } from "./fts-query.js";
7
9
  import { lexicalNameTokens, structuralNameTokenMatch } from "./name-match.js";
8
10
  import { applyBeliefStateScoreCeiling, applyScoreContributors, applyUtilityContributors, defaultRankingContributors, defaultUtilityRankingContributors, } from "./ranking-contributors.js";
9
11
  /**
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.
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.
15
17
  */
18
+ const LEXICAL_WEIGHT = 0.7;
19
+ const SEMANTIC_WEIGHT = 0.3;
16
20
  /**
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.
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.
29
25
  */
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 });
34
- }
35
- return ftsScoreMap;
26
+ function unitKindFromFragmentId(fragmentId) {
27
+ return fragmentId === null ? "card" : "fragment";
36
28
  }
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
- });
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.
34
+ */
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);
59
49
  }
60
- for (const [id, cosine] of options.embedScoreMap) {
61
- if (seenIds.has(id))
62
- continue;
63
- const found = options.getEntryById(id);
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
+ }
62
+ }
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;
88
+ }
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);
64
140
  if (!found)
65
141
  continue;
66
- if (options.typeFilter && found.entry.type !== options.typeFilter)
142
+ if (includeTypes && !includeTypes.has(found.entry.type))
67
143
  continue;
68
- // #627 — drop vector-only neighbors whose type is excluded on the default path.
69
- if (excludeTypeSet?.has(found.entry.type))
144
+ if (excludeTypes?.has(found.entry.type))
70
145
  continue;
71
- scored.push({
72
- id,
146
+ results.push({
147
+ id: entryId,
73
148
  entry: found.entry,
74
149
  filePath: found.filePath,
75
- score: cosine * VEC_WEIGHT,
76
- rankingMode: "semantic",
150
+ score,
151
+ rankingMode: lexicalHit && semanticHit ? "hybrid" : lexicalHit ? "fts" : "semantic",
77
152
  itemRef: found.itemRef,
78
153
  bundleId: found.bundleId,
79
154
  conceptId: found.conceptId,
155
+ ...(lexicalHit ? { lexicalMatch: lexicalHit.lexicalMatch } : {}),
156
+ ...(fragmentId ? { fragmentId } : {}),
157
+ matchedUnit: { unitHash, fragmentId, kind: unitKindFromFragmentId(fragmentId) },
80
158
  });
81
159
  }
82
- return scored;
160
+ return results;
83
161
  }
84
162
  export function applyRankingRules(options) {
85
163
  const queryTokens = buildLexicalQueryPlan(options.query).tokens.map((token) => token.toLowerCase());
@@ -0,0 +1,159 @@
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/.
4
+ import { parseMarkdownToc } from "../../core/asset/markdown.js";
5
+ import { splitMarkdownFragments } from "../../core/asset/markdown-fragments.js";
6
+ import { hashEmbeddableText } from "../../core/hash.js";
7
+ import { getMarkdownFragmentContent, hasMarkdownFragmentContent } from "../passes/metadata.js";
8
+ import { buildSearchFields } from "../search/search-fields.js";
9
+ /**
10
+ * Separator between the entry name and a fragment's section title in a unit
11
+ * header — the exact format the contract specifies, kept as one named
12
+ * constant so every header is built the same way.
13
+ */
14
+ const UNIT_HEADER_SECTION_SEPARATOR = " › ";
15
+ /** Unit 0's body: description, tags, hints, parameters — the non-empty ones, one per line, in that order. */
16
+ function structuredFieldsText(source) {
17
+ const body = [source.description, source.tags, source.hints, source.parameters]
18
+ .filter((field) => field.length > 0)
19
+ .join("\n");
20
+ return `${source.name}\n${body}`;
21
+ }
22
+ /**
23
+ * One line per parameter — `name`, or `name: description` when the
24
+ * parameter has a description — lowercased to match `buildSearchFields`'s
25
+ * other structured fields (`units_fts` is case-insensitive either way; this
26
+ * keeps the card unit's casing uniform). `""` when `parameters` is absent or
27
+ * empty, so `structuredFieldsText`'s filter drops it cleanly.
28
+ */
29
+ function parametersText(parameters) {
30
+ if (!parameters || parameters.length === 0)
31
+ return "";
32
+ return parameters
33
+ .map((param) => (param.description ? `${param.name}: ${param.description}` : param.name))
34
+ .join("\n")
35
+ .toLowerCase();
36
+ }
37
+ function fragmentHeaderText(name, sectionTitle) {
38
+ return sectionTitle ? `${name}${UNIT_HEADER_SECTION_SEPARATOR}${sectionTitle}` : name;
39
+ }
40
+ /**
41
+ * The section title standing over each fragment, in fragment order: a
42
+ * fragment's own first heading line when it starts with one, else the
43
+ * nearest heading at or before it (tracked while scanning fragments in
44
+ * document order), else `null`. Reuses `parseMarkdownToc`, the same heading
45
+ * list `splitMarkdownFragments` derives its section boundaries from, so a
46
+ * "#" inside a fenced code block is never mistaken for a real heading.
47
+ */
48
+ function fragmentSectionTitles(safeMarkdown, fragments) {
49
+ const headings = parseMarkdownToc(safeMarkdown).headings;
50
+ let headingIndex = 0;
51
+ let current = null;
52
+ return fragments.map((fragment) => {
53
+ while (headingIndex < headings.length && headings[headingIndex].line <= fragment.startLine) {
54
+ current = headings[headingIndex].text;
55
+ headingIndex++;
56
+ }
57
+ return current;
58
+ });
59
+ }
60
+ /**
61
+ * Split `text` into pieces no longer than `maxChars`: cut at the last "\n"
62
+ * before the bound, else the last space, else hard-split a single unbroken
63
+ * run (e.g. a URL) so every piece still respects the bound and the loop
64
+ * always terminates. The cut character itself is dropped, not carried by
65
+ * either side.
66
+ */
67
+ function splitOverflowingText(text, maxChars) {
68
+ const pieces = [];
69
+ let rest = text;
70
+ while (rest.length > maxChars) {
71
+ const window = rest.slice(0, maxChars);
72
+ const newlineCut = window.lastIndexOf("\n");
73
+ if (newlineCut > 0) {
74
+ pieces.push(rest.slice(0, newlineCut));
75
+ rest = rest.slice(newlineCut + 1);
76
+ continue;
77
+ }
78
+ const spaceCut = window.lastIndexOf(" ");
79
+ if (spaceCut > 0) {
80
+ pieces.push(rest.slice(0, spaceCut));
81
+ rest = rest.slice(spaceCut + 1);
82
+ continue;
83
+ }
84
+ pieces.push(rest.slice(0, maxChars));
85
+ rest = rest.slice(maxChars);
86
+ }
87
+ if (rest.length > 0)
88
+ pieces.push(rest);
89
+ return pieces;
90
+ }
91
+ /**
92
+ * maxChars bounds every unit's text; a unit over it is split at the last
93
+ * "\n" (else the last space) before the bound into sub-units that keep the
94
+ * source fragmentId and take the next ordinals.
95
+ */
96
+ export function deriveUnits(source, maxChars) {
97
+ if (!Number.isFinite(maxChars) || maxChars <= 0) {
98
+ throw new RangeError("deriveUnits: maxChars must be a positive finite number");
99
+ }
100
+ const units = [];
101
+ let ordinal = 0;
102
+ const pushUnit = (fragmentId, text) => {
103
+ units.push({ entryId: source.entryId, ordinal: ordinal++, fragmentId, hash: hashEmbeddableText(text), text });
104
+ };
105
+ for (const text of splitOverflowingText(structuredFieldsText(source), maxChars))
106
+ pushUnit(null, text);
107
+ if (source.safeMarkdown != null) {
108
+ const fragments = splitMarkdownFragments(source.safeMarkdown);
109
+ const sectionTitles = fragmentSectionTitles(source.safeMarkdown, fragments);
110
+ fragments.forEach((fragment, index) => {
111
+ const header = fragmentHeaderText(source.name, sectionTitles[index] ?? null);
112
+ const text = `${header}\n${fragment.text}`;
113
+ for (const piece of splitOverflowingText(text, maxChars))
114
+ pushUnit(fragment.fragmentId, piece);
115
+ });
116
+ }
117
+ return units;
118
+ }
119
+ /**
120
+ * `UnitSource` from an already-parsed `IndexDocument` — `buildSearchFields`
121
+ * for the structured fields, the entry's own carried markdown for fragments.
122
+ * Shared by `reconcile.ts` (a freshly-parsed entry) and `enrich.ts` (the same
123
+ * entry merged with LLM-enriched description/tags/searchHints, index-redesign
124
+ * B5e) — a leaf in `units/`, not either caller, so importing it never creates
125
+ * a reconcile.ts ↔ enrich.ts cycle.
126
+ *
127
+ * `hasMarkdownFragmentContent`/`getMarkdownFragmentContent` is the `akm`
128
+ * adapter's own line-structure-preserving fragment projection
129
+ * (`applyPreContributorFields`, gated `.md`-only and excluding sensitive
130
+ * types), set during `recognize` and present ONLY for that adapter (or a
131
+ * caller that re-tags a derived copy via `setMarkdownFragmentContent`, as
132
+ * `enrich.ts` does). Every other adapter (`okf`, ...) never calls it, so
133
+ * `hasMarkdownFragmentContent` is always false for their entries — falling
134
+ * straight to `null` here would leave their body content in `entries_fts`'s
135
+ * single per-entry `content` column (`buildSearchFields`, unconditional) but
136
+ * in NO unit at all, an asymmetry that would silently blank a whole
137
+ * adapter's fragment search once unit coverage is complete
138
+ * (index-redesign-contract.md B3). `entry.content` — the same field already
139
+ * surfaced through search hits and `show`, so already that adapter's own
140
+ * public-safe projection — is the fallback fragment source for exactly this
141
+ * case.
142
+ */
143
+ export function toUnitSource(entryId, entry) {
144
+ const fields = buildSearchFields(entry);
145
+ const safeMarkdown = hasMarkdownFragmentContent(entry)
146
+ ? (getMarkdownFragmentContent(entry) ?? null)
147
+ : typeof entry.content === "string" && entry.content.trim()
148
+ ? entry.content
149
+ : null;
150
+ return {
151
+ entryId,
152
+ name: fields.name,
153
+ description: fields.description,
154
+ tags: fields.tags,
155
+ hints: fields.hints,
156
+ parameters: parametersText(entry.parameters),
157
+ safeMarkdown,
158
+ };
159
+ }
@@ -456,6 +456,15 @@ export async function probeLlmReachable(config) {
456
456
  return { reachable: false, error: err instanceof Error ? err.message : String(err) };
457
457
  }
458
458
  }
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;
459
468
  /**
460
469
  * Reachability probe for `akm health` (#914): one GET against the
461
470
  * OpenAI-compatible `/models` route, bounded by `timeoutMs`. Any HTTP
@@ -463,7 +472,7 @@ export async function probeLlmReachable(config) {
463
472
  * answers, not whether the route exists or the credential is right — so a
464
473
  * cold local server is never asked to load a model just to be checked.
465
474
  */
466
- export async function probeLlmEndpoint(config, timeoutMs = 3_000) {
475
+ export async function probeLlmEndpoint(config, timeoutMs = HEALTH_PROBE_TIMEOUT_MS) {
467
476
  try {
468
477
  await fetch(`${config.endpoint.replace(/\/+$/, "")}/models`, { signal: AbortSignal.timeout(timeoutMs) });
469
478
  return { reachable: true };
@@ -104,10 +104,17 @@ 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.
107
114
  */
108
- export async function embedBatch(texts, embeddingConfig, signal, onSkip, onBatch) {
115
+ export async function embedBatch(texts, embeddingConfig, signal, onSkip, onBatch, packing) {
109
116
  if (embedderOverrides?.embedBatch) {
110
- return embedderOverrides.embedBatch(texts, embeddingConfig, signal, onSkip, onBatch);
117
+ return embedderOverrides.embedBatch(texts, embeddingConfig, signal, onSkip, onBatch, packing);
111
118
  }
112
119
  if (texts.length === 0)
113
120
  return [];
@@ -121,7 +128,7 @@ export async function embedBatch(texts, embeddingConfig, signal, onSkip, onBatch
121
128
  return embeddings;
122
129
  }
123
130
  if (embeddingConfig && hasRemoteEndpoint(embeddingConfig)) {
124
- return new RemoteEmbedder(embeddingConfig).embedBatch(texts, signal, onSkip, onBatch);
131
+ return new RemoteEmbedder(embeddingConfig).embedBatch(texts, signal, onSkip, onBatch, packing);
125
132
  }
126
133
  // Local transformer: use the batched path (chunks of 32 via LocalEmbedder).
127
134
  // When a localModel override is set we cannot share the singleton (which uses