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.
Files changed (34) hide show
  1. package/CHANGELOG.md +15 -28
  2. package/dist/assets/stash-skeleton/facts/conventions/backlinks.md +3 -4
  3. package/dist/assets/stash-skeleton/facts/conventions/organization.md +1 -3
  4. package/dist/commands/improve/collapse-detector.js +3 -4
  5. package/dist/commands/improve/extract-prompt.js +64 -22
  6. package/dist/commands/improve/extract.js +122 -53
  7. package/dist/commands/read/curate.js +43 -22
  8. package/dist/commands/sources/bundle-cli.js +1 -1
  9. package/dist/commands/sources/installed-stashes.js +67 -26
  10. package/dist/core/adapter/adapters/akm-adapter.js +2 -1
  11. package/dist/core/config/config.js +2 -6
  12. package/dist/core/config/schema/index-config.js +0 -27
  13. package/dist/indexer/index-written-assets.js +17 -9
  14. package/dist/indexer/indexer.js +32 -214
  15. package/dist/indexer/materialize-embeddings.js +155 -0
  16. package/dist/indexer/passes/metadata.js +263 -118
  17. package/dist/indexer/scan/doc-to-entry.js +0 -1
  18. package/dist/indexer/search/db-search.js +58 -28
  19. package/dist/indexer/search/fts-query.js +40 -40
  20. package/dist/indexer/search/ranking.js +36 -1
  21. package/dist/indexer/search/search-attribution.js +3 -1
  22. package/dist/indexer/search/search-fields.js +23 -14
  23. package/dist/output/text/command-format.js +3 -1
  24. package/dist/scripts/akm-migrate-node.js +12892 -12731
  25. package/dist/scripts/akm-migrate.js +12892 -12731
  26. package/dist/storage/repositories/index-entries-repository.js +40 -26
  27. package/dist/storage/repositories/index-entry-schema.js +1 -1
  28. package/dist/storage/repositories/index-fts-repository.js +56 -63
  29. package/dist/storage/repositories/index-schema.js +4 -9
  30. package/dist/storage/repositories/index-vec-repository.js +55 -6
  31. package/docs/reference/cli.md +4 -4
  32. package/docs/reference/configuration.md +6 -9
  33. package/package.json +1 -1
  34. 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, sanitizeFtsQuery } from "./fts-query.js";
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 && sanitizeFtsQuery(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
- // Deterministic tiebreaker on equal scores.
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
- * indexes description/tags/searchHints/aliases, not raw body prose), while the
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
- if (input.attributionSource)
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 scored results by file path, keeping only the highest-scored
862
- * entry per unique path. Sorts by score descending internally to ensure the
863
- * precondition is always met regardless of caller.
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 sorted.filter((item) => {
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-string helpers, extracted from indexer/db/db.ts.
5
+ * Pure FTS5 query planning and ref-query helpers.
6
6
  *
7
- * These transform a raw user query into an FTS5-safe MATCH expression. They
8
- * touch no database state, so they are unit-testable with zero DB setup.
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
- * Sanitize a raw user query into an FTS5-safe implicit-AND expression.
14
- *
15
- * Allows only characters safe in FTS5 queries: letters, digits, underscores,
16
- * and whitespace. Everything else (hyphens, dots, quotes, parens, asterisks,
17
- * colons, carets, @, !, etc.) is replaced with a space so that compound
18
- * identifiers like "code-review" or "k8s.setup" become AND-joined tokens
19
- * ("code review", "k8s setup") rather than triggering FTS5 syntax errors.
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 a prefix query from an FTS5 query string by appending `*` to each
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
- * Returns null if no tokens qualify for prefix expansion.
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 buildPrefixQuery(ftsQuery) {
41
- const tokens = ftsQuery.split(/\s+/).filter(Boolean);
42
- let hasPrefix = false;
43
- const prefixTokens = tokens.map((t) => {
44
- if (t.length >= 3) {
45
- hasPrefix = true;
46
- return `${t}*`;
47
- }
48
- return t;
49
- });
50
- if (!hasPrefix)
51
- return null;
52
- return prefixTokens.join(" ");
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.toLowerCase().split(/\s+/).filter(Boolean);
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: TOC headings + parameters + the config-gated body opening
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
- // Stash-organization conventions (SPEC-8): the self-situating body opening
69
- // (captured by the metadata pass only when `index.indexBodyOpening` is on)
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
- return [fields.name, fields.description, fields.tags, fields.hints, fields.content]
88
- .filter((s) => s.length > 0)
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(`update: ${item.id} synced (${item.kind})`);
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}`);