akm-cli 0.9.2-alpha.1 → 0.9.2-alpha.2
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 +15 -28
- package/dist/assets/stash-skeleton/facts/conventions/backlinks.md +3 -4
- package/dist/assets/stash-skeleton/facts/conventions/organization.md +1 -3
- package/dist/commands/improve/collapse-detector.js +3 -4
- package/dist/commands/improve/extract-prompt.js +64 -22
- package/dist/commands/improve/extract.js +122 -53
- package/dist/commands/read/curate.js +43 -22
- package/dist/commands/sources/bundle-cli.js +1 -1
- package/dist/commands/sources/installed-stashes.js +67 -26
- package/dist/core/adapter/adapters/akm-adapter.js +2 -1
- package/dist/core/config/config.js +2 -6
- package/dist/core/config/schema/index-config.js +0 -27
- package/dist/indexer/index-written-assets.js +17 -9
- package/dist/indexer/indexer.js +32 -214
- package/dist/indexer/materialize-embeddings.js +155 -0
- package/dist/indexer/passes/metadata.js +263 -118
- package/dist/indexer/scan/doc-to-entry.js +0 -1
- package/dist/indexer/search/db-search.js +58 -28
- package/dist/indexer/search/fts-query.js +40 -40
- package/dist/indexer/search/ranking.js +36 -1
- package/dist/indexer/search/search-attribution.js +3 -1
- package/dist/indexer/search/search-fields.js +23 -14
- package/dist/output/text/command-format.js +3 -1
- package/dist/scripts/akm-migrate-node.js +12892 -12731
- package/dist/scripts/akm-migrate.js +12892 -12731
- package/dist/storage/repositories/index-entries-repository.js +40 -26
- package/dist/storage/repositories/index-entry-schema.js +1 -1
- package/dist/storage/repositories/index-fts-repository.js +56 -63
- package/dist/storage/repositories/index-schema.js +4 -9
- package/dist/storage/repositories/index-vec-repository.js +55 -6
- package/docs/reference/cli.md +4 -4
- package/docs/reference/configuration.md +6 -9
- package/package.json +1 -1
- package/schemas/akm-config.json +0 -8
|
@@ -32,8 +32,9 @@ import { ensureIndex } from "../ensure-index.js";
|
|
|
32
32
|
import { collectGraphRelatedHit, loadGraphBoostContext } from "../graph/graph-boost.js";
|
|
33
33
|
import { isProposedQuality } from "../passes/metadata.js";
|
|
34
34
|
import { resolveProjectContext } from "../walk/project-context.js";
|
|
35
|
-
import { parseRefPrefixQuery, parseRetiredTypePrefixQuery,
|
|
36
|
-
import { applyRankingRules, combineSearchScores, normalizeFtsScores } from "./ranking.js";
|
|
35
|
+
import { buildLexicalQueryPlan, parseRefPrefixQuery, parseRetiredTypePrefixQuery, } from "./fts-query.js";
|
|
36
|
+
import { applyRankingRules, combineSearchScores, lexicalNameMatchTier, normalizeFtsScores } from "./ranking.js";
|
|
37
|
+
import { typeBoostFor } from "./ranking-contributors.js";
|
|
37
38
|
import { attachSearchHitAttribution, copySearchHitAttribution, getSearchHitAttribution } from "./search-attribution.js";
|
|
38
39
|
import { enrichSearchHit } from "./search-hit-enrichers.js";
|
|
39
40
|
import { buildEditHint, findSourceForPath, isEditable } from "./search-source.js";
|
|
@@ -220,8 +221,38 @@ export async function searchLocal(input) {
|
|
|
220
221
|
}
|
|
221
222
|
}
|
|
222
223
|
// ── Database search ─────────────────────────────────────────────────────────
|
|
224
|
+
/**
|
|
225
|
+
* Keep one deterministic ranking order before stable path deduplication. Exact
|
|
226
|
+
* names survive the public score ceiling, while raw contributor differences
|
|
227
|
+
* are quantized so utility-recency epsilon cannot reorder visible ties.
|
|
228
|
+
*/
|
|
229
|
+
function buildSearchResultComparator(query) {
|
|
230
|
+
const queryTokens = buildLexicalQueryPlan(query).tokens.map((token) => token.toLowerCase());
|
|
231
|
+
const displayScore = (score) => Math.round(Math.min(1, Math.max(0, score)) * 10000) / 10000;
|
|
232
|
+
const stableRankScore = (score) => Math.round(score * 10000) / 10000;
|
|
233
|
+
return (a, b) => {
|
|
234
|
+
const aNameTier = lexicalNameMatchTier(a.entry, queryTokens);
|
|
235
|
+
const bNameTier = lexicalNameMatchTier(b.entry, queryTokens);
|
|
236
|
+
if (aNameTier === 3 || bNameTier === 3) {
|
|
237
|
+
const nameDiff = bNameTier - aNameTier;
|
|
238
|
+
if (nameDiff !== 0)
|
|
239
|
+
return nameDiff;
|
|
240
|
+
}
|
|
241
|
+
const scoreDiff = displayScore(b.score) - displayScore(a.score);
|
|
242
|
+
if (scoreDiff !== 0)
|
|
243
|
+
return scoreDiff;
|
|
244
|
+
const rawScoreDiff = stableRankScore(b.score) - stableRankScore(a.score);
|
|
245
|
+
if (rawScoreDiff !== 0)
|
|
246
|
+
return rawScoreDiff;
|
|
247
|
+
const nameDiff = bNameTier - aNameTier;
|
|
248
|
+
if (nameDiff !== 0)
|
|
249
|
+
return nameDiff;
|
|
250
|
+
const typeDiff = typeBoostFor(b.entry.type) - typeBoostFor(a.entry.type);
|
|
251
|
+
return typeDiff || a.filePath.localeCompare(b.filePath);
|
|
252
|
+
};
|
|
253
|
+
}
|
|
223
254
|
async function searchDatabase(db, query, searchType, limit, stashDir, allSourceDirs, config, sources, rendererRegistry = defaultRendererRegistry, filters, includeProposed = false, beliefFilter = "all", restrictToSources = false, includeExcludedTypes = false, disableProjectContext = false, disableScopedUtility = false) {
|
|
224
|
-
const hasSearchableTokens = query.length > 0 &&
|
|
255
|
+
const hasSearchableTokens = query.length > 0 && buildLexicalQueryPlan(query).tokens.length > 0;
|
|
225
256
|
// #627 — resolve the default type-exclusion policy. It applies ONLY on the
|
|
226
257
|
// untyped ('any') path and only when the caller did not opt back in via
|
|
227
258
|
// `includeExcludedTypes`. When the config key is ABSENT a built-in default of
|
|
@@ -383,21 +414,7 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
|
|
|
383
414
|
const preFilter = minScore > 0
|
|
384
415
|
? scored.filter((item) => item.rankingMode !== "semantic" || (item.preCeilingScore ?? item.score) >= minScore)
|
|
385
416
|
: scored;
|
|
386
|
-
|
|
387
|
-
//
|
|
388
|
-
// CRITICAL: sort on the SAME clamped+rounded value the user sees (see the
|
|
389
|
-
// `finalScore`/round-to-4dp logic below at buildDbHit), NOT the raw pre-clamp
|
|
390
|
-
// `item.score`. The boost loop can push scores above 1.0 (utility, graph,
|
|
391
|
-
// project boosts) and carries ~15 significant digits. Two entries that DISPLAY
|
|
392
|
-
// an identical score (e.g. both clamp to 1.0000) can still differ in their raw
|
|
393
|
-
// pre-clamp score by a timing-dependent epsilon — utility recency uses
|
|
394
|
-
// `Date.now()` and `last_used_at`, so the same query run twice in one process
|
|
395
|
-
// can yield raw scores that diverge at the 6th decimal. Sorting on the raw
|
|
396
|
-
// value lets that invisible epsilon decide the order, so the visible name
|
|
397
|
-
// tiebreaker never engages and the order flips run-to-run (Issue #14). Quantize
|
|
398
|
-
// to the display value first; only then does `localeCompare` break true ties.
|
|
399
|
-
const displayScore = (s) => Math.round(Math.min(1, Math.max(0, s)) * 10000) / 10000;
|
|
400
|
-
preFilter.sort((a, b) => displayScore(b.score) - displayScore(a.score) || a.entry.name.localeCompare(b.entry.name));
|
|
417
|
+
preFilter.sort(buildSearchResultComparator(query));
|
|
401
418
|
// Deduplicate by file path — keep only the highest-scored entry per file.
|
|
402
419
|
const deduped = deduplicateByPath(preFilter);
|
|
403
420
|
// Source → scope → proposed-quality → derived-twin belief inheritance →
|
|
@@ -431,6 +448,7 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
|
|
|
431
448
|
score: Math.round(finalScore * 10000) / 10000,
|
|
432
449
|
query,
|
|
433
450
|
rankingMode,
|
|
451
|
+
lexicalMatch: ranked.lexicalMatch,
|
|
434
452
|
defaultStashDir: stashDir,
|
|
435
453
|
allSourceDirs,
|
|
436
454
|
sources,
|
|
@@ -560,7 +578,7 @@ async function enumerateEntries(opts) {
|
|
|
560
578
|
* What this does NOT unify — and deliberately leaves divergent — is CANDIDATE-
|
|
561
579
|
* POOL construction, which is inherent search-vs-browse semantics: the scored
|
|
562
580
|
* path's pool is `searchFts`/vector matches for the query's own tokens (FTS
|
|
563
|
-
*
|
|
581
|
+
* includes structured fields and bounded adapter content), while the
|
|
564
582
|
* enumerate path's pool is `getAllEntries` for the type, independent of query
|
|
565
583
|
* text. A derived twin sharing no indexed token with the query is therefore an
|
|
566
584
|
* enumerate-path candidate but never a scored-path candidate — see
|
|
@@ -736,7 +754,7 @@ export async function buildDbHit(input) {
|
|
|
736
754
|
// Round to 4 decimal places, no boost multiplication
|
|
737
755
|
const score = Math.round(input.score * 10000) / 10000;
|
|
738
756
|
const graphBoost = getSearchHitAttribution(input.attributionSource ?? {})?.graphExtraction?.boost ?? 0;
|
|
739
|
-
const whyMatched = buildWhyMatched(input.entry, input.query, input.rankingMode, qualityBoost, confidenceBoost, input.utilityBoosted, graphBoost);
|
|
757
|
+
const whyMatched = buildWhyMatched(input.entry, input.query, input.rankingMode, qualityBoost, confidenceBoost, input.utilityBoosted, graphBoost, input.lexicalMatch);
|
|
740
758
|
const graphHit = input.graphContext ? collectGraphRelatedHit(input.graphContext, absolutePath) : null;
|
|
741
759
|
const source = findSourceForPath(absolutePath, input.sources);
|
|
742
760
|
const defaultBundleId = input.config?.defaultBundle ??
|
|
@@ -768,8 +786,7 @@ export async function buildDbHit(input) {
|
|
|
768
786
|
...(input.entry.currentBeliefRefs ? { currentBeliefRefs: input.entry.currentBeliefRefs } : {}),
|
|
769
787
|
...(graphHit ? { graph: { entities: graphHit.entities, relations: graphHit.relations } } : {}),
|
|
770
788
|
};
|
|
771
|
-
|
|
772
|
-
copySearchHitAttribution(input.attributionSource, hit);
|
|
789
|
+
attachDbHitAttribution(hit, input);
|
|
773
790
|
if (input.entry.derivedFrom) {
|
|
774
791
|
attachSearchHitAttribution(hit, {
|
|
775
792
|
memoryInference: { exposure: "direct" },
|
|
@@ -784,9 +801,21 @@ export async function buildDbHit(input) {
|
|
|
784
801
|
});
|
|
785
802
|
return hit;
|
|
786
803
|
}
|
|
804
|
+
function attachDbHitAttribution(hit, input) {
|
|
805
|
+
if (input.lexicalMatch) {
|
|
806
|
+
attachSearchHitAttribution(hit, {
|
|
807
|
+
lexical: {
|
|
808
|
+
execution: input.lexicalMatch,
|
|
809
|
+
nameMatchTier: lexicalNameMatchTier(input.entry, buildLexicalQueryPlan(input.query).tokens),
|
|
810
|
+
},
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
if (input.attributionSource)
|
|
814
|
+
copySearchHitAttribution(input.attributionSource, hit);
|
|
815
|
+
}
|
|
787
816
|
export function buildWhyMatched(entry, query,
|
|
788
817
|
// "hybrid" ranking mode
|
|
789
|
-
rankingMode, qualityBoost, confidenceBoost, utilityBoosted, graphBoost) {
|
|
818
|
+
rankingMode, qualityBoost, confidenceBoost, utilityBoosted, graphBoost, lexicalMatch) {
|
|
790
819
|
const reasons = [
|
|
791
820
|
rankingMode === "hybrid"
|
|
792
821
|
? "hybrid (fts + semantic)"
|
|
@@ -794,6 +823,8 @@ rankingMode, qualityBoost, confidenceBoost, utilityBoosted, graphBoost) {
|
|
|
794
823
|
? "semantic similarity"
|
|
795
824
|
: "fts bm25 relevance",
|
|
796
825
|
];
|
|
826
|
+
if (lexicalMatch === "relaxed")
|
|
827
|
+
reasons.push("lexical recovery after strict query returned no hits");
|
|
797
828
|
const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
798
829
|
const queryLower = query.toLowerCase().trim();
|
|
799
830
|
const name = entry.name.toLowerCase();
|
|
@@ -858,14 +889,13 @@ export function deriveSize(bytes) {
|
|
|
858
889
|
return "large";
|
|
859
890
|
}
|
|
860
891
|
/**
|
|
861
|
-
* Deduplicate
|
|
862
|
-
*
|
|
863
|
-
*
|
|
892
|
+
* Deduplicate the already-ranked result stream by file path. The caller owns
|
|
893
|
+
* the one ranking order; re-sorting here would silently discard exact-name and
|
|
894
|
+
* relaxed-recovery ordering in favor of an internal pre-clamp score.
|
|
864
895
|
*/
|
|
865
896
|
function deduplicateByPath(items) {
|
|
866
|
-
const sorted = [...items].sort((a, b) => (b.score ?? 0) - (a.score ?? 0) || a.filePath.localeCompare(b.filePath));
|
|
867
897
|
const seen = new Set();
|
|
868
|
-
return
|
|
898
|
+
return items.filter((item) => {
|
|
869
899
|
if (seen.has(item.filePath))
|
|
870
900
|
return false;
|
|
871
901
|
seen.add(item.filePath);
|
|
@@ -2,54 +2,54 @@
|
|
|
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
|
/**
|
|
5
|
-
* Pure FTS5 query
|
|
5
|
+
* Pure FTS5 query planning and ref-query helpers.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* The lexical planner transforms a raw user query into bounded FTS5-safe
|
|
8
|
+
* MATCH expressions. It touches no database state, so it is unit-testable
|
|
9
|
+
* with zero DB setup.
|
|
9
10
|
* `parseRefPrefixQuery` is the one non-FTS helper: it decides whether a raw
|
|
10
11
|
* query should bypass FTS entirely (SPEC-4 ref-prefix enumeration).
|
|
11
12
|
*/
|
|
12
|
-
/**
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
*/
|
|
21
|
-
export function sanitizeFtsQuery(query) {
|
|
22
|
-
let sanitized = query.replace(/[^a-zA-Z0-9_\s]/g, " ");
|
|
23
|
-
// Neutralize the NEAR operator (FTS5 proximity syntax)
|
|
24
|
-
sanitized = sanitized.replace(/\bNEAR\b/g, " ");
|
|
25
|
-
const tokens = sanitized.split(/\s+/).filter((t) => t.length >= 1);
|
|
26
|
-
if (tokens.length === 0)
|
|
27
|
-
return "";
|
|
28
|
-
// Use implicit AND (space-separated tokens) for precision. FTS5 treats
|
|
29
|
-
// space-separated tokens as an implicit AND, matching only rows that
|
|
30
|
-
// contain ALL terms.
|
|
31
|
-
return tokens.join(" ");
|
|
13
|
+
/** Maximum number of distinct lexical terms one query may execute. */
|
|
14
|
+
export const MAX_LEXICAL_QUERY_TOKENS = 16;
|
|
15
|
+
const UNICODE_TOKEN = /[\p{L}\p{N}]+/gu;
|
|
16
|
+
function quoteToken(token) {
|
|
17
|
+
return `"${token}"`;
|
|
18
|
+
}
|
|
19
|
+
function prefixToken(token) {
|
|
20
|
+
return [...token].length >= 3 ? `${quoteToken(token)}*` : quoteToken(token);
|
|
32
21
|
}
|
|
33
22
|
/**
|
|
34
|
-
* Build
|
|
35
|
-
* token that is 3+ characters long. Tokens shorter than 3 characters are
|
|
36
|
-
* kept as-is (no prefix expansion) to avoid overly broad matches.
|
|
23
|
+
* Build the sole lexical retrieval plan from raw user input.
|
|
37
24
|
*
|
|
38
|
-
*
|
|
25
|
+
* Tokenization follows the useful portion of SQLite FTS5's `unicode61`
|
|
26
|
+
* tokenizer (Unicode letters and numbers). Quoting every term makes FTS
|
|
27
|
+
* operators ordinary searchable words. Tokens are normalized, deduplicated
|
|
28
|
+
* case-insensitively, and capped before any SQL executes.
|
|
39
29
|
*/
|
|
40
|
-
export function
|
|
41
|
-
const tokens =
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
30
|
+
export function buildLexicalQueryPlan(query) {
|
|
31
|
+
const tokens = [];
|
|
32
|
+
const seen = new Set();
|
|
33
|
+
const normalized = query.normalize("NFKC");
|
|
34
|
+
for (const match of normalized.matchAll(UNICODE_TOKEN)) {
|
|
35
|
+
const token = match[0];
|
|
36
|
+
const key = token.toLowerCase();
|
|
37
|
+
if (seen.has(key))
|
|
38
|
+
continue;
|
|
39
|
+
seen.add(key);
|
|
40
|
+
tokens.push(token);
|
|
41
|
+
if (tokens.length === MAX_LEXICAL_QUERY_TOKENS)
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
const exact = tokens.map(quoteToken).join(" ");
|
|
45
|
+
const prefixTokens = tokens.map(prefixToken);
|
|
46
|
+
const exactPrefix = prefixTokens.some((token) => token.endsWith("*")) ? prefixTokens.join(" ") : undefined;
|
|
47
|
+
// A slash-bearing, whitespace-free input is an identifier/ref lookup, not
|
|
48
|
+
// sentence prose. Keep it conjunctive so a mistyped/bare ref never fans out
|
|
49
|
+
// across every path token through OR recovery.
|
|
50
|
+
const isRefLikeIdentifier = !/\s/u.test(query.trim()) && query.includes("/");
|
|
51
|
+
const relaxed = tokens.length > 1 && !isRefLikeIdentifier ? prefixTokens.join(" OR ") : undefined;
|
|
52
|
+
return { tokens, exact, exactPrefix, relaxed };
|
|
53
53
|
}
|
|
54
54
|
/**
|
|
55
55
|
* D4 — parse a conceptId-prefix browse query.
|
|
@@ -2,6 +2,7 @@
|
|
|
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 { getUtilityScoresByIds } from "../../storage/repositories/index-utility-repository.js";
|
|
5
|
+
import { buildLexicalQueryPlan } from "./fts-query.js";
|
|
5
6
|
import { applyBeliefStateScoreCeiling, applyContributorAblation, applyScoreContributors, applyUtilityContributors, defaultRankingContributors, defaultUtilityRankingContributors, } from "./ranking-contributors.js";
|
|
6
7
|
export function normalizeFtsScores(results) {
|
|
7
8
|
const ftsScoreMap = new Map();
|
|
@@ -33,6 +34,7 @@ export function combineSearchScores(options) {
|
|
|
33
34
|
filePath: result.filePath,
|
|
34
35
|
score: combinedScore,
|
|
35
36
|
rankingMode: embedScore !== undefined ? "hybrid" : "fts",
|
|
37
|
+
lexicalMatch: result.lexicalMatch,
|
|
36
38
|
itemRef: result.itemRef,
|
|
37
39
|
bundleId: result.bundleId,
|
|
38
40
|
conceptId: result.conceptId,
|
|
@@ -63,7 +65,7 @@ export function combineSearchScores(options) {
|
|
|
63
65
|
return scored;
|
|
64
66
|
}
|
|
65
67
|
export function applyRankingRules(options) {
|
|
66
|
-
const queryTokens = options.query
|
|
68
|
+
const queryTokens = buildLexicalQueryPlan(options.query).tokens.map((token) => token.toLowerCase());
|
|
67
69
|
const queryLower = options.query.toLowerCase().trim();
|
|
68
70
|
const rankingContext = {
|
|
69
71
|
db: options.db,
|
|
@@ -103,6 +105,7 @@ export function applyRankingRules(options) {
|
|
|
103
105
|
};
|
|
104
106
|
for (const item of options.items) {
|
|
105
107
|
applyUtilityContributors(item, utilityContext, activeUtilityContributors);
|
|
108
|
+
applyRelaxedLexicalScoreCeiling(item, queryTokens);
|
|
106
109
|
// SPEC-5: demoting belief states (superseded/contradicted/archived/
|
|
107
110
|
// deprecated) cap the FINAL score. The additive belief penalty inside the
|
|
108
111
|
// multiplicative boost sum cannot overcome the FTS min-max normalization
|
|
@@ -112,3 +115,35 @@ export function applyRankingRules(options) {
|
|
|
112
115
|
}
|
|
113
116
|
return options.items;
|
|
114
117
|
}
|
|
118
|
+
const RELAXED_NON_NAME_SCORE_CEILING = 0.65;
|
|
119
|
+
/**
|
|
120
|
+
* Rank name evidence without relying on punctuation or ASCII-only splitting.
|
|
121
|
+
* The tiers are intentionally structural: an exact normalized name, all query
|
|
122
|
+
* tokens in a longer name, any query token in the name, or no name evidence.
|
|
123
|
+
*/
|
|
124
|
+
export function lexicalNameMatchTier(entry, queryTokens) {
|
|
125
|
+
if (queryTokens.length === 0)
|
|
126
|
+
return 0;
|
|
127
|
+
const nameBase = entry.name.toLowerCase().split("/").pop() ?? entry.name.toLowerCase();
|
|
128
|
+
const nameTokens = buildLexicalQueryPlan(nameBase).tokens.map((token) => token.toLowerCase());
|
|
129
|
+
const tokenMatches = (left, right) => left === right ||
|
|
130
|
+
(Math.min([...left].length, [...right].length) >= 3 && (left.startsWith(right) || right.startsWith(left)));
|
|
131
|
+
if (nameTokens.length === queryTokens.length &&
|
|
132
|
+
nameTokens.every((token, index) => tokenMatches(token, queryTokens[index]))) {
|
|
133
|
+
return 3;
|
|
134
|
+
}
|
|
135
|
+
const matched = queryTokens.filter((token) => nameTokens.some((nameToken) => tokenMatches(nameToken, token))).length;
|
|
136
|
+
if (matched === queryTokens.length)
|
|
137
|
+
return 2;
|
|
138
|
+
return matched > 0 ? 1 : 0;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* A relaxed OR query admits intentionally weak candidates. Candidates with no
|
|
142
|
+
* query token in their name remain visible for body-only recall, but cannot
|
|
143
|
+
* saturate at the same displayed score as stronger name-bearing recoveries.
|
|
144
|
+
*/
|
|
145
|
+
function applyRelaxedLexicalScoreCeiling(item, queryTokens) {
|
|
146
|
+
if (item.lexicalMatch !== "relaxed" || lexicalNameMatchTier(item.entry, queryTokens) > 0)
|
|
147
|
+
return;
|
|
148
|
+
item.score = Math.min(item.score, RELAXED_NON_NAME_SCORE_CEILING);
|
|
149
|
+
}
|
|
@@ -17,11 +17,13 @@ export function copySearchHitAttribution(from, to, outputDescription) {
|
|
|
17
17
|
const memorySurvives = memoryInference?.exposure !== "surface" ||
|
|
18
18
|
(memoryInference.surfaceDescription !== undefined && memoryInference.surfaceDescription === outputDescription);
|
|
19
19
|
const applicable = {
|
|
20
|
+
...(attribution.lexical ? { lexical: attribution.lexical } : {}),
|
|
20
21
|
...(memorySurvives && memoryInference ? { memoryInference } : {}),
|
|
21
22
|
...(attribution.graphExtraction ? { graphExtraction: attribution.graphExtraction } : {}),
|
|
22
23
|
};
|
|
23
|
-
if (applicable.memoryInference || applicable.graphExtraction)
|
|
24
|
+
if (applicable.lexical || applicable.memoryInference || applicable.graphExtraction) {
|
|
24
25
|
attachSearchHitAttribution(to, applicable);
|
|
26
|
+
}
|
|
25
27
|
}
|
|
26
28
|
export function getSearchHitAttribution(target) {
|
|
27
29
|
return target[ATTRIBUTION];
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
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
|
+
/** Structured metadata plus bounded body text supplied to embedding providers. */
|
|
5
|
+
export const SEARCH_TEXT_MAX_CHARS = 8_192;
|
|
4
6
|
/**
|
|
5
7
|
* Return per-field search text for multi-column FTS5 indexing.
|
|
6
8
|
*
|
|
@@ -9,7 +11,7 @@
|
|
|
9
11
|
* - description: entry description
|
|
10
12
|
* - tags: tags + aliases joined
|
|
11
13
|
* - hints: searchHints + examples + usage + intent fields
|
|
12
|
-
* - content:
|
|
14
|
+
* - content: bounded native/adapter body projection + TOC headings + parameters
|
|
13
15
|
* (lowest-weight catch-all)
|
|
14
16
|
*/
|
|
15
17
|
// NOTE (R5): the collapse detector's frozen canary queries are built from the
|
|
@@ -53,8 +55,6 @@ export function buildSearchFields(entry) {
|
|
|
53
55
|
hintParts.push(entry.whenToUse);
|
|
54
56
|
const hints = hintParts.join(" ").toLowerCase();
|
|
55
57
|
const contentParts = [];
|
|
56
|
-
if (entry.content)
|
|
57
|
-
contentParts.push(entry.content);
|
|
58
58
|
if (entry.toc) {
|
|
59
59
|
contentParts.push(entry.toc.map((h) => h.text).join(" "));
|
|
60
60
|
}
|
|
@@ -65,15 +65,8 @@ export function buildSearchFields(entry) {
|
|
|
65
65
|
contentParts.push(param.description);
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
// folds into the lowest-weight catch-all column — never name/description/
|
|
71
|
-
// tags/hints — so orientation prose is retrievable without outranking
|
|
72
|
-
// structured-field matches. The fold is unconditional on the entry field:
|
|
73
|
-
// `rebuildFts` rebuilds FTS rows from stored document_json and must reproduce
|
|
74
|
-
// the same fields without re-reading config.
|
|
75
|
-
if (entry.bodyOpening)
|
|
76
|
-
contentParts.push(entry.bodyOpening);
|
|
68
|
+
if (entry.content)
|
|
69
|
+
contentParts.push(entry.content);
|
|
77
70
|
const content = contentParts.join(" ").toLowerCase();
|
|
78
71
|
return { name, description, tags, hints, content };
|
|
79
72
|
}
|
|
@@ -84,7 +77,23 @@ export function buildSearchFields(entry) {
|
|
|
84
77
|
*/
|
|
85
78
|
export function buildSearchText(entry) {
|
|
86
79
|
const fields = buildSearchFields(entry);
|
|
87
|
-
|
|
88
|
-
.filter((
|
|
80
|
+
const structured = [fields.name, fields.description, fields.tags, fields.hints]
|
|
81
|
+
.filter((field) => field.length > 0)
|
|
89
82
|
.join(" ");
|
|
83
|
+
if (structured.length >= SEARCH_TEXT_MAX_CHARS)
|
|
84
|
+
return truncateUnicodeSafe(structured, SEARCH_TEXT_MAX_CHARS);
|
|
85
|
+
if (!fields.content)
|
|
86
|
+
return structured;
|
|
87
|
+
const separator = structured ? " " : "";
|
|
88
|
+
const remaining = SEARCH_TEXT_MAX_CHARS - structured.length - separator.length;
|
|
89
|
+
return `${structured}${separator}${truncateUnicodeSafe(fields.content, remaining)}`;
|
|
90
|
+
}
|
|
91
|
+
function truncateUnicodeSafe(text, maxChars) {
|
|
92
|
+
if (text.length <= maxChars)
|
|
93
|
+
return text;
|
|
94
|
+
let cut = text.slice(0, maxChars);
|
|
95
|
+
const lastCode = cut.charCodeAt(cut.length - 1);
|
|
96
|
+
if (lastCode >= 0xd800 && lastCode <= 0xdbff)
|
|
97
|
+
cut = cut.slice(0, -1);
|
|
98
|
+
return cut.trimEnd();
|
|
90
99
|
}
|
|
@@ -582,7 +582,9 @@ export function formatUpdatePlain(r) {
|
|
|
582
582
|
}
|
|
583
583
|
}
|
|
584
584
|
for (const item of plainSynced ?? []) {
|
|
585
|
-
lines.push(
|
|
585
|
+
lines.push(item.kind === "filesystem"
|
|
586
|
+
? `update: ${item.id} reconciled (filesystem)`
|
|
587
|
+
: `update: ${item.id} synced (${item.kind})`);
|
|
586
588
|
}
|
|
587
589
|
for (const item of skipped ?? []) {
|
|
588
590
|
lines.push(`update: ${item.id} skipped — ${item.reason}`);
|