dsh-continual-evolve 0.3.0 → 0.5.0

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/lib/inject.js CHANGED
@@ -2,6 +2,9 @@ import { isArchived } from "./types.js";
2
2
  import { mergeHarnessStates } from "./state.js";
3
3
  import { entryLine } from "./render.js";
4
4
  import { recordInjection } from "./usage.js";
5
+ import { buildRelevanceIndex, relevanceScore, tokenize } from "./search.js";
6
+ /** CJK-bigram tokenizer re-exported for ranking consumers (see search.ts). */
7
+ export { tokenize };
5
8
  /** Prompt sections render at most this many entries per kind. */
6
9
  export const MAX_INJECTED_ENTRIES_PER_KIND = 6;
7
10
  /** Per-entry content budget inside the injected block (matches render.ts). */
@@ -18,31 +21,6 @@ export const MAX_QUERY_CHARS = 400;
18
21
  function stableCompare(a, b) {
19
22
  return [a.path, a.title, a.id].join("\0").localeCompare([b.path, b.title, b.id].join("\0"));
20
23
  }
21
- /**
22
- * Lowercase tokenization for the keyword relevance scorer: runs of ASCII
23
- * alphanumerics and CJK characters become tokens (CJK is not split so whole
24
- * Chinese words/characters stay comparable), everything else is a separator.
25
- */
26
- export function tokenize(text) {
27
- return text
28
- .toLowerCase()
29
- .split(/[^a-z0-9\u4e00-\u9fff]+/)
30
- .filter((token) => token.length > 0);
31
- }
32
- /**
33
- * Keyword hit count of `query` tokens inside an entry: title hits weigh 2×,
34
- * content/path hits 1×. BM25-level relevance without any external service.
35
- */
36
- export function relevanceHits(entry, query) {
37
- const titleTokens = tokenize(entry.title);
38
- const bodyTokens = tokenize(`${entry.content} ${entry.path}`);
39
- let hits = 0;
40
- for (const token of tokenize(query)) {
41
- hits += titleTokens.filter((t) => t === token).length * 2;
42
- hits += bodyTokens.filter((t) => t === token).length;
43
- }
44
- return hits;
45
- }
46
24
  /**
47
25
  * Normalized recency in [0, 1]: 1 when the entry was just updated, decaying
48
26
  * linearly to 0 after {@link RECENCY_HALF_LIFE_MS}. Unparseable timestamps
@@ -61,20 +39,34 @@ export function recencyScore(entry, now) {
61
39
  }
62
40
  /**
63
41
  * Rank entries for injection, best first. With no query the ranking is pure
64
- * recency (newest first). With a query, any entry with at least one keyword
65
- * hit outranks every hit-less entry (`hits * 2 + recency <= 1` for the
66
- * latter), and hits decide the order among relevant entries; recency then
67
- * breaks remaining ties, and the stable dictionary order is the final
68
- * tiebreak, so the result is deterministic.
42
+ * recency (newest first). With a query, entries are scored once against a
43
+ * per-call BM25 index (CJK bigrams; field-weighted title ×2 see
44
+ * search.ts): any entry with a positive score (≥1 matched token) outranks
45
+ * every hit-less entry (score exactly 0), scores decide the order among
46
+ * relevant entries, recency breaks remaining ties, and the stable dictionary
47
+ * order is the final tiebreak, so the result is deterministic. The input is
48
+ * never mutated.
69
49
  */
70
50
  export function rankEntries(entries, query, now = Date.now()) {
71
51
  const q = (query ?? "").trim();
72
- return [...entries].sort((a, b) => {
73
- if (q.length > 0) {
74
- const relevanceDelta = relevanceHits(b, q) * 2 - relevanceHits(a, q) * 2;
75
- if (relevanceDelta !== 0) {
76
- return relevanceDelta;
52
+ if (q.length === 0) {
53
+ return [...entries].sort((a, b) => {
54
+ const recencyDelta = recencyScore(b, now) - recencyScore(a, now);
55
+ if (recencyDelta !== 0) {
56
+ return recencyDelta;
77
57
  }
58
+ return stableCompare(a, b);
59
+ });
60
+ }
61
+ // Precompute scores once: the old comparator re-tokenized both sides on
62
+ // every comparison (O(n log n) tokenizations); one index + one score per
63
+ // entry turns the pass into table lookups.
64
+ const index = buildRelevanceIndex(entries);
65
+ const scores = new Map(entries.map((entry) => [entry, relevanceScore(index, entry, q)]));
66
+ return [...entries].sort((a, b) => {
67
+ const relevanceDelta = (scores.get(b) ?? 0) - (scores.get(a) ?? 0);
68
+ if (relevanceDelta !== 0) {
69
+ return relevanceDelta;
78
70
  }
79
71
  const recencyDelta = recencyScore(b, now) - recencyScore(a, now);
80
72
  if (recencyDelta !== 0) {
@@ -190,22 +182,45 @@ export function formatSubagentSpecsSection(entries, query) {
190
182
  * the model a zero-cost overview of what exists so it can ask for full text
191
183
  * via `evolve_list` or `/evolve list`. The directory is appended after the
192
184
  * curated top-N injection sections and adds minimal tokens.
185
+ *
186
+ * 2026-08-22 throttle: the directory is CAPPED at {@link DEFAULT_DIRECTORY_LINES}
187
+ * lines (oldest-sorted stable order) with the remainder folded into a single
188
+ * counter line — an uncapped directory across a polluted global store was
189
+ * measured at ~2K chars of every build in every project.
193
190
  */
191
+ export const DEFAULT_DIRECTORY_LINES = 15;
192
+ /** How many of the variadic arrays are content-section kinds (prompt, subagent). */
193
+ const CONTENT_SECTION_KINDS = 2;
194
194
  export function formatEntriesDirectory(...kindEntries) {
195
+ return formatEntriesDirectoryCapped(DEFAULT_DIRECTORY_LINES, ...kindEntries);
196
+ }
197
+ /** {@link formatEntriesDirectory} with an explicit cap (configurable). */
198
+ export function formatEntriesDirectoryCapped(maxLines, ...kindEntries) {
195
199
  const allEntries = kindEntries.flat().filter((e) => !isArchived(e));
196
200
  if (allEntries.length === 0) {
197
201
  return "";
198
202
  }
199
- // Skip the directory when it would be redundant (all entries already shown
200
- // in the curated sections above 6/kind cap means ≤6 entries total).
201
- const totalCapped = kindEntries.reduce((sum, entries) => sum + Math.min(entries.filter((e) => !isArchived(e)).length, MAX_INJECTED_ENTRIES_PER_KIND), 0);
202
- if (allEntries.length <= totalCapped) {
203
+ // Skip the directory only when EVERY entry is already content-visible.
204
+ // Only the first two arrays (prompt, subagent) have curated sections
205
+ // memories and skills have NO content injection, so they are invisible
206
+ // unless the directory lists them (pre-2026-08-22 the redundancy check
207
+ // wrongly counted them as "already shown", hiding small stores entirely).
208
+ const contentVisible = kindEntries
209
+ .slice(0, CONTENT_SECTION_KINDS)
210
+ .reduce((sum, entries) => sum + Math.min(entries.filter((e) => !isArchived(e)).length, MAX_INJECTED_ENTRIES_PER_KIND), 0);
211
+ if (allEntries.length <= contentVisible) {
203
212
  return "";
204
213
  }
214
+ const sorted = [...allEntries].sort((a, b) => `${a.kind}:${a.id}`.localeCompare(`${b.kind}:${b.id}`));
205
215
  const lines = ["# Continual Harness — Entry Directory", "All entries (use evolve_list for full text of any entry):"];
206
- for (const entry of allEntries.sort((a, b) => `${a.kind}:${a.id}`.localeCompare(`${b.kind}:${b.id}`))) {
216
+ const shown = sorted.slice(0, Math.max(maxLines, 1));
217
+ for (const entry of shown) {
207
218
  lines.push(`- [${entry.kind}:${entry.id}] ${entry.title}`);
208
219
  }
220
+ const hidden = sorted.length - shown.length;
221
+ if (hidden > 0) {
222
+ lines.push(`- …and ${hidden} more entries (evolve_list for the full index)`);
223
+ }
209
224
  return lines.join("\n");
210
225
  }
211
226
  /**
@@ -236,8 +251,13 @@ export function nearestLocalStateWithEntries(engine, agent) {
236
251
  * (relevance first, then recency; see {@link rankEntries}). Returns "" when
237
252
  * nothing is injectable — the prompt renderer then drops the section, so an
238
253
  * empty store adds zero tokens to every assembly.
254
+ *
255
+ * `opts.directoryLines` caps the entry-directory index (2026-08-22 throttle).
256
+ * Usage recording covers ALL kinds — memories and skills appear as directory
257
+ * lines, prompts/subagents as content — and is deduped per session so the
258
+ * counts read "how many sessions saw this", not "how many prompt builds".
239
259
  */
240
- export function entriesSectionText(engine, agent, query) {
260
+ export function entriesSectionText(engine, agent, query, opts) {
241
261
  if (!agent) {
242
262
  return "";
243
263
  }
@@ -250,28 +270,41 @@ export function entriesSectionText(engine, agent, query) {
250
270
  // Build injected text and collect which entries were included (gap B1).
251
271
  const promptText = formatPromptEntriesSection(promptEntries, relevanceQuery);
252
272
  const subagentText = formatSubagentSpecsSection(subagentEntries, relevanceQuery);
253
- const injectedKeys = [];
273
+ const injectedKeys = new Set();
254
274
  // Collect keys from the visible (ranked, capped) entries that actually appear.
255
275
  const visiblePrompt = promptEntries.filter((e) => !isArchived(e));
256
276
  const visibleSubagent = subagentEntries.filter((e) => !isArchived(e));
257
277
  for (const entry of rankEntries(visiblePrompt, relevanceQuery).slice(0, MAX_INJECTED_ENTRIES_PER_KIND)) {
258
- injectedKeys.push(`prompt:${entry.id}`);
278
+ injectedKeys.add(`prompt:${entry.id}`);
259
279
  }
260
280
  for (const entry of rankEntries(visibleSubagent, relevanceQuery).slice(0, MAX_INJECTED_ENTRIES_PER_KIND)) {
261
- injectedKeys.push(`subagent:${entry.id}`);
281
+ injectedKeys.add(`subagent:${entry.id}`);
282
+ }
283
+ // Gap B3: lightweight directory of ALL entries (id+title, one line each).
284
+ // Zero-cost index so the model knows what exists and can ask for full text.
285
+ const directoryText = formatEntriesDirectoryCapped(opts?.directoryLines ?? DEFAULT_DIRECTORY_LINES, Object.values(merged.entries.prompt), Object.values(merged.entries.memory), Object.values(merged.entries.skill), Object.values(merged.entries.subagent));
286
+ // Directory-visible keys count too: a memory's injection IS its directory
287
+ // line. Set semantics keep content-injected entries single-counted.
288
+ for (const kind of ["prompt", "memory", "skill", "subagent"]) {
289
+ for (const entry of Object.values(merged.entries[kind])) {
290
+ if (isArchived(entry))
291
+ continue;
292
+ const key = `${kind}:${entry.id}`;
293
+ if (injectedKeys.has(key) || directoryText.includes(`[${key}]`)) {
294
+ injectedKeys.add(key);
295
+ }
296
+ }
262
297
  }
263
298
  // Record usage durably (best-effort: failure never blocks injection).
264
- if (injectedKeys.length > 0) {
299
+ // Deduped per session — see recordInjection.
300
+ if (injectedKeys.size > 0) {
265
301
  try {
266
- recordInjection(engine.baseDir, injectedKeys);
302
+ recordInjection(engine.baseDir, [...injectedKeys], agent.id);
267
303
  }
268
304
  catch {
269
305
  // Usage recording is diagnostic; never interrupt the injection path.
270
306
  }
271
307
  }
272
- // Gap B3: lightweight directory of ALL entries (id+title, one line each).
273
- // Zero-cost index so the model knows what exists and can ask for full text.
274
- const directoryText = formatEntriesDirectory(Object.values(merged.entries.prompt), Object.values(merged.entries.memory), Object.values(merged.entries.skill), Object.values(merged.entries.subagent));
275
308
  const parts = [promptText, subagentText, directoryText].filter((part) => part.length > 0);
276
309
  return parts.join("\n\n");
277
310
  }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Promotion policy: mechanical, code-enforced gates that decide whether a
3
+ * local entry MAY be promoted to the cross-session global store ("模型提议,
4
+ * 代码保证"). The LLM classification proposes; these guards dispose.
5
+ *
6
+ * Rationale (2026-08-22 store audit): the global store is shared by every
7
+ * project on one profile, so promoting project-scoped knowledge taxes every
8
+ * future session in every project. Measured failure modes:
9
+ * - absolute paths / session ids in promoted content (project-scoped),
10
+ * - near-duplicates of existing global entries re-promoted from later
11
+ * sessions (title matching alone missed them),
12
+ * - one-line facts whose framing costs more than their content.
13
+ *
14
+ * Pure functions only — the callers (wrapup command, gate local-fate phase)
15
+ * supply the resolved {@link PromotionPolicy}.
16
+ */
17
+ import type { HarnessEntry, HarnessState, RefinementKind } from "./types.js";
18
+ export interface PromotionPolicy {
19
+ /** Content matching any pattern is project-scoped and stays local. */
20
+ blockPatterns: RegExp[];
21
+ /** Whole promotions below this content length stay local (chars). */
22
+ minPromoteChars: number;
23
+ /** Content overlap above this against a global entry = duplicate. */
24
+ maxContentOverlap: number;
25
+ }
26
+ export declare const DEFAULT_PROMOTION_POLICY: PromotionPolicy;
27
+ /**
28
+ * Write-time conflict guard (R2): a global create whose similarity against an
29
+ * existing same-kind entry reaches this score is rejected outright — a
30
+ * near-duplicate adds zero information and the model should evolve_update
31
+ * the existing entry instead.
32
+ */
33
+ export declare const CONFLICT_BLOCK_SCORE = 0.8;
34
+ /** Similarity at/above this stamps {@link CONFLICT_HINT_KEY} but lets the write proceed. */
35
+ export declare const CONFLICT_WARN_SCORE = 0.5;
36
+ /**
37
+ * Build a policy from config values (schemastery strings compiled here so
38
+ * the config layer never touches RegExp). Invalid patterns are skipped —
39
+ * a broken user pattern must not disable the remaining guards.
40
+ */
41
+ export declare function resolvePromotionPolicy(options: {
42
+ blockPatterns?: readonly string[] | undefined;
43
+ minPromoteChars?: number | undefined;
44
+ maxContentOverlap?: number | undefined;
45
+ }): PromotionPolicy;
46
+ /**
47
+ * First reason the content reads as project-scoped, or undefined when it
48
+ * looks portable. Returns the matched pattern source so skip reports stay
49
+ * explainable in reviews.jsonl rationales.
50
+ */
51
+ export declare function projectScopedReason(content: string, policy: PromotionPolicy): string | undefined;
52
+ /**
53
+ * Tokenize for cheap similarity: ASCII words plus CJK character bigrams
54
+ * (single CJK chars are too ambiguous; bigrams survive segmentation-free
55
+ * Chinese text). Lowercased; single-char ASCII tokens dropped as noise.
56
+ */
57
+ export declare function normalizedTokens(text: string): Set<string>;
58
+ /** Jaccard similarity of two texts' normalized token sets (0..1). */
59
+ export declare function contentOverlap(a: string, b: string): number;
60
+ export interface SimilarEntryHit {
61
+ id: string;
62
+ title: string;
63
+ score: number;
64
+ }
65
+ /**
66
+ * Human/LLM-readable description of a similarity hit, shared by the block
67
+ * error and the approval-question suffix so both surfaces explain the same
68
+ * way.
69
+ */
70
+ export declare function buildConflictNotice(hit: SimilarEntryHit): string;
71
+ /**
72
+ * The most similar entry of the list, above `minScore`. Title and content
73
+ * both feed the comparison (titles are short; content carries the real
74
+ * signal). Generic form of {@link mostSimilarGlobalEntry} — callers decide
75
+ * which corpus (global store, merged view) the candidates come from.
76
+ */
77
+ export declare function mostSimilarEntry(entries: readonly HarnessEntry[], title: string, content: string, minScore: number): SimilarEntryHit | undefined;
78
+ /**
79
+ * The most similar non-archived global entry of the same kind, above the
80
+ * policy threshold.
81
+ */
82
+ export declare function mostSimilarGlobalEntry(globalState: HarnessState, kind: RefinementKind, title: string, content: string, policy: PromotionPolicy): SimilarEntryHit | undefined;
83
+ //# sourceMappingURL=promotion.d.ts.map
@@ -0,0 +1,127 @@
1
+ import { isArchived } from "./types.js";
2
+ /** Regex sources that mark content as project-scoped (never global). */
3
+ const DEFAULT_BLOCK_PATTERNS = [
4
+ String.raw `\/(?:mnt|home|Users)\/`, // absolute POSIX paths: "/home/…", "/mnt/…", "/Users/…"
5
+ String.raw `\bsession-[0-9a-f]{8}\b`, // session-scoped identifiers
6
+ String.raw `~/\.dsh\b`, // user harness home references
7
+ ];
8
+ export const DEFAULT_PROMOTION_POLICY = {
9
+ blockPatterns: DEFAULT_BLOCK_PATTERNS.map((source) => new RegExp(source, "i")),
10
+ minPromoteChars: 100,
11
+ maxContentOverlap: 0.6,
12
+ };
13
+ /**
14
+ * Write-time conflict guard (R2): a global create whose similarity against an
15
+ * existing same-kind entry reaches this score is rejected outright — a
16
+ * near-duplicate adds zero information and the model should evolve_update
17
+ * the existing entry instead.
18
+ */
19
+ export const CONFLICT_BLOCK_SCORE = 0.8;
20
+ /** Similarity at/above this stamps {@link CONFLICT_HINT_KEY} but lets the write proceed. */
21
+ export const CONFLICT_WARN_SCORE = 0.5;
22
+ /**
23
+ * Build a policy from config values (schemastery strings compiled here so
24
+ * the config layer never touches RegExp). Invalid patterns are skipped —
25
+ * a broken user pattern must not disable the remaining guards.
26
+ */
27
+ export function resolvePromotionPolicy(options) {
28
+ const patterns = [...(options.blockPatterns ?? [])]
29
+ .map((source) => {
30
+ try {
31
+ return new RegExp(source, "i");
32
+ }
33
+ catch {
34
+ return undefined;
35
+ }
36
+ })
37
+ .filter((pattern) => pattern !== undefined);
38
+ return {
39
+ blockPatterns: patterns.length > 0 ? patterns : DEFAULT_PROMOTION_POLICY.blockPatterns,
40
+ minPromoteChars: options.minPromoteChars ?? DEFAULT_PROMOTION_POLICY.minPromoteChars,
41
+ maxContentOverlap: options.maxContentOverlap ?? DEFAULT_PROMOTION_POLICY.maxContentOverlap,
42
+ };
43
+ }
44
+ /**
45
+ * First reason the content reads as project-scoped, or undefined when it
46
+ * looks portable. Returns the matched pattern source so skip reports stay
47
+ * explainable in reviews.jsonl rationales.
48
+ */
49
+ export function projectScopedReason(content, policy) {
50
+ for (const pattern of policy.blockPatterns) {
51
+ if (pattern.test(content)) {
52
+ return `project-scoped content (matches /${pattern.source}/)`;
53
+ }
54
+ }
55
+ return undefined;
56
+ }
57
+ /**
58
+ * Tokenize for cheap similarity: ASCII words plus CJK character bigrams
59
+ * (single CJK chars are too ambiguous; bigrams survive segmentation-free
60
+ * Chinese text). Lowercased; single-char ASCII tokens dropped as noise.
61
+ */
62
+ export function normalizedTokens(text) {
63
+ const lowered = text.toLowerCase();
64
+ const tokens = new Set();
65
+ for (const match of lowered.matchAll(/[a-z0-9_]{2,}/g)) {
66
+ tokens.add(match[0] ?? "");
67
+ }
68
+ let previous;
69
+ for (const match of lowered.matchAll(/[\u3400-\u9fff]/g)) {
70
+ const char = match[0] ?? "";
71
+ if (previous !== undefined) {
72
+ tokens.add(`${previous}${char}`);
73
+ }
74
+ previous = char;
75
+ }
76
+ tokens.delete("");
77
+ return tokens;
78
+ }
79
+ /** Jaccard similarity of two texts' normalized token sets (0..1). */
80
+ export function contentOverlap(a, b) {
81
+ const left = normalizedTokens(a);
82
+ const right = normalizedTokens(b);
83
+ if (left.size === 0 || right.size === 0) {
84
+ return 0;
85
+ }
86
+ let intersection = 0;
87
+ for (const token of left) {
88
+ if (right.has(token)) {
89
+ intersection += 1;
90
+ }
91
+ }
92
+ return intersection / (left.size + right.size - intersection);
93
+ }
94
+ /**
95
+ * Human/LLM-readable description of a similarity hit, shared by the block
96
+ * error and the approval-question suffix so both surfaces explain the same
97
+ * way.
98
+ */
99
+ export function buildConflictNotice(hit) {
100
+ return `near-duplicate of ${hit.id} 「${hit.title}」 (similarity ${Math.round(hit.score * 100)}%)`;
101
+ }
102
+ /**
103
+ * The most similar entry of the list, above `minScore`. Title and content
104
+ * both feed the comparison (titles are short; content carries the real
105
+ * signal). Generic form of {@link mostSimilarGlobalEntry} — callers decide
106
+ * which corpus (global store, merged view) the candidates come from.
107
+ */
108
+ export function mostSimilarEntry(entries, title, content, minScore) {
109
+ let best;
110
+ for (const other of entries) {
111
+ if (isArchived(other))
112
+ continue;
113
+ const score = Math.max(contentOverlap(title, other.title), contentOverlap(content, other.content));
114
+ if (score >= minScore && (best === undefined || score > best.score)) {
115
+ best = { id: other.id, title: other.title, score };
116
+ }
117
+ }
118
+ return best;
119
+ }
120
+ /**
121
+ * The most similar non-archived global entry of the same kind, above the
122
+ * policy threshold.
123
+ */
124
+ export function mostSimilarGlobalEntry(globalState, kind, title, content, policy) {
125
+ return mostSimilarEntry(Object.values(globalState.entries[kind]), title, content, policy.maxContentOverlap);
126
+ }
127
+ //# sourceMappingURL=promotion.js.map
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Retrieval primitives for injection ranking: CJK-aware tokenization plus a
3
+ * dependency-free, field-weighted BM25 scorer (ADR
4
+ * `implemented/feature/2026-08-24-cjk-bigram-bm25-retrieval.md`).
5
+ *
6
+ * Why this shape:
7
+ * - the old scorer tokenized whole CJK runs as single tokens compared for
8
+ * exact equality, so any rewording of a Chinese query missed every entry
9
+ * (「检索升级」 never matched 「全文检索方案」); overlapping character
10
+ * bigrams — the standard cjk_bigram analyzer shape (Elasticsearch/
11
+ * OpenSearch) — fix recall while keeping English behavior identical;
12
+ * - raw hit counts weighted common words and distinctive terms alike; BM25's
13
+ * IDF separates them. We use Lucene's non-negative variant
14
+ * `ln(1 + (N - df + 0.5) / (df + 0.5))` so EVERY matched token scores > 0
15
+ * and unmatched documents score exactly 0, preserving rankEntries'
16
+ * "relevant outranks recent-but-irrelevant" ordering invariant;
17
+ * - the index lifetime is one ranking call. Stores hold tens~hundreds of
18
+ * entries (measured full rebuild + score: single-digit milliseconds), the
19
+ * JSON state files stay the single source of truth, and npm installers
20
+ * carry zero native/WASM baggage — better-sqlite3 FTS5, sql.js, MiniSearch,
21
+ * FlexSearch were all evaluated and rejected (see the ADR's alternatives).
22
+ */
23
+ import type { HarnessEntry } from "./types.js";
24
+ /** BM25 term-saturation constant (Okapi k1). */
25
+ export declare const BM25_K1 = 1.5;
26
+ /** BM25 document-length normalization constant (Okapi b). */
27
+ export declare const BM25_B = 0.75;
28
+ /** Multiplier for title-field matches (title hits outweigh body hits 2:1). */
29
+ export declare const TITLE_FIELD_WEIGHT = 2;
30
+ /**
31
+ * Tokenize text for relevance scoring: lowercase ASCII runs become word
32
+ * tokens; CJK runs become overlapping character bigrams (a single-character
33
+ * run stays a unigram). Deterministic and allocation-light — safe to call per
34
+ * injection build.
35
+ *
36
+ * @param text Arbitrary entry or query text.
37
+ * @returns Tokens in occurrence order (duplicates kept; consumers dedupe when
38
+ * order-independent weighting is wanted).
39
+ */
40
+ export declare function tokenize(text: string): string[];
41
+ /** Per-field corpus statistics for one ranking pass. */
42
+ interface FieldStats {
43
+ /** Term-frequency map per document, positioned like {@link RelevanceIndex.positions}. */
44
+ readonly tfs: ReadonlyArray<ReadonlyMap<string, number>>;
45
+ /** Token count per document (this field's dl for BM25 length normalization). */
46
+ readonly lengths: ReadonlyArray<number>;
47
+ /** Document frequency per distinct term across the corpus. */
48
+ readonly df: ReadonlyMap<string, number>;
49
+ /** Average token count across the corpus (0 when empty). */
50
+ readonly averageLength: number;
51
+ }
52
+ /** Precomputed corpus statistics backing {@link relevanceScore}. */
53
+ export interface RelevanceIndex {
54
+ /** Number of documents in the corpus. */
55
+ readonly size: number;
56
+ /** Identity positions: the exact entry objects the index was built from. */
57
+ readonly positions: ReadonlyMap<object, number>;
58
+ readonly titles: FieldStats;
59
+ readonly bodies: FieldStats;
60
+ }
61
+ /**
62
+ * Build the corpus statistics (per-field tf, df, average length) for one
63
+ * ranking call. Pure: the index reads its input snapshot and never mutates
64
+ * the entries.
65
+ *
66
+ * @param entries The candidate entries being ranked this call.
67
+ * @returns An index that answers {@link relevanceScore} for exactly these
68
+ * entries (identity-keyed).
69
+ */
70
+ export declare function buildRelevanceIndex(entries: readonly HarnessEntry[]): RelevanceIndex;
71
+ /**
72
+ * Field-weighted BM25 score of `entry` against `query` within `index`:
73
+ * `TITLE_FIELD_WEIGHT × bm25(title) + bm25(body)`. Query tokens are deduped,
74
+ * so repeating a word adds no weight.
75
+ *
76
+ * @returns A score > 0 when at least one query token occurs in the entry
77
+ * (either field), and exactly 0 otherwise — the property rankEntries'
78
+ * relevance-first ordering relies on. Never throws; an entry outside
79
+ * the index simply scores 0.
80
+ */
81
+ export declare function relevanceScore(index: RelevanceIndex, entry: HarnessEntry, query: string): number;
82
+ export {};
83
+ //# sourceMappingURL=search.d.ts.map
package/lib/search.js ADDED
@@ -0,0 +1,136 @@
1
+ /** BM25 term-saturation constant (Okapi k1). */
2
+ export const BM25_K1 = 1.5;
3
+ /** BM25 document-length normalization constant (Okapi b). */
4
+ export const BM25_B = 0.75;
5
+ /** Multiplier for title-field matches (title hits outweigh body hits 2:1). */
6
+ export const TITLE_FIELD_WEIGHT = 2;
7
+ /**
8
+ * One pass, two run shapes: ASCII alphanumeric words, or maximal CJK runs.
9
+ * Runs never mix scripts because the alternation splits at the boundary, so
10
+ * 「深色主题dark主题」 tokenizes as 深色/色主/主题, "dark", 主题.
11
+ */
12
+ const TOKEN_RUN_PATTERN = /[a-z0-9]+|[\u4e00-\u9fff]+/g;
13
+ const ASCII_RUN_PATTERN = /^[a-z0-9]+$/;
14
+ /**
15
+ * Tokenize text for relevance scoring: lowercase ASCII runs become word
16
+ * tokens; CJK runs become overlapping character bigrams (a single-character
17
+ * run stays a unigram). Deterministic and allocation-light — safe to call per
18
+ * injection build.
19
+ *
20
+ * @param text Arbitrary entry or query text.
21
+ * @returns Tokens in occurrence order (duplicates kept; consumers dedupe when
22
+ * order-independent weighting is wanted).
23
+ */
24
+ export function tokenize(text) {
25
+ const tokens = [];
26
+ for (const match of text.toLowerCase().matchAll(TOKEN_RUN_PATTERN)) {
27
+ const run = match[0];
28
+ if (ASCII_RUN_PATTERN.test(run)) {
29
+ tokens.push(run);
30
+ }
31
+ else if (run.length === 1) {
32
+ tokens.push(run);
33
+ }
34
+ else {
35
+ for (let i = 0; i < run.length - 1; i += 1) {
36
+ tokens.push(run.slice(i, i + 2));
37
+ }
38
+ }
39
+ }
40
+ return tokens;
41
+ }
42
+ function buildFieldStats(fieldTokens) {
43
+ const tfs = fieldTokens.map((tokens) => {
44
+ const tf = new Map();
45
+ for (const token of tokens) {
46
+ tf.set(token, (tf.get(token) ?? 0) + 1);
47
+ }
48
+ return tf;
49
+ });
50
+ const df = new Map();
51
+ let totalLength = 0;
52
+ for (let position = 0; position < tfs.length; position += 1) {
53
+ const tf = tfs[position];
54
+ let length = 0;
55
+ for (const count of tf.values()) {
56
+ length += count;
57
+ }
58
+ totalLength += length;
59
+ for (const token of tf.keys()) {
60
+ df.set(token, (df.get(token) ?? 0) + 1);
61
+ }
62
+ }
63
+ return {
64
+ tfs,
65
+ lengths: fieldTokens.map((tokens) => tokens.length),
66
+ df,
67
+ averageLength: tfs.length === 0 ? 0 : totalLength / tfs.length,
68
+ };
69
+ }
70
+ /**
71
+ * Build the corpus statistics (per-field tf, df, average length) for one
72
+ * ranking call. Pure: the index reads its input snapshot and never mutates
73
+ * the entries.
74
+ *
75
+ * @param entries The candidate entries being ranked this call.
76
+ * @returns An index that answers {@link relevanceScore} for exactly these
77
+ * entries (identity-keyed).
78
+ */
79
+ export function buildRelevanceIndex(entries) {
80
+ const titles = [];
81
+ const bodies = [];
82
+ for (const entry of entries) {
83
+ titles.push(tokenize(entry.title));
84
+ bodies.push(tokenize(`${entry.content} ${entry.path}`));
85
+ }
86
+ return {
87
+ size: entries.length,
88
+ positions: new Map(entries.map((entry, position) => [entry, position])),
89
+ titles: buildFieldStats(titles),
90
+ bodies: buildFieldStats(bodies),
91
+ };
92
+ }
93
+ /** One field's BM25 contribution for a single term (0 when the term is absent). */
94
+ function bm25Term(termFrequency, documentFrequency, corpusSize, docLength, averageLength) {
95
+ if (termFrequency <= 0 || documentFrequency <= 0) {
96
+ return 0;
97
+ }
98
+ const inverseDocumentFrequency = Math.log(1 + (corpusSize - documentFrequency + 0.5) / (documentFrequency + 0.5));
99
+ const normalization = averageLength > 0 ? 1 - BM25_B + BM25_B * (docLength / averageLength) : 1;
100
+ return inverseDocumentFrequency * ((termFrequency * (BM25_K1 + 1)) / (termFrequency + BM25_K1 * normalization));
101
+ }
102
+ /**
103
+ * Field-weighted BM25 score of `entry` against `query` within `index`:
104
+ * `TITLE_FIELD_WEIGHT × bm25(title) + bm25(body)`. Query tokens are deduped,
105
+ * so repeating a word adds no weight.
106
+ *
107
+ * @returns A score > 0 when at least one query token occurs in the entry
108
+ * (either field), and exactly 0 otherwise — the property rankEntries'
109
+ * relevance-first ordering relies on. Never throws; an entry outside
110
+ * the index simply scores 0.
111
+ */
112
+ export function relevanceScore(index, entry, query) {
113
+ const position = index.positions.get(entry);
114
+ if (position === undefined || index.size === 0) {
115
+ return 0;
116
+ }
117
+ const seen = new Set();
118
+ let titleScore = 0;
119
+ let bodyScore = 0;
120
+ for (const token of tokenize(query)) {
121
+ if (seen.has(token)) {
122
+ continue;
123
+ }
124
+ seen.add(token);
125
+ const titleDf = index.titles.df.get(token) ?? 0;
126
+ if (titleDf > 0) {
127
+ titleScore += bm25Term(index.titles.tfs[position]?.get(token) ?? 0, titleDf, index.size, index.titles.lengths[position] ?? 0, index.titles.averageLength);
128
+ }
129
+ const bodyDf = index.bodies.df.get(token) ?? 0;
130
+ if (bodyDf > 0) {
131
+ bodyScore += bm25Term(index.bodies.tfs[position]?.get(token) ?? 0, bodyDf, index.size, index.bodies.lengths[position] ?? 0, index.bodies.averageLength);
132
+ }
133
+ }
134
+ return TITLE_FIELD_WEIGHT * titleScore + bodyScore;
135
+ }
136
+ //# sourceMappingURL=search.js.map