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

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,24 @@
1
1
  /**
2
- * Workspace Context Injection Hook
2
+ * History Pruning Hook (v2 — no injection)
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
+ * v2: Removed workspace injection entirely.
8
+ * Each tool (search, workspace_list, etc.) now returns full workspace
9
+ * state inline. This hook only prunes previous outputs to prevent
10
+ * context bloat.
13
11
  *
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)
19
- *
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
12
+ * Pruning strategy:
13
+ * 1. WORKSPACE TOOLS: Find all outputs from search/workspace_* tools.
14
+ * Keep only the LAST one (it has the latest workspace state).
15
+ * Replace the rest with compact 1-line summaries.
16
+ * 2. READ TOOLS: Replace old read() outputs with compact summaries.
17
+ * Keep the last read output (agent may reference it).
18
+ * 3. COMPACT: Remove old tool call parts entirely (keep last N turns).
27
19
  */
28
20
 
29
21
  import type { SessionState } from "./types.ts"
30
- import { workspaceCache } from "../cache/manager.ts"
31
22
 
32
23
  // ── Types matching OpenCode plugin message format ───────────────────────────
33
24
 
@@ -35,6 +26,10 @@ interface MessagePart {
35
26
  type: string
36
27
  content?: string
37
28
  text?: string
29
+ tool?: string
30
+ state?: { status?: string; output?: string }
31
+ input?: any
32
+ id?: string
38
33
  [key: string]: any
39
34
  }
40
35
 
@@ -47,271 +42,56 @@ interface Message {
47
42
  [key: string]: any
48
43
  }
49
44
 
50
- // ── Hook ────────────────────────────────────────────────────────────────────
51
-
52
- /**
53
- * Create the messages transform handler that injects workspace context.
54
- */
55
- export function createWorkspaceInjectionHandler(state: SessionState) {
56
- return async (_input: {}, output: { messages: Message[] }) => {
57
- // Don't inject or prune for sub-agents (title generation, etc.)
58
- if (state.isSubAgent) return
59
-
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
- }
45
+ // ── Constants ───────────────────────────────────────────────────────────────
183
46
 
184
- // ── Helpers ─────────────────────────────────────────────────────────────────
47
+ /** Tools that return full workspace state in their output. */
48
+ const WORKSPACE_TOOLS = new Set([
49
+ "search",
50
+ "workspace_list",
51
+ "workspace_forget",
52
+ "workspace_clear",
53
+ "workspace_restore",
54
+ ])
185
55
 
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
- }
56
+ /** Minimum output length to consider pruning. Short outputs are kept as-is. */
57
+ const MIN_PRUNE_LENGTH = 500
205
58
 
206
- return output
207
- }
59
+ /** Keep last N turns intact (don't compact recent tool calls). */
60
+ const KEEP_LAST_N_TURNS = 5
208
61
 
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
- }
62
+ /** Tools eligible for compaction (removing old call + output parts). */
63
+ const COMPACT_TOOLS = new Set(["search", "read", "Read", "workspace_list", "workspace_forget", "workspace_clear", "workspace_restore"])
227
64
 
228
- return block
229
- }
65
+ // ── Hook ────────────────────────────────────────────────────────────────────
230
66
 
231
67
  /**
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.
68
+ * Create the history pruning handler.
69
+ * No injection only prunes old tool outputs from chat history.
234
70
  */
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}`)
249
- }
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
- }
71
+ export function createWorkspaceInjectionHandler(state: SessionState) {
72
+ return async (_input: {}, output: { messages: Message[] }) => {
73
+ // Don't prune for sub-agents (title generation, etc.)
74
+ if (state.isSubAgent) return
278
75
 
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
- }
76
+ pruneWorkspaceToolOutputs(output.messages)
77
+ pruneReadToolOutputs(output.messages)
78
+ compactOldToolCalls(output.messages)
284
79
  }
285
- return null
286
80
  }
287
81
 
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: "'
82
+ // ── Workspace Tool Pruning ──────────────────────────────────────────────────
301
83
 
302
84
  /**
303
- * Replace search tool outputs in chat history with compact summaries.
85
+ * Replace old workspace tool outputs with compact summaries.
304
86
  *
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.
87
+ * Workspace tools (search, workspace_list, etc.) now return full workspace
88
+ * state in their output. Only the LAST such output is kept all previous
89
+ * ones are replaced with a 1-line summary.
308
90
  *
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).
91
+ * This ensures only ONE copy of workspace state is in context at any time.
311
92
  */
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 }[] = []
93
+ function pruneWorkspaceToolOutputs(messages: Message[]): void {
94
+ const wsParts: { msgIdx: number; partIdx: number; part: MessagePart }[] = []
315
95
 
316
96
  for (let i = 0; i < messages.length; i++) {
317
97
  const msg = messages[i]
@@ -321,57 +101,52 @@ export function pruneSearchToolOutputs(messages: Message[]): void {
321
101
  const part = parts[j]
322
102
  if (
323
103
  part.type === "tool" &&
324
- part.tool === "search" &&
104
+ part.tool &&
105
+ WORKSPACE_TOOLS.has(part.tool) &&
325
106
  part.state?.status === "completed" &&
326
107
  typeof part.state?.output === "string" &&
327
- part.state.output.length > MIN_PRUNE_LENGTH &&
328
- part.state.output.startsWith(SEARCH_OUTPUT_MARKER)
108
+ part.state.output.length > MIN_PRUNE_LENGTH
329
109
  ) {
330
- searchParts.push({ msgIdx: i, partIdx: j, part })
110
+ wsParts.push({ msgIdx: i, partIdx: j, part })
331
111
  }
332
112
  }
333
113
  }
334
114
 
335
- // Keep the last search output (agent may reference it) — prune the rest
336
- if (searchParts.length <= 1) return
115
+ // Keep the last workspace tool output — prune the rest
116
+ if (wsParts.length <= 1) return
337
117
 
338
- const toPrune = searchParts.slice(0, -1)
118
+ const toPrune = wsParts.slice(0, -1)
339
119
 
340
120
  for (const { part } of toPrune) {
341
- const output = part.state.output as string
121
+ const output = part.state!.output as string
342
122
 
343
- // Extract query from output: ## Search: "query" (...)
344
- const queryMatch = output.match(/^## Search: "([^"]+)"/)
345
- const query = queryMatch?.[1] || "?"
123
+ // Extract info from workspace_state tag
124
+ const wsMatch = output.match(/<workspace_state\s+chunks="(\d+)"\s+files="(\d+)"\s+tokens="(\d+)"/)
125
+ const chunks = wsMatch?.[1] || "?"
126
+ const files = wsMatch?.[2] || "?"
127
+ const tokens = wsMatch?.[3] || "?"
346
128
 
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.]`
129
+ // For search: extract query
130
+ const queryMatch = output.match(/^## Search: "([^"]+)"/)
131
+ const query = queryMatch?.[1]
132
+
133
+ if (query) {
134
+ part.state!.output =
135
+ `[Search "${query}" workspace had ${chunks} chunks, ${files} files, ${tokens} tokens. Superseded by newer state.]`
136
+ } else {
137
+ part.state!.output =
138
+ `[Workspace state: ${chunks} chunks, ${files} files, ${tokens} tokens. Superseded by newer state.]`
139
+ }
360
140
  }
361
141
  }
362
142
 
143
+ // ── Read Tool Pruning ───────────────────────────────────────────────────────
144
+
363
145
  /**
364
- * Replace read() tool outputs in chat history with compact summaries.
365
- *
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.
369
- *
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).
146
+ * Replace old read() tool outputs with compact summaries.
147
+ * Keep the last read output (agent may reference it).
372
148
  */
373
- export function pruneReadToolOutputs(messages: Message[]): void {
374
- // Find all read tool parts (completed, with long output)
149
+ function pruneReadToolOutputs(messages: Message[]): void {
375
150
  const readParts: { msgIdx: number; partIdx: number; part: MessagePart }[] = []
376
151
 
377
152
  for (let i = 0; i < messages.length; i++) {
@@ -392,166 +167,135 @@ export function pruneReadToolOutputs(messages: Message[]): void {
392
167
  }
393
168
  }
394
169
 
395
- // Keep the last read output (agent may reference it) — prune the rest
170
+ // Keep the last read output — prune the rest
396
171
  if (readParts.length <= 1) return
397
172
 
398
173
  const toPrune = readParts.slice(0, -1)
399
174
 
400
175
  for (const { part } of toPrune) {
401
- const output = part.state.output as string
176
+ const output = part.state!.output as string
402
177
 
403
- // Extract file path from output or input
404
- const filePath = part.input?.filePath || extractFilePathFromOutput(output)
178
+ // Skip already-pruned outputs
179
+ if (output.startsWith("[") || output.startsWith("✓")) continue
405
180
 
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
- }
181
+ // Extract file path from input or output
182
+ const filePath = part.input?.filePath || extractFilePathFromOutput(output)
411
183
 
412
- // Replace with compact summary
413
- part.state.output = `[Read "${filePath || "file"}" — content available in workspace context]`
184
+ part.state!.output = `[Read "${filePath || "file"}" — content pruned from history]`
414
185
  }
415
186
  }
416
187
 
417
188
  /**
418
189
  * Extract file path from read() output.
419
- * Output usually starts with file path or has markers.
420
190
  */
421
191
  function extractFilePathFromOutput(output: string): string | null {
422
- // Try to find file path in first line
423
192
  const firstLine = output.split("\n")[0]
424
-
425
- // Pattern: "## path/to/file.ts" or "path/to/file.ts"
426
193
  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
194
  if (pathMatch) {
428
195
  return pathMatch[1].trim()
429
196
  }
430
-
431
197
  return null
432
198
  }
433
199
 
434
200
  // ── Tool Call Compaction ────────────────────────────────────────────────────
435
201
 
436
202
  /**
437
- * Remove old tool calls (search/read) from chat history.
438
- *
203
+ * Remove old tool call parts from chat history.
204
+ *
439
205
  * Strategy:
440
- * - Keep last N turns (default: 5) — agent may reference recent calls
441
- * - Only compact search/read tools (not edit/write/grep/glob)
206
+ * - Keep last N turns intact
207
+ * - Only compact search/read/workspace tools
442
208
  * - 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
209
+ * - Add compact marker showing how many calls removed
450
210
  */
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
211
+ function compactOldToolCalls(messages: Message[]): void {
469
212
  const toolPairs = findToolCallPairs(messages)
470
-
213
+
471
214
  if (toolPairs.length === 0) return
472
-
473
- // Calculate turns from end for each pair
215
+
474
216
  const totalTurns = messages.length
475
-
476
- // Filter: only old, completed, search/read with pruned outputs
217
+
218
+ // Filter: only old, completed, compactable tools with pruned outputs
477
219
  const toCompact = toolPairs.filter(pair => {
478
220
  const turnsFromEnd = totalTurns - pair.msgIndex
479
221
  return (
480
222
  turnsFromEnd > KEEP_LAST_N_TURNS &&
481
- pair.status === 'completed' &&
482
- COMPACT_TOOLS.includes(pair.tool) &&
223
+ pair.status === "completed" &&
224
+ COMPACT_TOOLS.has(pair.tool) &&
483
225
  pair.outputPart &&
484
- isPrunedOutput(pair.outputPart.state?.output || '')
226
+ isPrunedOutput(pair.outputPart.state?.output || "")
485
227
  )
486
228
  })
487
-
229
+
488
230
  if (toCompact.length === 0) return
489
-
231
+
490
232
  // Remove tool parts from messages
491
233
  const removedIds = new Set<string>()
492
-
234
+
493
235
  for (const pair of toCompact) {
494
- removedIds.add(pair.callPart.id)
495
- if (pair.outputPart) {
496
- removedIds.add(pair.outputPart.id)
497
- }
236
+ if (pair.callPart.id) removedIds.add(pair.callPart.id)
237
+ if (pair.outputPart?.id) removedIds.add(pair.outputPart.id)
498
238
  }
499
-
500
- // Filter out removed parts from messages
239
+
501
240
  for (const msg of messages) {
502
241
  if (!msg.parts || !Array.isArray(msg.parts)) continue
503
- msg.parts = msg.parts.filter(part => !removedIds.has(part.id))
242
+ msg.parts = msg.parts.filter(part => !part.id || !removedIds.has(part.id))
504
243
  }
505
-
244
+
506
245
  // Add compact marker to first user message
507
- const firstUserMsg = messages.find(m => m?.info?.role === 'user')
246
+ const firstUserMsg = messages.find(m => m?.info?.role === "user")
508
247
  if (firstUserMsg && firstUserMsg.parts) {
509
248
  const marker = {
510
- type: 'text',
511
- text: `<!-- ${toCompact.length} tool calls compacted (search/read results in workspace) -->`,
512
- id: 'compact-marker-' + Date.now(),
249
+ type: "text",
250
+ text: `<!-- ${toCompact.length} tool calls compacted (search/read/workspace results pruned) -->`,
251
+ id: "compact-marker-" + Date.now(),
513
252
  }
514
253
  firstUserMsg.parts.unshift(marker)
515
254
  }
516
255
  }
517
256
 
257
+ interface ToolCallPair {
258
+ msgIndex: number
259
+ callPart: MessagePart
260
+ outputPart?: MessagePart
261
+ tool: string
262
+ status: string
263
+ }
264
+
518
265
  /**
519
266
  * Find all tool call + output pairs in messages.
520
267
  */
521
268
  function findToolCallPairs(messages: Message[]): ToolCallPair[] {
522
269
  const pairs: ToolCallPair[] = []
523
-
270
+
524
271
  for (let i = 0; i < messages.length; i++) {
525
272
  const msg = messages[i]
526
273
  if (!msg.parts || !Array.isArray(msg.parts)) continue
527
-
274
+
528
275
  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)
276
+ if (part.type === "tool" && part.tool) {
277
+ const status = part.state?.status || "unknown"
278
+
279
+ // Find matching output part (usually in same message)
533
280
  let outputPart: MessagePart | undefined
534
-
535
- // Check same message first
536
281
  for (const p of msg.parts) {
537
- if (p.type === 'tool' && p.tool === part.tool && p.state?.output && p.id !== part.id) {
282
+ if (p.type === "tool" && p.tool === part.tool && p.state?.output && p.id !== part.id) {
538
283
  outputPart = p
539
284
  break
540
285
  }
541
286
  }
542
-
287
+
543
288
  pairs.push({
544
289
  msgIndex: i,
545
290
  callPart: part,
546
291
  outputPart,
547
292
  tool: part.tool,
548
293
  status,
549
- turnsSinceEnd: 0, // Will be calculated in compactOldToolCalls
550
294
  })
551
295
  }
552
296
  }
553
297
  }
554
-
298
+
555
299
  return pairs
556
300
  }
557
301
 
@@ -560,7 +304,9 @@ function findToolCallPairs(messages: Message[]): ToolCallPair[] {
560
304
  */
561
305
  function isPrunedOutput(output: string): boolean {
562
306
  if (!output) return false
563
-
564
- // Pruned outputs start with [ or ✓
565
- return output.startsWith('[') || output.startsWith('✓')
307
+ return output.startsWith("[") || output.startsWith("✓")
566
308
  }
309
+
310
+ // ── Exports for testing ─────────────────────────────────────────────────────
311
+
312
+ export { pruneWorkspaceToolOutputs, pruneReadToolOutputs, compactOldToolCalls }
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.3",
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
  })