@comfanion/usethis_search 4.1.0-dev.1 → 4.1.0-dev.2
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/tool-substitution.ts +261 -0
- package/package.json +2 -1
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool Output Substitution Hook
|
|
3
|
+
*
|
|
4
|
+
* Intercepts read() tool outputs and replaces them with compact messages
|
|
5
|
+
* when the file is in the workspace cache.
|
|
6
|
+
*
|
|
7
|
+
* Rationale:
|
|
8
|
+
* - search() attaches files to workspace cache
|
|
9
|
+
* - message.before hook injects full file content into context
|
|
10
|
+
* - Tool outputs are redundant — files already visible to AI
|
|
11
|
+
* - Substitution saves tokens by replacing big outputs with 1-line summaries
|
|
12
|
+
*
|
|
13
|
+
* Behavior:
|
|
14
|
+
* - read(file): If file in workspace → "[File is in workspace context]"
|
|
15
|
+
* - grep(pattern): NOT substituted (AI needs line numbers and match context)
|
|
16
|
+
* - glob(pattern): NOT substituted (discovery tool, paths are metadata)
|
|
17
|
+
* - Config flag: substituteToolOutputs (default: true)
|
|
18
|
+
* - Sub-agents: Skip substitution (title gen, summarizer)
|
|
19
|
+
* - Empty workspace: Skip substitution
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { SessionState } from "./types.ts"
|
|
23
|
+
import { workspaceCache, WorkspaceCache } from "../cache/manager.ts"
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Create the tool output substitution handler.
|
|
27
|
+
*
|
|
28
|
+
* Hook: tool.execute.after
|
|
29
|
+
* Replaces tool outputs when all matched files are in workspace.
|
|
30
|
+
*
|
|
31
|
+
* @param state Session state (tracks sub-agent detection)
|
|
32
|
+
* @param cache Optional workspace cache (for testing; defaults to singleton)
|
|
33
|
+
*/
|
|
34
|
+
export function createToolSubstitutionHandler(state: SessionState, cache?: WorkspaceCache) {
|
|
35
|
+
const wsCache = cache || workspaceCache
|
|
36
|
+
|
|
37
|
+
return async (
|
|
38
|
+
input: {
|
|
39
|
+
tool: string
|
|
40
|
+
sessionID: string
|
|
41
|
+
callID: string
|
|
42
|
+
},
|
|
43
|
+
output: {
|
|
44
|
+
title: string
|
|
45
|
+
output: string
|
|
46
|
+
metadata: any
|
|
47
|
+
}
|
|
48
|
+
): Promise<void> => {
|
|
49
|
+
// Skip for sub-agents (title generation, summarization, etc.)
|
|
50
|
+
if (state.isSubAgent) return
|
|
51
|
+
|
|
52
|
+
// ── Track dirty files (edit/write tools modify files on disk) ────────
|
|
53
|
+
// Mark files as dirty so read() substitution is bypassed until freshen()
|
|
54
|
+
if (input.tool === "edit" || input.tool === "write" || input.tool === "Edit" || input.tool === "Write") {
|
|
55
|
+
const filePath = output.metadata?.filePath || output.metadata?.path || extractFilePathFromTitle(output.title)
|
|
56
|
+
if (filePath && wsCache.has(filePath)) {
|
|
57
|
+
wsCache.markDirty(filePath)
|
|
58
|
+
}
|
|
59
|
+
return // edit/write don't need output substitution
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Skip if workspace is empty
|
|
63
|
+
if (wsCache.size === 0) return
|
|
64
|
+
|
|
65
|
+
// Check config flag
|
|
66
|
+
const wsConfig = wsCache.getConfig()
|
|
67
|
+
if (wsConfig.substituteToolOutputs === false) return
|
|
68
|
+
|
|
69
|
+
// Route to appropriate substitution handler
|
|
70
|
+
// NOTE: grep/glob NOT substituted — their structured output (file:line:content)
|
|
71
|
+
// is valuable for AI navigation. Only read() is substituted.
|
|
72
|
+
switch (input.tool) {
|
|
73
|
+
case "read":
|
|
74
|
+
substituteReadOutput(output, wsCache)
|
|
75
|
+
break
|
|
76
|
+
// case "grep": // Disabled — AI needs line numbers and match context
|
|
77
|
+
// case "glob": // Disabled — discovery tool, paths are metadata not content
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Substitute read() output if file is in workspace.
|
|
84
|
+
*
|
|
85
|
+
* Input: { filePath: "src/auth.ts", offset?: 0, limit?: 100 }
|
|
86
|
+
* Output: "export function login(...)\n..."
|
|
87
|
+
*
|
|
88
|
+
* If file in workspace AND no offset/limit (full read):
|
|
89
|
+
* Replace with: "[File "src/auth.ts" is in workspace context — see <workspace_context> for full content.]"
|
|
90
|
+
*
|
|
91
|
+
* If offset/limit present (partial read):
|
|
92
|
+
* Keep original (partial reads are not in workspace injection)
|
|
93
|
+
*/
|
|
94
|
+
function substituteReadOutput(output: { title: string; output: string; metadata: any }, cache: WorkspaceCache): void {
|
|
95
|
+
try {
|
|
96
|
+
// Extract filePath from metadata or title
|
|
97
|
+
const filePath = output.metadata?.filePath || extractFilePathFromTitle(output.title)
|
|
98
|
+
if (!filePath) return
|
|
99
|
+
|
|
100
|
+
// Check if this is a partial read (offset/limit present)
|
|
101
|
+
const isPartialRead = output.metadata?.offset !== undefined || output.metadata?.limit !== undefined
|
|
102
|
+
if (isPartialRead) return
|
|
103
|
+
|
|
104
|
+
// Check if file is in workspace
|
|
105
|
+
if (!cache.has(filePath)) return
|
|
106
|
+
|
|
107
|
+
// Don't substitute if file was modified (dirty) — workspace has stale content
|
|
108
|
+
if (cache.isDirty(filePath)) return
|
|
109
|
+
|
|
110
|
+
// Replace output with compact message
|
|
111
|
+
output.output = `[File "${filePath}" is in workspace context — see <workspace_context> for full content.]`
|
|
112
|
+
} catch {
|
|
113
|
+
// Silently fail — don't break tool execution
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Substitute grep() output if ALL matched files are in workspace.
|
|
119
|
+
*
|
|
120
|
+
* Input: { pattern: "auth", include?: "*.ts" }
|
|
121
|
+
* Output: "src/auth.ts:10:export function login(...)\nsrc/types.ts:5:interface User {...}"
|
|
122
|
+
*
|
|
123
|
+
* Parse output to extract file paths, check if ALL are in workspace.
|
|
124
|
+
* If yes: Replace with "[Pattern "auth" matched N files, all in workspace context:\n- file1\n- file2\n...]"
|
|
125
|
+
* If partial: Keep original
|
|
126
|
+
*/
|
|
127
|
+
function substituteGrepOutput(output: { title: string; output: string; metadata: any }, cache: WorkspaceCache): void {
|
|
128
|
+
try {
|
|
129
|
+
const pattern = output.metadata?.pattern || extractPatternFromTitle(output.title)
|
|
130
|
+
if (!pattern) return
|
|
131
|
+
|
|
132
|
+
// Parse grep output to extract file paths
|
|
133
|
+
// Format: "path:line:content" or just "path"
|
|
134
|
+
const filePaths = parseGrepOutput(output.output)
|
|
135
|
+
if (filePaths.length === 0) return
|
|
136
|
+
|
|
137
|
+
// Check if ALL files are in workspace
|
|
138
|
+
const allInWorkspace = filePaths.every(fp => cache.has(fp))
|
|
139
|
+
if (!allInWorkspace) return
|
|
140
|
+
|
|
141
|
+
// Replace with compact message
|
|
142
|
+
const fileList = filePaths.map(fp => `- ${fp}`).join("\n")
|
|
143
|
+
output.output = `[Pattern "${pattern}" matched ${filePaths.length} files, all in workspace context:\n${fileList}]`
|
|
144
|
+
} catch {
|
|
145
|
+
// Silently fail
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Substitute glob() output if ALL matched files are in workspace.
|
|
151
|
+
*
|
|
152
|
+
* Input: { pattern: "src/[glob-pattern].ts" }
|
|
153
|
+
* Output: "src/auth.ts\nsrc/types.ts\nsrc/utils.ts"
|
|
154
|
+
*
|
|
155
|
+
* Parse output (newline-separated paths), check if ALL are in workspace.
|
|
156
|
+
* If yes: Replace with "[Pattern matched N files, all in workspace context:\n- file1\n- file2\n...]"
|
|
157
|
+
* If partial: Keep original
|
|
158
|
+
*/
|
|
159
|
+
function substituteGlobOutput(output: { title: string; output: string; metadata: any }, cache: WorkspaceCache): void {
|
|
160
|
+
try {
|
|
161
|
+
const pattern = output.metadata?.pattern || extractPatternFromTitle(output.title)
|
|
162
|
+
if (!pattern) return
|
|
163
|
+
|
|
164
|
+
// Parse glob output (newline-separated file paths)
|
|
165
|
+
const filePaths = parseGlobOutput(output.output)
|
|
166
|
+
if (filePaths.length === 0) return
|
|
167
|
+
|
|
168
|
+
// Check if ALL files are in workspace
|
|
169
|
+
const allInWorkspace = filePaths.every(fp => cache.has(fp))
|
|
170
|
+
if (!allInWorkspace) return
|
|
171
|
+
|
|
172
|
+
// Replace with compact message
|
|
173
|
+
const fileList = filePaths.map(fp => `- ${fp}`).join("\n")
|
|
174
|
+
output.output = `[Pattern "${pattern}" matched ${filePaths.length} files, all in workspace context:\n${fileList}]`
|
|
175
|
+
} catch {
|
|
176
|
+
// Silently fail
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Extract file path from read() title.
|
|
184
|
+
* Title format: "Read file: src/auth.ts" or similar
|
|
185
|
+
*/
|
|
186
|
+
function extractFilePathFromTitle(title: string): string | null {
|
|
187
|
+
// Try common patterns
|
|
188
|
+
const patterns = [
|
|
189
|
+
/Read file:\s*(.+?)(?:\s*\(|$)/,
|
|
190
|
+
/read\s+(.+?)(?:\s*\(|$)/i,
|
|
191
|
+
/file:\s*(.+?)(?:\s*\(|$)/i,
|
|
192
|
+
]
|
|
193
|
+
|
|
194
|
+
for (const pattern of patterns) {
|
|
195
|
+
const match = title.match(pattern)
|
|
196
|
+
if (match) {
|
|
197
|
+
return match[1].trim()
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return null
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Extract pattern from grep() or glob() title.
|
|
206
|
+
* Title format: "Search for: auth" or "Find files: src/[pattern].ts" or similar
|
|
207
|
+
*/
|
|
208
|
+
function extractPatternFromTitle(title: string): string | null {
|
|
209
|
+
// Try common patterns
|
|
210
|
+
const patterns = [
|
|
211
|
+
/(?:search|find|pattern|glob).*?:\s*(.+?)(?:\s*\(|$)/i,
|
|
212
|
+
/(?:search|find|pattern|glob)\s+(.+?)(?:\s*\(|$)/i,
|
|
213
|
+
]
|
|
214
|
+
|
|
215
|
+
for (const pattern of patterns) {
|
|
216
|
+
const match = title.match(pattern)
|
|
217
|
+
if (match) {
|
|
218
|
+
return match[1].trim()
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return null
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Parse grep output to extract file paths.
|
|
227
|
+
*
|
|
228
|
+
* Format variations:
|
|
229
|
+
* - "path:line:content" (standard grep)
|
|
230
|
+
* - "path:line" (grep -n without content)
|
|
231
|
+
* - "path" (grep -l, list files only)
|
|
232
|
+
*
|
|
233
|
+
* Returns unique file paths.
|
|
234
|
+
*/
|
|
235
|
+
function parseGrepOutput(output: string): string[] {
|
|
236
|
+
const lines = output.split("\n").filter(l => l.trim())
|
|
237
|
+
const paths = new Set<string>()
|
|
238
|
+
|
|
239
|
+
for (const line of lines) {
|
|
240
|
+
// Extract path (everything before first colon, or entire line if no colon)
|
|
241
|
+
const colonIndex = line.indexOf(":")
|
|
242
|
+
const path = colonIndex >= 0 ? line.substring(0, colonIndex) : line
|
|
243
|
+
|
|
244
|
+
if (path.trim()) {
|
|
245
|
+
paths.add(path.trim())
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return Array.from(paths)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Parse glob output to extract file paths.
|
|
254
|
+
*
|
|
255
|
+
* Format: newline-separated file paths
|
|
256
|
+
* Returns unique file paths.
|
|
257
|
+
*/
|
|
258
|
+
function parseGlobOutput(output: string): string[] {
|
|
259
|
+
const lines = output.split("\n").filter(l => l.trim())
|
|
260
|
+
return Array.from(new Set(lines.map(l => l.trim())))
|
|
261
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@comfanion/usethis_search",
|
|
3
|
-
"version": "4.1.0-dev.
|
|
3
|
+
"version": "4.1.0-dev.2",
|
|
4
4
|
"description": "OpenCode plugin: semantic search with workspace injection + tool output substitution (v4.1-dev: read() substitution, dirty file tracking)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.ts",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"tools/workspace.ts",
|
|
28
28
|
"cache/manager.ts",
|
|
29
29
|
"hooks/message-before.ts",
|
|
30
|
+
"hooks/tool-substitution.ts",
|
|
30
31
|
"hooks/types.ts",
|
|
31
32
|
"vectorizer/index.ts",
|
|
32
33
|
"vectorizer/content-cleaner.ts",
|