@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.
- package/hooks/message-before.ts +134 -388
- 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,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* History Pruning Hook (v2 — no injection)
|
|
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
|
-
*
|
|
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
|
-
*
|
|
15
|
-
* 1.
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
-
// ──
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
207
|
-
|
|
59
|
+
/** Keep last N turns intact (don't compact recent tool calls). */
|
|
60
|
+
const KEEP_LAST_N_TURNS = 5
|
|
208
61
|
|
|
209
|
-
/**
|
|
210
|
-
|
|
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
|
-
|
|
229
|
-
}
|
|
65
|
+
// ── Hook ────────────────────────────────────────────────────────────────────
|
|
230
66
|
|
|
231
67
|
/**
|
|
232
|
-
*
|
|
233
|
-
*
|
|
68
|
+
* Create the history pruning handler.
|
|
69
|
+
* No injection — only prunes old tool outputs from chat history.
|
|
234
70
|
*/
|
|
235
|
-
function
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
|
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
|
|
85
|
+
* Replace old workspace tool outputs with compact summaries.
|
|
304
86
|
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
313
|
-
|
|
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
|
|
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
|
-
|
|
110
|
+
wsParts.push({ msgIdx: i, partIdx: j, part })
|
|
331
111
|
}
|
|
332
112
|
}
|
|
333
113
|
}
|
|
334
114
|
|
|
335
|
-
// Keep the last
|
|
336
|
-
if (
|
|
115
|
+
// Keep the last workspace tool output — prune the rest
|
|
116
|
+
if (wsParts.length <= 1) return
|
|
337
117
|
|
|
338
|
-
const toPrune =
|
|
118
|
+
const toPrune = wsParts.slice(0, -1)
|
|
339
119
|
|
|
340
120
|
for (const { part } of toPrune) {
|
|
341
|
-
const output = part.state
|
|
121
|
+
const output = part.state!.output as string
|
|
342
122
|
|
|
343
|
-
// Extract
|
|
344
|
-
const
|
|
345
|
-
const
|
|
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
|
-
//
|
|
348
|
-
const
|
|
349
|
-
const
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
176
|
+
const output = part.state!.output as string
|
|
402
177
|
|
|
403
|
-
//
|
|
404
|
-
|
|
178
|
+
// Skip already-pruned outputs
|
|
179
|
+
if (output.startsWith("[") || output.startsWith("✓")) continue
|
|
405
180
|
|
|
406
|
-
//
|
|
407
|
-
|
|
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
|
-
|
|
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
|
|
438
|
-
*
|
|
203
|
+
* Remove old tool call parts from chat history.
|
|
204
|
+
*
|
|
439
205
|
* Strategy:
|
|
440
|
-
* - Keep last N turns
|
|
441
|
-
* - Only compact search/read tools
|
|
206
|
+
* - Keep last N turns intact
|
|
207
|
+
* - Only compact search/read/workspace tools
|
|
442
208
|
* - Only compact completed calls with pruned outputs
|
|
443
|
-
* -
|
|
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
|
-
|
|
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,
|
|
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 ===
|
|
482
|
-
COMPACT_TOOLS.
|
|
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 ===
|
|
246
|
+
const firstUserMsg = messages.find(m => m?.info?.role === "user")
|
|
508
247
|
if (firstUserMsg && firstUserMsg.parts) {
|
|
509
248
|
const marker = {
|
|
510
|
-
type:
|
|
511
|
-
text: `<!-- ${toCompact.length} tool calls compacted (search/read results
|
|
512
|
-
id:
|
|
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 ===
|
|
530
|
-
const status = part.state?.status ||
|
|
531
|
-
|
|
532
|
-
// Find matching output part (usually in same message
|
|
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 ===
|
|
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
|
-
//
|
|
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.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 (
|
|
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
|
})
|