pi-memoria 0.2.0 → 0.3.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/AGENTS.md +110 -0
- package/README.md +126 -166
- package/index.ts +301 -204
- package/package.json +29 -69
- package/src/aliases.ts +104 -0
- package/src/config.ts +22 -184
- package/src/database.ts +301 -0
- package/src/errors.ts +55 -0
- package/src/files.ts +29 -0
- package/src/hot-memory.ts +231 -0
- package/src/results.ts +264 -0
- package/src/sessions.ts +246 -843
- package/src/text.ts +48 -0
- package/skills/memoria/SKILL.md +0 -77
- package/skills/memoria-sessions/SKILL.md +0 -69
- package/src/commands.ts +0 -398
- package/src/frontmatter.ts +0 -318
- package/src/index-engine.ts +0 -1071
- package/src/recall.ts +0 -211
- package/src/rerank.ts +0 -85
- package/src/runtime.ts +0 -1469
- package/src/search.ts +0 -499
- package/src/similarity.ts +0 -374
- package/src/store.ts +0 -1724
- package/src/synonyms.ts +0 -105
- package/src/timeexpr.ts +0 -145
- package/src/tokenize.ts +0 -295
- package/src/tools.ts +0 -813
- package/src/types.ts +0 -541
- package/src/usage.ts +0 -188
- package/src/util.ts +0 -141
package/src/index-engine.ts
DELETED
|
@@ -1,1071 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* In-memory inverted index with incremental persistence.
|
|
3
|
-
*
|
|
4
|
-
* Data layout (mirrors the persisted JSON so load/save are cheap):
|
|
5
|
-
* terms: string[] term dictionary
|
|
6
|
-
* postings: number[][] parallel to terms; flat [docIdx, tf, docIdx, tf, ...]
|
|
7
|
-
* docs: IndexedDocMeta[] parallel to docTerms/docTfs
|
|
8
|
-
* docTerms: number[][] term ids per doc (for incremental removal)
|
|
9
|
-
* docTfs: number[][] matching weights
|
|
10
|
-
* df: number[] live document frequency per term
|
|
11
|
-
* alive: boolean[] tombstones for removed docs
|
|
12
|
-
*
|
|
13
|
-
* Removal uses tombstones; compaction happens lazily once garbage exceeds a
|
|
14
|
-
* threshold. Searches skip dead docs and rely on the maintained `df` for BM25.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import { existsSync } from "node:fs";
|
|
18
|
-
import { rm } from "node:fs/promises";
|
|
19
|
-
import { basename, join } from "node:path";
|
|
20
|
-
import { INDEX_BIN_FILE, INDEX_DIR, INDEX_FILE, TRASH_DIR } from "./config.ts";
|
|
21
|
-
import { GENERATED_INDEX, readMemoryDoc, scanLibrary, isIndexableRelPath } from "./store.ts";
|
|
22
|
-
import { atomicWriteFile, debounce, oneLine, readFileBufferOrUndefined, readFileOrUndefined, shortHash, truncateChars } from "./util.ts";
|
|
23
|
-
import { termTrigrams, editDistance } from "./tokenize.ts";
|
|
24
|
-
import type { IndexedDocMeta, MemoryDoc, PersistedIndex, SearchOptions, SearchResult, StoreStats } from "./types.ts";
|
|
25
|
-
import { searchIndex } from "./search.ts";
|
|
26
|
-
|
|
27
|
-
export const INDEX_VERSION = 4;
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Above this many notes the index is persisted as a binary blob instead of
|
|
31
|
-
* JSON when `indexFormat` is "auto". The JSON encoding of a 50k-note index is
|
|
32
|
-
* tens of megabytes of text to parse; the binary sections are read directly
|
|
33
|
-
* into typed arrays.
|
|
34
|
-
*/
|
|
35
|
-
export const BINARY_AUTO_THRESHOLD_DOCS = 20_000;
|
|
36
|
-
|
|
37
|
-
const BINARY_MAGIC = 0x4d4d5842; // "MMXB"
|
|
38
|
-
const BINARY_HEADER_BYTES = 48;
|
|
39
|
-
|
|
40
|
-
/** True for filesystem events that must not invalidate the index. */
|
|
41
|
-
export function isIgnoredWatchPath(relName: string): boolean {
|
|
42
|
-
const name = normalizeRelPath(relName);
|
|
43
|
-
if (!name) return true;
|
|
44
|
-
if (name === INDEX_DIR || name.startsWith(`${INDEX_DIR}/`)) return true;
|
|
45
|
-
if (name === ".history" || name.startsWith(".history/")) return true;
|
|
46
|
-
if (name === TRASH_DIR || name.startsWith(`${TRASH_DIR}/`)) return true;
|
|
47
|
-
if (basename(name) === GENERATED_INDEX) return true;
|
|
48
|
-
return false;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function normalizeRelPath(relPath: string): string {
|
|
52
|
-
return relPath.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const COMPACT_THRESHOLD_RATIO = 0.25;
|
|
56
|
-
const COMPACT_THRESHOLD_MIN = 32;
|
|
57
|
-
|
|
58
|
-
/** Simple byte-budgeted FIFO cache for memory bodies. */
|
|
59
|
-
class BodyCache {
|
|
60
|
-
private map = new Map<string, string>();
|
|
61
|
-
private bytes = 0;
|
|
62
|
-
private readonly maxBytes: number;
|
|
63
|
-
|
|
64
|
-
constructor(maxBytes: number) {
|
|
65
|
-
this.maxBytes = maxBytes;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
get(key: string): string | undefined {
|
|
69
|
-
const value = this.map.get(key);
|
|
70
|
-
if (value === undefined) return undefined;
|
|
71
|
-
// Refresh recency.
|
|
72
|
-
this.map.delete(key);
|
|
73
|
-
this.map.set(key, value);
|
|
74
|
-
return value;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
set(key: string, value: string): void {
|
|
78
|
-
if (this.maxBytes <= 0 || value.length > this.maxBytes) return;
|
|
79
|
-
const existing = this.map.get(key);
|
|
80
|
-
if (existing !== undefined) {
|
|
81
|
-
this.bytes -= existing.length;
|
|
82
|
-
this.map.delete(key);
|
|
83
|
-
}
|
|
84
|
-
this.map.set(key, value);
|
|
85
|
-
this.bytes += value.length;
|
|
86
|
-
while (this.bytes > this.maxBytes) {
|
|
87
|
-
const oldest = this.map.keys().next();
|
|
88
|
-
if (oldest.done) break;
|
|
89
|
-
const oldestValue = this.map.get(oldest.value)!;
|
|
90
|
-
this.map.delete(oldest.value);
|
|
91
|
-
this.bytes -= oldestValue.length;
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
clear(): void {
|
|
96
|
-
this.map.clear();
|
|
97
|
-
this.bytes = 0;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
delete(key: string): void {
|
|
101
|
-
const existing = this.map.get(key);
|
|
102
|
-
if (existing === undefined) return;
|
|
103
|
-
this.bytes -= existing.length;
|
|
104
|
-
this.map.delete(key);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
get size(): number {
|
|
108
|
-
return this.map.size;
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* A doc without the fields that are recomputed on load (`root` from the store
|
|
114
|
-
* and `path` from the relative path). On a 20k-note store this is ~2 MB of
|
|
115
|
-
* redundant JSON.
|
|
116
|
-
*/
|
|
117
|
-
function leanDoc(doc: IndexedDocMeta): IndexedDocMeta {
|
|
118
|
-
const copy = { ...doc } as Record<string, unknown>;
|
|
119
|
-
delete copy.root;
|
|
120
|
-
delete copy.path;
|
|
121
|
-
return copy as unknown as IndexedDocMeta;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
export interface MemoryIndexOptions {
|
|
125
|
-
bodyCacheBytes?: number;
|
|
126
|
-
maxScanIntervalMs?: number;
|
|
127
|
-
/** Path fragments excluded from indexing (mirrors `config.exclude`). */
|
|
128
|
-
exclude?: string[];
|
|
129
|
-
/** Persistence format: auto (binary above a threshold), json or binary. */
|
|
130
|
-
indexFormat?: string;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
export class MemoryIndex {
|
|
134
|
-
readonly root: string;
|
|
135
|
-
|
|
136
|
-
terms: string[] = [];
|
|
137
|
-
postings: number[][] = [];
|
|
138
|
-
docs: IndexedDocMeta[] = [];
|
|
139
|
-
docTerms: number[][] = [];
|
|
140
|
-
docTfs: number[][] = [];
|
|
141
|
-
df: number[] = [];
|
|
142
|
-
alive: boolean[] = [];
|
|
143
|
-
|
|
144
|
-
builtAt = 0;
|
|
145
|
-
loadMs = 0;
|
|
146
|
-
lastSearchMs = 0;
|
|
147
|
-
lastScanAt = 0;
|
|
148
|
-
/** Set when files changed since the last scan. */
|
|
149
|
-
dirty = true;
|
|
150
|
-
watcherActive = false;
|
|
151
|
-
|
|
152
|
-
private termIds = new Map<string, number>();
|
|
153
|
-
private idToIdx = new Map<string, number>();
|
|
154
|
-
/** Built only for lookups that miss canonical ids; aliases are not the hot path. */
|
|
155
|
-
private aliasToIdx: Map<string, number> | undefined;
|
|
156
|
-
private relPathToIdx = new Map<string, number>();
|
|
157
|
-
private bodyCache: BodyCache;
|
|
158
|
-
private trigramIndex: Map<string, number[]> | undefined;
|
|
159
|
-
private fuzzyCache = new Map<string, number[]>();
|
|
160
|
-
/** Term ids sorted by term text; rebuilt lazily for prefix search. */
|
|
161
|
-
private sortedTermIdsCache: number[] | undefined;
|
|
162
|
-
/** Reusable dense buffers for the search hot loop. */
|
|
163
|
-
private scratch: SearchScratch | undefined;
|
|
164
|
-
private scratchGeneration = 0;
|
|
165
|
-
/** Cached resolution of `related` / `supersedes` links. */
|
|
166
|
-
private linkCache: { supersedes: Map<string, string[]>; titles: Map<string, number>; related: Map<number, number[]> } | undefined;
|
|
167
|
-
private scanIntervalMs: number;
|
|
168
|
-
private exclude: string[];
|
|
169
|
-
private compactionQueued = false;
|
|
170
|
-
private indexFormat: string;
|
|
171
|
-
/** Bytes of the index file currently on disk, and which format it is in. */
|
|
172
|
-
persistedBytes = 0;
|
|
173
|
-
persistedFormat: "json" | "binary" = "json";
|
|
174
|
-
|
|
175
|
-
constructor(root: string, options: MemoryIndexOptions = {}) {
|
|
176
|
-
this.root = root;
|
|
177
|
-
this.bodyCache = new BodyCache(options.bodyCacheBytes ?? 8 * 1024 * 1024);
|
|
178
|
-
this.scanIntervalMs = options.maxScanIntervalMs ?? 5000;
|
|
179
|
-
this.exclude = options.exclude ?? [];
|
|
180
|
-
this.indexFormat = options.indexFormat ?? "auto";
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
get indexFilePath(): string {
|
|
184
|
-
return join(this.root, INDEX_DIR, INDEX_FILE);
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
get indexBinaryPath(): string {
|
|
188
|
-
return join(this.root, INDEX_DIR, INDEX_BIN_FILE);
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/** Which format this index should persist in. */
|
|
192
|
-
chosenFormat(): "json" | "binary" {
|
|
193
|
-
if (this.indexFormat === "binary") return "binary";
|
|
194
|
-
if (this.indexFormat === "json") return "json";
|
|
195
|
-
return this.aliveCount >= BINARY_AUTO_THRESHOLD_DOCS ? "binary" : "json";
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/** Change the on-disk format; the next save rewrites the index. */
|
|
199
|
-
setIndexFormat(format: string): void {
|
|
200
|
-
this.indexFormat = format;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
get aliveCount(): number {
|
|
204
|
-
let count = 0;
|
|
205
|
-
for (const value of this.alive) if (value) count += 1;
|
|
206
|
-
return count;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
get avgDocLength(): number {
|
|
210
|
-
let total = 0;
|
|
211
|
-
let count = 0;
|
|
212
|
-
for (let i = 0; i < this.docs.length; i += 1) {
|
|
213
|
-
if (!this.alive[i]) continue;
|
|
214
|
-
total += this.docs[i].tokenCount;
|
|
215
|
-
count += 1;
|
|
216
|
-
}
|
|
217
|
-
return count === 0 ? 1 : Math.max(1, total / count);
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
getDoc(idx: number): IndexedDocMeta | undefined {
|
|
221
|
-
return this.docs[idx];
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
/**
|
|
225
|
-
* Dense per-doc scratch buffers for scoring. Keyed by doc index, so they are
|
|
226
|
-
* invalidated whenever compaction renumbers documents.
|
|
227
|
-
*/
|
|
228
|
-
searchScratch(): SearchScratch {
|
|
229
|
-
const size = this.docs.length;
|
|
230
|
-
if (!this.scratch || this.scratch.scores.length < size || this.scratch.generation !== this.scratchGeneration) {
|
|
231
|
-
this.scratch = {
|
|
232
|
-
scores: new Float64Array(size),
|
|
233
|
-
coverage: new Int32Array(size),
|
|
234
|
-
mask: new Uint32Array(size),
|
|
235
|
-
touched: new Int32Array(size),
|
|
236
|
-
generation: this.scratchGeneration,
|
|
237
|
-
};
|
|
238
|
-
}
|
|
239
|
-
return this.scratch;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
idxForId(id: string): number | undefined {
|
|
243
|
-
const direct = this.idToIdx.get(id);
|
|
244
|
-
if (direct !== undefined) return direct;
|
|
245
|
-
// Imports may have ids that do not use the mem_ prefix. A merge records the
|
|
246
|
-
// retired id as an alias, so every former id must remain usable.
|
|
247
|
-
if (!this.aliasToIdx) {
|
|
248
|
-
this.aliasToIdx = new Map();
|
|
249
|
-
for (let idx = 0; idx < this.docs.length; idx += 1) {
|
|
250
|
-
if (!this.alive[idx]) continue;
|
|
251
|
-
for (const alias of this.docs[idx].aliases) {
|
|
252
|
-
if (!this.aliasToIdx.has(alias)) this.aliasToIdx.set(alias, idx);
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
return this.aliasToIdx.get(id);
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
idxForRelPath(relPath: string): number {
|
|
260
|
-
return this.relPathToIdx.get(normalizeRelPath(relPath)) ?? -1;
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
/** Read the body for a doc, using the cache and falling back to disk. */
|
|
264
|
-
async bodyFor(idx: number): Promise<string> {
|
|
265
|
-
const doc = this.docs[idx];
|
|
266
|
-
if (!doc) return "";
|
|
267
|
-
const cached = this.bodyCache.get(doc.path);
|
|
268
|
-
if (cached !== undefined) return cached;
|
|
269
|
-
const memory = await readMemoryDoc(this.root, doc.path);
|
|
270
|
-
const body = memory?.body ?? "";
|
|
271
|
-
this.bodyCache.set(doc.path, body);
|
|
272
|
-
return body;
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
/** Synchronous body access for hot paths; returns preview when not cached. */
|
|
276
|
-
bodyOrPreview(idx: number): string {
|
|
277
|
-
const doc = this.docs[idx];
|
|
278
|
-
if (!doc) return "";
|
|
279
|
-
return this.bodyCache.get(doc.path) ?? doc.preview;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
/* ---------------------------------------------------------------- */
|
|
283
|
-
/* Mutation */
|
|
284
|
-
/* ---------------------------------------------------------------- */
|
|
285
|
-
|
|
286
|
-
private termIdFor(term: string, create: boolean): number {
|
|
287
|
-
const existing = this.termIds.get(term);
|
|
288
|
-
if (existing !== undefined) return existing;
|
|
289
|
-
if (!create) return -1;
|
|
290
|
-
const id = this.terms.length;
|
|
291
|
-
this.terms.push(term);
|
|
292
|
-
this.postings.push([]);
|
|
293
|
-
this.df.push(0);
|
|
294
|
-
this.termIds.set(term, id);
|
|
295
|
-
this.sortedTermIdsCache = undefined;
|
|
296
|
-
return id;
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
/** Add a parsed doc to the index. */
|
|
300
|
-
addDoc(doc: MemoryDoc): number {
|
|
301
|
-
const idx = this.docs.length;
|
|
302
|
-
const meta: IndexedDocMeta = {
|
|
303
|
-
id: doc.id,
|
|
304
|
-
root: doc.root,
|
|
305
|
-
path: doc.path,
|
|
306
|
-
relPath: doc.relPath,
|
|
307
|
-
title: doc.title,
|
|
308
|
-
category: doc.category,
|
|
309
|
-
tags: doc.tags,
|
|
310
|
-
aliases: doc.aliases,
|
|
311
|
-
related: doc.related,
|
|
312
|
-
supersedes: doc.supersedes,
|
|
313
|
-
summary: doc.summary,
|
|
314
|
-
priority: doc.priority,
|
|
315
|
-
confidence: doc.confidence,
|
|
316
|
-
created: doc.created,
|
|
317
|
-
updated: doc.updated,
|
|
318
|
-
lastUsed: doc.lastUsed,
|
|
319
|
-
mtimeMs: doc.mtimeMs,
|
|
320
|
-
size: doc.size,
|
|
321
|
-
wordCount: doc.wordCount,
|
|
322
|
-
bodyChars: doc.body.length,
|
|
323
|
-
tokenCount: doc.tokenCount,
|
|
324
|
-
unfiled: doc.unfiled,
|
|
325
|
-
preview: oneLine(doc.body, 400),
|
|
326
|
-
hash: shortHash(`${doc.size}:${doc.mtimeMs}:${doc.body.length}:${doc.title}:${doc.aliases.join(",")}`),
|
|
327
|
-
};
|
|
328
|
-
const termIds: number[] = [];
|
|
329
|
-
const tfs: number[] = [];
|
|
330
|
-
for (const [term, tf] of Object.entries(doc.tokens)) {
|
|
331
|
-
const termId = this.termIdFor(term, true);
|
|
332
|
-
termIds.push(termId);
|
|
333
|
-
tfs.push(tf);
|
|
334
|
-
this.postings[termId].push(idx, tf);
|
|
335
|
-
this.df[termId] += 1;
|
|
336
|
-
}
|
|
337
|
-
this.docs.push(meta);
|
|
338
|
-
this.docTerms.push(termIds);
|
|
339
|
-
this.docTfs.push(tfs);
|
|
340
|
-
this.alive.push(true);
|
|
341
|
-
this.idToIdx.set(doc.id, idx);
|
|
342
|
-
this.aliasToIdx = undefined;
|
|
343
|
-
this.relPathToIdx.set(meta.relPath, idx);
|
|
344
|
-
this.bodyCache.set(doc.path, doc.body);
|
|
345
|
-
this.linkCache = undefined;
|
|
346
|
-
return idx;
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
/** Mark a doc as removed (tombstone). */
|
|
350
|
-
removeDoc(idx: number): void {
|
|
351
|
-
if (idx < 0 || !this.alive[idx]) return;
|
|
352
|
-
const doc = this.docs[idx];
|
|
353
|
-
this.alive[idx] = false;
|
|
354
|
-
this.idToIdx.delete(doc.id);
|
|
355
|
-
this.aliasToIdx = undefined;
|
|
356
|
-
this.relPathToIdx.delete(doc.relPath);
|
|
357
|
-
this.bodyCache.delete(doc.path);
|
|
358
|
-
const termIds = this.docTerms[idx] ?? [];
|
|
359
|
-
for (const termId of termIds) {
|
|
360
|
-
if (this.df[termId] > 0) this.df[termId] -= 1;
|
|
361
|
-
}
|
|
362
|
-
this.linkCache = undefined;
|
|
363
|
-
this.maybeQueueCompaction();
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
private maybeQueueCompaction(): void {
|
|
367
|
-
const total = this.docs.length;
|
|
368
|
-
const dead = total - this.aliveCount;
|
|
369
|
-
const threshold = Math.max(COMPACT_THRESHOLD_MIN, Math.floor(total * COMPACT_THRESHOLD_RATIO));
|
|
370
|
-
if (dead > threshold && !this.compactionQueued) {
|
|
371
|
-
this.compactionQueued = true;
|
|
372
|
-
queueMicrotask(() => {
|
|
373
|
-
this.compactionQueued = false;
|
|
374
|
-
this.compact();
|
|
375
|
-
});
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
/** Rebuild postings from live docs, dropping tombstones and renumbering. */
|
|
380
|
-
compact(): void {
|
|
381
|
-
const newDocs: IndexedDocMeta[] = [];
|
|
382
|
-
const newDocTerms: number[][] = [];
|
|
383
|
-
const newDocTfs: number[][] = [];
|
|
384
|
-
const newPostings: number[][] = [];
|
|
385
|
-
const newDf: number[] = [];
|
|
386
|
-
const newTermIds = new Map<string, number>();
|
|
387
|
-
const remap = new Map<number, number>();
|
|
388
|
-
for (let oldIdx = 0; oldIdx < this.docs.length; oldIdx += 1) {
|
|
389
|
-
if (!this.alive[oldIdx]) continue;
|
|
390
|
-
remap.set(oldIdx, newDocs.length);
|
|
391
|
-
newDocs.push(this.docs[oldIdx]);
|
|
392
|
-
}
|
|
393
|
-
const internTerm = (term: string): number => {
|
|
394
|
-
const existing = newTermIds.get(term);
|
|
395
|
-
if (existing !== undefined) return existing;
|
|
396
|
-
const id = newPostings.length;
|
|
397
|
-
newPostings.push([]);
|
|
398
|
-
newDf.push(0);
|
|
399
|
-
newTermIds.set(term, id);
|
|
400
|
-
return id;
|
|
401
|
-
};
|
|
402
|
-
// Rebuild the dictionary in the old term order to keep ids stable-ish.
|
|
403
|
-
const oldToNewTerm: number[] = new Array(this.terms.length).fill(-1);
|
|
404
|
-
for (let oldTermId = 0; oldTermId < this.terms.length; oldTermId += 1) {
|
|
405
|
-
if (this.df[oldTermId] <= 0) continue;
|
|
406
|
-
oldToNewTerm[oldTermId] = internTerm(this.terms[oldTermId]);
|
|
407
|
-
}
|
|
408
|
-
for (let oldIdx = 0; oldIdx < this.docs.length; oldIdx += 1) {
|
|
409
|
-
if (!this.alive[oldIdx]) continue;
|
|
410
|
-
const newIdx = remap.get(oldIdx)!;
|
|
411
|
-
const termIds: number[] = [];
|
|
412
|
-
const tfs: number[] = [];
|
|
413
|
-
const oldTerms = this.docTerms[oldIdx] ?? [];
|
|
414
|
-
const oldTfs = this.docTfs[oldIdx] ?? [];
|
|
415
|
-
for (let k = 0; k < oldTerms.length; k += 1) {
|
|
416
|
-
const newTermId = oldToNewTerm[oldTerms[k]];
|
|
417
|
-
if (newTermId === undefined || newTermId < 0) continue;
|
|
418
|
-
termIds.push(newTermId);
|
|
419
|
-
tfs.push(oldTfs[k]);
|
|
420
|
-
newPostings[newTermId].push(newIdx, oldTfs[k]);
|
|
421
|
-
newDf[newTermId] += 1;
|
|
422
|
-
}
|
|
423
|
-
newDocTerms.push(termIds);
|
|
424
|
-
newDocTfs.push(tfs);
|
|
425
|
-
}
|
|
426
|
-
this.terms = [...newTermIds.keys()];
|
|
427
|
-
this.termIds = newTermIds;
|
|
428
|
-
this.postings = newPostings;
|
|
429
|
-
this.df = newDf;
|
|
430
|
-
this.docs = newDocs;
|
|
431
|
-
this.docTerms = newDocTerms;
|
|
432
|
-
this.docTfs = newDocTfs;
|
|
433
|
-
this.alive = new Array(newDocs.length).fill(true);
|
|
434
|
-
this.idToIdx = new Map();
|
|
435
|
-
this.aliasToIdx = undefined;
|
|
436
|
-
this.relPathToIdx = new Map();
|
|
437
|
-
for (let i = 0; i < newDocs.length; i += 1) {
|
|
438
|
-
this.idToIdx.set(newDocs[i].id, i);
|
|
439
|
-
this.relPathToIdx.set(newDocs[i].relPath, i);
|
|
440
|
-
}
|
|
441
|
-
this.trigramIndex = undefined;
|
|
442
|
-
this.fuzzyCache.clear();
|
|
443
|
-
this.sortedTermIdsCache = undefined;
|
|
444
|
-
this.scratch = undefined;
|
|
445
|
-
this.linkCache = undefined;
|
|
446
|
-
this.scratchGeneration += 1;
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
clearDocs(): void {
|
|
450
|
-
this.terms = [];
|
|
451
|
-
this.postings = [];
|
|
452
|
-
this.docs = [];
|
|
453
|
-
this.docTerms = [];
|
|
454
|
-
this.docTfs = [];
|
|
455
|
-
this.df = [];
|
|
456
|
-
this.alive = [];
|
|
457
|
-
this.termIds = new Map();
|
|
458
|
-
this.idToIdx = new Map();
|
|
459
|
-
this.aliasToIdx = undefined;
|
|
460
|
-
this.relPathToIdx = new Map();
|
|
461
|
-
this.trigramIndex = undefined;
|
|
462
|
-
this.fuzzyCache.clear();
|
|
463
|
-
this.bodyCache.clear();
|
|
464
|
-
this.sortedTermIdsCache = undefined;
|
|
465
|
-
this.scratch = undefined;
|
|
466
|
-
this.linkCache = undefined;
|
|
467
|
-
this.scratchGeneration += 1;
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
/* ---------------------------------------------------------------- */
|
|
471
|
-
/* Term dictionary helpers */
|
|
472
|
-
/* ---------------------------------------------------------------- */
|
|
473
|
-
|
|
474
|
-
/** Term ids ordered by their term text. Cached; invalidated on any dictionary change. */
|
|
475
|
-
sortedTermIds(): number[] {
|
|
476
|
-
if (this.sortedTermIdsCache) return this.sortedTermIdsCache;
|
|
477
|
-
const ids = new Array<number>(this.terms.length);
|
|
478
|
-
for (let i = 0; i < ids.length; i += 1) ids[i] = i;
|
|
479
|
-
ids.sort((a, b) => (this.terms[a] < this.terms[b] ? -1 : this.terms[a] > this.terms[b] ? 1 : 0));
|
|
480
|
-
this.sortedTermIdsCache = ids;
|
|
481
|
-
return ids;
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
termId(term: string): number {
|
|
485
|
-
return this.termIds.get(term) ?? -1;
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
documentFrequency(termId: number): number {
|
|
489
|
-
return termId >= 0 ? this.df[termId] ?? 0 : 0;
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
/**
|
|
493
|
-
* Weighted term frequency for a single document.
|
|
494
|
-
*
|
|
495
|
-
* Posting lists are kept sorted by document index, so this is a binary
|
|
496
|
-
* search instead of a scan. Used by `explain` to attribute scores to terms.
|
|
497
|
-
*/
|
|
498
|
-
postingTfFor(termId: number, docIdx: number): number {
|
|
499
|
-
if (termId < 0 || termId >= this.postings.length) return 0;
|
|
500
|
-
const posting = this.postings[termId];
|
|
501
|
-
let lo = 0;
|
|
502
|
-
let hi = posting.length / 2 - 1;
|
|
503
|
-
while (lo <= hi) {
|
|
504
|
-
const mid = (lo + hi) >> 1;
|
|
505
|
-
const doc = posting[mid * 2];
|
|
506
|
-
if (doc === docIdx) return posting[mid * 2 + 1];
|
|
507
|
-
if (doc < docIdx) lo = mid + 1;
|
|
508
|
-
else hi = mid - 1;
|
|
509
|
-
}
|
|
510
|
-
return 0;
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
/* ---------------------------------------------------------------- */
|
|
514
|
-
/* Note links (`related` / `supersedes`) */
|
|
515
|
-
/* ---------------------------------------------------------------- */
|
|
516
|
-
|
|
517
|
-
private links(): { supersedes: Map<string, string[]>; titles: Map<string, number>; related: Map<number, number[]> } {
|
|
518
|
-
if (this.linkCache) return this.linkCache;
|
|
519
|
-
const titles = new Map<string, number>();
|
|
520
|
-
for (let i = 0; i < this.docs.length; i += 1) {
|
|
521
|
-
if (!this.alive[i]) continue;
|
|
522
|
-
const title = this.docs[i].title.toLowerCase();
|
|
523
|
-
if (!titles.has(title)) titles.set(title, i);
|
|
524
|
-
}
|
|
525
|
-
const resolve = (ref: string): number | undefined => {
|
|
526
|
-
const trimmed = ref.trim();
|
|
527
|
-
if (!trimmed) return undefined;
|
|
528
|
-
const byId = this.idxForId(trimmed);
|
|
529
|
-
if (byId !== undefined) return byId;
|
|
530
|
-
const byPath = this.relPathToIdx.get(normalizeRelPath(trimmed));
|
|
531
|
-
if (byPath !== undefined) return byPath;
|
|
532
|
-
return titles.get(trimmed.toLowerCase());
|
|
533
|
-
};
|
|
534
|
-
const supersedes = new Map<string, string[]>();
|
|
535
|
-
const related = new Map<number, number[]>();
|
|
536
|
-
for (let i = 0; i < this.docs.length; i += 1) {
|
|
537
|
-
if (!this.alive[i]) continue;
|
|
538
|
-
const meta = this.docs[i];
|
|
539
|
-
for (const ref of meta.supersedes ?? []) {
|
|
540
|
-
const target = resolve(ref);
|
|
541
|
-
if (target === undefined || target === i) continue;
|
|
542
|
-
const targetId = this.docs[target].id;
|
|
543
|
-
const list = supersedes.get(targetId) ?? [];
|
|
544
|
-
if (!list.includes(meta.id)) list.push(meta.id);
|
|
545
|
-
supersedes.set(targetId, list);
|
|
546
|
-
}
|
|
547
|
-
const targets: number[] = [];
|
|
548
|
-
for (const ref of meta.related ?? []) {
|
|
549
|
-
const target = resolve(ref);
|
|
550
|
-
if (target !== undefined && target !== i && !targets.includes(target)) targets.push(target);
|
|
551
|
-
}
|
|
552
|
-
if (targets.length > 0) related.set(i, targets);
|
|
553
|
-
}
|
|
554
|
-
this.linkCache = { supersedes, titles, related };
|
|
555
|
-
return this.linkCache;
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
/** Resolve a `related`/`supersedes` reference to a live doc index. */
|
|
559
|
-
resolveLink(ref: string): number | undefined {
|
|
560
|
-
const titles = this.links().titles;
|
|
561
|
-
const trimmed = ref.trim();
|
|
562
|
-
if (!trimmed) return undefined;
|
|
563
|
-
const byId = this.idxForId(trimmed);
|
|
564
|
-
if (byId !== undefined && this.alive[byId]) return byId;
|
|
565
|
-
const byPath = this.relPathToIdx.get(normalizeRelPath(trimmed));
|
|
566
|
-
if (byPath !== undefined && this.alive[byPath]) return byPath;
|
|
567
|
-
const byTitle = titles.get(trimmed.toLowerCase());
|
|
568
|
-
return byTitle !== undefined && this.alive[byTitle] ? byTitle : undefined;
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
/** Map of doc id to the ids of live notes that declare it superseded. */
|
|
572
|
-
supersedeMap(): Map<string, string[]> {
|
|
573
|
-
return this.links().supersedes;
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
/** Live doc indices this doc links to through `related:`. */
|
|
577
|
-
relatedDocs(idx: number): number[] {
|
|
578
|
-
return this.links().related.get(idx) ?? [];
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
/**
|
|
582
|
-
* Rebuild a weighted term-frequency map for one doc from the in-memory
|
|
583
|
-
* postings. Used by similarity checks; never on the search path.
|
|
584
|
-
*/
|
|
585
|
-
docTokens(idx: number): Record<string, number> {
|
|
586
|
-
const out: Record<string, number> = {};
|
|
587
|
-
const termIds = this.docTerms[idx] ?? [];
|
|
588
|
-
const tfs = this.docTfs[idx] ?? [];
|
|
589
|
-
for (let i = 0; i < termIds.length; i += 1) {
|
|
590
|
-
const term = this.terms[termIds[i]];
|
|
591
|
-
if (term) out[term] = tfs[i] ?? 0;
|
|
592
|
-
}
|
|
593
|
-
return out;
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
/** Terms beginning with `prefix`, closest length first. */
|
|
597
|
-
prefixMatches(prefix: string, limit: number): number[] {
|
|
598
|
-
const ids = this.sortedTermIds();
|
|
599
|
-
let lo = 0;
|
|
600
|
-
let hi = ids.length;
|
|
601
|
-
while (lo < hi) {
|
|
602
|
-
const mid = (lo + hi) >> 1;
|
|
603
|
-
if (this.terms[ids[mid]] < prefix) lo = mid + 1;
|
|
604
|
-
else hi = mid;
|
|
605
|
-
}
|
|
606
|
-
const out: number[] = [];
|
|
607
|
-
for (let i = lo; i < ids.length && out.length < limit; i += 1) {
|
|
608
|
-
const termId = ids[i];
|
|
609
|
-
if (!this.terms[termId].startsWith(prefix)) break;
|
|
610
|
-
if (this.df[termId] > 0) out.push(termId);
|
|
611
|
-
}
|
|
612
|
-
out.sort((a, b) => Math.abs(this.terms[a].length - prefix.length) - Math.abs(this.terms[b].length - prefix.length));
|
|
613
|
-
return out;
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
private buildTrigramIndex(): void {
|
|
617
|
-
const index = new Map<string, number[]>();
|
|
618
|
-
for (let termId = 0; termId < this.terms.length; termId += 1) {
|
|
619
|
-
if (this.df[termId] <= 0) continue;
|
|
620
|
-
const term = this.terms[termId];
|
|
621
|
-
for (const trigram of new Set(termTrigrams(term))) {
|
|
622
|
-
const bucket = index.get(trigram);
|
|
623
|
-
if (bucket) bucket.push(termId);
|
|
624
|
-
else index.set(trigram, [termId]);
|
|
625
|
-
}
|
|
626
|
-
}
|
|
627
|
-
this.trigramIndex = index;
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
/** Fuzzy term candidates within edit distance `maxDistance`. */
|
|
631
|
-
fuzzyMatches(term: string, maxDistance: number, limit: number): number[] {
|
|
632
|
-
if (term.length < 4) return [];
|
|
633
|
-
const cached = this.fuzzyCache.get(term);
|
|
634
|
-
if (cached) return cached;
|
|
635
|
-
if (!this.trigramIndex) this.buildTrigramIndex();
|
|
636
|
-
const trigrams = [...new Set(termTrigrams(term))];
|
|
637
|
-
const counts = new Map<number, number>();
|
|
638
|
-
for (const trigram of trigrams) {
|
|
639
|
-
for (const termId of this.trigramIndex!.get(trigram) ?? []) {
|
|
640
|
-
counts.set(termId, (counts.get(termId) ?? 0) + 1);
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
const minShared = Math.max(1, Math.ceil(trigrams.length * 0.4));
|
|
644
|
-
const candidates: Array<{ termId: number; distance: number }> = [];
|
|
645
|
-
for (const [termId, shared] of counts) {
|
|
646
|
-
if (shared < minShared) continue;
|
|
647
|
-
const candidate = this.terms[termId];
|
|
648
|
-
if (Math.abs(candidate.length - term.length) > maxDistance) continue;
|
|
649
|
-
const distance = editDistance(term, candidate, maxDistance);
|
|
650
|
-
if (distance <= maxDistance) candidates.push({ termId, distance });
|
|
651
|
-
}
|
|
652
|
-
candidates.sort((a, b) => a.distance - b.distance || this.df[b.termId] - this.df[a.termId]);
|
|
653
|
-
const out = candidates.slice(0, limit).map((entry) => entry.termId);
|
|
654
|
-
this.fuzzyCache.set(term, out);
|
|
655
|
-
return out;
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
/* ---------------------------------------------------------------- */
|
|
659
|
-
/* Persistence and freshness */
|
|
660
|
-
/* ---------------------------------------------------------------- */
|
|
661
|
-
|
|
662
|
-
/** Load the persisted index, then reconcile against the filesystem. */
|
|
663
|
-
async load(): Promise<void> {
|
|
664
|
-
const started = performance.now();
|
|
665
|
-
let loaded = false;
|
|
666
|
-
// Binary is preferred whenever it is present and decodable; the JSON path
|
|
667
|
-
// stays as a fallback (hand-edited stores, older versions, corruption).
|
|
668
|
-
const binary = await readFileBufferOrUndefined(this.indexBinaryPath);
|
|
669
|
-
if (binary) {
|
|
670
|
-
if (this.hydrateBinary(binary)) {
|
|
671
|
-
loaded = true;
|
|
672
|
-
this.persistedFormat = "binary";
|
|
673
|
-
this.persistedBytes = binary.length;
|
|
674
|
-
}
|
|
675
|
-
}
|
|
676
|
-
if (!loaded) {
|
|
677
|
-
const raw = await readFileOrUndefined(this.indexFilePath);
|
|
678
|
-
if (raw) {
|
|
679
|
-
try {
|
|
680
|
-
const parsed = JSON.parse(raw) as PersistedIndex;
|
|
681
|
-
if (parsed.version === INDEX_VERSION && parsed.root === this.root && Array.isArray(parsed.docs)) {
|
|
682
|
-
this.hydrate(parsed);
|
|
683
|
-
this.persistedFormat = "json";
|
|
684
|
-
this.persistedBytes = raw.length;
|
|
685
|
-
}
|
|
686
|
-
} catch {
|
|
687
|
-
this.clearDocs();
|
|
688
|
-
}
|
|
689
|
-
}
|
|
690
|
-
}
|
|
691
|
-
this.loadMs = Math.round((performance.now() - started) * 100) / 100;
|
|
692
|
-
await this.refresh(true);
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
/**
|
|
696
|
-
* Encode the index as a binary blob.
|
|
697
|
-
*
|
|
698
|
-
* Layout (little-endian): header, docs JSON, term offsets + term bytes,
|
|
699
|
-
* posting offsets, posting doc indices (uint32) and posting weights
|
|
700
|
-
* (float32). Postings are the bulk of an index, and this avoids both the
|
|
701
|
-
* JSON text and the per-number parse cost for large stores.
|
|
702
|
-
*/
|
|
703
|
-
serializeBinary(): Buffer {
|
|
704
|
-
const parsed = this.serialize();
|
|
705
|
-
const docsJson = Buffer.from(JSON.stringify(parsed.docs), "utf8");
|
|
706
|
-
const termCount = parsed.terms.length;
|
|
707
|
-
const termOffsets = Buffer.allocUnsafe((termCount + 1) * 4);
|
|
708
|
-
const encodedTerms: Buffer[] = [];
|
|
709
|
-
let termBytes = 0;
|
|
710
|
-
for (let i = 0; i < termCount; i += 1) {
|
|
711
|
-
termOffsets.writeUInt32LE(termBytes, i * 4);
|
|
712
|
-
const encoded = Buffer.from(parsed.terms[i], "utf8");
|
|
713
|
-
encodedTerms.push(encoded);
|
|
714
|
-
termBytes += encoded.length;
|
|
715
|
-
}
|
|
716
|
-
termOffsets.writeUInt32LE(termBytes, termCount * 4);
|
|
717
|
-
const postingOffsets = Buffer.allocUnsafe((termCount + 1) * 4);
|
|
718
|
-
let postingCount = 0;
|
|
719
|
-
for (let i = 0; i < termCount; i += 1) {
|
|
720
|
-
postingOffsets.writeUInt32LE(postingCount, i * 4);
|
|
721
|
-
postingCount += Math.floor((parsed.postings[i]?.length ?? 0) / 2);
|
|
722
|
-
}
|
|
723
|
-
postingOffsets.writeUInt32LE(postingCount, termCount * 4);
|
|
724
|
-
const postingDocs = Buffer.allocUnsafe(postingCount * 4);
|
|
725
|
-
const postingTfs = Buffer.allocUnsafe(postingCount * 4);
|
|
726
|
-
let at = 0;
|
|
727
|
-
for (let i = 0; i < termCount; i += 1) {
|
|
728
|
-
const posting = parsed.postings[i] ?? [];
|
|
729
|
-
for (let k = 0; k + 1 < posting.length; k += 2) {
|
|
730
|
-
postingDocs.writeUInt32LE(posting[k], at * 4);
|
|
731
|
-
postingTfs.writeFloatLE(posting[k + 1], at * 4);
|
|
732
|
-
at += 1;
|
|
733
|
-
}
|
|
734
|
-
}
|
|
735
|
-
const rootHash = parseInt(shortHash(this.root), 16) >>> 0;
|
|
736
|
-
const header = Buffer.alloc(BINARY_HEADER_BYTES);
|
|
737
|
-
header.writeUInt32LE(BINARY_MAGIC, 0);
|
|
738
|
-
header.writeUInt32LE(INDEX_VERSION, 4);
|
|
739
|
-
header.writeDoubleLE(parsed.builtAt, 8);
|
|
740
|
-
header.writeUInt32LE(parsed.docs.length, 16);
|
|
741
|
-
header.writeUInt32LE(termCount, 20);
|
|
742
|
-
header.writeUInt32LE(postingCount, 24);
|
|
743
|
-
header.writeUInt32LE(docsJson.length, 28);
|
|
744
|
-
header.writeUInt32LE(termBytes, 32);
|
|
745
|
-
header.writeUInt32LE(rootHash, 36);
|
|
746
|
-
header.writeUInt32LE(0, 40);
|
|
747
|
-
header.writeUInt32LE(0, 44);
|
|
748
|
-
return Buffer.concat([header, docsJson, termOffsets, ...encodedTerms, postingOffsets, postingDocs, postingTfs]);
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
/** Decode a blob produced by `serializeBinary`. Returns false when invalid. */
|
|
752
|
-
hydrateBinary(buffer: Buffer): boolean {
|
|
753
|
-
try {
|
|
754
|
-
if (buffer.length < BINARY_HEADER_BYTES) return false;
|
|
755
|
-
if (buffer.readUInt32LE(0) !== BINARY_MAGIC) return false;
|
|
756
|
-
if (buffer.readUInt32LE(4) !== INDEX_VERSION) return false;
|
|
757
|
-
if ((buffer.readUInt32LE(36) >>> 0) !== (parseInt(shortHash(this.root), 16) >>> 0)) return false;
|
|
758
|
-
const builtAt = buffer.readDoubleLE(8);
|
|
759
|
-
const docCount = buffer.readUInt32LE(16);
|
|
760
|
-
const termCount = buffer.readUInt32LE(20);
|
|
761
|
-
const postingCount = buffer.readUInt32LE(24);
|
|
762
|
-
const docsJsonBytes = buffer.readUInt32LE(28);
|
|
763
|
-
const termBytes = buffer.readUInt32LE(32);
|
|
764
|
-
const docsStart = BINARY_HEADER_BYTES;
|
|
765
|
-
const termOffsetsStart = docsStart + docsJsonBytes;
|
|
766
|
-
const termBytesStart = termOffsetsStart + (termCount + 1) * 4;
|
|
767
|
-
const postingOffsetsStart = termBytesStart + termBytes;
|
|
768
|
-
const postingDocsStart = postingOffsetsStart + (termCount + 1) * 4;
|
|
769
|
-
const postingTfsStart = postingDocsStart + postingCount * 4;
|
|
770
|
-
if (postingTfsStart + postingCount * 4 !== buffer.length) return false;
|
|
771
|
-
const docs = JSON.parse(buffer.toString("utf8", docsStart, termOffsetsStart)) as PersistedIndex["docs"];
|
|
772
|
-
if (!Array.isArray(docs) || docs.length !== docCount) return false;
|
|
773
|
-
const terms = new Array<string>(termCount);
|
|
774
|
-
for (let i = 0; i < termCount; i += 1) {
|
|
775
|
-
const from = termBytesStart + buffer.readUInt32LE(termOffsetsStart + i * 4);
|
|
776
|
-
const to = termBytesStart + buffer.readUInt32LE(termOffsetsStart + (i + 1) * 4);
|
|
777
|
-
terms[i] = buffer.toString("utf8", from, to);
|
|
778
|
-
}
|
|
779
|
-
const postings = new Array<number[]>(termCount);
|
|
780
|
-
for (let i = 0; i < termCount; i += 1) {
|
|
781
|
-
const from = buffer.readUInt32LE(postingOffsetsStart + i * 4);
|
|
782
|
-
const to = buffer.readUInt32LE(postingOffsetsStart + (i + 1) * 4);
|
|
783
|
-
const list = new Array<number>((to - from) * 2);
|
|
784
|
-
for (let k = from; k < to; k += 1) {
|
|
785
|
-
list[(k - from) * 2] = buffer.readUInt32LE(postingDocsStart + k * 4);
|
|
786
|
-
list[(k - from) * 2 + 1] = buffer.readFloatLE(postingTfsStart + k * 4);
|
|
787
|
-
}
|
|
788
|
-
postings[i] = list;
|
|
789
|
-
}
|
|
790
|
-
this.hydrate({ version: INDEX_VERSION, root: this.root, builtAt, docs, terms, postings });
|
|
791
|
-
return true;
|
|
792
|
-
} catch {
|
|
793
|
-
this.clearDocs();
|
|
794
|
-
return false;
|
|
795
|
-
}
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
hydrate(parsed: PersistedIndex): void {
|
|
799
|
-
this.clearDocs();
|
|
800
|
-
this.terms = Array.isArray(parsed.terms) ? parsed.terms : [];
|
|
801
|
-
this.postings = Array.isArray(parsed.postings) ? parsed.postings : [];
|
|
802
|
-
this.docs = Array.isArray(parsed.docs) ? parsed.docs : [];
|
|
803
|
-
// Defensive: fields added after the first release, plus the two derivable
|
|
804
|
-
// fields the serializer strips (`root`/`path` cost ~100 bytes per note).
|
|
805
|
-
for (const doc of this.docs) {
|
|
806
|
-
if (!doc.root) doc.root = this.root;
|
|
807
|
-
if (!doc.path) doc.path = join(this.root, doc.relPath);
|
|
808
|
-
if (!Array.isArray(doc.aliases)) doc.aliases = [];
|
|
809
|
-
if (!Array.isArray(doc.related)) doc.related = [];
|
|
810
|
-
if (!Array.isArray(doc.supersedes)) doc.supersedes = [];
|
|
811
|
-
if (typeof doc.lastUsed !== "number") doc.lastUsed = doc.updated ?? 0;
|
|
812
|
-
if (typeof doc.bodyChars !== "number") doc.bodyChars = doc.preview?.length ?? 0;
|
|
813
|
-
}
|
|
814
|
-
if (this.postings.length !== this.terms.length) {
|
|
815
|
-
// Corrupt or truncated index: fall back to a clean slate.
|
|
816
|
-
this.clearDocs();
|
|
817
|
-
return;
|
|
818
|
-
}
|
|
819
|
-
this.builtAt = parsed.builtAt ?? 0;
|
|
820
|
-
this.docTerms = [];
|
|
821
|
-
this.docTfs = [];
|
|
822
|
-
this.df = new Array(this.terms.length).fill(0);
|
|
823
|
-
this.termIds = new Map();
|
|
824
|
-
for (let i = 0; i < this.terms.length; i += 1) this.termIds.set(this.terms[i], i);
|
|
825
|
-
// Rebuild df + docTerms from postings (single pass over the compact arrays).
|
|
826
|
-
for (let termId = 0; termId < this.postings.length; termId += 1) {
|
|
827
|
-
const posting = this.postings[termId];
|
|
828
|
-
this.df[termId] = posting.length / 2;
|
|
829
|
-
}
|
|
830
|
-
const docCount = this.docs.length;
|
|
831
|
-
this.docTerms = new Array(docCount);
|
|
832
|
-
this.docTfs = new Array(docCount);
|
|
833
|
-
for (let i = 0; i < docCount; i += 1) {
|
|
834
|
-
this.docTerms[i] = [];
|
|
835
|
-
this.docTfs[i] = [];
|
|
836
|
-
}
|
|
837
|
-
for (let termId = 0; termId < this.postings.length; termId += 1) {
|
|
838
|
-
const posting = this.postings[termId];
|
|
839
|
-
for (let i = 0; i + 1 < posting.length; i += 2) {
|
|
840
|
-
const docIdx = posting[i];
|
|
841
|
-
if (docIdx < 0 || docIdx >= docCount) continue;
|
|
842
|
-
this.docTerms[docIdx].push(termId);
|
|
843
|
-
this.docTfs[docIdx].push(posting[i + 1]);
|
|
844
|
-
}
|
|
845
|
-
}
|
|
846
|
-
this.alive = new Array(docCount).fill(true);
|
|
847
|
-
this.idToIdx = new Map();
|
|
848
|
-
this.aliasToIdx = undefined;
|
|
849
|
-
this.relPathToIdx = new Map();
|
|
850
|
-
for (let i = 0; i < docCount; i += 1) {
|
|
851
|
-
this.idToIdx.set(this.docs[i].id, i);
|
|
852
|
-
this.relPathToIdx.set(this.docs[i].relPath, i);
|
|
853
|
-
}
|
|
854
|
-
this.sortedTermIdsCache = undefined;
|
|
855
|
-
}
|
|
856
|
-
|
|
857
|
-
/** Serialize the index for persistence, omitting derivable per-doc fields. */
|
|
858
|
-
serialize(): PersistedIndex {
|
|
859
|
-
if (!this.alive.every(Boolean)) this.compact();
|
|
860
|
-
return {
|
|
861
|
-
version: INDEX_VERSION,
|
|
862
|
-
root: this.root,
|
|
863
|
-
builtAt: this.builtAt || Date.now(),
|
|
864
|
-
docs: this.docs.map(leanDoc),
|
|
865
|
-
terms: this.terms,
|
|
866
|
-
// Strip dead postings defensively; compact() already guarantees this.
|
|
867
|
-
postings: this.postings,
|
|
868
|
-
};
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
async save(): Promise<number> {
|
|
872
|
-
const format = this.chosenFormat();
|
|
873
|
-
if (format === "binary") {
|
|
874
|
-
const payload = this.serializeBinary();
|
|
875
|
-
await atomicWriteFile(this.indexBinaryPath, payload);
|
|
876
|
-
if (existsSync(this.indexFilePath)) await rm(this.indexFilePath, { force: true }).catch(() => {});
|
|
877
|
-
this.persistedBytes = payload.length;
|
|
878
|
-
this.persistedFormat = "binary";
|
|
879
|
-
return payload.length;
|
|
880
|
-
}
|
|
881
|
-
const json = JSON.stringify(this.serialize());
|
|
882
|
-
await atomicWriteFile(this.indexFilePath, json);
|
|
883
|
-
if (existsSync(this.indexBinaryPath)) await rm(this.indexBinaryPath, { force: true }).catch(() => {});
|
|
884
|
-
this.persistedBytes = json.length;
|
|
885
|
-
this.persistedFormat = "json";
|
|
886
|
-
return json.length;
|
|
887
|
-
}
|
|
888
|
-
|
|
889
|
-
/**
|
|
890
|
-
* Reconcile the in-memory index with the filesystem.
|
|
891
|
-
* Returns true when anything changed.
|
|
892
|
-
*/
|
|
893
|
-
async refresh(force = false): Promise<boolean> {
|
|
894
|
-
const now = Date.now();
|
|
895
|
-
if (!force && !this.dirty && now - this.lastScanAt < this.scanIntervalMs) return false;
|
|
896
|
-
this.lastScanAt = now;
|
|
897
|
-
this.dirty = false;
|
|
898
|
-
const scanned = await scanLibrary(this.root, this.exclude);
|
|
899
|
-
let changed = false;
|
|
900
|
-
const seen = new Set<string>();
|
|
901
|
-
for (const [relPath, entry] of scanned) {
|
|
902
|
-
seen.add(relPath);
|
|
903
|
-
const existingIdx = this.relPathToIdx.get(relPath) ?? -1;
|
|
904
|
-
if (existingIdx === -1) {
|
|
905
|
-
const doc = await readMemoryDoc(this.root, entry.path);
|
|
906
|
-
if (doc) {
|
|
907
|
-
this.addDoc(doc);
|
|
908
|
-
changed = true;
|
|
909
|
-
}
|
|
910
|
-
continue;
|
|
911
|
-
}
|
|
912
|
-
const existing = this.docs[existingIdx];
|
|
913
|
-
if (existing.mtimeMs !== entry.mtimeMs || existing.size !== entry.size) {
|
|
914
|
-
const doc = await readMemoryDoc(this.root, entry.path);
|
|
915
|
-
this.removeDoc(existingIdx);
|
|
916
|
-
if (doc) this.addDoc(doc);
|
|
917
|
-
changed = true;
|
|
918
|
-
}
|
|
919
|
-
}
|
|
920
|
-
for (let idx = 0; idx < this.docs.length; idx += 1) {
|
|
921
|
-
if (!this.alive[idx]) continue;
|
|
922
|
-
const doc = this.docs[idx];
|
|
923
|
-
if (!isIndexableRelPath(doc.relPath, this.exclude)) continue;
|
|
924
|
-
if (seen.has(doc.relPath)) continue;
|
|
925
|
-
this.removeDoc(idx);
|
|
926
|
-
changed = true;
|
|
927
|
-
}
|
|
928
|
-
if (changed) {
|
|
929
|
-
this.builtAt = Date.now();
|
|
930
|
-
this.fuzzyCache.clear();
|
|
931
|
-
}
|
|
932
|
-
return changed;
|
|
933
|
-
}
|
|
934
|
-
|
|
935
|
-
/** Full rebuild from disk. */
|
|
936
|
-
async rebuild(): Promise<number> {
|
|
937
|
-
this.clearDocs();
|
|
938
|
-
const scanned = await scanLibrary(this.root, this.exclude);
|
|
939
|
-
for (const entry of scanned.values()) {
|
|
940
|
-
const doc = await readMemoryDoc(this.root, entry.path);
|
|
941
|
-
if (doc) this.addDoc(doc);
|
|
942
|
-
}
|
|
943
|
-
this.builtAt = Date.now();
|
|
944
|
-
this.dirty = true;
|
|
945
|
-
this.lastScanAt = Date.now();
|
|
946
|
-
await this.save();
|
|
947
|
-
return this.aliveCount;
|
|
948
|
-
}
|
|
949
|
-
|
|
950
|
-
/* ---------------------------------------------------------------- */
|
|
951
|
-
/* Search */
|
|
952
|
-
/* ---------------------------------------------------------------- */
|
|
953
|
-
|
|
954
|
-
async search(query: string, options: SearchOptions = {}): Promise<SearchResult> {
|
|
955
|
-
const started = performance.now();
|
|
956
|
-
await this.refresh(false);
|
|
957
|
-
const result = this.searchWarm(query, options);
|
|
958
|
-
this.lastSearchMs = Math.round((performance.now() - started) * 100) / 100;
|
|
959
|
-
return result;
|
|
960
|
-
}
|
|
961
|
-
|
|
962
|
-
/** Score an index the caller has already refreshed. */
|
|
963
|
-
searchWarm(query: string, options: SearchOptions = {}): SearchResult {
|
|
964
|
-
const started = performance.now();
|
|
965
|
-
const result = searchIndex(this, query, options);
|
|
966
|
-
this.lastSearchMs = Math.round((performance.now() - started) * 100) / 100;
|
|
967
|
-
return result;
|
|
968
|
-
}
|
|
969
|
-
|
|
970
|
-
stats(indexBytes = 0): StoreStats {
|
|
971
|
-
const categories: Record<string, number> = {};
|
|
972
|
-
for (let i = 0; i < this.docs.length; i += 1) {
|
|
973
|
-
if (!this.alive[i]) continue;
|
|
974
|
-
const key = this.docs[i].category || "(root)";
|
|
975
|
-
categories[key] = (categories[key] ?? 0) + 1;
|
|
976
|
-
}
|
|
977
|
-
let totalBytes = 0;
|
|
978
|
-
for (let i = 0; i < this.docs.length; i += 1) if (this.alive[i]) totalBytes += this.docs[i].size;
|
|
979
|
-
return {
|
|
980
|
-
root: this.root,
|
|
981
|
-
docs: this.aliveCount,
|
|
982
|
-
categories,
|
|
983
|
-
totalBytes,
|
|
984
|
-
indexBytes,
|
|
985
|
-
indexBuiltAt: this.builtAt,
|
|
986
|
-
indexLoadMs: this.loadMs,
|
|
987
|
-
lastSearchMs: this.lastSearchMs,
|
|
988
|
-
watcherActive: this.watcherActive,
|
|
989
|
-
dirty: this.dirty,
|
|
990
|
-
};
|
|
991
|
-
}
|
|
992
|
-
|
|
993
|
-
/** Live doc metadata (skipping tombstones). */
|
|
994
|
-
liveDocs(): Array<{ idx: number; meta: IndexedDocMeta }> {
|
|
995
|
-
const out: Array<{ idx: number; meta: IndexedDocMeta }> = [];
|
|
996
|
-
for (let i = 0; i < this.docs.length; i += 1) {
|
|
997
|
-
if (this.alive[i]) out.push({ idx: i, meta: this.docs[i] });
|
|
998
|
-
}
|
|
999
|
-
return out;
|
|
1000
|
-
}
|
|
1001
|
-
|
|
1002
|
-
/** Cheap aggregate metadata (no allocations proportional to library size). */
|
|
1003
|
-
overview(recentLimit = 4): IndexOverview {
|
|
1004
|
-
const counts = new Map<string, number>();
|
|
1005
|
-
let docs = 0;
|
|
1006
|
-
const recent: IndexOverview["recent"] = [];
|
|
1007
|
-
for (let i = 0; i < this.docs.length; i += 1) {
|
|
1008
|
-
if (!this.alive[i]) continue;
|
|
1009
|
-
const meta = this.docs[i];
|
|
1010
|
-
docs += 1;
|
|
1011
|
-
const key = meta.category || "(root)";
|
|
1012
|
-
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
1013
|
-
if (recent.length < recentLimit) {
|
|
1014
|
-
recent.push({ id: meta.id, title: meta.title, relPath: meta.relPath, created: meta.created });
|
|
1015
|
-
recent.sort((a, b) => b.created - a.created);
|
|
1016
|
-
} else if (recentLimit > 0 && meta.created > recent[recent.length - 1].created) {
|
|
1017
|
-
recent[recent.length - 1] = { id: meta.id, title: meta.title, relPath: meta.relPath, created: meta.created };
|
|
1018
|
-
recent.sort((a, b) => b.created - a.created);
|
|
1019
|
-
}
|
|
1020
|
-
}
|
|
1021
|
-
return {
|
|
1022
|
-
docs,
|
|
1023
|
-
categories: [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])),
|
|
1024
|
-
recent,
|
|
1025
|
-
};
|
|
1026
|
-
}
|
|
1027
|
-
|
|
1028
|
-
/** Stop-gap for very large previews. */
|
|
1029
|
-
static previewOf(body: string): string {
|
|
1030
|
-
return truncateChars(oneLine(body, 400), 400);
|
|
1031
|
-
}
|
|
1032
|
-
}
|
|
1033
|
-
|
|
1034
|
-
/** Per-prompt overview used to render the system section without building big arrays. */
|
|
1035
|
-
export interface IndexOverview {
|
|
1036
|
-
docs: number;
|
|
1037
|
-
categories: Array<[string, number]>;
|
|
1038
|
-
recent: Array<{ id: string; title: string; relPath: string; created: number }>;
|
|
1039
|
-
}
|
|
1040
|
-
|
|
1041
|
-
/** Dense scratch buffers reused across searches to avoid per-query allocation. */
|
|
1042
|
-
export interface SearchScratch {
|
|
1043
|
-
scores: Float64Array;
|
|
1044
|
-
coverage: Int32Array;
|
|
1045
|
-
mask: Uint32Array;
|
|
1046
|
-
touched: Int32Array;
|
|
1047
|
-
generation: number;
|
|
1048
|
-
}
|
|
1049
|
-
|
|
1050
|
-
/** Debounced saver bound to an index instance. */
|
|
1051
|
-
export function createIndexSaver(index: MemoryIndex, delayMs = 400): { schedule: () => void; flush: () => Promise<void> } {
|
|
1052
|
-
let pending: Promise<void> | undefined;
|
|
1053
|
-
const save = debounce(() => {
|
|
1054
|
-
pending = index.save().then(
|
|
1055
|
-
() => undefined,
|
|
1056
|
-
() => undefined,
|
|
1057
|
-
);
|
|
1058
|
-
}, delayMs);
|
|
1059
|
-
return {
|
|
1060
|
-
schedule: () => save(),
|
|
1061
|
-
flush: async () => {
|
|
1062
|
-
save.flush();
|
|
1063
|
-
while (pending) {
|
|
1064
|
-
const current = pending;
|
|
1065
|
-
pending = undefined;
|
|
1066
|
-
await current;
|
|
1067
|
-
}
|
|
1068
|
-
await index.save().catch(() => {});
|
|
1069
|
-
},
|
|
1070
|
-
};
|
|
1071
|
-
}
|