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.
- package/CHANGELOG.md +144 -0
- package/dist/assets/tasks/core/index-refresh.yml +1 -1
- package/dist/cli/retired-commands.js +2 -0
- package/dist/cli/unknown-flags.js +36 -3
- package/dist/commands/improve/collapse-detector.js +2 -2
- package/dist/commands/improve/consolidate.js +6 -4
- package/dist/commands/improve/improve-cli.js +1 -1
- package/dist/commands/proposal/repository.js +12 -3
- package/dist/commands/read/curate.js +34 -44
- package/dist/commands/read/search.js +50 -2
- package/dist/commands/sources/index-status.js +99 -0
- package/dist/commands/sources/info.js +8 -8
- package/dist/commands/sources/installed-stashes.js +33 -12
- package/dist/commands/sources/source-add.js +21 -6
- package/dist/commands/sources/stash-cli.js +119 -111
- package/dist/core/adapter/adapters/akm-adapter.js +35 -3
- package/dist/core/adapter/adapters/akm-metadata.js +11 -1
- package/dist/core/asset/asset-placement.js +35 -0
- package/dist/core/config/schema/embedding.js +7 -30
- package/dist/core/config/schema/search.js +11 -9
- package/dist/core/errors.js +5 -2
- package/dist/core/hash.js +18 -0
- package/dist/core/maintenance-barrier.js +8 -6
- package/dist/core/paths.js +0 -11
- package/dist/core/run-lock.js +5 -2
- package/dist/core/state/migrations.js +26 -1
- package/dist/core/state-db.js +63 -27
- package/dist/indexer/drain.js +306 -0
- package/dist/indexer/embedding-identity.js +20 -0
- package/dist/indexer/enrich.js +260 -0
- package/dist/indexer/ensure-index.js +5 -0
- package/dist/indexer/index-written-assets.js +133 -171
- package/dist/indexer/indexer.js +458 -1621
- package/dist/indexer/lookup/adapter-concept-owner.js +19 -5
- package/dist/indexer/passes/metadata.js +18 -1
- package/dist/indexer/reconcile.js +890 -0
- package/dist/indexer/scan/drain-dir.js +27 -70
- package/dist/indexer/scan/parse-file.js +66 -0
- package/dist/indexer/search/db-search.js +373 -89
- package/dist/indexer/search/ranking-contributors.js +21 -16
- package/dist/indexer/search/ranking.js +135 -57
- package/dist/indexer/units/unit.js +159 -0
- package/dist/llm/client.js +10 -1
- package/dist/llm/embedder.js +10 -3
- package/dist/llm/embedders/provider-limits.js +288 -0
- package/dist/llm/embedders/remote.js +133 -104
- package/dist/llm/feature-gate.js +4 -2
- package/dist/llm/rerank-client.js +3 -3
- package/dist/output/shapes/passthrough.js +1 -0
- package/dist/output/text/command-format.js +19 -13
- package/dist/output/text/helpers.js +1 -1
- package/dist/output/text/index.js +5 -2
- package/dist/scripts/akm-migrate-node.js +1141 -1237
- package/dist/scripts/akm-migrate.js +1141 -1237
- package/dist/setup/semantic-assets.js +2 -2
- package/dist/setup/steps/connection.js +3 -2
- package/dist/storage/repositories/files-repository.js +181 -0
- package/dist/storage/repositories/index-connection.js +1 -3
- package/dist/storage/repositories/index-entries-repository.js +77 -68
- package/dist/storage/repositories/index-entry-schema.js +16 -25
- package/dist/storage/repositories/index-fts-repository.js +29 -263
- package/dist/storage/repositories/index-meta-repository.js +0 -29
- package/dist/storage/repositories/index-schema.js +115 -122
- package/dist/storage/repositories/index-utility-repository.js +1 -1
- package/dist/storage/repositories/index-vec-repository.js +21 -334
- package/dist/storage/repositories/units-repository.js +510 -0
- package/docs/migration/release-notes/0.9.15.md +34 -36
- package/docs/migration/release-notes/0.9.16.md +110 -0
- package/docs/migration/release-notes/README.md +5 -0
- package/docs/reference/cli.md +93 -87
- package/docs/reference/configuration.md +128 -89
- package/docs/reference/data-and-telemetry.md +2 -1
- package/package.json +1 -1
- package/schemas/akm-config.json +2 -58
- package/dist/indexer/index-db-contention.js +0 -56
- package/dist/indexer/index-rebuild-lock.js +0 -73
- package/dist/indexer/materialize-embeddings.js +0 -771
- package/dist/indexer/passes/dir-staleness.js +0 -161
- 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
|
-
*
|
|
11
|
-
* the
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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 (
|
|
142
|
+
if (includeTypes && !includeTypes.has(found.entry.type))
|
|
67
143
|
continue;
|
|
68
|
-
|
|
69
|
-
if (excludeTypeSet?.has(found.entry.type))
|
|
144
|
+
if (excludeTypes?.has(found.entry.type))
|
|
70
145
|
continue;
|
|
71
|
-
|
|
72
|
-
id,
|
|
146
|
+
results.push({
|
|
147
|
+
id: entryId,
|
|
73
148
|
entry: found.entry,
|
|
74
149
|
filePath: found.filePath,
|
|
75
|
-
score
|
|
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
|
|
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
|
+
}
|
package/dist/llm/client.js
CHANGED
|
@@ -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 =
|
|
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 };
|
package/dist/llm/embedder.js
CHANGED
|
@@ -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
|