@oh-my-pi/pi-mnemopi 18.3.1 → 18.3.3

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 CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.3.3] - 2026-09-27
6
+
7
+ ### Fixed
8
+
9
+ - Fixed recall search matching query terms inside unrelated words, improving result relevance and ensuring exact matches are prioritized.
10
+ - Fixed sleep summaries replacing shortened terms within larger words, preventing unintended word corruption.
11
+
5
12
  ## [18.2.5] - 2026-09-17
6
13
 
7
14
  ### Fixed
package/README.md CHANGED
@@ -29,6 +29,8 @@ console.log(id, results[0]?.content);
29
29
  memory.close();
30
30
  ```
31
31
 
32
+ Lexical recall matches whole tokens, declared synonyms, underscore-separated identifier parts, and other forms of a word of at least four characters: the word plus `s`, `es`, `d`, `ed`, `ing`, `er`, `ers`, `ment` or `ments`, a final `e` replaced by `ing`/`ion`/`ions`, or a final `y` replaced by `ies`/`ied`. `backup` matches `backups`, `facts` matches `fact` and `caching` matches `cache`, but `1Password` does not match `pass` or `word`, and `redis` does not match `redistribution`.
33
+
32
34
  ## Configuration
33
35
 
34
36
  `Mnemopi` accepts LLM and embedding options directly. `MNEMOPI_*` environment variables remain fallbacks/defaults when the matching constructor option is omitted.
@@ -30,8 +30,6 @@ export declare function parseQueryTime(queryTime?: string | Date | null): Date;
30
30
  export declare function parseTimestampFast(ts: string | null | undefined, beam?: Pick<BeamMemoryState, "caches"> | null): Date | null;
31
31
  export declare function recencyDecay(timestamp: string | null | undefined, halflifeHours?: number, now?: Date): number;
32
32
  export declare function temporalBoost(memoryTimestamp: string | null | undefined, queryTime: Date | string, halflifeHours?: number, beam?: Pick<BeamMemoryState, "caches"> | null): number;
33
- export declare function lexicalRelevance(queryTokens: readonly string[], content: string, queryLower?: string): number;
34
- export declare function strictFactMatches(query: string, factText: string): boolean;
35
33
  export declare function buildFtsQuery(query: string): string;
36
34
  export declare function cjkLikeSearch(db: Database, query: string, k?: number, working?: boolean): Array<FtsRankResult | WorkingFtsRankResult>;
37
35
  export declare function ftsSearch(db: Database, query: string, k?: number): FtsRankResult[];
@@ -3,8 +3,15 @@ export declare const RECALL_SYNONYMS: Readonly<Record<string, readonly string[]>
3
3
  export declare function isCjkChar(ch: string): boolean;
4
4
  export declare function hasCjk(text: string): boolean;
5
5
  export declare const containsSpacelessCjk: typeof hasCjk;
6
+ /**
7
+ * Weak match between two forms of one word, in either direction: the longer token is the shorter
8
+ * one plus an inflectional or common derivational ending (`facts`/`fact`, `deploy`/`deployment`,
9
+ * `caching`/`cache`, `stories`/`story`). The shorter token needs at least four characters. Any
10
+ * other extension or fragment never matches, so `1password`, `password` and `passport` do not
11
+ * find `pass`, and `redis` does not find `redistribution`. This is deliberately not a stemmer.
12
+ */
13
+ export declare function matchesWordForm(left: string, right: string): boolean;
6
14
  export declare function recallTokens(text: string): string[];
7
- export declare function factMatchTokens(text: string): Set<string>;
8
15
  export declare function expandedQueryTokens(tokens: readonly string[]): string[];
9
16
  export declare function minimumRecallRelevance(queryTokens: readonly string[]): number;
10
17
  export declare function cjkFtsTerms(text: string): string[];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-mnemopi",
4
- "version": "18.3.1",
4
+ "version": "18.3.3",
5
5
  "description": "Local SQLite memory engine for omp agents",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": {
@@ -42,10 +42,10 @@
42
42
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
43
43
  },
44
44
  "dependencies": {
45
- "@oh-my-pi/pi-ai": "18.3.1",
46
- "@oh-my-pi/pi-catalog": "18.3.1",
47
- "@oh-my-pi/pi-natives": "18.3.1",
48
- "@oh-my-pi/pi-utils": "18.3.1"
45
+ "@oh-my-pi/pi-ai": "18.3.3",
46
+ "@oh-my-pi/pi-catalog": "18.3.3",
47
+ "@oh-my-pi/pi-natives": "18.3.3",
48
+ "@oh-my-pi/pi-utils": "18.3.3"
49
49
  },
50
50
  "peerDependencies": {
51
51
  "fastembed": "2.1.0",
package/src/core/aaak.ts CHANGED
@@ -80,9 +80,29 @@ function reverseMap<const T extends Readonly<Record<string, string>>>(source: T)
80
80
 
81
81
  export const REV_CATEGORY = reverseMap(CATEGORY_MAP);
82
82
 
83
- const SORTED_PHRASES = Object.entries(PHRASE_MAP).sort(([left], [right]) => right.length - left.length);
83
+ /**
84
+ * Literal pattern for `phrase` that never matches inside a longer word: an edge that is a
85
+ * word character must sit on a word boundary, so `complete` leaves `incomplete` and
86
+ * `completed` intact. Space and punctuation edges match as before.
87
+ */
88
+ function wholeWordPattern(phrase: string): RegExp {
89
+ const start = /^[\p{L}\p{N}_]/u.test(phrase) ? "(?<![\\p{L}\\p{N}_])" : "";
90
+ const end = /[\p{L}\p{N}_]$/u.test(phrase) ? "(?![\\p{L}\\p{N}_])" : "";
91
+ return new RegExp(`${start}${RegExp.escape(phrase)}${end}`, "gu");
92
+ }
93
+
94
+ const PHRASE_PATTERNS = Object.entries(PHRASE_MAP)
95
+ .sort(([left], [right]) => right.length - left.length)
96
+ .map(([phrase, shorthand]) => [wholeWordPattern(phrase), shorthand] as const);
84
97
  export const REV_PHRASE = reverseMap(PHRASE_MAP);
85
98
 
99
+ const STATUS_SHORTHANDS = [
100
+ [wholeWordPattern("working correctly"), "OK"],
101
+ [wholeWordPattern("working"), "OK"],
102
+ [wholeWordPattern("complete"), "DONE"],
103
+ [wholeWordPattern("completed"), "DONE"],
104
+ ] as const;
105
+
86
106
  function replaceAllLiteral(text: string, pattern: string, replacement: string): string {
87
107
  return text.replaceAll(pattern, replacement);
88
108
  }
@@ -100,8 +120,8 @@ export function applyCategoryPrefixes(text: string): string {
100
120
 
101
121
  export function applyPhrases(text: string): string {
102
122
  let result = text;
103
- for (const [phrase, shorthand] of SORTED_PHRASES) {
104
- result = replaceAllLiteral(result, phrase, shorthand);
123
+ for (const [pattern, shorthand] of PHRASE_PATTERNS) {
124
+ result = result.replace(pattern, shorthand);
105
125
  }
106
126
  return result;
107
127
  }
@@ -132,10 +152,9 @@ export function encode(text: string): string {
132
152
  result = applyPhrases(result);
133
153
  result = applyStructural(result);
134
154
  result = compactParens(result);
135
- result = result.replaceAll("working correctly", "OK");
136
- result = result.replaceAll("working", "OK");
137
- result = result.replaceAll("complete", "DONE");
138
- result = result.replaceAll("completed", "DONE");
155
+ for (const [pattern, shorthand] of STATUS_SHORTHANDS) {
156
+ result = result.replace(pattern, shorthand);
157
+ }
139
158
  return result.trim();
140
159
  }
141
160
 
@@ -1,17 +1,7 @@
1
1
  import type { Database } from "bun:sqlite";
2
2
  import { logger } from "@oh-my-pi/pi-utils";
3
3
  import { generateId as generateTimedId, sha256Hex16, stableMemoryId } from "../../util/ids";
4
- import {
5
- cjkFtsTerms,
6
- containsSpacelessCjk,
7
- FACT_MATCH_STOPWORDS,
8
- factMatchTokens,
9
- ftsQueryTerms,
10
- hasCjk,
11
- isCjkChar,
12
- RECALL_SYNONYMS,
13
- recallTokens,
14
- } from "../../util/regex";
4
+ import { cjkFtsTerms, containsSpacelessCjk, ftsQueryTerms, hasCjk, isCjkChar, recallTokens } from "../../util/regex";
15
5
  import { currentEmbeddingModel, embed } from "../embeddings";
16
6
  import { getMnemopiRuntimeOptions, mnemopiDebugEnabled, withMnemopiRuntimeOptions } from "../runtime-options";
17
7
  import { buildExactVectorIndex, searchExactVectorIndex } from "../vector-index";
@@ -46,7 +36,6 @@ const DEFAULT_WEIGHTS: HybridWeights = [0.5, 0.3, 0.2];
46
36
  const TS_CACHE_MAX = 2000;
47
37
  const moduleTimestampCache = new Map<string, Date>();
48
38
 
49
- const SPLIT_TOKEN_RE = /[_:/.-]+/g;
50
39
  const WORD_RE = /[\p{L}\p{N}_]+/gu;
51
40
  function envNumber(name: string, fallback: number): number {
52
41
  const raw = process.env[name];
@@ -187,68 +176,6 @@ export function temporalBoost(
187
176
  return Math.exp(-hoursDelta / halflife);
188
177
  }
189
178
 
190
- export function lexicalRelevance(queryTokens: readonly string[], content: string, queryLower = ""): number {
191
- const contentLower = content.toLowerCase();
192
- const queryCjk = new Set(Array.from(queryLower).filter(isCjkChar));
193
- if (queryTokens.length === 0 && queryCjk.size === 0) return 0;
194
-
195
- const contentTokens = new Set(recallTokens(contentLower));
196
- for (const token of Array.from(contentTokens)) {
197
- for (const part of token.split(SPLIT_TOKEN_RE)) {
198
- if (part.length >= 3 && !FACT_MATCH_STOPWORDS.has(part) && !/^\d+$/.test(part)) contentTokens.add(part);
199
- }
200
- }
201
- if (contentTokens.size === 0 && queryCjk.size === 0) return 0;
202
-
203
- let exact = 0;
204
- let partial = 0;
205
- for (const token of queryTokens) {
206
- if (contentTokens.has(token)) {
207
- exact += 1;
208
- continue;
209
- }
210
- const synonyms = RECALL_SYNONYMS[token] ?? [];
211
- if (synonyms.some(syn => contentTokens.has(syn))) {
212
- partial += 0.75;
213
- continue;
214
- }
215
- if (
216
- token.length >= 4 &&
217
- Array.from(contentTokens).some(
218
- contentToken => contentToken.length >= 4 && (token.includes(contentToken) || contentToken.includes(token)),
219
- )
220
- ) {
221
- partial += 0.4;
222
- }
223
- }
224
-
225
- const fullMatch = queryLower !== "" && contentLower.includes(queryLower) ? 1 : 0;
226
- let score = (exact + partial + fullMatch) / Math.max(queryTokens.length, 1);
227
- if (score === 0 && queryCjk.size > 0) {
228
- const contentCjk = new Set(Array.from(contentLower).filter(isCjkChar));
229
- let overlap = 0;
230
- for (const ch of queryCjk) if (contentCjk.has(ch)) overlap += 1;
231
- score = overlap / queryCjk.size;
232
- }
233
- return Math.min(score, 1);
234
- }
235
-
236
- export function strictFactMatches(query: string, factText: string): boolean {
237
- const queryLower = query.toLowerCase().trim();
238
- const factLower = factText.toLowerCase().trim();
239
- if (!queryLower || !factLower) return false;
240
- if (factLower.includes(queryLower)) return true;
241
- const queryTokens = factMatchTokens(queryLower);
242
- const factTokens = factMatchTokens(factLower);
243
- if (queryTokens.size === 0 || factTokens.size === 0) return false;
244
- const overlap = Array.from(queryTokens).filter(token => factTokens.has(token));
245
- if (overlap.length >= 2) return true;
246
- const token = overlap[0];
247
- if (token === undefined) return false;
248
- if (token.length >= 8 && /[./:_-]/.test(token)) return true;
249
- return token.length >= 5;
250
- }
251
-
252
179
  export function buildFtsQuery(query: string): string {
253
180
  return ftsQueryTerms(query).join(" OR ");
254
181
  }
@@ -1,8 +1,9 @@
1
1
  import { normalizedRecallWeights, temporalHalflifeHours } from "../../config";
2
+ import { hasCjk, matchesWordForm } from "../../util/regex";
2
3
  import { embedQuery } from "../embeddings";
3
4
  import { mmrRerank } from "../mmr";
4
5
  import { adjustWeights, classifyIntent } from "../query-intent";
5
- import { getSynonyms, normalizeQuery, STOP_WORDS as QUERY_STOP_WORDS } from "../synonyms";
6
+ import { getSynonyms, STOP_WORDS as QUERY_STOP_WORDS } from "../synonyms";
6
7
  import { extractTemporal } from "../temporal-parser";
7
8
  import { cosineSimilarity } from "../vector-math";
8
9
  import type { BeamMemoryState, RecallEnhancedOptions, RecallOptions, RecallResult } from "./types";
@@ -251,79 +252,53 @@ function factExpandedTokenGroups(query: string, content: string): string[][] {
251
252
  return groups;
252
253
  }
253
254
 
254
- function tokensFromGroups(groups: readonly (readonly string[])[]): string[] {
255
- const seen = new Set<string>();
256
- for (const group of groups) {
257
- for (const token of group) seen.add(token);
258
- }
259
- return [...seen];
260
- }
261
-
262
- function contentMatchesToken(contentLower: string, contentTokens: ReadonlySet<string>, token: string): boolean {
263
- if (contentTokens.has(token) || contentLower.includes(token)) return true;
264
- for (const contentToken of contentTokens) {
265
- if (
266
- contentToken.length >= 4 &&
267
- token.length >= 4 &&
268
- (contentToken.includes(token) || token.includes(contentToken))
269
- ) {
270
- return true;
255
+ // Match whole tokens or another form of the same word; CJK text need not separate words with spaces.
256
+ function lexicalGroupRelevance(queryGroups: readonly (readonly string[])[], content: string): number {
257
+ if (queryGroups.length === 0) return 0;
258
+ const tokens = tokenize(content);
259
+ const contentTokens = new Set(tokens);
260
+ // Split document identifiers only; a query synonym such as data_store must stay atomic.
261
+ for (const token of tokens) {
262
+ if (!token.includes("_")) continue;
263
+ for (const part of token.split("_")) {
264
+ if (part.length > 0) contentTokens.add(part);
271
265
  }
272
266
  }
273
- return false;
274
- }
275
-
276
- function lexicalGroupRelevance(
277
- queryGroups: readonly (readonly string[])[],
278
- content: string,
279
- normalizedQuery: string,
280
- ): number {
281
- if (queryGroups.length === 0) return 0;
282
- const contentLower = content.toLowerCase();
283
- if (queryGroups.length > 1 && normalizedQuery.length > 0 && contentLower.includes(normalizedQuery)) return 1;
284
- const contentTokens = new Set(tokenize(contentLower));
285
- let exact = 0;
267
+ const cjkContent = queryGroups.some(group => group.some(hasCjk)) ? content.toLowerCase() : null;
268
+ let matched = 0;
286
269
  let partial = 0;
287
270
  for (const group of queryGroups) {
288
- let matched = false;
289
- for (const token of group) {
290
- if (contentMatchesToken(contentLower, contentTokens, token)) {
291
- matched = true;
292
- break;
293
- }
294
- }
295
- if (matched) exact += 1;
296
- else {
297
- for (const token of group) {
271
+ if (group.some(token => contentTokens.has(token) || (hasCjk(token) && cjkContent?.includes(token)))) {
272
+ matched += 1;
273
+ } else if (
274
+ group.some(token => {
298
275
  for (const contentToken of contentTokens) {
299
- if (
300
- contentToken.length >= 4 &&
301
- token.length >= 4 &&
302
- (contentToken.includes(token) || token.includes(contentToken))
303
- ) {
304
- partial += 1;
305
- matched = true;
306
- break;
307
- }
276
+ if (matchesWordForm(token, contentToken)) return true;
308
277
  }
309
- if (matched) break;
310
- }
278
+ return false;
279
+ })
280
+ ) {
281
+ partial += 1;
311
282
  }
312
283
  }
313
- if (queryGroups.length === 1) {
314
- if (exact === 0 && partial === 0) return 0;
315
- const token = queryGroups[0]?.[0] ?? "";
316
- let count = 0;
317
- let offset = 0;
318
- while (token.length > 0) {
319
- const idx = contentLower.indexOf(token, offset);
320
- if (idx < 0) break;
284
+ if (queryGroups.length !== 1) return (matched + partial * 0.5) / queryGroups.length;
285
+ if (matched === 0) return partial > 0 ? 0.35 : 0;
286
+ const token = queryGroups[0]?.[0];
287
+ let count = 0;
288
+ if (token && hasCjk(token) && cjkContent !== null) {
289
+ for (
290
+ let offset = cjkContent.indexOf(token);
291
+ offset >= 0;
292
+ offset = cjkContent.indexOf(token, offset + token.length)
293
+ ) {
321
294
  count += 1;
322
- offset = idx + token.length;
323
295
  }
324
- return clamp01(0.7 + Math.min(Math.max(count - 1, 0), 3) * 0.1);
296
+ } else {
297
+ for (const contentToken of tokens) {
298
+ if (contentToken === token) count += 1;
299
+ }
325
300
  }
326
- return clamp01((exact + partial * 0.5) / queryGroups.length);
301
+ return clamp01(0.7 + Math.min(Math.max(count - 1, 0), 3) * 0.1);
327
302
  }
328
303
 
329
304
  function queryAsksCurrent(query: string): boolean {
@@ -346,45 +321,6 @@ function minimumRelevance(tokens: readonly string[]): number {
346
321
  return 0.22;
347
322
  }
348
323
 
349
- function lexicalRelevance(queryTokens: readonly string[], content: string, normalizedQuery: string): number {
350
- if (queryTokens.length === 0) return 0;
351
- const contentLower = content.toLowerCase();
352
- if (queryTokens.length > 1 && normalizedQuery.length > 0 && contentLower.includes(normalizedQuery)) return 1;
353
- if (queryTokens.length === 1) {
354
- const token = queryTokens[0] ?? "";
355
- if (token.length === 0 || !contentLower.includes(token)) return 0;
356
- let count = 0;
357
- let offset = 0;
358
- while (true) {
359
- const idx = contentLower.indexOf(token, offset);
360
- if (idx < 0) break;
361
- count += 1;
362
- offset = idx + token.length;
363
- }
364
- return clamp01(0.7 + Math.min(Math.max(count - 1, 0), 3) * 0.1);
365
- }
366
- const contentTokens = new Set(tokenize(contentLower));
367
- let exact = 0;
368
- let partial = 0;
369
- for (const token of queryTokens) {
370
- if (contentTokens.has(token) || contentLower.includes(token)) {
371
- exact += 1;
372
- continue;
373
- }
374
- for (const contentToken of contentTokens) {
375
- if (
376
- contentToken.length >= 4 &&
377
- token.length >= 4 &&
378
- (contentToken.includes(token) || token.includes(contentToken))
379
- ) {
380
- partial += 1;
381
- break;
382
- }
383
- }
384
- }
385
- return clamp01((exact + partial * 0.5) / queryTokens.length);
386
- }
387
-
388
324
  function recencyDecay(timestamp: unknown, halfLifeHours = 72): number {
389
325
  const raw = asString(timestamp);
390
326
  if (raw.length === 0) return 0;
@@ -723,16 +659,12 @@ function scoreCandidate(
723
659
  candidate: MemoryCandidate,
724
660
  queryTokens: readonly string[],
725
661
  queryGroups: readonly (readonly string[])[],
726
- normalizedQueryLower: string,
727
662
  weights: readonly [number, number, number],
728
663
  options: RecallOptionsInternal,
729
664
  ): RecallResult | null {
730
665
  const content = asString(candidate.row.content);
731
666
  const searchableContent = asString(candidate.row.embed_text) || content;
732
- const lexical =
733
- queryGroups.length > 0
734
- ? lexicalGroupRelevance(queryGroups, searchableContent, normalizedQueryLower)
735
- : lexicalRelevance(queryTokens, searchableContent, normalizedQueryLower);
667
+ const lexical = lexicalGroupRelevance(queryGroups, searchableContent);
736
668
  const minRel = minimumRelevance(queryTokens);
737
669
  if (lexical < minRel && candidate.signals.dense < 0.65) return null;
738
670
  const [vecWeight, ftsWeight, importanceWeight] = weights;
@@ -993,11 +925,10 @@ export async function recall(
993
925
  const useSynonyms = options.useSynonyms !== false;
994
926
  const tokens = expandedTokens(query, useSynonyms);
995
927
  const tokenGroups = expandedTokenGroups(query, useSynonyms);
996
- const normalized = normalizeQuery(query).toLowerCase();
997
928
  const candidates = collectMemoryCandidates(beam, query, topK, temporalOptions);
998
929
  const scored: RecallResult[] = [];
999
930
  for (const candidate of candidates) {
1000
- const result = scoreCandidate(candidate, tokens, tokenGroups, normalized, weights, temporalOptions);
931
+ const result = scoreCandidate(candidate, tokens, tokenGroups, weights, temporalOptions);
1001
932
  if (result !== null) scored.push(result);
1002
933
  }
1003
934
  scored.sort((left, right) => (right.score ?? 0) - (left.score ?? 0));
@@ -1184,7 +1115,6 @@ export function factRecall(beam: BeamMemoryState, query: string, topK = 30): Fac
1184
1115
  if (rowids.length === 0) return [];
1185
1116
  const visibility = factVisibilityWhere(beam, "");
1186
1117
  const ranks = normalizeRanks(matched, "rowid");
1187
- const normalized = normalizeQuery(query).toLowerCase();
1188
1118
  const rows = queryAll(
1189
1119
  beam,
1190
1120
  `SELECT rowid, fact_id, subject, predicate, object, timestamp, confidence
@@ -1203,11 +1133,7 @@ export function factRecall(beam: BeamMemoryState, query: string, topK = 30): Fac
1203
1133
  const content = object.length > 0 ? object : `${subject} ${predicate}`.trim();
1204
1134
  const searchable = factSearchableText(subject, predicate, object);
1205
1135
  const queryGroups = factExpandedTokenGroups(query, searchable);
1206
- const queryTokens = tokensFromGroups(queryGroups);
1207
- const lexical =
1208
- queryGroups.length > 0
1209
- ? lexicalGroupRelevance(queryGroups, searchable, normalized)
1210
- : lexicalRelevance(queryTokens, searchable, normalized);
1136
+ const lexical = lexicalGroupRelevance(queryGroups, searchable);
1211
1137
  const rank = ranks.get(asNumber(row.rowid)) ?? 0;
1212
1138
  const result: FactRecallResult = {
1213
1139
  id: asString(row.fact_id),
package/src/util/regex.ts CHANGED
@@ -79,6 +79,32 @@ export function hasCjk(text: string): boolean {
79
79
 
80
80
  export const containsSpacelessCjk = hasCjk;
81
81
 
82
+ /** Endings that turn one form of a word into another: `backup`/`backups`, `deploy`/`deployment`. */
83
+ const WORD_FORM_ENDINGS: readonly string[] = ["s", "es", "d", "ed", "ing", "er", "ers", "ment", "ments"];
84
+ /** Endings that replace a final `e`: `cache`/`caching`, `create`/`creation`. */
85
+ const SILENT_E_ENDINGS: readonly string[] = ["ing", "ion", "ions"];
86
+ /** Endings that replace a final `y`: `story`/`stories`, `copy`/`copied`. */
87
+ const FINAL_Y_ENDINGS: readonly string[] = ["ies", "ied"];
88
+
89
+ /**
90
+ * Weak match between two forms of one word, in either direction: the longer token is the shorter
91
+ * one plus an inflectional or common derivational ending (`facts`/`fact`, `deploy`/`deployment`,
92
+ * `caching`/`cache`, `stories`/`story`). The shorter token needs at least four characters. Any
93
+ * other extension or fragment never matches, so `1password`, `password` and `passport` do not
94
+ * find `pass`, and `redis` does not find `redistribution`. This is deliberately not a stemmer.
95
+ */
96
+ export function matchesWordForm(left: string, right: string): boolean {
97
+ const [shorter, longer] = left.length <= right.length ? [left, right] : [right, left];
98
+ if (shorter.length < 4 || longer.length === shorter.length) return false;
99
+ if (longer.startsWith(shorter) && WORD_FORM_ENDINGS.includes(longer.slice(shorter.length))) return true;
100
+ const stem = shorter.slice(0, -1);
101
+ if (!longer.startsWith(stem)) return false;
102
+ const ending = longer.slice(stem.length);
103
+ if (shorter.endsWith("e")) return SILENT_E_ENDINGS.includes(ending);
104
+ if (shorter.endsWith("y")) return FINAL_Y_ENDINGS.includes(ending);
105
+ return false;
106
+ }
107
+
82
108
  export function recallTokens(text: string): string[] {
83
109
  RECALL_TOKEN_RE.lastIndex = 0;
84
110
  const tokens: string[] = [];
@@ -92,10 +118,6 @@ export function recallTokens(text: string): string[] {
92
118
  return tokens;
93
119
  }
94
120
 
95
- export function factMatchTokens(text: string): Set<string> {
96
- return new Set(recallTokens(text));
97
- }
98
-
99
121
  export function expandedQueryTokens(tokens: readonly string[]): string[] {
100
122
  const expanded: string[] = [];
101
123
  const seen = new Set<string>();