pi-memoria 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +672 -0
- package/index.ts +260 -0
- package/package.json +71 -0
- package/skills/memoria/SKILL.md +65 -0
- package/skills/memoria-sessions/SKILL.md +66 -0
- package/src/commands.ts +472 -0
- package/src/config.ts +196 -0
- package/src/frontmatter.ts +318 -0
- package/src/index-engine.ts +1044 -0
- package/src/learn.ts +299 -0
- package/src/recall.ts +172 -0
- package/src/rerank.ts +85 -0
- package/src/runtime.ts +1633 -0
- package/src/search.ts +499 -0
- package/src/sessions.ts +592 -0
- package/src/similarity.ts +374 -0
- package/src/store.ts +1643 -0
- package/src/synonyms.ts +105 -0
- package/src/timeexpr.ts +145 -0
- package/src/tokenize.ts +295 -0
- package/src/tools.ts +935 -0
- package/src/transfer.ts +180 -0
- package/src/types.ts +533 -0
- package/src/usage.ts +174 -0
- package/src/util.ts +138 -0
package/src/synonyms.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query-side synonym expansion.
|
|
3
|
+
*
|
|
4
|
+
* `k8s` should find notes that say `kubernetes` without re-indexing them, so
|
|
5
|
+
* synonyms are applied when the query is analyzed. The table comes from
|
|
6
|
+
* `config.synonyms` (inline) merged with `<root>/synonyms.json`, which makes it
|
|
7
|
+
* per-store: one person's shorthand never leaks into another store.
|
|
8
|
+
*
|
|
9
|
+
* File format (all keys and values lowercase; expansion is bidirectional):
|
|
10
|
+
*
|
|
11
|
+
* {
|
|
12
|
+
* "k8s": ["kubernetes", "kube"],
|
|
13
|
+
* "postgres": ["postgresql", "pg"]
|
|
14
|
+
* }
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { SYNONYMS_FILE } from "./config.ts";
|
|
19
|
+
import { readFileOrUndefined } from "./util.ts";
|
|
20
|
+
|
|
21
|
+
/** Upper bound on the size of a store's synonym table. */
|
|
22
|
+
export const MAX_SYNONYM_TERMS = 2000;
|
|
23
|
+
|
|
24
|
+
/** Normalize an arbitrary JSON value into a clean `term -> alternatives` table. */
|
|
25
|
+
export function normalizeSynonymTable(raw: unknown): Record<string, string[]> {
|
|
26
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
27
|
+
const out: Record<string, string[]> = {};
|
|
28
|
+
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
|
|
29
|
+
const term = key.trim().toLowerCase();
|
|
30
|
+
if (!term || term.length > 60) continue;
|
|
31
|
+
const list = Array.isArray(value) ? value : typeof value === "string" ? value.split(/[,\s]+/) : [];
|
|
32
|
+
const cleaned = list
|
|
33
|
+
.map((entry) => String(entry ?? "").trim().toLowerCase())
|
|
34
|
+
.filter((entry) => entry && entry.length <= 60 && entry !== term);
|
|
35
|
+
if (cleaned.length > 0) out[term] = [...new Set(cleaned)].slice(0, 12);
|
|
36
|
+
if (Object.keys(out).length >= MAX_SYNONYM_TERMS) break;
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Merge tables left to right; later entries append alternatives. */
|
|
42
|
+
export function mergeSynonymTables(...tables: Array<Record<string, string[]> | undefined>): Record<string, string[]> {
|
|
43
|
+
const out = new Map<string, string[]>();
|
|
44
|
+
for (const table of tables) {
|
|
45
|
+
if (!table) continue;
|
|
46
|
+
for (const [term, values] of Object.entries(table)) {
|
|
47
|
+
const existing = out.get(term) ?? [];
|
|
48
|
+
out.set(term, [...new Set([...existing, ...values])].slice(0, 12));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return Object.fromEntries(out);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Make expansion symmetric: if `k8s -> kubernetes` then `kubernetes -> k8s`
|
|
56
|
+
* too, so the feature works from whichever word the query happens to use.
|
|
57
|
+
*/
|
|
58
|
+
export function expandSynonymTable(table: Record<string, string[]>): Record<string, string[]> {
|
|
59
|
+
const out = new Map<string, Set<string>>();
|
|
60
|
+
const add = (from: string, to: string): void => {
|
|
61
|
+
if (from === to) return;
|
|
62
|
+
const set = out.get(from) ?? new Set<string>();
|
|
63
|
+
set.add(to);
|
|
64
|
+
out.set(from, set);
|
|
65
|
+
};
|
|
66
|
+
for (const [term, values] of Object.entries(table)) {
|
|
67
|
+
for (const value of values) {
|
|
68
|
+
add(term, value);
|
|
69
|
+
add(value, term);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return Object.fromEntries([...out.entries()].map(([term, set]) => [term, [...set].slice(0, 12)]));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Load `<root>/synonyms.json`; a missing or malformed file yields an empty table. */
|
|
76
|
+
export async function loadSynonymsFile(root: string): Promise<Record<string, string[]>> {
|
|
77
|
+
const raw = await readFileOrUndefined(join(root, SYNONYMS_FILE));
|
|
78
|
+
if (!raw) return {};
|
|
79
|
+
try {
|
|
80
|
+
return normalizeSynonymTable(JSON.parse(raw));
|
|
81
|
+
} catch {
|
|
82
|
+
return {};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Alternatives for a term, already lowercased and deduplicated. */
|
|
87
|
+
export function synonymsFor(table: Record<string, string[]>, term: string): string[] {
|
|
88
|
+
return table[term] ?? [];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Example file content (used by `/memoria doctor` and the README). */
|
|
92
|
+
export function synonymsFileTemplate(): string {
|
|
93
|
+
return `${JSON.stringify(
|
|
94
|
+
{
|
|
95
|
+
k8s: ["kubernetes"],
|
|
96
|
+
postgres: ["postgresql", "pg"],
|
|
97
|
+
auth: ["authentication", "login"],
|
|
98
|
+
deps: ["dependencies"],
|
|
99
|
+
repo: ["repository"],
|
|
100
|
+
perf: ["performance"],
|
|
101
|
+
},
|
|
102
|
+
null,
|
|
103
|
+
2,
|
|
104
|
+
)}\n`;
|
|
105
|
+
}
|
package/src/timeexpr.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Natural-language time expressions in queries.
|
|
3
|
+
*
|
|
4
|
+
* "what did we decide last week?" should not depend on the word "week" being in
|
|
5
|
+
* the stored note, so the window is parsed out of the query and applied as a
|
|
6
|
+
* recency filter instead. Pure and locale-independent in its assertions: all
|
|
7
|
+
* windows are derived from the supplied `now`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { TimeWindow } from "./types.ts";
|
|
11
|
+
|
|
12
|
+
const DAY = 86_400_000;
|
|
13
|
+
|
|
14
|
+
/** Start of the local day containing `epochMs`. */
|
|
15
|
+
export function startOfDay(epochMs: number): number {
|
|
16
|
+
const date = new Date(epochMs);
|
|
17
|
+
date.setHours(0, 0, 0, 0);
|
|
18
|
+
return date.getTime();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Start of the ISO week (Monday) containing `epochMs`. */
|
|
22
|
+
function startOfWeek(epochMs: number): number {
|
|
23
|
+
const date = new Date(startOfDay(epochMs));
|
|
24
|
+
const day = (date.getDay() + 6) % 7; // Monday = 0
|
|
25
|
+
date.setDate(date.getDate() - day);
|
|
26
|
+
return date.getTime();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Start of the month containing `epochMs`. */
|
|
30
|
+
function startOfMonth(epochMs: number): number {
|
|
31
|
+
const date = new Date(epochMs);
|
|
32
|
+
return new Date(date.getFullYear(), date.getMonth(), 1).getTime();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Start of the year containing `epochMs`. */
|
|
36
|
+
function startOfYear(epochMs: number): number {
|
|
37
|
+
return new Date(new Date(epochMs).getFullYear(), 0, 1).getTime();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const MONTHS = [
|
|
41
|
+
"january",
|
|
42
|
+
"february",
|
|
43
|
+
"march",
|
|
44
|
+
"april",
|
|
45
|
+
"may",
|
|
46
|
+
"june",
|
|
47
|
+
"july",
|
|
48
|
+
"august",
|
|
49
|
+
"september",
|
|
50
|
+
"october",
|
|
51
|
+
"november",
|
|
52
|
+
"december",
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
const WEEKDAYS = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"];
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Parse a time expression from a query.
|
|
59
|
+
*
|
|
60
|
+
* Recognizes: today, yesterday, this week, last week, this month, last month,
|
|
61
|
+
* this year, last year, last N days/weeks/months/years, N days ago, recently /
|
|
62
|
+
* lately, this/last quarter, weekday names, and month names ("in March").
|
|
63
|
+
* Returns undefined when the query has no usable time hint.
|
|
64
|
+
*/
|
|
65
|
+
export function parseTimeExpression(text: string, now = Date.now()): TimeWindow | undefined {
|
|
66
|
+
const lower = ` ${text.toLowerCase()} `;
|
|
67
|
+
const relative = (since: number, label: string, until?: number): TimeWindow => ({ label, since, until });
|
|
68
|
+
|
|
69
|
+
if (/\b(today|this morning|this afternoon|tonight)\b/.test(lower)) {
|
|
70
|
+
return relative(startOfDay(now), "today", now);
|
|
71
|
+
}
|
|
72
|
+
if (/\byesterday\b/.test(lower)) {
|
|
73
|
+
const start = startOfDay(now) - DAY;
|
|
74
|
+
return relative(start, "yesterday", start + DAY);
|
|
75
|
+
}
|
|
76
|
+
if (/\b(just now|moments ago|a moment ago)\b/.test(lower)) {
|
|
77
|
+
return relative(now - 3_600_000, "the last hour", now);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const countMatch = lower.match(/\b(?:last|past|previous)\s+(\d{1,3})\s*(day|days|week|weeks|month|months|year|years)\b/);
|
|
81
|
+
const agoMatch = lower.match(/\b(\d{1,3})\s*(day|days|week|weeks|month|months|year|years)\s+ago\b/);
|
|
82
|
+
const span = countMatch ?? agoMatch;
|
|
83
|
+
if (span) {
|
|
84
|
+
const count = Number(span[1]);
|
|
85
|
+
const unit = span[2].replace(/s$/, "");
|
|
86
|
+
const ms = unit === "day" ? DAY : unit === "week" ? 7 * DAY : unit === "month" ? 30 * DAY : 365 * DAY;
|
|
87
|
+
if (Number.isFinite(count) && count > 0) return relative(now - count * ms, `the last ${count} ${span[2]}`, now);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (/\b(this|last|past|previous)\s+week\b/.test(lower)) {
|
|
91
|
+
const thisWeek = startOfWeek(now);
|
|
92
|
+
return /\blast|previous|past\b/.test(lower)
|
|
93
|
+
? relative(thisWeek - 7 * DAY, "last week", thisWeek)
|
|
94
|
+
: relative(thisWeek, "this week", now);
|
|
95
|
+
}
|
|
96
|
+
if (/\b(this|last|past|previous)\s+month\b/.test(lower)) {
|
|
97
|
+
const thisMonth = startOfMonth(now);
|
|
98
|
+
return /\blast|previous|past\b/.test(lower)
|
|
99
|
+
? relative(startOfMonth(thisMonth - 1), "last month", thisMonth)
|
|
100
|
+
: relative(thisMonth, "this month", now);
|
|
101
|
+
}
|
|
102
|
+
if (/\b(this|last|past|previous)\s+year\b/.test(lower)) {
|
|
103
|
+
const thisYear = startOfYear(now);
|
|
104
|
+
return /\blast|previous|past\b/.test(lower)
|
|
105
|
+
? relative(startOfYear(thisYear - 1), "last year", thisYear)
|
|
106
|
+
: relative(thisYear, "this year", now);
|
|
107
|
+
}
|
|
108
|
+
const quarter = lower.match(/\b(this|last|previous)\s+quarter\b/);
|
|
109
|
+
if (quarter) {
|
|
110
|
+
const month = new Date(now).getMonth();
|
|
111
|
+
const quarterStartMonth = month - (month % 3);
|
|
112
|
+
const start = new Date(new Date(now).getFullYear(), quarterStartMonth, 1).getTime();
|
|
113
|
+
if (/last|previous/.test(quarter[1])) {
|
|
114
|
+
const previousStart = new Date(new Date(now).getFullYear(), quarterStartMonth - 3, 1).getTime();
|
|
115
|
+
return relative(previousStart, "last quarter", start);
|
|
116
|
+
}
|
|
117
|
+
return relative(start, "this quarter", now);
|
|
118
|
+
}
|
|
119
|
+
if (/\b(recently|lately|these days|the other day)\b/.test(lower)) {
|
|
120
|
+
return relative(now - 30 * DAY, "the last 30 days", now);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Weekday names: "on Monday" means the most recent Monday.
|
|
124
|
+
for (let index = 0; index < WEEKDAYS.length; index += 1) {
|
|
125
|
+
if (!new RegExp(`\\b(on|last|this|since)?\\s*${WEEKDAYS[index]}\\b`).test(lower)) continue;
|
|
126
|
+
const start = startOfDay(now);
|
|
127
|
+
const current = (new Date(start).getDay() + 6) % 7; // Monday = 0
|
|
128
|
+
const target = (index + 6) % 7;
|
|
129
|
+
let delta = current - target;
|
|
130
|
+
if (delta < 0) delta += 7;
|
|
131
|
+
return relative(start - delta * DAY, `the last ${WEEKDAYS[index]}`, start - delta * DAY + DAY);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Month names, with an optional 4-digit year: "in March", "since March 2024".
|
|
135
|
+
for (let index = 0; index < MONTHS.length; index += 1) {
|
|
136
|
+
const pattern = new RegExp(`\\b(?:in|during|since|from)\\s+${MONTHS[index]}(?:\\s+(\\d{4}))?\\b`);
|
|
137
|
+
const match = lower.match(pattern);
|
|
138
|
+
if (!match) continue;
|
|
139
|
+
const year = match[1] ? Number(match[1]) : new Date(now).getFullYear();
|
|
140
|
+
const start = new Date(year, index, 1).getTime();
|
|
141
|
+
const end = new Date(year, index + 1, 1).getTime();
|
|
142
|
+
return { label: `${MONTHS[index][0].toUpperCase()}${MONTHS[index].slice(1)}${match[1] ? ` ${year}` : ""}`, since: start, until: end };
|
|
143
|
+
}
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
package/src/tokenize.ts
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tokenizer for memoria.
|
|
3
|
+
*
|
|
4
|
+
* Design goals:
|
|
5
|
+
* - Exact recall for identifiers, paths, versions and error codes.
|
|
6
|
+
* - Tolerance for plural/verb/derivational variants via a conservative stemmer.
|
|
7
|
+
* - CJK support via unigrams + bigrams.
|
|
8
|
+
* - Zero dependencies and fast enough to re-index thousands of notes per second.
|
|
9
|
+
*
|
|
10
|
+
* Both the raw token and its stem are emitted, so exact matches always outrank
|
|
11
|
+
* stemmed coincidences.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface TokenizeOptions {
|
|
15
|
+
/** Drop common English stopwords. Default true. */
|
|
16
|
+
stopwords?: boolean;
|
|
17
|
+
/** Emit stemmed variants. Default true. */
|
|
18
|
+
stem?: boolean;
|
|
19
|
+
/** Minimum length for latin tokens. Default 2. */
|
|
20
|
+
minLength?: number;
|
|
21
|
+
/** Hard cap on emitted unique tokens. Default 6000. */
|
|
22
|
+
maxTokens?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const STOPWORDS = new Set(
|
|
26
|
+
`a about above after again against all also am an and any are aren't as at be because been before being below between both but by can cannot can't could couldn't did didn't do does doesn't doing don't down during each few for from further had hadn't has hasn't have haven't having he her here hers herself him himself his how i if in into is isn't it its itself just let's me more most mustn't my myself no nor not of off on once only or other ought our ours ourselves out over own same shan't she should shouldn't so some such than that the their theirs them themselves then there these they this those through to too under until up very was wasn't we were weren't what when where which while who whom why with won't would wouldn't you your yours yourself yourselves`.split(
|
|
27
|
+
/\s+/,
|
|
28
|
+
),
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const CJK_CHAR_RE = /[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uac00-\ud7af]/;
|
|
32
|
+
const IDENT_CHAR_RE = /[\p{L}\p{N}_]/u;
|
|
33
|
+
const IDENT_SEPARATOR_RE = /[._/\\-]/;
|
|
34
|
+
const HAN_RE = /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]/;
|
|
35
|
+
const HANGUL_RE = /[\uac00-\ud7af]/;
|
|
36
|
+
|
|
37
|
+
type CjkClass = "han" | "kana" | "hangul";
|
|
38
|
+
|
|
39
|
+
function cjkClass(ch: string): CjkClass | undefined {
|
|
40
|
+
if (HAN_RE.test(ch)) return "han";
|
|
41
|
+
if (HANGUL_RE.test(ch)) return "hangul";
|
|
42
|
+
if (CJK_CHAR_RE.test(ch)) return "kana";
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function isCjkChar(ch: string): boolean {
|
|
47
|
+
return CJK_CHAR_RE.test(ch);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Light, targeted stemmer. Conservative on purpose: false merges hurt precision. */
|
|
51
|
+
export function stem(token: string): string {
|
|
52
|
+
let t = token;
|
|
53
|
+
if (t.length < 4) return t;
|
|
54
|
+
const rules: Array<[RegExp, string]> = [
|
|
55
|
+
[/sses$/, "ss"],
|
|
56
|
+
[/ies$/, "y"],
|
|
57
|
+
[/izations?$/, "iz"],
|
|
58
|
+
[/isations?$/, "is"],
|
|
59
|
+
[/izing$/, "iz"],
|
|
60
|
+
[/ized$/, "iz"],
|
|
61
|
+
[/izes$/, "iz"],
|
|
62
|
+
[/ize$/, "iz"],
|
|
63
|
+
[/ising$/, "is"],
|
|
64
|
+
[/ised$/, "is"],
|
|
65
|
+
[/ises$/, "is"],
|
|
66
|
+
[/ise$/, "is"],
|
|
67
|
+
[/ments?$/, "ment"],
|
|
68
|
+
[/nesses$/, "ness"],
|
|
69
|
+
[/ness$/, "ness"],
|
|
70
|
+
[/ingly$/, ""],
|
|
71
|
+
[/edly$/, ""],
|
|
72
|
+
[/ing$/, ""],
|
|
73
|
+
[/ed$/, ""],
|
|
74
|
+
[/ly$/, ""],
|
|
75
|
+
[/s$/, ""],
|
|
76
|
+
];
|
|
77
|
+
for (const [pattern, replacement] of rules) {
|
|
78
|
+
if (!pattern.test(t)) continue;
|
|
79
|
+
if (pattern.source === "s$" && /(ss|us|is|as|os)$/.test(t)) continue;
|
|
80
|
+
const candidate = t.replace(pattern, replacement);
|
|
81
|
+
if (candidate.length < 3) continue;
|
|
82
|
+
// Undo a doubled final consonant produced by dropping a suffix: runn -> run.
|
|
83
|
+
t = /([bdfgmnprt])\1$/.test(candidate) ? candidate.slice(0, -1) : candidate;
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
return t;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function splitCamelAndCase(word: string): string[] {
|
|
90
|
+
if (!/[A-Z]/.test(word)) return [word];
|
|
91
|
+
const parts = word
|
|
92
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
93
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
|
|
94
|
+
.split(/\s+/)
|
|
95
|
+
.filter(Boolean);
|
|
96
|
+
return parts.length > 1 ? parts : [word];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Strip diacritics: `café` -> `cafe`. */
|
|
100
|
+
function stripDiacritics(input: string): string {
|
|
101
|
+
return input.normalize("NFKD").replace(/[\u0300-\u036f]/g, "");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Split text into raw surface tokens before stopword removal and stemming.
|
|
106
|
+
*
|
|
107
|
+
* Identifier runs (paths, dotted names, snake_case, kebab-case, camelCase) are
|
|
108
|
+
* emitted whole and also decomposed, so both `src/core/index.ts` and `index.ts`
|
|
109
|
+
* are searchable. CJK runs are split by script and emitted as unigrams, bigrams
|
|
110
|
+
* and the full run.
|
|
111
|
+
*/
|
|
112
|
+
export function splitSurfaceTokens(text: string): string[] {
|
|
113
|
+
const out: string[] = [];
|
|
114
|
+
const source = stripDiacritics(text);
|
|
115
|
+
|
|
116
|
+
const emitRun = (run: string) => {
|
|
117
|
+
// Deduplicate only within one run so repeated words keep their frequency.
|
|
118
|
+
const local = new Set<string>();
|
|
119
|
+
const pushLocal = (value: string) => {
|
|
120
|
+
const lowered = value.toLowerCase();
|
|
121
|
+
if (!lowered || local.has(lowered)) return;
|
|
122
|
+
local.add(lowered);
|
|
123
|
+
out.push(lowered);
|
|
124
|
+
};
|
|
125
|
+
pushLocal(run);
|
|
126
|
+
// Split on path separators first so `index.ts` survives as a segment,
|
|
127
|
+
// then decompose each segment further.
|
|
128
|
+
for (const segment of run.split(/[/\\]+/)) {
|
|
129
|
+
if (!segment) continue;
|
|
130
|
+
if (segment !== run) pushLocal(segment);
|
|
131
|
+
for (const camelPart of splitCamelAndCase(segment)) {
|
|
132
|
+
if (camelPart !== segment) pushLocal(camelPart);
|
|
133
|
+
const subs = camelPart.split(/[._-]+/).filter(Boolean);
|
|
134
|
+
if (subs.length > 1) for (const sub of subs) if (sub !== camelPart) pushLocal(sub);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
let latin = "";
|
|
140
|
+
const flushLatin = () => {
|
|
141
|
+
if (latin) emitRun(latin);
|
|
142
|
+
latin = "";
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
let i = 0;
|
|
146
|
+
while (i < source.length) {
|
|
147
|
+
const ch = source[i];
|
|
148
|
+
const script = cjkClass(ch);
|
|
149
|
+
if (script) {
|
|
150
|
+
flushLatin();
|
|
151
|
+
let j = i;
|
|
152
|
+
while (j < source.length && cjkClass(source[j]) === script) j += 1;
|
|
153
|
+
const run = source.slice(i, j);
|
|
154
|
+
if (run.length <= 12) out.push(run.toLowerCase());
|
|
155
|
+
const local = new Set<string>();
|
|
156
|
+
for (let k = 0; k < run.length; k += 1) {
|
|
157
|
+
const unigram = run[k].toLowerCase();
|
|
158
|
+
if (!local.has(unigram)) {
|
|
159
|
+
local.add(unigram);
|
|
160
|
+
out.push(unigram);
|
|
161
|
+
}
|
|
162
|
+
if (k + 1 < run.length) {
|
|
163
|
+
const bigram = run.slice(k, k + 2).toLowerCase();
|
|
164
|
+
if (!local.has(bigram)) {
|
|
165
|
+
local.add(bigram);
|
|
166
|
+
out.push(bigram);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
i = j;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (IDENT_CHAR_RE.test(ch)) {
|
|
174
|
+
latin += ch;
|
|
175
|
+
i += 1;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (IDENT_SEPARATOR_RE.test(ch) && latin && i + 1 < source.length && IDENT_CHAR_RE.test(source[i + 1])) {
|
|
179
|
+
latin += ch;
|
|
180
|
+
i += 1;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
flushLatin();
|
|
184
|
+
i += 1;
|
|
185
|
+
}
|
|
186
|
+
flushLatin();
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function acceptToken(token: string, stopwords: boolean, minLength: number): boolean {
|
|
191
|
+
if (!token) return false;
|
|
192
|
+
const isCjk = CJK_CHAR_RE.test(token);
|
|
193
|
+
if (token.length < (isCjk ? 1 : minLength)) return false;
|
|
194
|
+
if (stopwords && !isCjk && token.length <= 12 && STOPWORDS.has(token)) return false;
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Tokenize into unique surface terms (no stemming). Used for query analysis. */
|
|
199
|
+
export function tokenizeRaw(text: string, options: TokenizeOptions = {}): string[] {
|
|
200
|
+
const { stopwords = true, minLength = 2, maxTokens = 6000 } = options;
|
|
201
|
+
const out: string[] = [];
|
|
202
|
+
const seen = new Set<string>();
|
|
203
|
+
for (const token of splitSurfaceTokens(text)) {
|
|
204
|
+
if (!acceptToken(token, stopwords, minLength)) continue;
|
|
205
|
+
if (seen.has(token)) continue;
|
|
206
|
+
seen.add(token);
|
|
207
|
+
out.push(token);
|
|
208
|
+
if (out.length >= maxTokens) break;
|
|
209
|
+
}
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Tokenize into unique surface + stemmed terms. Used for query analysis. */
|
|
214
|
+
export function tokenize(text: string, options: TokenizeOptions = {}): string[] {
|
|
215
|
+
const { stem: doStem = true } = options;
|
|
216
|
+
const base = tokenizeRaw(text, options);
|
|
217
|
+
if (!doStem) return base;
|
|
218
|
+
const out: string[] = [];
|
|
219
|
+
const seen = new Set<string>();
|
|
220
|
+
for (const token of base) {
|
|
221
|
+
if (!seen.has(token)) {
|
|
222
|
+
seen.add(token);
|
|
223
|
+
out.push(token);
|
|
224
|
+
}
|
|
225
|
+
const reduced = stem(token);
|
|
226
|
+
if (reduced !== token && !seen.has(reduced)) {
|
|
227
|
+
seen.add(reduced);
|
|
228
|
+
out.push(reduced);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Raw term frequencies (surface tokens only) with stopword/min-length filtering. */
|
|
235
|
+
export function termCounts(text: string, options: TokenizeOptions = {}): Map<string, number> {
|
|
236
|
+
const { stopwords = true, minLength = 2, maxTokens = 6000 } = options;
|
|
237
|
+
const counts = new Map<string, number>();
|
|
238
|
+
for (const token of splitSurfaceTokens(text)) {
|
|
239
|
+
if (!acceptToken(token, stopwords, minLength)) continue;
|
|
240
|
+
counts.set(token, (counts.get(token) ?? 0) + 1);
|
|
241
|
+
if (counts.size >= maxTokens) break;
|
|
242
|
+
}
|
|
243
|
+
return counts;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Tokenize `text` and accumulate weighted frequencies into `target`.
|
|
248
|
+
* Field boosts are applied by calling this once per field with a multiplier.
|
|
249
|
+
*/
|
|
250
|
+
export function accumulateTokens(
|
|
251
|
+
text: string,
|
|
252
|
+
weight: number,
|
|
253
|
+
target: Record<string, number>,
|
|
254
|
+
options: TokenizeOptions = {},
|
|
255
|
+
): void {
|
|
256
|
+
if (!text) return;
|
|
257
|
+
const { stem: doStem = true } = options;
|
|
258
|
+
for (const [token, count] of termCounts(text, options)) {
|
|
259
|
+
const weighted = count * weight;
|
|
260
|
+
target[token] = (target[token] ?? 0) + weighted;
|
|
261
|
+
if (doStem) {
|
|
262
|
+
const reduced = stem(token);
|
|
263
|
+
if (reduced !== token) target[reduced] = (target[reduced] ?? 0) + weighted * 0.9;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Character trigrams of a term, used for fuzzy term lookup. */
|
|
269
|
+
export function termTrigrams(term: string): string[] {
|
|
270
|
+
const padded = `^${term}$`;
|
|
271
|
+
const out: string[] = [];
|
|
272
|
+
for (let i = 0; i + 3 <= padded.length; i += 1) out.push(padded.slice(i, i + 3));
|
|
273
|
+
return out;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Levenshtein distance with an early-out band. */
|
|
277
|
+
export function editDistance(a: string, b: string, max = 2): number {
|
|
278
|
+
if (a === b) return 0;
|
|
279
|
+
if (Math.abs(a.length - b.length) > max) return max + 1;
|
|
280
|
+
const prev = new Array<number>(b.length + 1);
|
|
281
|
+
const curr = new Array<number>(b.length + 1);
|
|
282
|
+
for (let j = 0; j <= b.length; j += 1) prev[j] = j;
|
|
283
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
284
|
+
curr[0] = i;
|
|
285
|
+
let rowMin = curr[0];
|
|
286
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
287
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
288
|
+
curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
|
|
289
|
+
if (curr[j] < rowMin) rowMin = curr[j];
|
|
290
|
+
}
|
|
291
|
+
if (rowMin > max) return max + 1;
|
|
292
|
+
for (let j = 0; j <= b.length; j += 1) prev[j] = curr[j];
|
|
293
|
+
}
|
|
294
|
+
return prev[b.length];
|
|
295
|
+
}
|