@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.
- package/hooks/message-before.ts +73 -497
- package/index.ts +2 -5
- package/package.json +3 -2
- package/tools/search.ts +21 -12
- package/tools/workspace-state.ts +129 -0
- package/tools/workspace.ts +33 -155
package/hooks/message-before.ts
CHANGED
|
@@ -1,33 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* History Pruning Hook (v3 — workspace-only, DCP handles the rest)
|
|
3
3
|
*
|
|
4
|
-
* Uses "experimental.chat.messages.transform" to
|
|
5
|
-
*
|
|
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
|
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
|
|
71
|
+
// Don't prune for sub-agents (title generation, etc.)
|
|
58
72
|
if (state.isSubAgent) return
|
|
59
73
|
|
|
60
|
-
|
|
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
|
-
|
|
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
|
|
81
|
+
* Replace old workspace tool outputs with compact summaries.
|
|
304
82
|
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
371
|
-
*
|
|
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
|
|
374
|
-
|
|
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
|
-
|
|
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
|
-
|
|
111
|
+
wsParts.push({ msgIdx: i, partIdx: j, part })
|
|
391
112
|
}
|
|
392
113
|
}
|
|
393
114
|
}
|
|
394
115
|
|
|
395
|
-
// Keep the last
|
|
396
|
-
if (
|
|
116
|
+
// Keep the last workspace tool output — prune the rest
|
|
117
|
+
if (wsParts.length <= 1) return
|
|
397
118
|
|
|
398
|
-
const toPrune =
|
|
119
|
+
const toPrune = wsParts.slice(0, -1)
|
|
399
120
|
|
|
400
121
|
for (const { part } of toPrune) {
|
|
401
|
-
const output = part.state
|
|
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
|
-
//
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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
|
-
//
|
|
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.
|
|
4
|
-
"description": "OpenCode plugin: semantic search with
|
|
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 (
|
|
2
|
+
* Semantic Code Search Tool (v6 — context-efficient workspace state)
|
|
3
3
|
*
|
|
4
4
|
* Uses local embeddings + LanceDB vector store via bundled vectorizer.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
726
|
-
|
|
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
|
+
}
|
package/tools/workspace.ts
CHANGED
|
@@ -1,157 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Workspace Management Tools (
|
|
2
|
+
* Workspace Management Tools (v2 — context-efficient)
|
|
3
3
|
*
|
|
4
4
|
* Manual control over the workspace cache:
|
|
5
|
-
* workspace_list — show
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* workspace_restore — restore a saved session snapshot
|
|
5
|
+
* workspace_list — show full workspace state with chunk content
|
|
6
|
+
* workspace_forget — remove 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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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: `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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
|
-
|
|
94
|
+
summary = `Removed ${removed} chunk(s) from "${args.what}".`
|
|
221
95
|
}
|
|
222
96
|
|
|
223
97
|
// 4. Otherwise, treat as search query
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
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)
|
|
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}"
|
|
160
|
+
return `Restored workspace from "${args.sessionId}".` + buildWorkspaceOutput()
|
|
283
161
|
},
|
|
284
162
|
})
|