@comfanion/usethis_search 3.0.1 → 4.1.0-dev.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.
@@ -0,0 +1,751 @@
1
+ /**
2
+ * Workspace Cache Manager
3
+ *
4
+ * In-memory cache of files attached to the AI's workspace context.
5
+ * search() attaches top results + graph relations here.
6
+ * message.before hook injects cached content into every LLM request.
7
+ *
8
+ * Persistence:
9
+ * .opencode/.workspace/{sessionId}.json — per-session workspace state
10
+ * .opencode/.workspace/latest.json — fallback (current session)
11
+ *
12
+ * Only metadata + paths are saved. Content is re-read from disk on restore.
13
+ *
14
+ * Lifecycle:
15
+ * search("auth") -> attach top N main files + graph relations
16
+ * message.before -> inject all cached files into user message
17
+ * workspace.detach / workspace.clear -> manual cleanup
18
+ * ensureBudget() -> auto-evict oldest low-score files when over limit
19
+ * save() / restore() -> persist to / load from .opencode/.workspace/
20
+ */
21
+
22
+ import path from "path"
23
+ import fs from "fs/promises"
24
+ import crypto from "crypto"
25
+
26
+ // ── Types ───────────────────────────────────────────────────────────────────
27
+
28
+ export interface WorkspaceEntry {
29
+ /** Relative file path (e.g. "src/auth/login.ts") */
30
+ path: string
31
+ /** Full file content (loaded in memory, NOT persisted to JSON) */
32
+ content: string
33
+ /** Estimated token count (~content.length / 4) */
34
+ tokens: number
35
+ /** MD5 hash of content — used by freshen() to detect disk changes */
36
+ contentHash: string
37
+ /** How this file got into workspace */
38
+ role: "search-main" | "search-graph" | "manual"
39
+ /** Timestamp when attached */
40
+ attachedAt: number
41
+ /** Search query or "manual" */
42
+ attachedBy: string
43
+ /** Search relevance score (0-1) */
44
+ score?: number
45
+ /** File metadata from search results */
46
+ metadata?: {
47
+ language?: string
48
+ function_name?: string
49
+ class_name?: string
50
+ heading_context?: string
51
+ /** Graph relation type (for role=search-graph) */
52
+ relation?: string
53
+ /** Which main file pulled this graph file in */
54
+ mainFile?: string
55
+ }
56
+ }
57
+
58
+ /** Serialized entry — content included only when persistContent=true */
59
+ interface PersistedEntry {
60
+ path: string
61
+ tokens: number
62
+ role: WorkspaceEntry["role"]
63
+ attachedAt: number
64
+ attachedBy: string
65
+ score?: number
66
+ metadata?: WorkspaceEntry["metadata"]
67
+ /** Only present when config.persistContent=true (debug mode) */
68
+ content?: string
69
+ }
70
+
71
+ /** On-disk workspace snapshot */
72
+ interface WorkspaceSnapshot {
73
+ sessionId: string
74
+ savedAt: string
75
+ totalTokens: number
76
+ fileCount: number
77
+ /** true if content is included in entries */
78
+ debug: boolean
79
+ entries: PersistedEntry[]
80
+ }
81
+
82
+ export interface WorkspaceConfig {
83
+ /** Max total tokens across all files (default: 50000) */
84
+ maxTokens: number
85
+ /** Max number of files (default: 30) */
86
+ maxFiles: number
87
+ /** How many top search results to attach (default: 5) */
88
+ attachTopN: number
89
+ /** Max graph relations per main file (default: 3) */
90
+ attachRelatedPerFile: number
91
+ /** Min score for main files to be attached (default: 0.65) */
92
+ minScoreMain: number
93
+ /** Min score for graph relations to be attached (default: 0.5) */
94
+ minScoreRelated: number
95
+ /**
96
+ * Save full file content in workspace snapshots (default: false).
97
+ * When true: .workspace/{sessionId}.json includes content for every file.
98
+ * Useful for debugging — see exactly what the AI saw.
99
+ * Warning: snapshots can be 10-50MB+ with content enabled.
100
+ */
101
+ persistContent: boolean
102
+ /**
103
+ * Auto-prune old search tool outputs from chat history (default: true).
104
+ * When true: message.before hook replaces old search outputs with 1-line summaries.
105
+ * Files are already in workspace injection — keeping big outputs wastes tokens.
106
+ */
107
+ autoPruneSearch: boolean
108
+ /**
109
+ * Substitute tool outputs when files are in workspace (default: true).
110
+ * When true: read(), grep(), glob() outputs are replaced with compact messages
111
+ * if all matched files are in workspace cache.
112
+ * Saves tokens since files are already visible in workspace injection.
113
+ */
114
+ substituteToolOutputs: boolean
115
+ }
116
+
117
+ export const DEFAULT_WORKSPACE_CONFIG: WorkspaceConfig = {
118
+ maxTokens: 50_000,
119
+ maxFiles: 30,
120
+ attachTopN: 5,
121
+ attachRelatedPerFile: 3,
122
+ minScoreMain: 0.65,
123
+ minScoreRelated: 0.5,
124
+ persistContent: false,
125
+ autoPruneSearch: true,
126
+ substituteToolOutputs: true,
127
+ }
128
+
129
+ // ── Implementation ──────────────────────────────────────────────────────────
130
+
131
+ class WorkspaceCache {
132
+ private entries = new Map<string, WorkspaceEntry>()
133
+ private _totalTokens = 0
134
+ private config: WorkspaceConfig
135
+
136
+ /** Project root directory (set on init/restore) */
137
+ private projectRoot: string | null = null
138
+ /** Current session ID */
139
+ private sessionId: string | null = null
140
+ /** Auto-save debounce timer */
141
+ private _saveTimer: ReturnType<typeof setTimeout> | null = null
142
+ /** Debounce delay in ms */
143
+ private _saveDebounceMs = 2000
144
+ /** Files modified by edit/write since last freshen() — bypass substitution */
145
+ private dirtyFiles = new Set<string>()
146
+
147
+ constructor(config?: Partial<WorkspaceConfig>) {
148
+ this.config = { ...DEFAULT_WORKSPACE_CONFIG, ...config }
149
+ }
150
+
151
+ // ── Persistence ─────────────────────────────────────────────────────────
152
+
153
+ /**
154
+ * Directory where workspace snapshots are stored.
155
+ */
156
+ private getWorkspaceDir(): string | null {
157
+ if (!this.projectRoot) return null
158
+ return path.join(this.projectRoot, ".opencode", ".workspace")
159
+ }
160
+
161
+ /**
162
+ * Initialize workspace with project root and optional session ID.
163
+ * If a saved workspace exists for this session, it will be restored.
164
+ *
165
+ * Safe to call multiple times — second call with a different sessionId
166
+ * will save current state, then restore for the new session.
167
+ */
168
+ async init(projectRoot: string, sessionId?: string): Promise<void> {
169
+ // If re-initializing with a new session — flush current state first
170
+ if (this.projectRoot && this.sessionId && sessionId && sessionId !== this.sessionId) {
171
+ await this.save()
172
+ }
173
+
174
+ this.projectRoot = projectRoot
175
+ this.sessionId = sessionId || `session-${Date.now()}`
176
+
177
+ // Try to restore existing workspace for this session
178
+ const restored = await this.restore(this.sessionId)
179
+ if (restored) {
180
+ console.log(`[workspace] Restored session ${this.sessionId} (${this.entries.size} files, ${this._totalTokens} tokens)`)
181
+ } else if (!sessionId) {
182
+ // Only fall back to latest.json when no specific session requested
183
+ // (avoids clobbering a fresh session with stale data)
184
+ const restoredLatest = await this.restore("latest")
185
+ if (restoredLatest) {
186
+ console.log(`[workspace] Restored from latest (${this.entries.size} files, ${this._totalTokens} tokens)`)
187
+ }
188
+ }
189
+
190
+ // Register process exit handler (once)
191
+ this.registerShutdownHook()
192
+ }
193
+
194
+ /** Whether shutdown hook is registered */
195
+ private _shutdownRegistered = false
196
+
197
+ /**
198
+ * Register a graceful shutdown hook to flush workspace to disk.
199
+ * Prevents data loss on process exit / SIGTERM / SIGINT.
200
+ */
201
+ private registerShutdownHook(): void {
202
+ if (this._shutdownRegistered) return
203
+ this._shutdownRegistered = true
204
+
205
+ const flush = () => {
206
+ // Synchronous-ish flush — best effort
207
+ // Node process is exiting, so we can't await.
208
+ // Use writeFileSync as last resort.
209
+ try {
210
+ const dir = this.getWorkspaceDir()
211
+ if (!dir || this.entries.size === 0) return
212
+
213
+ const id = this.sessionId || "latest"
214
+ const includeContent = this.config.persistContent
215
+
216
+ const snapshot = {
217
+ sessionId: id,
218
+ savedAt: new Date().toISOString(),
219
+ totalTokens: this._totalTokens,
220
+ fileCount: this.entries.size,
221
+ debug: includeContent,
222
+ entries: Array.from(this.entries.values()).map(e => {
223
+ const p: any = {
224
+ path: e.path,
225
+ tokens: e.tokens,
226
+ role: e.role,
227
+ attachedAt: e.attachedAt,
228
+ attachedBy: e.attachedBy,
229
+ score: e.score,
230
+ metadata: e.metadata,
231
+ }
232
+ if (includeContent) p.content = e.content
233
+ return p
234
+ }),
235
+ }
236
+
237
+ const data = JSON.stringify(snapshot, null, 2)
238
+ const fsSync = require("fs")
239
+
240
+ fsSync.mkdirSync(dir, { recursive: true })
241
+ fsSync.writeFileSync(path.join(dir, `${id}.json`), data, "utf-8")
242
+ fsSync.writeFileSync(path.join(dir, "latest.json"), data, "utf-8")
243
+ } catch {
244
+ // Best effort — process is dying
245
+ }
246
+ }
247
+
248
+ process.on("exit", flush)
249
+ process.on("SIGINT", () => { flush(); process.exit(0) })
250
+ process.on("SIGTERM", () => { flush(); process.exit(0) })
251
+ }
252
+
253
+ /**
254
+ * Save workspace metadata to disk (debounced).
255
+ * Content is NOT saved — only paths + metadata.
256
+ */
257
+ scheduleSave(): void {
258
+ if (this._saveTimer) clearTimeout(this._saveTimer)
259
+ this._saveTimer = setTimeout(() => {
260
+ this.save().catch(err => {
261
+ console.error("[workspace] Save failed:", err)
262
+ })
263
+ }, this._saveDebounceMs)
264
+ // Don't keep the process alive just for a debounced save —
265
+ // the shutdown hook handles final flush synchronously.
266
+ if (this._saveTimer && typeof this._saveTimer === "object" && "unref" in this._saveTimer) {
267
+ ;(this._saveTimer as NodeJS.Timeout).unref()
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Save workspace metadata to disk immediately.
273
+ */
274
+ async save(snapshotId?: string): Promise<void> {
275
+ const dir = this.getWorkspaceDir()
276
+ if (!dir) return
277
+
278
+ const id = snapshotId || this.sessionId || "latest"
279
+
280
+ const includeContent = this.config.persistContent
281
+
282
+ const snapshot: WorkspaceSnapshot = {
283
+ sessionId: id,
284
+ savedAt: new Date().toISOString(),
285
+ totalTokens: this._totalTokens,
286
+ fileCount: this.entries.size,
287
+ debug: includeContent,
288
+ entries: Array.from(this.entries.values()).map(e => {
289
+ const persisted: PersistedEntry = {
290
+ path: e.path,
291
+ tokens: e.tokens,
292
+ role: e.role,
293
+ attachedAt: e.attachedAt,
294
+ attachedBy: e.attachedBy,
295
+ score: e.score,
296
+ metadata: e.metadata,
297
+ }
298
+ if (includeContent) {
299
+ persisted.content = e.content
300
+ }
301
+ return persisted
302
+ }),
303
+ }
304
+
305
+ try {
306
+ await fs.mkdir(dir, { recursive: true })
307
+
308
+ // Save session-specific file
309
+ const sessionFile = path.join(dir, `${id}.json`)
310
+ await fs.writeFile(sessionFile, JSON.stringify(snapshot, null, 2), "utf-8")
311
+
312
+ // Also save as latest.json (always keep current state accessible)
313
+ const latestFile = path.join(dir, "latest.json")
314
+ await fs.writeFile(latestFile, JSON.stringify(snapshot, null, 2), "utf-8")
315
+ } catch (error) {
316
+ console.error("[workspace] Failed to save:", error)
317
+ }
318
+ }
319
+
320
+ /**
321
+ * Restore workspace from disk.
322
+ * Re-reads file content from project files (NOT from snapshot).
323
+ * Files that no longer exist on disk are skipped.
324
+ *
325
+ * Returns true if snapshot was found and loaded.
326
+ */
327
+ async restore(snapshotId?: string): Promise<boolean> {
328
+ const dir = this.getWorkspaceDir()
329
+ if (!dir || !this.projectRoot) return false
330
+
331
+ const id = snapshotId || this.sessionId || "latest"
332
+ const snapshotFile = path.join(dir, `${id}.json`)
333
+
334
+ let snapshot: WorkspaceSnapshot
335
+ try {
336
+ const raw = await fs.readFile(snapshotFile, "utf-8")
337
+ snapshot = JSON.parse(raw)
338
+ } catch {
339
+ return false // No snapshot found
340
+ }
341
+
342
+ // Clear current state
343
+ this.entries.clear()
344
+ this._totalTokens = 0
345
+
346
+ // Re-read file content from disk (or use saved content in debug mode)
347
+ let loaded = 0
348
+ let skipped = 0
349
+
350
+ for (const entry of snapshot.entries) {
351
+ let content: string | null = null
352
+
353
+ // If snapshot has content (debug mode) — use it directly
354
+ if (entry.content) {
355
+ content = entry.content
356
+ } else {
357
+ // Otherwise re-read from disk
358
+ try {
359
+ const fullPath = path.join(this.projectRoot, entry.path)
360
+ content = await fs.readFile(fullPath, "utf-8")
361
+ } catch {
362
+ // File no longer exists or unreadable
363
+ }
364
+ }
365
+
366
+ if (content === null) {
367
+ skipped++
368
+ continue
369
+ }
370
+
371
+ const tokens = this.countTokens(content)
372
+
373
+ this.entries.set(entry.path, {
374
+ path: entry.path,
375
+ content,
376
+ tokens,
377
+ contentHash: this.hashContent(content),
378
+ role: entry.role,
379
+ attachedAt: entry.attachedAt,
380
+ attachedBy: entry.attachedBy,
381
+ score: entry.score,
382
+ metadata: entry.metadata,
383
+ })
384
+ this._totalTokens += tokens
385
+ loaded++
386
+ }
387
+
388
+ if (skipped > 0) {
389
+ console.log(`[workspace] Restore: ${loaded} loaded, ${skipped} skipped (files missing)`)
390
+ }
391
+
392
+ return loaded > 0
393
+ }
394
+
395
+ /**
396
+ * List all saved workspace snapshots.
397
+ */
398
+ async listSnapshots(): Promise<{ id: string; savedAt: string; fileCount: number; totalTokens: number }[]> {
399
+ const dir = this.getWorkspaceDir()
400
+ if (!dir) return []
401
+
402
+ try {
403
+ const files = await fs.readdir(dir)
404
+ const snapshots: { id: string; savedAt: string; fileCount: number; totalTokens: number }[] = []
405
+
406
+ for (const file of files) {
407
+ if (!file.endsWith(".json") || file === "latest.json") continue
408
+
409
+ try {
410
+ const raw = await fs.readFile(path.join(dir, file), "utf-8")
411
+ const snap: WorkspaceSnapshot = JSON.parse(raw)
412
+ snapshots.push({
413
+ id: snap.sessionId,
414
+ savedAt: snap.savedAt,
415
+ fileCount: snap.fileCount,
416
+ totalTokens: snap.totalTokens,
417
+ })
418
+ } catch {
419
+ // Corrupt file — skip
420
+ }
421
+ }
422
+
423
+ // Sort by savedAt descending
424
+ snapshots.sort((a, b) => b.savedAt.localeCompare(a.savedAt))
425
+ return snapshots
426
+ } catch {
427
+ return []
428
+ }
429
+ }
430
+
431
+ /**
432
+ * Delete a saved snapshot.
433
+ */
434
+ async deleteSnapshot(snapshotId: string): Promise<boolean> {
435
+ const dir = this.getWorkspaceDir()
436
+ if (!dir) return false
437
+
438
+ try {
439
+ await fs.unlink(path.join(dir, `${snapshotId}.json`))
440
+ return true
441
+ } catch {
442
+ return false
443
+ }
444
+ }
445
+
446
+ // ── Core operations ─────────────────────────────────────────────────────
447
+
448
+ /**
449
+ * Attach a file to workspace.
450
+ * If file already exists, updates it (replaces content/score).
451
+ * Auto-saves to disk (debounced).
452
+ */
453
+ attach(entry: Omit<WorkspaceEntry, "tokens" | "contentHash">): void {
454
+ const tokens = this.countTokens(entry.content)
455
+ const contentHash = this.hashContent(entry.content)
456
+
457
+ // Update existing — subtract old tokens first
458
+ const existing = this.entries.get(entry.path)
459
+ if (existing) {
460
+ this._totalTokens -= existing.tokens
461
+ }
462
+
463
+ this.entries.set(entry.path, { ...entry, tokens, contentHash })
464
+ this._totalTokens += tokens
465
+
466
+ // Auto-evict if over budget
467
+ this.ensureBudget()
468
+
469
+ // Auto-save (debounced)
470
+ this.scheduleSave()
471
+ }
472
+
473
+ /**
474
+ * Remove a single file from workspace.
475
+ * Returns true if file was found and removed.
476
+ */
477
+ detach(filePath: string): boolean {
478
+ const entry = this.entries.get(filePath)
479
+ if (!entry) return false
480
+
481
+ this.entries.delete(filePath)
482
+ this._totalTokens -= entry.tokens
483
+
484
+ // Auto-save (debounced)
485
+ this.scheduleSave()
486
+ return true
487
+ }
488
+
489
+ /**
490
+ * Remove all files older than `ms` milliseconds.
491
+ * Returns number of files removed.
492
+ */
493
+ detachOlderThan(ms: number): number {
494
+ const cutoff = Date.now() - ms
495
+ let removed = 0
496
+
497
+ for (const [filePath, entry] of this.entries) {
498
+ if (entry.attachedAt < cutoff) {
499
+ this.entries.delete(filePath)
500
+ this._totalTokens -= entry.tokens
501
+ removed++
502
+ }
503
+ }
504
+
505
+ if (removed > 0) this.scheduleSave()
506
+ return removed
507
+ }
508
+
509
+ /**
510
+ * Remove all files that were attached by a specific search query.
511
+ * Returns number of files removed.
512
+ */
513
+ detachByQuery(query: string): number {
514
+ let removed = 0
515
+
516
+ for (const [filePath, entry] of this.entries) {
517
+ if (entry.attachedBy === query || entry.attachedBy.startsWith(`${query} (`)) {
518
+ this.entries.delete(filePath)
519
+ this._totalTokens -= entry.tokens
520
+ removed++
521
+ }
522
+ }
523
+
524
+ if (removed > 0) this.scheduleSave()
525
+ return removed
526
+ }
527
+
528
+ /**
529
+ * Get all entries sorted by: search-main first (by score desc), then search-graph, then manual.
530
+ */
531
+ getAll(): WorkspaceEntry[] {
532
+ return Array.from(this.entries.values()).sort((a, b) => {
533
+ // Main files first
534
+ const roleOrder = { "search-main": 0, "search-graph": 1, manual: 2 }
535
+ const ra = roleOrder[a.role] ?? 2
536
+ const rb = roleOrder[b.role] ?? 2
537
+ if (ra !== rb) return ra - rb
538
+
539
+ // Within same role: higher score first
540
+ if (a.score !== undefined && b.score !== undefined) {
541
+ return b.score - a.score
542
+ }
543
+
544
+ // Fallback: alphabetical
545
+ return a.path.localeCompare(b.path)
546
+ })
547
+ }
548
+
549
+ /**
550
+ * Check if a file is in workspace.
551
+ */
552
+ has(filePath: string): boolean {
553
+ return this.entries.has(filePath)
554
+ }
555
+
556
+ /**
557
+ * Get a single entry by path.
558
+ */
559
+ get(filePath: string): WorkspaceEntry | undefined {
560
+ return this.entries.get(filePath)
561
+ }
562
+
563
+ /**
564
+ * Remove all files from workspace.
565
+ */
566
+ clear(): void {
567
+ this.entries.clear()
568
+ this._totalTokens = 0
569
+ this.scheduleSave()
570
+ }
571
+
572
+ /**
573
+ * Total tokens across all cached files.
574
+ */
575
+ get totalTokens(): number {
576
+ return this._totalTokens
577
+ }
578
+
579
+ /**
580
+ * Number of files in workspace.
581
+ */
582
+ get size(): number {
583
+ return this.entries.size
584
+ }
585
+
586
+ /**
587
+ * Current session ID.
588
+ */
589
+ getSessionId(): string | null {
590
+ return this.sessionId
591
+ }
592
+
593
+ /**
594
+ * Current config (read-only).
595
+ */
596
+ getConfig(): Readonly<WorkspaceConfig> {
597
+ return this.config
598
+ }
599
+
600
+ /**
601
+ * Update config at runtime.
602
+ */
603
+ updateConfig(partial: Partial<WorkspaceConfig>): void {
604
+ Object.assign(this.config, partial)
605
+ this.ensureBudget()
606
+ }
607
+
608
+ /**
609
+ * Mark file as dirty (modified by edit/write tool).
610
+ * Dirty files bypass read() substitution until next freshen().
611
+ */
612
+ markDirty(filePath: string): void {
613
+ this.dirtyFiles.add(filePath)
614
+ }
615
+
616
+ /**
617
+ * Check if file is dirty (modified since last freshen).
618
+ */
619
+ isDirty(filePath: string): boolean {
620
+ return this.dirtyFiles.has(filePath)
621
+ }
622
+
623
+ /**
624
+ * Clear all dirty flags (called by freshen after re-reading files).
625
+ */
626
+ clearDirty(): void {
627
+ this.dirtyFiles.clear()
628
+ }
629
+
630
+ // ── Freshness ────────────────────────────────────────────────────────────
631
+
632
+ /**
633
+ * Re-read workspace files from disk if they changed.
634
+ * Compares stored contentHash with current file hash.
635
+ * Removes entries whose files no longer exist on disk.
636
+ *
637
+ * Called by message.before hook — ensures AI always sees fresh content.
638
+ * Cost: ~2ms for 30 files (stat + optional readFile).
639
+ *
640
+ * Returns { updated, removed } counts.
641
+ */
642
+ async freshen(): Promise<{ updated: number; removed: number }> {
643
+ if (!this.projectRoot) return { updated: 0, removed: 0 }
644
+
645
+ let updated = 0
646
+ let removed = 0
647
+
648
+ for (const [filePath, entry] of this.entries) {
649
+ try {
650
+ const fullPath = path.join(this.projectRoot, filePath)
651
+ const content = await fs.readFile(fullPath, "utf-8")
652
+ const newHash = this.hashContent(content)
653
+
654
+ if (newHash !== entry.contentHash) {
655
+ // File changed on disk — update in-memory
656
+ const oldTokens = entry.tokens
657
+ const newTokens = this.countTokens(content)
658
+
659
+ entry.content = content
660
+ entry.contentHash = newHash
661
+ entry.tokens = newTokens
662
+ this._totalTokens += newTokens - oldTokens
663
+
664
+ updated++
665
+ }
666
+ } catch {
667
+ // File no longer exists — remove from workspace
668
+ this.entries.delete(filePath)
669
+ this._totalTokens -= entry.tokens
670
+ removed++
671
+ }
672
+ }
673
+
674
+ if (updated > 0 || removed > 0) {
675
+ this.scheduleSave()
676
+ }
677
+
678
+ // Clear dirty flags — all files are now fresh from disk
679
+ this.clearDirty()
680
+
681
+ return { updated, removed }
682
+ }
683
+
684
+ // ── Budget management ───────────────────────────────────────────────────
685
+
686
+ /**
687
+ * Evict oldest / lowest-score files until within budget.
688
+ * Priority: evict search-graph first, then search-main, then manual.
689
+ * Within same role: lowest score first, then oldest first.
690
+ */
691
+ private ensureBudget(): void {
692
+ // Check both token and file count limits
693
+ if (
694
+ this._totalTokens <= this.config.maxTokens &&
695
+ this.entries.size <= this.config.maxFiles
696
+ ) {
697
+ return
698
+ }
699
+
700
+ // Build eviction priority list
701
+ const candidates = Array.from(this.entries.values()).sort((a, b) => {
702
+ // Evict graph files before main files before manual files
703
+ const roleOrder = { "search-graph": 0, "search-main": 1, manual: 2 }
704
+ const ra = roleOrder[a.role] ?? 2
705
+ const rb = roleOrder[b.role] ?? 2
706
+ if (ra !== rb) return ra - rb
707
+
708
+ // Within same role: lowest score first
709
+ const sa = a.score ?? 0
710
+ const sb = b.score ?? 0
711
+ if (sa !== sb) return sa - sb
712
+
713
+ // Then oldest first
714
+ return a.attachedAt - b.attachedAt
715
+ })
716
+
717
+ for (const entry of candidates) {
718
+ if (
719
+ this._totalTokens <= this.config.maxTokens &&
720
+ this.entries.size <= this.config.maxFiles
721
+ ) {
722
+ break
723
+ }
724
+
725
+ this.entries.delete(entry.path)
726
+ this._totalTokens -= entry.tokens
727
+ }
728
+ }
729
+
730
+ // ── Helpers ─────────────────────────────────────────────────────────────
731
+
732
+ /**
733
+ * Rough token count: ~4 chars per token.
734
+ */
735
+ private countTokens(content: string): number {
736
+ return Math.ceil(content.length / 4)
737
+ }
738
+
739
+ /**
740
+ * MD5 hash of content for change detection.
741
+ */
742
+ private hashContent(content: string): string {
743
+ return crypto.createHash("md5").update(content).digest("hex")
744
+ }
745
+ }
746
+
747
+ // ── Singleton ───────────────────────────────────────────────────────────────
748
+
749
+ export const workspaceCache = new WorkspaceCache()
750
+
751
+ export { WorkspaceCache }