@comfanion/usethis_search 4.3.0-dev.2 → 4.3.0-dev.4

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.
@@ -1,33 +1,28 @@
1
1
  /**
2
- * Workspace Context Injection Hook
2
+ * History Pruning Hook (v3 — workspace-only, DCP handles the rest)
3
3
  *
4
- * Uses "experimental.chat.messages.transform" to inject workspace chunks
5
- * into the conversation context. The AI sees attached chunks as part of
6
- * the message stream — no read() needed.
4
+ * Uses "experimental.chat.messages.transform" to prune old workspace tool
5
+ * outputs from chat history. Only the LAST workspace state is kept in context.
7
6
  *
8
- * Architecture:
9
- * search("auth") workspaceCache.attach(chunks)
10
- * [this hook] → inject cached chunks into messages (grouped by file)
11
- * AI sees: chunk content organized by file
12
- * Chat history: search outputs auto-pruned (chunks already in workspace)
7
+ * v3: Removed read pruning and tool compaction — delegated to DCP plugin
8
+ * (@tarquinen/opencode-dcp) which handles deduplication, supersede-writes,
9
+ * error purging, and agent-facing discard/extract tools.
13
10
  *
14
- * Two responsibilities:
15
- * 1. INJECT: synthetic <workspace_context> message before last user message
16
- * 2. PRUNE: replace old search tool outputs with compact summaries
17
- * (the chunk content is already in workspace injection — no need to keep
18
- * the big search output in chat history)
11
+ * This hook ONLY handles workspace state pruning — something DCP can't do
12
+ * because it doesn't understand that different search queries produce
13
+ * workspace state blocks that supersede each other.
19
14
  *
20
- * Injection strategy:
21
- * - Injects a synthetic user message with <workspace_context> BEFORE
22
- * the last user message (so AI sees chunks as "already known" context)
23
- * - Uses cache_control: ephemeral for Anthropic prompt caching (90% savings)
24
- * - Groups chunks by file: search-main first, then search-graph, then manual
25
- * - Within each file: chunks sorted by chunkIndex (order in file)
26
- * - Shows chunk metadata: function name, heading, line numbers
15
+ * Pruning strategy:
16
+ * Find all outputs from search/workspace_* tools that contain
17
+ * <workspace_state> blocks. Keep only the LAST one. Replace the rest
18
+ * with compact 1-line summaries.
19
+ *
20
+ * DCP companion config (.opencode/dcp.jsonc):
21
+ * Our workspace tools are added to DCP's protectedTools so DCP
22
+ * doesn't try to prune them (we handle them ourselves).
27
23
  */
28
24
 
29
25
  import type { SessionState } from "./types.ts"
30
- import { workspaceCache } from "../cache/manager.ts"
31
26
 
32
27
  // ── Types matching OpenCode plugin message format ───────────────────────────
33
28
 
@@ -35,6 +30,10 @@ interface MessagePart {
35
30
  type: string
36
31
  content?: string
37
32
  text?: string
33
+ tool?: string
34
+ state?: { status?: string; output?: string }
35
+ input?: any
36
+ id?: string
38
37
  [key: string]: any
39
38
  }
40
39
 
@@ -47,332 +46,53 @@ interface Message {
47
46
  [key: string]: any
48
47
  }
49
48
 
49
+ // ── Constants ───────────────────────────────────────────────────────────────
50
+
51
+ /** Tools that return full workspace state in their output. */
52
+ const WORKSPACE_TOOLS = new Set([
53
+ "search",
54
+ "workspace_list",
55
+ "workspace_forget",
56
+ "workspace_clear",
57
+ "workspace_restore",
58
+ ])
59
+
60
+ /** Minimum output length to consider pruning. Short outputs are kept as-is. */
61
+ const MIN_PRUNE_LENGTH = 500
62
+
50
63
  // ── Hook ────────────────────────────────────────────────────────────────────
51
64
 
52
65
  /**
53
- * Create the messages transform handler that injects workspace context.
66
+ * Create the history pruning handler.
67
+ * Only prunes old workspace state outputs — DCP handles everything else.
54
68
  */
55
69
  export function createWorkspaceInjectionHandler(state: SessionState) {
56
70
  return async (_input: {}, output: { messages: Message[] }) => {
57
- // Don't inject or prune for sub-agents (title generation, etc.)
71
+ // Don't prune for sub-agents (title generation, etc.)
58
72
  if (state.isSubAgent) return
59
73
 
60
- // ── Prune & Compact: optimize chat history ────────────────────────────
61
- // 1. Prune: replace old tool outputs with compact summaries
62
- // 2. Compact: remove old tool calls entirely (keep last N turns)
63
- // Files are already in workspace injection — no need for big outputs
64
- // in chat history. This runs even when workspace is empty
65
- // (handles case where workspace was cleared but old outputs remain).
66
- const wsConfig = workspaceCache.getConfig()
67
- if (wsConfig.autoPruneSearch !== false) {
68
- pruneSearchToolOutputs(output.messages)
69
- pruneReadToolOutputs(output.messages)
70
- compactOldToolCalls(output.messages)
71
- }
72
-
73
- let entries = workspaceCache.getAll()
74
-
75
- // Nothing in workspace — skip injection (but pruning already happened)
76
- if (entries.length === 0) return
77
-
78
- // ── Freshen: re-read changed files from disk ──────────────────────────
79
- const { updated, removed } = await workspaceCache.freshen()
80
- if (updated > 0 || removed > 0) {
81
- // Re-fetch entries after freshen (some may be removed)
82
- entries = workspaceCache.getAll()
83
- if (entries.length === 0) return
84
- }
85
-
86
- // ── Build workspace context block ─────────────────────────────────────
87
- const totalTokens = workspaceCache.totalTokens
88
- const chunkCount = entries.length
89
-
90
- // Group chunks by file path
91
- const byFile = new Map<string, typeof entries>()
92
- for (const entry of entries) {
93
- if (!byFile.has(entry.path)) {
94
- byFile.set(entry.path, [])
95
- }
96
- byFile.get(entry.path)!.push(entry)
97
- }
98
-
99
- // Sort chunks within each file by chunkIndex
100
- for (const chunks of byFile.values()) {
101
- chunks.sort((a, b) => a.chunkIndex - b.chunkIndex)
102
- }
103
-
104
- const fileCount = byFile.size
105
-
106
- let workspace = `<workspace_context chunks="${chunkCount}" files="${fileCount}" tokens="${totalTokens}">\n`
107
-
108
- // Group by role for clear structure
109
- const mainFiles = entries.filter(e => e.role === "search-main")
110
- const contextFiles = entries.filter(e => e.role === "search-context")
111
- const graphFiles = entries.filter(e => e.role === "search-graph")
112
- const manualFiles = entries.filter(e => e.role === "manual")
113
-
114
- // Main search results
115
- if (mainFiles.length > 0) {
116
- workspace += formatChunksByFile(mainFiles, byFile)
117
- }
118
-
119
- // Expanded context (class methods, class headers)
120
- if (contextFiles.length > 0) {
121
- workspace += `\n<!-- Expanded context (class methods/headers for completeness) -->\n`
122
- workspace += formatChunksByFile(contextFiles, byFile)
123
- }
124
-
125
- // Graph relations (imports, extends, used_by)
126
- if (graphFiles.length > 0) {
127
- workspace += `\n<!-- Search graph relations -->\n`
128
- workspace += formatChunksByFile(graphFiles, byFile)
129
- }
130
-
131
- // Manually attached chunks
132
- if (manualFiles.length > 0) {
133
- workspace += `\n<!-- Manually attached -->\n`
134
- workspace += formatChunksByFile(manualFiles, byFile)
135
- }
136
-
137
- workspace += `</workspace_context>`
138
-
139
- // ── Inject into messages ──────────────────────────────────────────────
140
- // Get base message for creating proper synthetic message
141
- const lastUserMessage = findLastUserMessage(output.messages)
142
- if (!lastUserMessage) return
143
-
144
- // Create proper synthetic workspace message (like DCP does)
145
- const workspaceMessage = createSyntheticUserMessage(lastUserMessage, workspace)
146
-
147
- // Add to end of messages (like DCP does with push, not splice)
148
- output.messages.push(workspaceMessage)
149
- }
150
- }
151
-
152
- /**
153
- * Create a properly-formed synthetic user message for workspace injection.
154
- * Follows OpenCode SDK message format to avoid "Invalid prompt" errors.
155
- */
156
- function createSyntheticUserMessage(baseMessage: Message, content: string): Message {
157
- const userInfo = baseMessage.info as any
158
- const now = Date.now()
159
-
160
- return {
161
- info: {
162
- id: "msg_workspace_01234567890123",
163
- sessionID: userInfo.sessionID,
164
- role: "user",
165
- agent: userInfo.agent || "code",
166
- model: userInfo.model,
167
- time: { created: now },
168
- variant: userInfo.variant,
169
- },
170
- parts: [
171
- {
172
- id: "prt_workspace_01234567890123",
173
- sessionID: userInfo.sessionID,
174
- messageID: "msg_workspace_01234567890123",
175
- type: "text",
176
- text: content, // TEXT not content!
177
- // Anthropic prompt caching for 90% token savings
178
- cache_control: { type: "ephemeral" },
179
- },
180
- ],
181
- }
182
- }
183
-
184
- // ── Helpers ─────────────────────────────────────────────────────────────────
185
-
186
- /**
187
- * Format chunks grouped by file path.
188
- * Groups chunks from the same file together, sorted by chunkIndex.
189
- */
190
- function formatChunksByFile(
191
- entries: ReturnType<typeof workspaceCache.getAll>,
192
- byFile: Map<string, ReturnType<typeof workspaceCache.getAll>>
193
- ): string {
194
- let output = ""
195
- const processedFiles = new Set<string>()
196
-
197
- for (const entry of entries) {
198
- // Skip if we already processed this file
199
- if (processedFiles.has(entry.path)) continue
200
- processedFiles.add(entry.path)
201
-
202
- const chunks = byFile.get(entry.path) || []
203
- output += formatFileWithChunks(entry.path, chunks)
204
- }
205
-
206
- return output
207
- }
208
-
209
- /**
210
- * Format a single file with all its chunks.
211
- */
212
- function formatFileWithChunks(
213
- filePath: string,
214
- chunks: ReturnType<typeof workspaceCache.getAll>
215
- ): string {
216
- let block = `\n## ${filePath}\n`
217
-
218
- // Chunk list comment: "Chunks: 2, 5 (partial file)"
219
- const chunkIndices = chunks.map(c => c.chunkIndex).join(", ")
220
- const isPartial = chunks.length > 0 ? " (partial file)" : ""
221
- block += `<!-- Chunks: ${chunkIndices}${isPartial} -->\n`
222
-
223
- // Format each chunk
224
- for (const chunk of chunks) {
225
- block += formatChunk(chunk)
226
- }
227
-
228
- return block
229
- }
230
-
231
- /**
232
- * Format a single chunk with metadata and line numbers (cat -n style).
233
- * This allows the agent to see exact line numbers without needing grep.
234
- */
235
- function formatChunk(entry: ReturnType<typeof workspaceCache.getAll>[0]): string {
236
- let block = ""
237
-
238
- // Chunk subheader: "### Chunk N: description"
239
- const description = entry.metadata?.function_name || entry.metadata?.heading_context || "code"
240
- block += `\n### Chunk ${entry.chunkIndex}: ${description}\n`
241
-
242
- // Chunk metadata line
243
- const meta: string[] = []
244
- if (entry.score !== undefined) meta.push(`score: ${entry.score.toFixed(3)}`)
245
- if (entry.metadata?.language) meta.push(entry.metadata.language)
246
- if (entry.metadata?.class_name) meta.push(`class: ${entry.metadata.class_name}`)
247
- if (entry.metadata?.startLine !== undefined && entry.metadata?.endLine !== undefined) {
248
- meta.push(`lines: ${entry.metadata.startLine}-${entry.metadata.endLine}`)
74
+ pruneWorkspaceToolOutputs(output.messages)
249
75
  }
250
- if (entry.metadata?.relation) {
251
- const mainBase = entry.metadata.mainChunkId?.split(":").pop() || "?"
252
- meta.push(`${entry.metadata.relation} from ${mainBase}`)
253
- }
254
-
255
- if (meta.length > 0) {
256
- block += `<!-- ${meta.join(" | ")} -->\n`
257
- }
258
-
259
- // Chunk content WITH LINE NUMBERS (cat -n style)
260
- // This allows agent to reference exact lines without grep
261
- const startLine = entry.metadata?.startLine ?? 1
262
- const lines = entry.content.split("\n")
263
- const lang = entry.metadata?.language || ""
264
-
265
- block += `\`\`\`${lang}\n`
266
-
267
- for (let i = 0; i < lines.length; i++) {
268
- const lineNum = startLine + i
269
- const lineContent = lines[i]
270
- // Format: " 123| line content" (5 chars for line number + tab)
271
- block += `${lineNum.toString().padStart(5, " ")}| ${lineContent}\n`
272
- }
273
-
274
- block += `\`\`\`\n`
275
-
276
- return block
277
76
  }
278
77
 
279
- function findLastUserMessage(messages: Message[]): Message | null {
280
- for (let i = messages.length - 1; i >= 0; i--) {
281
- if (messages[i]?.info?.role === "user") {
282
- return messages[i]
283
- }
284
- }
285
- return null
286
- }
287
-
288
- // ── Tool output pruning ─────────────────────────────────────────────────────
289
-
290
- /**
291
- * Minimum output length to consider pruning.
292
- * Short outputs (errors, "no results") are kept as-is.
293
- */
294
- const MIN_PRUNE_LENGTH = 500
295
-
296
- /**
297
- * Marker prefix that search tool outputs start with.
298
- * Used to identify search results in chat history.
299
- */
300
- const SEARCH_OUTPUT_MARKER = '## Search: "'
78
+ // ── Workspace Tool Pruning ──────────────────────────────────────────────────
301
79
 
302
80
  /**
303
- * Replace search tool outputs in chat history with compact summaries.
81
+ * Replace old workspace tool outputs with compact summaries.
304
82
  *
305
- * Why: search() returns a big markdown block with file listings, scores, etc.
306
- * After workspace injection, the full file content is already in context.
307
- * Keeping the search output wastes tokens — replace it with a 1-line summary.
308
- *
309
- * Only prunes completed search calls with output longer than MIN_PRUNE_LENGTH.
310
- * The last search output is kept (the agent may still be referencing it).
311
- */
312
- export function pruneSearchToolOutputs(messages: Message[]): void {
313
- // Find all search tool parts (completed, with long output)
314
- const searchParts: { msgIdx: number; partIdx: number; part: MessagePart }[] = []
315
-
316
- for (let i = 0; i < messages.length; i++) {
317
- const msg = messages[i]
318
- const parts = Array.isArray(msg.parts) ? msg.parts : []
319
-
320
- for (let j = 0; j < parts.length; j++) {
321
- const part = parts[j]
322
- if (
323
- part.type === "tool" &&
324
- part.tool === "search" &&
325
- part.state?.status === "completed" &&
326
- typeof part.state?.output === "string" &&
327
- part.state.output.length > MIN_PRUNE_LENGTH &&
328
- part.state.output.startsWith(SEARCH_OUTPUT_MARKER)
329
- ) {
330
- searchParts.push({ msgIdx: i, partIdx: j, part })
331
- }
332
- }
333
- }
334
-
335
- // Keep the last search output (agent may reference it) — prune the rest
336
- if (searchParts.length <= 1) return
337
-
338
- const toPrune = searchParts.slice(0, -1)
339
-
340
- for (const { part } of toPrune) {
341
- const output = part.state.output as string
342
-
343
- // Extract query from output: ## Search: "query" (...)
344
- const queryMatch = output.match(/^## Search: "([^"]+)"/)
345
- const query = queryMatch?.[1] || "?"
346
-
347
- // Extract file count from output: *N files (M chunks)...*
348
- const filesMatch = output.match(/\*(\d+) files? \((\d+) chunks?\)/)
349
- const fileCount = filesMatch?.[1] || "?"
350
- const chunkCount = filesMatch?.[2] || "?"
351
-
352
- // Extract attached count: ### Attached to workspace (N files)
353
- const attachedMatch = output.match(/### Attached to workspace \((\d+) files?\)/)
354
- const attachedCount = attachedMatch?.[1] || "0"
355
-
356
- // Replace with compact summary
357
- part.state.output =
358
- `[Search "${query}" — ${fileCount} files (${chunkCount} chunks), ` +
359
- `${attachedCount} attached to workspace. Full content available via workspace context.]`
360
- }
361
- }
362
-
363
- /**
364
- * Replace read() tool outputs in chat history with compact summaries.
83
+ * Workspace tools (search, workspace_list, etc.) return full workspace
84
+ * state in their output. Only the LAST such output is kept all previous
85
+ * ones are replaced with a 1-line summary.
365
86
  *
366
- * Why: read() returns full file content or large chunks.
367
- * After workspace injection (or auto-attach), the content is already in context.
368
- * Keeping the read output wastes tokens — replace it with a 1-line summary.
87
+ * This ensures only ONE copy of workspace state is in context at any time.
369
88
  *
370
- * Only prunes completed read calls with output longer than MIN_PRUNE_LENGTH.
371
- * The last read output is kept (the agent may still be referencing it).
89
+ * Note: DCP's deduplication only prunes IDENTICAL tool calls (same params).
90
+ * Two different search queries wouldn't be deduplicated by DCP, but both
91
+ * contain workspace state that supersedes each other. That's why we need
92
+ * this workspace-specific pruning.
372
93
  */
373
- export function pruneReadToolOutputs(messages: Message[]): void {
374
- // Find all read tool parts (completed, with long output)
375
- const readParts: { msgIdx: number; partIdx: number; part: MessagePart }[] = []
94
+ export function pruneWorkspaceToolOutputs(messages: Message[]): void {
95
+ const wsParts: { msgIdx: number; partIdx: number; part: MessagePart }[] = []
376
96
 
377
97
  for (let i = 0; i < messages.length; i++) {
378
98
  const msg = messages[i]
@@ -382,185 +102,41 @@ export function pruneReadToolOutputs(messages: Message[]): void {
382
102
  const part = parts[j]
383
103
  if (
384
104
  part.type === "tool" &&
385
- (part.tool === "read" || part.tool === "Read") &&
105
+ part.tool &&
106
+ WORKSPACE_TOOLS.has(part.tool) &&
386
107
  part.state?.status === "completed" &&
387
108
  typeof part.state?.output === "string" &&
388
109
  part.state.output.length > MIN_PRUNE_LENGTH
389
110
  ) {
390
- readParts.push({ msgIdx: i, partIdx: j, part })
111
+ wsParts.push({ msgIdx: i, partIdx: j, part })
391
112
  }
392
113
  }
393
114
  }
394
115
 
395
- // Keep the last read output (agent may reference it) — prune the rest
396
- if (readParts.length <= 1) return
116
+ // Keep the last workspace tool output — prune the rest
117
+ if (wsParts.length <= 1) return
397
118
 
398
- const toPrune = readParts.slice(0, -1)
119
+ const toPrune = wsParts.slice(0, -1)
399
120
 
400
121
  for (const { part } of toPrune) {
401
- const output = part.state.output as string
402
-
403
- // Extract file path from output or input
404
- const filePath = part.input?.filePath || extractFilePathFromOutput(output)
122
+ const output = part.state!.output as string
405
123
 
406
- // Check if it's a substituted output (already compact)
407
- if (output.startsWith("[File ") || output.startsWith("[Lines ") || output.startsWith("✓ Attached chunk")) {
408
- // Already substituted keep as-is
409
- continue
410
- }
411
-
412
- // Replace with compact summary
413
- part.state.output = `[Read "${filePath || "file"}" — content available in workspace context]`
414
- }
415
- }
416
-
417
- /**
418
- * Extract file path from read() output.
419
- * Output usually starts with file path or has markers.
420
- */
421
- function extractFilePathFromOutput(output: string): string | null {
422
- // Try to find file path in first line
423
- const firstLine = output.split("\n")[0]
424
-
425
- // Pattern: "## path/to/file.ts" or "path/to/file.ts"
426
- const pathMatch = firstLine.match(/##?\s*(.+?\.(ts|js|go|py|md|txt|yaml|json|tsx|jsx|rs|java|kt|swift|c|cpp|h|cs|rb|php))/)
427
- if (pathMatch) {
428
- return pathMatch[1].trim()
429
- }
430
-
431
- return null
432
- }
433
-
434
- // ── Tool Call Compaction ────────────────────────────────────────────────────
435
-
436
- /**
437
- * Remove old tool calls (search/read) from chat history.
438
- *
439
- * Strategy:
440
- * - Keep last N turns (default: 5) — agent may reference recent calls
441
- * - Only compact search/read tools (not edit/write/grep/glob)
442
- * - Only compact completed calls with pruned outputs
443
- * - Remove both call + output parts
444
- * - Add compact marker at start showing how many calls removed
445
- *
446
- * Why: Tool calls contain full args (200+ tokens). After pruning outputs,
447
- * the calls themselves are redundant — chunks already in workspace.
448
- *
449
- * Savings: ~220 tokens per compacted call × N calls = 2K-10K tokens
450
- */
451
- const KEEP_LAST_N_TURNS = 5
452
- const COMPACT_TOOLS = ['search', 'read', 'Read']
453
-
454
- interface ToolCallPair {
455
- msgIndex: number
456
- callPart: MessagePart
457
- outputPart?: MessagePart
458
- tool: string
459
- status: string
460
- turnsSinceEnd: number
461
- }
462
-
463
- /**
464
- * Compact old tool calls by removing them from chat history.
465
- * Keeps last N turns intact.
466
- */
467
- export function compactOldToolCalls(messages: Message[]): void {
468
- // Find all tool call pairs
469
- const toolPairs = findToolCallPairs(messages)
470
-
471
- if (toolPairs.length === 0) return
472
-
473
- // Calculate turns from end for each pair
474
- const totalTurns = messages.length
475
-
476
- // Filter: only old, completed, search/read with pruned outputs
477
- const toCompact = toolPairs.filter(pair => {
478
- const turnsFromEnd = totalTurns - pair.msgIndex
479
- return (
480
- turnsFromEnd > KEEP_LAST_N_TURNS &&
481
- pair.status === 'completed' &&
482
- COMPACT_TOOLS.includes(pair.tool) &&
483
- pair.outputPart &&
484
- isPrunedOutput(pair.outputPart.state?.output || '')
485
- )
486
- })
487
-
488
- if (toCompact.length === 0) return
489
-
490
- // Remove tool parts from messages
491
- const removedIds = new Set<string>()
492
-
493
- for (const pair of toCompact) {
494
- removedIds.add(pair.callPart.id)
495
- if (pair.outputPart) {
496
- removedIds.add(pair.outputPart.id)
497
- }
498
- }
499
-
500
- // Filter out removed parts from messages
501
- for (const msg of messages) {
502
- if (!msg.parts || !Array.isArray(msg.parts)) continue
503
- msg.parts = msg.parts.filter(part => !removedIds.has(part.id))
504
- }
505
-
506
- // Add compact marker to first user message
507
- const firstUserMsg = messages.find(m => m?.info?.role === 'user')
508
- if (firstUserMsg && firstUserMsg.parts) {
509
- const marker = {
510
- type: 'text',
511
- text: `<!-- ${toCompact.length} tool calls compacted (search/read results in workspace) -->`,
512
- id: 'compact-marker-' + Date.now(),
513
- }
514
- firstUserMsg.parts.unshift(marker)
515
- }
516
- }
124
+ // Extract info from workspace_state tag
125
+ const wsMatch = output.match(/<workspace_state\s+chunks="(\d+)"\s+files="(\d+)"\s+tokens="(\d+)"/)
126
+ const chunks = wsMatch?.[1] || "?"
127
+ const files = wsMatch?.[2] || "?"
128
+ const tokens = wsMatch?.[3] || "?"
517
129
 
518
- /**
519
- * Find all tool call + output pairs in messages.
520
- */
521
- function findToolCallPairs(messages: Message[]): ToolCallPair[] {
522
- const pairs: ToolCallPair[] = []
523
-
524
- for (let i = 0; i < messages.length; i++) {
525
- const msg = messages[i]
526
- if (!msg.parts || !Array.isArray(msg.parts)) continue
527
-
528
- for (const part of msg.parts) {
529
- if (part.type === 'tool' && part.tool) {
530
- const status = part.state?.status || 'unknown'
531
-
532
- // Find matching output part (usually in same message or next)
533
- let outputPart: MessagePart | undefined
534
-
535
- // Check same message first
536
- for (const p of msg.parts) {
537
- if (p.type === 'tool' && p.tool === part.tool && p.state?.output && p.id !== part.id) {
538
- outputPart = p
539
- break
540
- }
541
- }
542
-
543
- pairs.push({
544
- msgIndex: i,
545
- callPart: part,
546
- outputPart,
547
- tool: part.tool,
548
- status,
549
- turnsSinceEnd: 0, // Will be calculated in compactOldToolCalls
550
- })
551
- }
130
+ // For search: extract query
131
+ const queryMatch = output.match(/^## Search: "([^"]+)"/)
132
+ const query = queryMatch?.[1]
133
+
134
+ if (query) {
135
+ part.state!.output =
136
+ `[Search "${query}" workspace had ${chunks} chunks, ${files} files, ${tokens} tokens. Superseded by newer state.]`
137
+ } else {
138
+ part.state!.output =
139
+ `[Workspace state: ${chunks} chunks, ${files} files, ${tokens} tokens. Superseded by newer state.]`
552
140
  }
553
141
  }
554
-
555
- return pairs
556
- }
557
-
558
- /**
559
- * Check if output is pruned (compact format).
560
- */
561
- function isPrunedOutput(output: string): boolean {
562
- if (!output) return false
563
-
564
- // Pruned outputs start with [ or ✓
565
- return output.startsWith('[') || output.startsWith('✓')
566
142
  }
package/index.ts CHANGED
@@ -5,7 +5,6 @@ import { workspace_list, workspace_forget, workspace_clear, workspace_restore }
5
5
  import FileIndexerPlugin from "./file-indexer"
6
6
  import { workspaceCache } from "./cache/manager"
7
7
  import { createWorkspaceInjectionHandler } from "./hooks/message-before"
8
- import { createToolSubstitutionHandler } from "./hooks/tool-substitution"
9
8
  import { createSessionState } from "./hooks/types"
10
9
  import { getWorkspaceConfig } from "./vectorizer/index.ts"
11
10
 
@@ -46,12 +45,10 @@ const UsethisSearchPlugin: Plugin = async ({ directory, client }) => {
46
45
 
47
46
  // ── Hooks ───────────────────────────────────────────────────────────
48
47
 
49
- // Inject workspace files into message context (before LLM sees them)
48
+ // Prune old workspace/search tool outputs from chat history
49
+ // (no injection — each tool returns workspace state inline)
50
50
  "experimental.chat.messages.transform": createWorkspaceInjectionHandler(state),
51
51
 
52
- // Substitute tool outputs when files are in workspace
53
- "tool.execute.after": createToolSubstitutionHandler(state),
54
-
55
52
  // Detect sub-agents (title gen, summarizer) via system prompt
56
53
  "experimental.chat.system.transform": async (_input: unknown, output: { system: string[] }) => {
57
54
  const systemText = output.system.join("\n")
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@comfanion/usethis_search",
3
- "version": "4.3.0-dev.2",
4
- "description": "OpenCode plugin: semantic search with auto-attach, line numbers in workspace, simplified API (v4.3: auto-detect modes, read() caching, tool call compaction, 99% token reduction, no grep needed, LSP memory leak fixed)",
3
+ "version": "4.3.0-dev.4",
4
+ "description": "OpenCode plugin: semantic search with context-efficient workspace state (v4.3: no injection, each tool returns full state inline, auto-prune history, auto-detect modes, line numbers, LSP memory leak fixed)",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
7
7
  "exports": {
@@ -25,6 +25,7 @@
25
25
  "tools/search.ts",
26
26
  "tools/codeindex.ts",
27
27
  "tools/workspace.ts",
28
+ "tools/workspace-state.ts",
28
29
  "tools/read-interceptor.ts",
29
30
  "cache/manager.ts",
30
31
  "hooks/message-before.ts",
package/tools/search.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  /**
2
- * Semantic Code Search Tool (v5chunk-based workspace injection)
2
+ * Semantic Code Search Tool (v6context-efficient workspace state)
3
3
  *
4
4
  * Uses local embeddings + LanceDB vector store via bundled vectorizer.
5
- * v5: Top N chunks + graph relations attached to workspace (chunk content only).
6
- * Rest returned as summary only.
7
- * AI sees chunks via message.before injection no read() needed.
5
+ * v6: Each tool call returns full workspace state inline.
6
+ * Previous call outputs are pruned from history by message-before hook.
7
+ * No injection workspace lives only in the latest tool output.
8
8
  *
9
9
  * Index data is stored in `.opencode/vectors/<index>/`.
10
10
  */
@@ -15,6 +15,7 @@ import fs from "fs/promises"
15
15
 
16
16
  import { CodebaseIndexer, getSearchConfig, getIndexer, releaseIndexer } from "../vectorizer/index.ts"
17
17
  import { workspaceCache } from "../cache/manager.ts"
18
+ import { buildWorkspaceOutput } from "./workspace-state.ts"
18
19
 
19
20
  // ── Context Expansion Helpers ─────────────────────────────────────────────
20
21
 
@@ -186,7 +187,8 @@ Accepts any query - semantic search, file path, or chunk ID:
186
187
  - "src/auth.ts:chunk-5" → attaches specific chunk
187
188
 
188
189
  Results are optimized for context - top chunks auto-attached with expanded context
189
- (related code, imports, class methods).
190
+ (related code, imports, class methods). Returns full workspace state inline.
191
+ Previous search outputs are automatically pruned from history.
190
192
 
191
193
  IMPORTANT: Workspace has limited token budget. Use workspace_forget() to remove
192
194
  irrelevant files or old searches before adding new context.
@@ -287,7 +289,9 @@ Examples:
287
289
  workspaceCache.save().catch(() => {})
288
290
 
289
291
  const entry = workspaceCache.get(chunkId!)!
290
- return `✓ Attached chunk to workspace\n\nChunk: ${chunkId}\nFile: ${chunk.file}\nTokens: ${entry.tokens.toLocaleString()}\nLanguage: ${chunk.language}\nLines: ${chunk.start_line}-${chunk.end_line}\n\nWorkspace: ${workspaceCache.size} chunks, ${workspaceCache.totalTokens.toLocaleString()} tokens`
292
+ let result = `✓ Attached chunk to workspace\n\nChunk: ${chunkId}\nFile: ${chunk.file}\nTokens: ${entry.tokens.toLocaleString()}\nLanguage: ${chunk.language}\nLines: ${chunk.start_line}-${chunk.end_line}`
293
+ result += buildWorkspaceOutput()
294
+ return result
291
295
  } finally {
292
296
  releaseIndexer(projectRoot, indexName)
293
297
  }
@@ -333,7 +337,9 @@ Examples:
333
337
 
334
338
  workspaceCache.save().catch(() => {})
335
339
 
336
- return `✓ Attached file to workspace\n\nFile: ${filePath}\nChunks: ${chunks.length}\nTokens: ${totalTokens.toLocaleString()}\nLanguage: ${chunks[0].language}\n\nWorkspace: ${workspaceCache.size} chunks, ${workspaceCache.totalTokens.toLocaleString()} tokens`
340
+ let result = `✓ Attached file to workspace\n\nFile: ${filePath}\nChunks: ${chunks.length}\nTokens: ${totalTokens.toLocaleString()}\nLanguage: ${chunks[0].language}`
341
+ result += buildWorkspaceOutput()
342
+ return result
337
343
  } finally {
338
344
  releaseIndexer(projectRoot, indexName)
339
345
  }
@@ -515,7 +521,9 @@ Examples:
515
521
  if (topChunks.length === 0) {
516
522
  const scope = args.searchAll ? "any index" : `index "${indexName}"`
517
523
  const filterNote = args.filter ? ` with filter "${args.filter}"` : ""
518
- return `No results found in ${scope}${filterNote} for: "${semanticQuery}" (min score: ${minScore})\n\nTry:\n- Different keywords or phrasing\n- Remove or broaden the filter\n- search({ query: "...", searchAll: true })`
524
+ let noResultsOutput = `No results found in ${scope}${filterNote} for: "${semanticQuery}" (min score: ${minScore})\n\nTry:\n- Different keywords or phrasing\n- Remove or broaden the filter\n- search({ query: "...", searchAll: true })`
525
+ noResultsOutput += buildWorkspaceOutput()
526
+ return noResultsOutput
519
527
  }
520
528
 
521
529
  // ══════════════════════════════════════════════════════════════════════
@@ -718,12 +726,13 @@ Examples:
718
726
  output += `\nUse \`workspace.attach(chunkId)\` to attach additional chunks.\n`
719
727
  }
720
728
 
721
- // ── Footer ────────────────────────────────────────────────────────────
729
+ // ── Footer: total found ────────────────────────────────────────────
722
730
  const totalChunks = allResults.length
723
731
  output += `\n---\n`
724
- output += `*${totalChunks} chunks found | `
725
- output += `Workspace: ${workspaceCache.size} chunks, ${workspaceCache.totalTokens.toLocaleString()} tokens*\n`
726
- output += `*Attached chunks are in workspace context reference them directly without read().*`
732
+ output += `*${totalChunks} chunks found*`
733
+
734
+ // ── Append full workspace state (replaces old injection approach) ──
735
+ output += buildWorkspaceOutput()
727
736
 
728
737
  return output
729
738
  } catch (error: any) {
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Workspace State Output Builder
3
+ *
4
+ * Builds full workspace state including chunk contents for tool responses.
5
+ * Each search/workspace tool appends this to its output.
6
+ * Previous tool outputs containing workspace state are pruned from history
7
+ * by the message-before hook — only the LATEST state is kept in context.
8
+ *
9
+ * This replaces the old injection approach (synthetic user message with
10
+ * <workspace_context>) — now each tool returns the state directly.
11
+ */
12
+
13
+ import { workspaceCache, type WorkspaceEntry } from "../cache/manager.ts"
14
+
15
+ /**
16
+ * Build the full workspace state output.
17
+ * Contains all chunks grouped by file with full content and metadata.
18
+ *
19
+ * Called by search(), workspace_list(), workspace_forget(),
20
+ * workspace_clear(), workspace_restore().
21
+ *
22
+ * Returns a <workspace_state> XML block that the agent can reference.
23
+ * The block is self-contained — all chunk content is inline.
24
+ */
25
+ export function buildWorkspaceOutput(): string {
26
+ const entries = workspaceCache.getAll()
27
+ const totalTokens = workspaceCache.totalTokens
28
+ const chunkCount = entries.length
29
+
30
+ if (chunkCount === 0) {
31
+ return `\n<workspace_state chunks="0" files="0" tokens="0">\nWorkspace is empty.\n</workspace_state>`
32
+ }
33
+
34
+ // Group chunks by file path
35
+ const byFile = new Map<string, WorkspaceEntry[]>()
36
+ for (const entry of entries) {
37
+ if (!byFile.has(entry.path)) {
38
+ byFile.set(entry.path, [])
39
+ }
40
+ byFile.get(entry.path)!.push(entry)
41
+ }
42
+
43
+ // Sort chunks within each file by chunkIndex
44
+ for (const chunks of byFile.values()) {
45
+ chunks.sort((a, b) => a.chunkIndex - b.chunkIndex)
46
+ }
47
+
48
+ const fileCount = byFile.size
49
+ const config = workspaceCache.getConfig()
50
+ const pct = Math.round((totalTokens / config.maxTokens) * 100)
51
+
52
+ let output = `\n<workspace_state chunks="${chunkCount}" files="${fileCount}" tokens="${totalTokens}" budget="${pct}%">\n`
53
+
54
+ // Format all files with their chunks (ordered by role priority)
55
+ const processedFiles = new Set<string>()
56
+
57
+ for (const entry of entries) {
58
+ if (processedFiles.has(entry.path)) continue
59
+ processedFiles.add(entry.path)
60
+
61
+ const chunks = byFile.get(entry.path) || []
62
+ output += formatFileWithChunks(entry.path, chunks)
63
+ }
64
+
65
+ output += `\n</workspace_state>`
66
+
67
+ return output
68
+ }
69
+
70
+ // ── Formatting helpers ──────────────────────────────────────────────────────
71
+
72
+ /**
73
+ * Format a single file with all its chunks.
74
+ */
75
+ function formatFileWithChunks(filePath: string, chunks: WorkspaceEntry[]): string {
76
+ let block = `\n## ${filePath}\n`
77
+
78
+ const chunkIndices = chunks.map(c => c.chunkIndex).join(", ")
79
+ block += `<!-- Chunks: ${chunkIndices} -->\n`
80
+
81
+ for (const chunk of chunks) {
82
+ block += formatChunk(chunk)
83
+ }
84
+
85
+ return block
86
+ }
87
+
88
+ /**
89
+ * Format a single chunk with metadata and line numbers (cat -n style).
90
+ * This allows the agent to see exact line numbers without needing grep.
91
+ */
92
+ function formatChunk(entry: WorkspaceEntry): string {
93
+ let block = ""
94
+
95
+ const description = entry.metadata?.function_name || entry.metadata?.heading_context || "code"
96
+ block += `\n### Chunk ${entry.chunkIndex}: ${description}\n`
97
+
98
+ const meta: string[] = []
99
+ if (entry.score !== undefined) meta.push(`score: ${entry.score.toFixed(3)}`)
100
+ if (entry.metadata?.language) meta.push(entry.metadata.language)
101
+ if (entry.metadata?.class_name) meta.push(`class: ${entry.metadata.class_name}`)
102
+ if (entry.metadata?.startLine !== undefined && entry.metadata?.endLine !== undefined) {
103
+ meta.push(`lines: ${entry.metadata.startLine}-${entry.metadata.endLine}`)
104
+ }
105
+ if (entry.metadata?.relation) {
106
+ const mainBase = entry.metadata.mainChunkId?.split(":").pop() || "?"
107
+ meta.push(`${entry.metadata.relation} from ${mainBase}`)
108
+ }
109
+
110
+ if (meta.length > 0) {
111
+ block += `<!-- ${meta.join(" | ")} -->\n`
112
+ }
113
+
114
+ // Chunk content with line numbers
115
+ const startLine = entry.metadata?.startLine ?? 1
116
+ const lines = entry.content.split("\n")
117
+ const lang = entry.metadata?.language || ""
118
+
119
+ block += `\`\`\`${lang}\n`
120
+
121
+ for (let i = 0; i < lines.length; i++) {
122
+ const lineNum = startLine + i
123
+ block += `${lineNum.toString().padStart(5, " ")}| ${lines[i]}\n`
124
+ }
125
+
126
+ block += `\`\`\`\n`
127
+
128
+ return block
129
+ }
@@ -1,157 +1,31 @@
1
1
  /**
2
- * Workspace Management Tools (Chunk-Based)
2
+ * Workspace Management Tools (v2 — context-efficient)
3
3
  *
4
4
  * Manual control over the workspace cache:
5
- * workspace_list — show all attached chunks grouped by file
6
- * workspace_attachmanually attach a file as single chunk
7
- * workspace_detach — remove chunks by chunkId, path, query, or age
8
- * workspace_clear remove all chunks
9
- * workspace_restore — restore a saved session snapshot
5
+ * workspace_list — show full workspace state with chunk content
6
+ * workspace_forgetremove chunks, return updated state
7
+ * workspace_clear — remove all chunks, return empty state
8
+ * workspace_restore restore a saved session snapshot, return state
10
9
  *
11
- * Chunk-based architecture:
12
- * - Each file can be split into multiple chunks (e.g. "src/auth.ts:chunk-5")
13
- * - Chunks are keyed by chunkId, grouped by file for display
14
- * - Manual attach treats file as single chunk (chunkIndex=0, chunkId=path:chunk-0)
10
+ * v2: Each tool returns full workspace state inline (via buildWorkspaceOutput).
11
+ * Previous tool outputs are pruned from history by message-before hook.
12
+ * No injection workspace lives only in the latest tool output.
15
13
  */
16
14
 
17
15
  import { tool } from "@opencode-ai/plugin"
18
- import path from "path"
19
- import fs from "fs/promises"
20
- import crypto from "crypto"
21
16
 
22
17
  import { workspaceCache } from "../cache/manager.ts"
18
+ import { buildWorkspaceOutput } from "./workspace-state.ts"
23
19
 
24
20
  // ── workspace.list ──────────────────────────────────────────────────────────
25
21
 
26
22
  export const workspace_list = tool({
27
- description: `List all chunks currently in workspace context, grouped by file. Shows chunk count, roles, scores, and token counts.`,
23
+ description: `Show full workspace state with all chunk content. Returns file listing and inline content for every attached chunk.`,
28
24
 
29
25
  args: {},
30
26
 
31
27
  async execute() {
32
- const entries = workspaceCache.getAll()
33
-
34
- if (entries.length === 0) {
35
- return `Workspace is empty.\n\nUse search() to find and attach chunks, or workspace_attach("path") to add a file manually.`
36
- }
37
-
38
- const sessionId = workspaceCache.getSessionId()
39
- let output = `## Workspace Status\n\n`
40
- if (sessionId) {
41
- output += `Session: ${sessionId}\n`
42
- }
43
- output += `Chunks: ${workspaceCache.size}\n`
44
- output += `Total tokens: ${workspaceCache.totalTokens.toLocaleString()}\n\n`
45
-
46
- // Group chunks by file
47
- const fileGroups = new Map<string, typeof entries>()
48
- for (const entry of entries) {
49
- if (!fileGroups.has(entry.path)) {
50
- fileGroups.set(entry.path, [])
51
- }
52
- fileGroups.get(entry.path)!.push(entry)
53
- }
54
-
55
- // Separate by role
56
- const mainFiles = Array.from(fileGroups.entries()).filter(([_, chunks]) =>
57
- chunks.some(c => c.role === "search-main")
58
- )
59
- const contextFiles = Array.from(fileGroups.entries()).filter(([_, chunks]) =>
60
- chunks.some(c => c.role === "search-context") && !chunks.some(c => c.role === "search-main")
61
- )
62
- const graphFiles = Array.from(fileGroups.entries()).filter(([_, chunks]) =>
63
- chunks.some(c => c.role === "search-graph") && !chunks.some(c => c.role === "search-main" || c.role === "search-context")
64
- )
65
- const manualFiles = Array.from(fileGroups.entries()).filter(([_, chunks]) =>
66
- chunks.some(c => c.role === "manual") && !chunks.some(c => c.role === "search-main" || c.role === "search-graph" || c.role === "search-context")
67
- )
68
-
69
- if (mainFiles.length > 0) {
70
- output += `### Search results (${mainFiles.length} files)\n`
71
- for (const [filePath, chunks] of mainFiles) {
72
- const totalTokens = chunks.reduce((sum, c) => sum + c.tokens, 0)
73
- const score = chunks[0]?.score ? ` score: ${chunks[0].score.toFixed(3)}` : ""
74
- const meta = chunks[0]?.metadata?.function_name || chunks[0]?.metadata?.class_name || ""
75
- const age = Math.floor((Date.now() - chunks[0].attachedAt) / 1000 / 60)
76
-
77
- output += `- **${filePath}** (${chunks.length} chunk${chunks.length > 1 ? "s" : ""}, ${totalTokens.toLocaleString()} tokens)${score}${meta ? ` (${meta})` : ""} — ${age}m ago\n`
78
-
79
- if (chunks.length > 1) {
80
- for (const chunk of chunks) {
81
- output += ` • ${chunk.chunkId} — chunk ${chunk.chunkIndex}, ${chunk.tokens.toLocaleString()} tok\n`
82
- }
83
- }
84
-
85
- if (chunks[0]?.attachedBy && chunks[0].attachedBy !== "manual") {
86
- output += ` query: "${chunks[0].attachedBy}"\n`
87
- }
88
- }
89
- output += `\n`
90
- }
91
-
92
- if (contextFiles.length > 0) {
93
- output += `### Expanded context (${contextFiles.length} files)\n`
94
- for (const [filePath, chunks] of contextFiles) {
95
- const totalTokens = chunks.reduce((sum, c) => sum + c.tokens, 0)
96
- const reason = chunks[0]?.attachedBy?.match(/\((.+)\)/)?.[1] || "context"
97
- const age = Math.floor((Date.now() - chunks[0].attachedAt) / 1000 / 60)
98
-
99
- output += `- **${filePath}** (${chunks.length} chunk${chunks.length > 1 ? "s" : ""}, ${totalTokens.toLocaleString()} tokens) — ${reason} — ${age}m ago\n`
100
-
101
- if (chunks.length > 1) {
102
- for (const chunk of chunks) {
103
- const meta = chunk.metadata?.function_name || chunk.metadata?.class_name || ""
104
- output += ` • ${chunk.chunkId} — ${meta} (chunk ${chunk.chunkIndex}, ${chunk.tokens.toLocaleString()} tok)\n`
105
- }
106
- }
107
- }
108
- output += `\n`
109
- }
110
-
111
- if (graphFiles.length > 0) {
112
- output += `### Graph relations (${graphFiles.length} files)\n`
113
- for (const [filePath, chunks] of graphFiles) {
114
- const totalTokens = chunks.reduce((sum, c) => sum + c.tokens, 0)
115
- const relation = chunks[0]?.metadata?.relation || "related"
116
- const mainChunkId = chunks[0]?.metadata?.mainChunkId || "?"
117
- const age = Math.floor((Date.now() - chunks[0].attachedAt) / 1000 / 60)
118
-
119
- output += `- **${filePath}** (${chunks.length} chunk${chunks.length > 1 ? "s" : ""}, ${totalTokens.toLocaleString()} tokens) — ${relation} from ${mainChunkId} — ${age}m ago\n`
120
-
121
- if (chunks.length > 1) {
122
- for (const chunk of chunks) {
123
- output += ` • ${chunk.chunkId} — chunk ${chunk.chunkIndex}, ${chunk.tokens.toLocaleString()} tok\n`
124
- }
125
- }
126
- }
127
- output += `\n`
128
- }
129
-
130
- if (manualFiles.length > 0) {
131
- output += `### Manually attached (${manualFiles.length} files)\n`
132
- for (const [filePath, chunks] of manualFiles) {
133
- const totalTokens = chunks.reduce((sum, c) => sum + c.tokens, 0)
134
- const age = Math.floor((Date.now() - chunks[0].attachedAt) / 1000 / 60)
135
-
136
- output += `- **${filePath}** (${chunks.length} chunk${chunks.length > 1 ? "s" : ""}, ${totalTokens.toLocaleString()} tokens) — ${age}m ago\n`
137
-
138
- if (chunks.length > 1) {
139
- for (const chunk of chunks) {
140
- output += ` • ${chunk.chunkId} — chunk ${chunk.chunkIndex}, ${chunk.tokens.toLocaleString()} tok\n`
141
- }
142
- }
143
- }
144
- output += `\n`
145
- }
146
-
147
- // Budget info
148
- const config = workspaceCache.getConfig()
149
- const pct = Math.round((workspaceCache.totalTokens / config.maxTokens) * 100)
150
- output += `---\n`
151
- output += `*Budget: ${workspaceCache.totalTokens.toLocaleString()} / ${config.maxTokens.toLocaleString()} tokens (${pct}%) | `
152
- output += `${workspaceCache.size} / ${config.maxChunks} chunks*`
153
-
154
- return output
28
+ return buildWorkspaceOutput()
155
29
  },
156
30
  })
157
31
 
@@ -180,52 +54,56 @@ Examples:
180
54
 
181
55
  async execute(args) {
182
56
  let removed = 0
57
+ let summary = ""
183
58
 
184
59
  // Auto-detect what to remove
185
60
  // 1. Check if it's a chunk ID (contains ":chunk-")
186
61
  if (args.what.includes(":chunk-")) {
187
62
  const entry = workspaceCache.get(args.what)
188
63
  if (!entry) {
189
- return `Chunk "${args.what}" not found in workspace.`
64
+ return `Chunk "${args.what}" not found in workspace.` + buildWorkspaceOutput()
190
65
  }
191
66
  removed = workspaceCache.detach(args.what) ? 1 : 0
192
67
  if (removed === 0) {
193
- return `Failed to remove chunk "${args.what}".`
68
+ return `Failed to remove chunk "${args.what}".` + buildWorkspaceOutput()
194
69
  }
195
- return `Removed chunk "${args.what}" from workspace.\nWorkspace: ${workspaceCache.size} chunks, ${workspaceCache.totalTokens.toLocaleString()} tokens`
70
+ summary = `Removed chunk "${args.what}" from workspace.`
196
71
  }
197
72
 
198
73
  // 2. Check if it's a number (age in minutes)
199
- const ageMatch = args.what.match(/^(\d+)$/)
200
- if (ageMatch) {
201
- const minutes = parseInt(ageMatch[1], 10)
74
+ else if (args.what.match(/^(\d+)$/)) {
75
+ const minutes = parseInt(args.what, 10)
202
76
  removed = workspaceCache.detachOlderThan(minutes * 60 * 1000)
203
- return `Removed ${removed} chunk(s) older than ${minutes} minutes.\nWorkspace: ${workspaceCache.size} chunks, ${workspaceCache.totalTokens.toLocaleString()} tokens`
77
+ summary = `Removed ${removed} chunk(s) older than ${minutes} minutes.`
204
78
  }
205
79
 
206
80
  // 3. Check if it's a file path (has extension or common path prefixes)
207
- if (
81
+ else if (
208
82
  args.what.match(/\.(md|ts|js|go|py|tsx|jsx|rs|java|kt|swift|txt|yaml|json|yml|toml)$/i) ||
209
83
  args.what.match(/^(src|docs|internal|pkg|lib|app|pages|components|api)\//i) ||
210
84
  args.what.includes("/")
211
85
  ) {
212
86
  const fileChunks = workspaceCache.getChunksByPath(args.what)
213
87
  if (fileChunks.length === 0) {
214
- return `File "${args.what}" not found in workspace.`
88
+ return `File "${args.what}" not found in workspace.` + buildWorkspaceOutput()
215
89
  }
216
90
  removed = workspaceCache.detachByPath(args.what)
217
91
  if (removed === 0) {
218
- return `Failed to remove chunks from "${args.what}".`
92
+ return `Failed to remove chunks from "${args.what}".` + buildWorkspaceOutput()
219
93
  }
220
- return `Removed ${removed} chunk(s) from "${args.what}".\nWorkspace: ${workspaceCache.size} chunks, ${workspaceCache.totalTokens.toLocaleString()} tokens`
94
+ summary = `Removed ${removed} chunk(s) from "${args.what}".`
221
95
  }
222
96
 
223
97
  // 4. Otherwise, treat as search query
224
- removed = workspaceCache.detachByQuery(args.what)
225
- if (removed === 0) {
226
- return `No chunks found attached by query "${args.what}".\n\nTip: Use workspace_list() to see what's in workspace.`
98
+ else {
99
+ removed = workspaceCache.detachByQuery(args.what)
100
+ if (removed === 0) {
101
+ return `No chunks found attached by query "${args.what}".` + buildWorkspaceOutput()
102
+ }
103
+ summary = `Removed ${removed} chunk(s) from search "${args.what}".`
227
104
  }
228
- return `Removed ${removed} chunk(s) from search "${args.what}".\nWorkspace: ${workspaceCache.size} chunks, ${workspaceCache.totalTokens.toLocaleString()} tokens`
105
+
106
+ return summary + buildWorkspaceOutput()
229
107
  },
230
108
  })
231
109
 
@@ -241,7 +119,7 @@ export const workspace_clear = tool({
241
119
  const tokens = workspaceCache.totalTokens
242
120
  workspaceCache.clear()
243
121
 
244
- return `Cleared workspace: ${count} chunks removed (${tokens.toLocaleString()} tokens freed).\nWorkspace is now empty.`
122
+ return `Cleared workspace: ${count} chunks removed (${tokens.toLocaleString()} tokens freed).` + buildWorkspaceOutput()
245
123
  },
246
124
  })
247
125
 
@@ -256,7 +134,7 @@ export const workspace_restore = tool({
256
134
 
257
135
  async execute(args) {
258
136
  if (!args.sessionId) {
259
- // List available snapshots
137
+ // List available snapshots (no workspace state needed — just metadata)
260
138
  const snapshots = await workspaceCache.listSnapshots()
261
139
 
262
140
  if (snapshots.length === 0) {
@@ -279,6 +157,6 @@ export const workspace_restore = tool({
279
157
  return `Snapshot "${args.sessionId}" not found or empty.`
280
158
  }
281
159
 
282
- return `Restored workspace from "${args.sessionId}".\nChunks: ${workspaceCache.size}\nTokens: ${workspaceCache.totalTokens.toLocaleString()}`
160
+ return `Restored workspace from "${args.sessionId}".` + buildWorkspaceOutput()
283
161
  },
284
162
  })