@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.
@@ -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.1",
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",