@ai-agent-forge/plugin-search 0.0.0-stage → 0.88.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +56 -2
  2. package/agent-forge.json +19 -0
  3. package/dist/cache.d.ts +103 -0
  4. package/dist/cache.d.ts.map +1 -0
  5. package/dist/cache.js +666 -0
  6. package/dist/cache.js.map +1 -0
  7. package/dist/capability.d.ts +22 -0
  8. package/dist/capability.d.ts.map +1 -0
  9. package/dist/capability.js +213 -0
  10. package/dist/capability.js.map +1 -0
  11. package/dist/entry.d.ts +3 -0
  12. package/dist/entry.d.ts.map +1 -0
  13. package/dist/entry.js +3 -0
  14. package/dist/entry.js.map +1 -0
  15. package/dist/extract.d.ts +18 -0
  16. package/dist/extract.d.ts.map +1 -0
  17. package/dist/extract.js +120 -0
  18. package/dist/extract.js.map +1 -0
  19. package/dist/http.d.ts +65 -0
  20. package/dist/http.d.ts.map +1 -0
  21. package/dist/http.js +307 -0
  22. package/dist/http.js.map +1 -0
  23. package/dist/index.d.ts +5 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +5 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/limits.d.ts +39 -0
  28. package/dist/limits.d.ts.map +1 -0
  29. package/dist/limits.js +43 -0
  30. package/dist/limits.js.map +1 -0
  31. package/dist/model-call.d.ts +10 -0
  32. package/dist/model-call.d.ts.map +1 -0
  33. package/dist/model-call.js +46 -0
  34. package/dist/model-call.js.map +1 -0
  35. package/dist/model-search.d.ts +14 -0
  36. package/dist/model-search.d.ts.map +1 -0
  37. package/dist/model-search.js +85 -0
  38. package/dist/model-search.js.map +1 -0
  39. package/dist/summarize.d.ts +40 -0
  40. package/dist/summarize.d.ts.map +1 -0
  41. package/dist/summarize.js +148 -0
  42. package/dist/summarize.js.map +1 -0
  43. package/dist/text.d.ts +38 -0
  44. package/dist/text.d.ts.map +1 -0
  45. package/dist/text.js +135 -0
  46. package/dist/text.js.map +1 -0
  47. package/dist/types.d.ts +38 -0
  48. package/dist/types.d.ts.map +1 -0
  49. package/dist/types.js +2 -0
  50. package/dist/types.js.map +1 -0
  51. package/dist/url-search.d.ts +36 -0
  52. package/dist/url-search.d.ts.map +1 -0
  53. package/dist/url-search.js +335 -0
  54. package/dist/url-search.js.map +1 -0
  55. package/package.json +57 -5
  56. package/plugin.json +12 -0
package/dist/cache.js ADDED
@@ -0,0 +1,666 @@
1
+ /**
2
+ * System-level disk page cache (搜索缓存持久化设计).
3
+ *
4
+ * Only extracted page content is persisted: title, text and the truncation flag.
5
+ * URLs, response headers, cookies, validators and fetch metadata stay out of the
6
+ * cache. Entries are shared by every agent/profile/session of the same OS user,
7
+ * use a hit-count-based retention window and are evicted by LFU then LRU.
8
+ *
9
+ * All I/O is asynchronous. Writes and hit-count bookkeeping are queued so a
10
+ * search call never waits for the disk; `flushWrites`/`flushUsage` exist for
11
+ * tests and graceful shutdown.
12
+ */
13
+ import { createHash, randomUUID } from "node:crypto";
14
+ import { mkdir, readdir, readFile, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
15
+ import { homedir } from "node:os";
16
+ import { join } from "node:path";
17
+ import { SEARCH_LIMITS } from "./limits.js";
18
+ import { byteLength, isRecord, normalizeUrl, urlHasUserInfo } from "./text.js";
19
+ const META_SUFFIX = ".meta.json";
20
+ const PAGE_SUFFIX = ".page.json";
21
+ const MAX_META_BYTES = 16 * 1024;
22
+ const HASH_PATTERN = /^[0-9a-f]{64}$/;
23
+ const SHARD_PATTERN = /^[0-9a-f]{2}$/;
24
+ function errorCode(error) {
25
+ if (typeof error !== "object" || error === null || !("code" in error))
26
+ return undefined;
27
+ const code = error.code;
28
+ return typeof code === "string" ? code : undefined;
29
+ }
30
+ function isMissing(error) {
31
+ return errorCode(error) === "ENOENT";
32
+ }
33
+ function finiteNonNegative(value) {
34
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
35
+ }
36
+ function parseMeta(value) {
37
+ if (!isRecord(value))
38
+ return undefined;
39
+ const { version, hash, bytes, storedAt, expiresAt, hits, lastAccess } = value;
40
+ if (version !== 1 || typeof hash !== "string" || !HASH_PATTERN.test(hash))
41
+ return undefined;
42
+ if (!finiteNonNegative(bytes) || !finiteNonNegative(storedAt) || !finiteNonNegative(expiresAt))
43
+ return undefined;
44
+ if (!finiteNonNegative(hits) || !Number.isInteger(hits) || !finiteNonNegative(lastAccess))
45
+ return undefined;
46
+ return { version: 1, hash, bytes, storedAt, expiresAt, hits, lastAccess };
47
+ }
48
+ function parsePage(value) {
49
+ if (!isRecord(value))
50
+ return undefined;
51
+ const { version, title, text, truncated } = value;
52
+ if (version !== 1 || typeof text !== "string" || text === "" || typeof truncated !== "boolean")
53
+ return undefined;
54
+ if (title !== undefined && typeof title !== "string")
55
+ return undefined;
56
+ return { version: 1, text, truncated, ...(title === undefined ? {} : { title }) };
57
+ }
58
+ function defaultTtlTiers(tiers) {
59
+ if (tiers !== undefined && tiers.length === 4)
60
+ return tiers;
61
+ return SEARCH_LIMITS.cacheTtlTiersMs;
62
+ }
63
+ function serializePage(page) {
64
+ const file = {
65
+ version: 1,
66
+ text: page.text,
67
+ truncated: page.truncated,
68
+ ...(page.title === undefined ? {} : { title: page.title }),
69
+ };
70
+ return JSON.stringify(file);
71
+ }
72
+ /** OS cache directory shared by all profiles of the current OS user. */
73
+ export function defaultPageCacheDirectory() {
74
+ const home = homedir();
75
+ if (process.platform === "darwin") {
76
+ return join(home, "Library", "Caches", "agent-forge", "search", "v1", "pages");
77
+ }
78
+ if (process.platform === "win32") {
79
+ const base = process.env.LOCALAPPDATA?.trim() || join(home, "AppData", "Local");
80
+ return join(base, "agent-forge", "Cache", "search", "v1", "pages");
81
+ }
82
+ const base = process.env.XDG_CACHE_HOME?.trim() || join(home, ".cache");
83
+ return join(base, "agent-forge", "search", "v1", "pages");
84
+ }
85
+ export class PageCache {
86
+ directory;
87
+ maxBytes;
88
+ maxEntries;
89
+ maxEntryBytes;
90
+ baseTtlMs;
91
+ ttlTiersMs;
92
+ writeQueueEntries;
93
+ writeQueueBytes;
94
+ metaFlushMs;
95
+ pruneIntervalMs;
96
+ logger;
97
+ entries = new Map();
98
+ pendingWrites = new Map();
99
+ dirtyMeta = new Map();
100
+ pendingWriteBytes = 0;
101
+ totalBytes = 0;
102
+ initPromise;
103
+ unavailable = false;
104
+ loggedUnavailable = false;
105
+ loggedSkippedWrite = false;
106
+ loggedWriteFailure = false;
107
+ metaTimer;
108
+ pruneTimer;
109
+ pruneChain = Promise.resolve();
110
+ constructor(directory = defaultPageCacheDirectory(), options = {}) {
111
+ this.directory = directory;
112
+ this.maxBytes = options.maxBytes ?? SEARCH_LIMITS.pageCacheBytes;
113
+ this.maxEntries = options.maxEntries ?? SEARCH_LIMITS.pageCacheEntries;
114
+ this.maxEntryBytes = options.maxEntryBytes ?? SEARCH_LIMITS.cacheMaxEntryBytes;
115
+ this.baseTtlMs = options.baseTtlMs ?? SEARCH_LIMITS.cacheBaseTtlMs;
116
+ this.ttlTiersMs = defaultTtlTiers(options.ttlTiersMs);
117
+ this.writeQueueEntries = options.writeQueueEntries ?? SEARCH_LIMITS.cacheWriteQueueEntries;
118
+ this.writeQueueBytes = options.writeQueueBytes ?? SEARCH_LIMITS.cacheWriteQueueBytes;
119
+ this.metaFlushMs = options.metaFlushMs ?? SEARCH_LIMITS.cacheMetaFlushMs;
120
+ this.pruneIntervalMs = options.pruneIntervalMs ?? SEARCH_LIMITS.cachePruneIntervalMs;
121
+ this.logger = options.logger;
122
+ }
123
+ /** Fresh content only; any cache failure degrades to a miss. */
124
+ async getFresh(rawUrl, now) {
125
+ try {
126
+ return await this.getFreshUnsafe(rawUrl, now);
127
+ }
128
+ catch (error) {
129
+ this.logDebug("page cache read failed; treating it as a miss", error);
130
+ return undefined;
131
+ }
132
+ }
133
+ async getFreshUnsafe(rawUrl, now) {
134
+ const hash = this.hashFor(rawUrl);
135
+ if (hash === undefined)
136
+ return undefined;
137
+ await this.ensureInitialized(now);
138
+ if (this.unavailable)
139
+ return undefined;
140
+ const pending = this.pendingWrites.get(hash);
141
+ if (pending !== undefined) {
142
+ await pending;
143
+ if (this.unavailable)
144
+ return undefined;
145
+ }
146
+ let entry = this.entries.get(hash);
147
+ if (entry === undefined) {
148
+ entry = await this.loadEntryFromDisk(hash, now);
149
+ if (entry === undefined)
150
+ return undefined;
151
+ this.entries.set(hash, entry);
152
+ this.totalBytes += entry.bytes;
153
+ }
154
+ if (now >= entry.expiresAt) {
155
+ await this.removeEntry(entry);
156
+ return undefined;
157
+ }
158
+ const content = await this.readPage(entry);
159
+ if (content === undefined) {
160
+ await this.removeEntry(entry);
161
+ return undefined;
162
+ }
163
+ if (this.entries.get(hash) !== entry)
164
+ return undefined;
165
+ entry.hits += 1;
166
+ entry.lastAccess = now;
167
+ entry.expiresAt = Math.max(entry.expiresAt, now + this.ttlForHits(entry.hits));
168
+ this.dirtyMeta.set(hash, entry);
169
+ this.scheduleMetaFlush();
170
+ return content;
171
+ }
172
+ /** Queue a content write; any cache failure is ignored by the caller. */
173
+ async set(rawUrl, page, now) {
174
+ try {
175
+ await this.setUnsafe(rawUrl, page, now);
176
+ }
177
+ catch (error) {
178
+ this.logDebug("page cache write setup failed; search continues", error);
179
+ }
180
+ }
181
+ async setUnsafe(rawUrl, page, now) {
182
+ const hash = this.hashFor(rawUrl);
183
+ if (hash === undefined)
184
+ return;
185
+ if (page.text.trim() === "")
186
+ return;
187
+ const bytes = byteLength(serializePage(page));
188
+ if (bytes > this.maxEntryBytes)
189
+ return;
190
+ await this.ensureInitialized(now);
191
+ if (this.unavailable)
192
+ return;
193
+ if (this.pendingWrites.has(hash))
194
+ return;
195
+ if (this.pendingWrites.size >= this.writeQueueEntries || this.pendingWriteBytes + bytes > this.writeQueueBytes) {
196
+ this.logSkippedWrite();
197
+ return;
198
+ }
199
+ const paths = this.pathsFor(hash);
200
+ const entry = {
201
+ hash,
202
+ pagePath: paths.pagePath,
203
+ metaPath: paths.metaPath,
204
+ bytes,
205
+ storedAt: now,
206
+ expiresAt: now + this.baseTtlMs,
207
+ hits: 0,
208
+ lastAccess: now,
209
+ };
210
+ const previous = this.entries.get(hash);
211
+ if (!this.makeRoom(hash, bytes - (previous?.bytes ?? 0), now)) {
212
+ this.schedulePrune();
213
+ return;
214
+ }
215
+ this.entries.set(hash, entry);
216
+ this.totalBytes += bytes - (previous?.bytes ?? 0);
217
+ this.pendingWriteBytes += bytes;
218
+ const write = this.writeEntry(entry, page).finally(() => {
219
+ this.pendingWriteBytes = Math.max(0, this.pendingWriteBytes - bytes);
220
+ this.pendingWrites.delete(hash);
221
+ });
222
+ this.pendingWrites.set(hash, write);
223
+ write.catch(() => {
224
+ // writeEntry records failures and drops the index entry; never surface
225
+ // an unhandled rejection from a background cache write.
226
+ });
227
+ }
228
+ /** Persist queued writes and hit counts; used by tests and graceful shutdown. */
229
+ async flushWrites() {
230
+ while (this.pendingWrites.size > 0) {
231
+ await Promise.allSettled([...this.pendingWrites.values()]);
232
+ }
233
+ await this.flushUsage();
234
+ }
235
+ async flushUsage() {
236
+ if (this.metaTimer !== undefined) {
237
+ clearTimeout(this.metaTimer);
238
+ this.metaTimer = undefined;
239
+ }
240
+ const snapshot = [...this.dirtyMeta.values()];
241
+ this.dirtyMeta.clear();
242
+ await Promise.all(snapshot.map(async (entry) => {
243
+ if (this.entries.get(entry.hash) !== entry)
244
+ return;
245
+ try {
246
+ await this.writeMetaFile(entry);
247
+ }
248
+ catch (error) {
249
+ if (this.entries.get(entry.hash) === entry)
250
+ this.dirtyMeta.set(entry.hash, entry);
251
+ this.logDebug("page cache could not flush hit bookkeeping", error);
252
+ }
253
+ }));
254
+ }
255
+ /** Remove expired entries and enforce the size/count ceiling. */
256
+ async prune(now) {
257
+ await this.ensureInitialized(now);
258
+ await this.enqueuePrune(now, false);
259
+ await this.flushUsage();
260
+ }
261
+ hashFor(rawUrl) {
262
+ if (urlHasUserInfo(rawUrl))
263
+ return undefined;
264
+ try {
265
+ return createHash("sha256")
266
+ .update(`search-page-v1\n${normalizeUrl(rawUrl)}`)
267
+ .digest("hex");
268
+ }
269
+ catch {
270
+ return undefined;
271
+ }
272
+ }
273
+ pathsFor(hash) {
274
+ const dir = join(this.directory, hash.slice(0, 2));
275
+ return {
276
+ pagePath: join(dir, `${hash}${PAGE_SUFFIX}`),
277
+ metaPath: join(dir, `${hash}${META_SUFFIX}`),
278
+ };
279
+ }
280
+ /** Evict low-value entries from the index before admitting a new one. */
281
+ makeRoom(replacingHash, bytesDelta, now) {
282
+ const within = () => this.entries.size + (this.entries.has(replacingHash) ? 0 : 1) <= this.maxEntries &&
283
+ this.totalBytes + bytesDelta <= this.maxBytes;
284
+ if (within())
285
+ return true;
286
+ const candidates = [...this.entries.values()]
287
+ .filter((entry) => entry.hash !== replacingHash && !this.pendingWrites.has(entry.hash))
288
+ .sort((left, right) => {
289
+ const leftExpired = now >= left.expiresAt ? 1 : 0;
290
+ const rightExpired = now >= right.expiresAt ? 1 : 0;
291
+ if (leftExpired !== rightExpired)
292
+ return rightExpired - leftExpired;
293
+ return left.hits - right.hits || left.lastAccess - right.lastAccess;
294
+ });
295
+ for (const entry of candidates) {
296
+ if (within())
297
+ break;
298
+ this.entries.delete(entry.hash);
299
+ this.totalBytes = Math.max(0, this.totalBytes - entry.bytes);
300
+ this.dirtyMeta.delete(entry.hash);
301
+ void this.removeFiles(entry.pagePath, entry.metaPath);
302
+ }
303
+ return within();
304
+ }
305
+ ttlForHits(hits) {
306
+ if (hits >= 50)
307
+ return this.ttlTiersMs[3] ?? this.baseTtlMs;
308
+ if (hits >= 10)
309
+ return this.ttlTiersMs[2] ?? this.baseTtlMs;
310
+ if (hits >= 3)
311
+ return this.ttlTiersMs[1] ?? this.baseTtlMs;
312
+ if (hits >= 1)
313
+ return this.ttlTiersMs[0] ?? this.baseTtlMs;
314
+ return this.baseTtlMs;
315
+ }
316
+ ensureInitialized(now) {
317
+ this.initPromise ??= this.loadIndex(now);
318
+ return this.initPromise;
319
+ }
320
+ async loadIndex(now) {
321
+ let shards;
322
+ try {
323
+ shards = await readdir(this.directory, { withFileTypes: true });
324
+ }
325
+ catch (error) {
326
+ if (isMissing(error))
327
+ return;
328
+ this.unavailable = true;
329
+ this.logUnavailable(error);
330
+ return;
331
+ }
332
+ for (const shard of shards) {
333
+ if (!shard.isDirectory() || !SHARD_PATTERN.test(shard.name))
334
+ continue;
335
+ const shardDir = join(this.directory, shard.name);
336
+ let files;
337
+ try {
338
+ files = await readdir(shardDir);
339
+ }
340
+ catch (error) {
341
+ if (!isMissing(error))
342
+ this.logDebug("page cache shard is unreadable", error);
343
+ continue;
344
+ }
345
+ for (const file of files) {
346
+ if (!file.endsWith(META_SUFFIX))
347
+ continue;
348
+ const hash = file.slice(0, -META_SUFFIX.length);
349
+ if (!HASH_PATTERN.test(hash))
350
+ continue;
351
+ const entry = await this.loadEntryFromDisk(hash, now);
352
+ if (entry === undefined)
353
+ continue;
354
+ this.entries.set(hash, entry);
355
+ this.totalBytes += entry.bytes;
356
+ }
357
+ }
358
+ await this.enqueuePrune(now, false);
359
+ }
360
+ async loadEntryFromDisk(hash, now) {
361
+ const paths = this.pathsFor(hash);
362
+ const meta = await this.readMetaFile(paths.metaPath);
363
+ if (meta === undefined) {
364
+ // A page file can be visible for a moment before its meta file is
365
+ // renamed into place by another process; never delete that fresh file.
366
+ return undefined;
367
+ }
368
+ if (meta.hash !== hash) {
369
+ await this.removeFiles(paths.pagePath, paths.metaPath);
370
+ return undefined;
371
+ }
372
+ let pageSize;
373
+ try {
374
+ const pageStat = await stat(paths.pagePath);
375
+ pageSize = pageStat.size;
376
+ }
377
+ catch (error) {
378
+ if (!isMissing(error))
379
+ this.logDebug("page cache entry has no readable page file", error);
380
+ await this.removeFiles(paths.pagePath, paths.metaPath);
381
+ return undefined;
382
+ }
383
+ if (pageSize !== meta.bytes || pageSize > this.maxEntryBytes) {
384
+ await this.removeFiles(paths.pagePath, paths.metaPath);
385
+ return undefined;
386
+ }
387
+ if (now >= meta.expiresAt) {
388
+ await this.removeFiles(paths.pagePath, paths.metaPath);
389
+ return undefined;
390
+ }
391
+ return {
392
+ hash,
393
+ pagePath: paths.pagePath,
394
+ metaPath: paths.metaPath,
395
+ bytes: meta.bytes,
396
+ storedAt: meta.storedAt,
397
+ expiresAt: meta.expiresAt,
398
+ hits: meta.hits,
399
+ lastAccess: meta.lastAccess,
400
+ };
401
+ }
402
+ async readMetaFile(path) {
403
+ let size;
404
+ try {
405
+ size = (await stat(path)).size;
406
+ }
407
+ catch {
408
+ return undefined;
409
+ }
410
+ if (size > MAX_META_BYTES)
411
+ return undefined;
412
+ try {
413
+ const raw = await readFile(path, "utf8");
414
+ return parseMeta(JSON.parse(raw));
415
+ }
416
+ catch {
417
+ return undefined;
418
+ }
419
+ }
420
+ async readPage(entry) {
421
+ try {
422
+ const info = await stat(entry.pagePath);
423
+ if (info.size !== entry.bytes)
424
+ return undefined;
425
+ const raw = await readFile(entry.pagePath, "utf8");
426
+ const parsed = parsePage(JSON.parse(raw));
427
+ if (parsed === undefined)
428
+ return undefined;
429
+ return {
430
+ text: parsed.text,
431
+ truncated: parsed.truncated,
432
+ ...(parsed.title === undefined ? {} : { title: parsed.title }),
433
+ };
434
+ }
435
+ catch {
436
+ return undefined;
437
+ }
438
+ }
439
+ async writeEntry(entry, page) {
440
+ try {
441
+ await mkdir(join(this.directory, entry.hash.slice(0, 2)), { recursive: true, mode: 0o700 });
442
+ await this.atomicWrite(entry.pagePath, serializePage(page));
443
+ await this.writeMetaFile(entry);
444
+ this.schedulePrune();
445
+ }
446
+ catch (error) {
447
+ await this.removeFiles(entry.pagePath, entry.metaPath);
448
+ if (this.entries.get(entry.hash) === entry) {
449
+ this.entries.delete(entry.hash);
450
+ this.totalBytes = Math.max(0, this.totalBytes - entry.bytes);
451
+ }
452
+ if (!this.loggedWriteFailure) {
453
+ this.loggedWriteFailure = true;
454
+ this.logger?.warn("page cache write failed; search continues without caching", {
455
+ cause: error instanceof Error ? error.message : String(error),
456
+ });
457
+ }
458
+ this.logDebug("page cache write failed", error);
459
+ }
460
+ }
461
+ async writeMetaFile(entry) {
462
+ const meta = {
463
+ version: 1,
464
+ hash: entry.hash,
465
+ bytes: entry.bytes,
466
+ storedAt: entry.storedAt,
467
+ expiresAt: entry.expiresAt,
468
+ hits: entry.hits,
469
+ lastAccess: entry.lastAccess,
470
+ };
471
+ await mkdir(join(this.directory, entry.hash.slice(0, 2)), { recursive: true, mode: 0o700 });
472
+ await this.atomicWrite(entry.metaPath, JSON.stringify(meta));
473
+ }
474
+ async atomicWrite(path, content) {
475
+ const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
476
+ try {
477
+ await writeFile(temp, content, { encoding: "utf8", mode: 0o600 });
478
+ try {
479
+ await rename(temp, path);
480
+ }
481
+ catch {
482
+ // Windows can refuse to replace an existing file; remove and retry.
483
+ await rm(path, { force: true });
484
+ await rename(temp, path);
485
+ }
486
+ }
487
+ finally {
488
+ await rm(temp, { force: true }).catch(() => { });
489
+ }
490
+ }
491
+ async removeEntry(entry) {
492
+ if (this.entries.get(entry.hash) === entry) {
493
+ this.entries.delete(entry.hash);
494
+ this.totalBytes = Math.max(0, this.totalBytes - entry.bytes);
495
+ }
496
+ this.dirtyMeta.delete(entry.hash);
497
+ await this.removeFiles(entry.pagePath, entry.metaPath);
498
+ }
499
+ async removeFiles(pagePath, metaPath) {
500
+ await Promise.all([
501
+ unlink(pagePath).catch((error) => {
502
+ if (!isMissing(error))
503
+ this.logDebug("page cache could not remove a page file", error);
504
+ }),
505
+ unlink(metaPath).catch((error) => {
506
+ if (!isMissing(error))
507
+ this.logDebug("page cache could not remove a meta file", error);
508
+ }),
509
+ ]);
510
+ }
511
+ enqueuePrune(now, capacityOnly) {
512
+ const run = () => this.pruneNow(now, capacityOnly);
513
+ this.pruneChain = this.pruneChain.then(run, run);
514
+ return this.pruneChain;
515
+ }
516
+ async pruneNow(now, capacityOnly) {
517
+ if (this.unavailable)
518
+ return;
519
+ const removable = [...this.entries.values()].filter((entry) => !this.pendingWrites.has(entry.hash));
520
+ const victims = [];
521
+ if (!capacityOnly) {
522
+ for (const entry of removable) {
523
+ if (now >= entry.expiresAt)
524
+ victims.push(entry);
525
+ }
526
+ }
527
+ let count = this.entries.size - victims.length;
528
+ let bytes = this.totalBytes - victims.reduce((sum, entry) => sum + entry.bytes, 0);
529
+ if (count > this.maxEntries || bytes > this.maxBytes) {
530
+ const candidates = removable
531
+ .filter((entry) => !victims.includes(entry))
532
+ .sort((left, right) => left.hits - right.hits || left.lastAccess - right.lastAccess);
533
+ for (const entry of candidates) {
534
+ if (count <= this.maxEntries && bytes <= this.maxBytes)
535
+ break;
536
+ victims.push(entry);
537
+ count -= 1;
538
+ bytes -= entry.bytes;
539
+ }
540
+ }
541
+ await Promise.all(victims.map((entry) => this.removeEntry(entry)));
542
+ await this.cleanupOrphanFiles(now);
543
+ }
544
+ async cleanupOrphanFiles(now) {
545
+ const staleBefore = now - 60 * 60 * 1000;
546
+ let shards;
547
+ try {
548
+ shards = await readdir(this.directory, { withFileTypes: true });
549
+ }
550
+ catch {
551
+ return;
552
+ }
553
+ for (const shard of shards) {
554
+ if (!shard.isDirectory() || !SHARD_PATTERN.test(shard.name))
555
+ continue;
556
+ const shardDir = join(this.directory, shard.name);
557
+ let files;
558
+ try {
559
+ files = await readdir(shardDir);
560
+ }
561
+ catch {
562
+ continue;
563
+ }
564
+ for (const file of files) {
565
+ const isPage = file.endsWith(PAGE_SUFFIX);
566
+ const isTemp = file.endsWith(".tmp");
567
+ if (!isPage && !isTemp)
568
+ continue;
569
+ if (isPage) {
570
+ const hash = file.slice(0, -PAGE_SUFFIX.length);
571
+ if (!HASH_PATTERN.test(hash) || this.entries.has(hash))
572
+ continue;
573
+ }
574
+ const path = join(shardDir, file);
575
+ try {
576
+ const info = await stat(path);
577
+ if (info.mtimeMs < staleBefore)
578
+ await unlink(path);
579
+ }
580
+ catch {
581
+ // A concurrent process may have removed it already.
582
+ }
583
+ }
584
+ }
585
+ }
586
+ scheduleMetaFlush() {
587
+ if (this.metaTimer !== undefined)
588
+ return;
589
+ this.metaTimer = setTimeout(() => {
590
+ this.metaTimer = undefined;
591
+ void this.flushUsage();
592
+ }, this.metaFlushMs);
593
+ this.metaTimer.unref?.();
594
+ }
595
+ schedulePrune() {
596
+ if (this.pruneTimer !== undefined)
597
+ return;
598
+ this.pruneTimer = setTimeout(() => {
599
+ this.pruneTimer = undefined;
600
+ void this.enqueuePrune(Date.now(), true).catch((error) => {
601
+ this.logDebug("page cache prune failed", error);
602
+ });
603
+ }, this.pruneIntervalMs);
604
+ this.pruneTimer.unref?.();
605
+ }
606
+ logUnavailable(error) {
607
+ if (this.loggedUnavailable)
608
+ return;
609
+ this.loggedUnavailable = true;
610
+ this.logger?.warn("page cache directory is unreadable; page caching is disabled", {
611
+ cause: error instanceof Error ? error.message : String(error),
612
+ });
613
+ }
614
+ logSkippedWrite() {
615
+ if (this.loggedSkippedWrite)
616
+ return;
617
+ this.loggedSkippedWrite = true;
618
+ this.logger?.debug("page cache write queue is full; skipping background writes");
619
+ }
620
+ logDebug(message, error) {
621
+ // A logger failure must never affect the search result.
622
+ try {
623
+ this.logger?.debug(message, error === undefined
624
+ ? undefined
625
+ : {
626
+ cause: error instanceof Error ? error.message : String(error),
627
+ });
628
+ }
629
+ catch {
630
+ // Ignore logger failures.
631
+ }
632
+ }
633
+ }
634
+ /** Small in-memory LRU for successful summaries; failures are never stored. */
635
+ export class SummaryCache {
636
+ entries = new Map();
637
+ maxEntries;
638
+ ttlMs;
639
+ constructor(maxEntries = SEARCH_LIMITS.summaryCacheEntries, ttlMs = SEARCH_LIMITS.summaryCacheTtlMs) {
640
+ this.maxEntries = maxEntries;
641
+ this.ttlMs = ttlMs;
642
+ }
643
+ get(key, now) {
644
+ const entry = this.entries.get(key);
645
+ if (entry === undefined)
646
+ return undefined;
647
+ if (now >= entry.expiresAt) {
648
+ this.entries.delete(key);
649
+ return undefined;
650
+ }
651
+ this.entries.delete(key);
652
+ this.entries.set(key, entry);
653
+ return entry.value;
654
+ }
655
+ set(key, value, now) {
656
+ this.entries.delete(key);
657
+ this.entries.set(key, { value, expiresAt: now + this.ttlMs });
658
+ while (this.entries.size > this.maxEntries) {
659
+ const oldest = this.entries.keys().next();
660
+ if (oldest.done === true)
661
+ break;
662
+ this.entries.delete(oldest.value);
663
+ }
664
+ }
665
+ }
666
+ //# sourceMappingURL=cache.js.map