@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.
- package/cache/manager.ts +751 -0
- package/hooks/message-before.ts +261 -0
- package/hooks/types.ts +23 -0
- package/index.ts +63 -1
- package/package.json +6 -2
- package/tools/search.ts +154 -63
- package/tools/workspace.ts +210 -0
- package/vectorizer/index.ts +47 -1
- package/vectorizer.yaml +11 -0
package/cache/manager.ts
ADDED
|
@@ -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 }
|