@comfanion/usethis_search 4.2.0-dev.4 → 4.3.0-dev.1

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/tools/search.ts CHANGED
@@ -178,47 +178,70 @@ function parseFilter(filter: string): {
178
178
  }
179
179
 
180
180
  export default tool({
181
- description: `Search the codebase semantically OR attach specific chunks/files to workspace.
181
+ description: `Search codebase and automatically attach relevant context to workspace.
182
182
 
183
- Three modes:
184
- 1. Semantic search (query) - Find relevant code by meaning
185
- 2. Direct chunk attach (chunkId) - Attach specific chunk by ID
186
- 3. File attach (path) - Attach all chunks from a file
183
+ Accepts any query - semantic search, file path, or chunk ID:
184
+ - "authentication logic" finds relevant code
185
+ - "docs/architecture.md" attaches file
186
+ - "src/auth.ts:chunk-5" attaches specific chunk
187
187
 
188
- Available indexes:
189
- - "code" (default) - Source code files (*.js, *.ts, *.py, *.go, etc.)
190
- - "docs" - Documentation files (*.md, *.txt, etc.)
191
- - searchAll: true - Search across all indexes
188
+ Results are optimized for context - top chunks auto-attached with expanded context
189
+ (related code, imports, class methods).
190
+
191
+ IMPORTANT: Workspace has limited token budget. Use workspace_forget() to remove
192
+ irrelevant files or old searches before adding new context.
193
+
194
+ Choose index based on what you're looking for:
195
+ - index: "code" → search source code
196
+ - index: "docs" → search documentation
197
+ - searchAll: true → search everywhere
192
198
 
193
199
  Examples:
194
200
  - search({ query: "authentication logic" })
195
201
  - search({ query: "how to deploy", index: "docs" })
196
- - search({ query: "tenant management", filter: "internal/domain/" })
197
- - search({ chunkId: "src/auth.ts:chunk-5" })
198
- - search({ path: "docs/architecture.md" })
199
- - search({ path: "src/auth.ts", index: "code" })`,
202
+ - search({ query: "docs/prd.md" }) // attach file
203
+ - search({ query: "internal/domain/", filter: "*.go" })`,
200
204
 
201
205
  args: {
202
- query: tool.schema.string().optional().describe("Semantic search query describing what you're looking for"),
203
- chunkId: tool.schema.string().optional().describe("Specific chunk ID to attach (e.g. 'src/auth.ts:chunk-5')"),
204
- path: tool.schema.string().optional().describe("File path to attach all chunks from (e.g. 'docs/architecture.md')"),
205
- index: tool.schema.string().optional().default("code").describe("Index to search: code, docs"),
206
- limit: tool.schema.number().optional().describe("Number of results (default from config, typically 10)"),
207
- searchAll: tool.schema.boolean().optional().default(false).describe("Search all indexes instead of just one"),
208
- filter: tool.schema.string().optional().describe("Filter results by path or language. Examples: 'internal/domain/', '*.go', 'internal/**/*.go', 'service'"),
206
+ query: tool.schema.string().describe("What to search: semantic query, file path, or chunk ID"),
207
+ index: tool.schema.string().optional().default("code").describe("Where to search: 'code', 'docs', or leave empty for auto-detect"),
208
+ limit: tool.schema.number().optional().describe("Max results (default: 10)"),
209
+ searchAll: tool.schema.boolean().optional().default(false).describe("Search all indexes instead of one"),
210
+ filter: tool.schema.string().optional().describe("Filter by path/language: 'internal/domain/', '*.go', 'service'"),
209
211
  },
210
212
 
211
213
  async execute(args) {
212
214
  const projectRoot = process.cwd()
213
215
 
214
216
  try {
215
- // Validate: exactly one of query, chunkId, or path must be specified
216
- const modes = [args.query, args.chunkId, args.path].filter(x => x !== undefined)
217
- if (modes.length === 0) {
218
- return `Error: Must specify one of: query (semantic search), chunkId (direct attach), or path (file attach)\n\nExamples:\n- search({ query: "authentication" })\n- search({ chunkId: "src/auth.ts:chunk-5" })\n- search({ path: "docs/architecture.md" })`
217
+ if (!args.query) {
218
+ return `Error: query is required\n\nExamples:\n- search({ query: "authentication logic" })\n- search({ query: "docs/architecture.md" })\n- search({ query: "src/auth.ts:chunk-5" })`
219
+ }
220
+
221
+ // Auto-detect mode from query
222
+ let mode: "chunkId" | "path" | "semantic"
223
+ let chunkId: string | undefined
224
+ let filePath: string | undefined
225
+ let semanticQuery: string | undefined
226
+
227
+ // 1. Check if it's a chunk ID (contains ":chunk-")
228
+ if (args.query.includes(":chunk-")) {
229
+ mode = "chunkId"
230
+ chunkId = args.query
231
+ }
232
+ // 2. Check if it's a file path (has extension or starts with common paths)
233
+ else if (
234
+ args.query.match(/\.(md|ts|js|go|py|tsx|jsx|rs|java|kt|swift|txt|yaml|json|yml|toml)$/i) ||
235
+ args.query.match(/^(src|docs|internal|pkg|lib|app|pages|components|api)\//i) ||
236
+ args.query.includes("/")
237
+ ) {
238
+ mode = "path"
239
+ filePath = args.query
219
240
  }
220
- if (modes.length > 1) {
221
- return `Error: Specify only ONE of: query, chunkId, or path (got ${modes.length})`
241
+ // 3. Otherwise, it's a semantic search
242
+ else {
243
+ mode = "semantic"
244
+ semanticQuery = args.query
222
245
  }
223
246
 
224
247
  // Load config defaults (parsed from vectorizer.yaml)
@@ -234,23 +257,23 @@ Examples:
234
257
  // ══════════════════════════════════════════════════════════════════════
235
258
  // MODE 1: Direct chunk attach by chunkId
236
259
  // ══════════════════════════════════════════════════════════════════════
237
- if (args.chunkId) {
260
+ if (mode === "chunkId") {
238
261
  const indexer = await getIndexer(projectRoot, indexName)
239
262
  try {
240
- const chunk = await indexer.findChunkById(args.chunkId)
263
+ const chunk = await indexer.findChunkById(chunkId!)
241
264
  if (!chunk) {
242
- return `Chunk "${args.chunkId}" not found in index "${indexName}".\n\nMake sure:\n1. The file is indexed\n2. The chunk ID is correct (format: "path:chunk-N")\n3. You're searching the right index`
265
+ return `Chunk "${chunkId}" not found in index "${indexName}".\n\nMake sure:\n1. The file is indexed\n2. The chunk ID is correct (format: "path:chunk-N")\n3. You're searching the right index`
243
266
  }
244
267
 
245
268
  // Attach to workspace
246
269
  workspaceCache.attach({
247
- chunkId: args.chunkId,
270
+ chunkId: chunkId!,
248
271
  path: chunk.file,
249
272
  content: chunk.content,
250
273
  chunkIndex: chunk.chunk_index ?? 0,
251
274
  role: "manual",
252
275
  attachedAt: Date.now(),
253
- attachedBy: `direct:${args.chunkId}`,
276
+ attachedBy: `direct:${chunkId}`,
254
277
  metadata: {
255
278
  language: chunk.language,
256
279
  function_name: chunk.function_name,
@@ -263,8 +286,8 @@ Examples:
263
286
 
264
287
  workspaceCache.save().catch(() => {})
265
288
 
266
- const entry = workspaceCache.get(args.chunkId)!
267
- return `✓ Attached chunk to workspace\n\nChunk: ${args.chunkId}\nFile: ${chunk.file}\nTokens: ${entry.tokens.toLocaleString()}\nLanguage: ${chunk.language}\nLines: ${chunk.start_line}-${chunk.end_line}\n\nWorkspace: ${workspaceCache.size} chunks, ${workspaceCache.totalTokens.toLocaleString()} tokens`
289
+ const entry = workspaceCache.get(chunkId!)!
290
+ return `✓ Attached chunk to workspace\n\nChunk: ${chunkId}\nFile: ${chunk.file}\nTokens: ${entry.tokens.toLocaleString()}\nLanguage: ${chunk.language}\nLines: ${chunk.start_line}-${chunk.end_line}\n\nWorkspace: ${workspaceCache.size} chunks, ${workspaceCache.totalTokens.toLocaleString()} tokens`
268
291
  } finally {
269
292
  releaseIndexer(projectRoot, indexName)
270
293
  }
@@ -273,27 +296,27 @@ Examples:
273
296
  // ══════════════════════════════════════════════════════════════════════
274
297
  // MODE 2: File attach by path (all chunks)
275
298
  // ══════════════════════════════════════════════════════════════════════
276
- if (args.path) {
299
+ if (mode === "path") {
277
300
  const indexer = await getIndexer(projectRoot, indexName)
278
301
  try {
279
- const chunks = await indexer.findChunksByPath(args.path)
302
+ const chunks = await indexer.findChunksByPath(filePath!)
280
303
  if (chunks.length === 0) {
281
- return `No chunks found for file "${args.path}" in index "${indexName}".\n\nMake sure:\n1. The file exists and is indexed\n2. The path is correct (relative to project root)\n3. You're searching the right index\n\nRun: bunx usethis_search reindex`
304
+ return `No chunks found for file "${filePath}" in index "${indexName}".\n\nMake sure:\n1. The file exists and is indexed\n2. The path is correct (relative to project root)\n3. You're searching the right index\n\nRun: bunx usethis_search reindex`
282
305
  }
283
306
 
284
307
  // Attach all chunks to workspace
285
308
  let totalTokens = 0
286
309
  for (const chunk of chunks) {
287
- const chunkId = chunk.chunk_id || `${args.path}:chunk-${chunk.chunk_index ?? 0}`
310
+ const chunkIdForChunk = chunk.chunk_id || `${filePath}:chunk-${chunk.chunk_index ?? 0}`
288
311
 
289
312
  workspaceCache.attach({
290
- chunkId,
291
- path: args.path,
313
+ chunkId: chunkIdForChunk,
314
+ path: filePath!,
292
315
  content: chunk.content,
293
316
  chunkIndex: chunk.chunk_index ?? 0,
294
317
  role: "manual",
295
318
  attachedAt: Date.now(),
296
- attachedBy: `file:${args.path}`,
319
+ attachedBy: `file:${filePath}`,
297
320
  metadata: {
298
321
  language: chunk.language,
299
322
  function_name: chunk.function_name,
@@ -304,13 +327,13 @@ Examples:
304
327
  },
305
328
  })
306
329
 
307
- const entry = workspaceCache.get(chunkId)!
330
+ const entry = workspaceCache.get(chunkIdForChunk)!
308
331
  totalTokens += entry.tokens
309
332
  }
310
333
 
311
334
  workspaceCache.save().catch(() => {})
312
335
 
313
- return `✓ Attached file to workspace\n\nFile: ${args.path}\nChunks: ${chunks.length}\nTokens: ${totalTokens.toLocaleString()}\nLanguage: ${chunks[0].language}\n\nWorkspace: ${workspaceCache.size} chunks, ${workspaceCache.totalTokens.toLocaleString()} tokens`
336
+ return `✓ Attached file to workspace\n\nFile: ${filePath}\nChunks: ${chunks.length}\nTokens: ${totalTokens.toLocaleString()}\nLanguage: ${chunks[0].language}\n\nWorkspace: ${workspaceCache.size} chunks, ${workspaceCache.totalTokens.toLocaleString()} tokens`
314
337
  } finally {
315
338
  releaseIndexer(projectRoot, indexName)
316
339
  }
@@ -352,7 +375,7 @@ Examples:
352
375
  for (const idx of indexes) {
353
376
  const indexer = await getIndexer(projectRoot, idx)
354
377
  try {
355
- const results = await indexer.search(args.query, limit, includeArchived, searchOptions)
378
+ const results = await indexer.search(semanticQuery!, limit, includeArchived, searchOptions)
356
379
  allResults.push(...results.map((r: any) => ({ ...r, _index: idx })))
357
380
  } finally {
358
381
  releaseIndexer(projectRoot, idx)
@@ -390,14 +413,14 @@ Examples:
390
413
 
391
414
  if (available.length > 0) {
392
415
  const list = available.map(i => `"${i}"`).join(", ")
393
- return `Index "${indexName}" not found. Available indexes: ${list}.\n\nTry: search({ query: "${args.query}", index: "${available[0]}" })\nOr search all: search({ query: "${args.query}", searchAll: true })`
416
+ return `Index "${indexName}" not found. Available indexes: ${list}.\n\nTry: search({ query: "${semanticQuery}", index: "${available[0]}" })\nOr search all: search({ query: "${semanticQuery}", searchAll: true })`
394
417
  }
395
418
  return `No indexes found. The codebase needs to be indexed first.\n\nRun the CLI: bunx usethis_search reindex`
396
419
  }
397
420
 
398
421
  const indexer = await getIndexer(projectRoot, indexName)
399
422
  try {
400
- const results = await indexer.search(args.query, limit, includeArchived, searchOptions)
423
+ const results = await indexer.search(semanticQuery!, limit, includeArchived, searchOptions)
401
424
  allResults = results.map((r: any) => ({ ...r, _index: indexName }))
402
425
  } finally {
403
426
  releaseIndexer(projectRoot, indexName)
@@ -411,20 +434,58 @@ Examples:
411
434
  })
412
435
 
413
436
  // ── Filter — apply path/language constraints from `filter` param ───────
437
+ // Strategy: Try strict filter first, fallback to relaxed if too few results
438
+ const unfilteredResults = [...allResults]
439
+ let filterApplied = false
440
+ let filterRelaxed = false
441
+
414
442
  if (filterParsed.pathPrefix) {
415
443
  const prefix = filterParsed.pathPrefix
416
- allResults = allResults.filter(r => r.file && r.file.startsWith(prefix))
444
+ const strictFiltered = allResults.filter(r => r.file && r.file.startsWith(prefix))
445
+
446
+ // Fallback: if strict gives < 3 results, try "contains" instead of "startsWith"
447
+ if (strictFiltered.length < 3 && allResults.length > strictFiltered.length) {
448
+ const relaxedFiltered = allResults.filter(r => r.file && r.file.includes(prefix))
449
+ if (relaxedFiltered.length > strictFiltered.length) {
450
+ allResults = relaxedFiltered
451
+ filterRelaxed = true
452
+ } else {
453
+ allResults = strictFiltered
454
+ }
455
+ } else {
456
+ allResults = strictFiltered
457
+ }
458
+ filterApplied = true
417
459
  }
460
+
418
461
  if (filterParsed.pathContains) {
419
462
  const needle = filterParsed.pathContains.toLowerCase()
420
463
  allResults = allResults.filter(r => r.file && r.file.toLowerCase().includes(needle))
464
+ filterApplied = true
421
465
  }
466
+
422
467
  if (filterParsed.language) {
423
- allResults = allResults.filter(r => !r.language || r.language === filterParsed.language || r.language === "unknown")
468
+ const strictFiltered = allResults.filter(r => r.language === filterParsed.language)
469
+
470
+ // Fallback: if strict language filter gives < 3 results, include "unknown" language
471
+ if (strictFiltered.length < 3 && allResults.length > strictFiltered.length) {
472
+ const relaxedFiltered = allResults.filter(r =>
473
+ !r.language || r.language === filterParsed.language || r.language === "unknown"
474
+ )
475
+ if (relaxedFiltered.length > strictFiltered.length) {
476
+ allResults = relaxedFiltered
477
+ filterRelaxed = true
478
+ } else {
479
+ allResults = strictFiltered
480
+ }
481
+ } else {
482
+ allResults = strictFiltered
483
+ }
484
+ filterApplied = true
424
485
  }
425
486
 
426
487
  // ── Reranking — boost results where query keywords appear in text ──────
427
- const queryKeywords = args.query.toLowerCase().split(/\s+/).filter((w: string) => w.length > 2)
488
+ const queryKeywords = semanticQuery!.toLowerCase().split(/\s+/).filter((w: string) => w.length > 2)
428
489
  for (const r of allResults) {
429
490
  const isBM25Only = !!r._bm25Only
430
491
  const vectorScore = r._distance != null ? Math.max(0, 1 - r._distance / 2) : 0
@@ -454,7 +515,7 @@ Examples:
454
515
  if (topChunks.length === 0) {
455
516
  const scope = args.searchAll ? "any index" : `index "${indexName}"`
456
517
  const filterNote = args.filter ? ` with filter "${args.filter}"` : ""
457
- return `No results found in ${scope}${filterNote} for: "${args.query}" (min score: ${minScore})\n\nTry:\n- Different keywords or phrasing\n- Remove or broaden the filter\n- search({ query: "...", searchAll: true })`
518
+ return `No results found in ${scope}${filterNote} for: "${semanticQuery}" (min score: ${minScore})\n\nTry:\n- Different keywords or phrasing\n- Remove or broaden the filter\n- search({ query: "...", searchAll: true })`
458
519
  }
459
520
 
460
521
  // ══════════════════════════════════════════════════════════════════════
@@ -479,15 +540,15 @@ Examples:
479
540
  // Attach main chunk
480
541
  const chunkId = chunk.chunkId || `${chunk.file}:chunk-${chunk.index ?? 0}`
481
542
 
482
- workspaceCache.attach({
483
- chunkId,
484
- path: chunk.file,
485
- content: chunk.content,
486
- chunkIndex: chunk.index ?? 0,
487
- role: "search-main",
488
- attachedAt: Date.now(),
489
- attachedBy: args.query,
490
- score: chunk._finalScore,
543
+ workspaceCache.attach({
544
+ chunkId,
545
+ path: chunk.file,
546
+ content: chunk.content,
547
+ chunkIndex: chunk.index ?? 0,
548
+ role: "search-main",
549
+ attachedAt: Date.now(),
550
+ attachedBy: semanticQuery!,
551
+ score: chunk._finalScore,
491
552
  metadata: {
492
553
  language: chunk.language,
493
554
  function_name: chunk.function_name,
@@ -511,14 +572,14 @@ Examples:
511
572
  // Check budget before adding
512
573
  if (workspaceCache.size >= wsConfig.maxChunks) break
513
574
 
514
- workspaceCache.attach({
515
- chunkId: expChunkId,
516
- path: expChunk.file,
517
- content: expChunk.content,
518
- chunkIndex: expChunk.chunk_index ?? 0,
519
- role: "search-context",
520
- attachedAt: Date.now(),
521
- attachedBy: `${args.query} (${reason})`,
575
+ workspaceCache.attach({
576
+ chunkId: expChunkId,
577
+ path: expChunk.file,
578
+ content: expChunk.content,
579
+ chunkIndex: expChunk.chunk_index ?? 0,
580
+ role: "search-context",
581
+ attachedAt: Date.now(),
582
+ attachedBy: `${semanticQuery} (${reason})`,
522
583
  score: chunk._finalScore * 0.9, // Slightly lower score than main
523
584
  metadata: {
524
585
  language: expChunk.language,
@@ -550,14 +611,14 @@ Examples:
550
611
  const relChunkId = rel.chunkId || `${rel.file}:chunk-${rel.index ?? 0}`
551
612
  if (alreadyAttached.has(relChunkId)) continue
552
613
 
553
- workspaceCache.attach({
554
- chunkId: relChunkId,
555
- path: rel.file,
556
- content: rel.content,
557
- chunkIndex: rel.index ?? 0,
558
- role: "search-graph",
559
- attachedAt: Date.now(),
560
- attachedBy: `${args.query} (${rel.relation} from ${chunkId})`,
614
+ workspaceCache.attach({
615
+ chunkId: relChunkId,
616
+ path: rel.file,
617
+ content: rel.content,
618
+ chunkIndex: rel.index ?? 0,
619
+ role: "search-graph",
620
+ attachedAt: Date.now(),
621
+ attachedBy: `${semanticQuery} (${rel.relation} from ${chunkId})`,
561
622
  score: rel.score,
562
623
  metadata: {
563
624
  language: rel.language,
@@ -590,12 +651,16 @@ Examples:
590
651
  const hasBM25Only = allResults.some((r: any) => r._bm25Only)
591
652
  const scope = args.searchAll ? "all indexes" : `index "${indexName}"`
592
653
  const filterLabel = args.filter ? ` filter:"${args.filter}"` : ""
593
- let output = `## Search: "${args.query}" (${scope}${filterLabel})\n\n`
654
+ let output = `## Search: "${semanticQuery}" (${scope}${filterLabel})\n\n`
594
655
 
595
656
  if (hasBM25Only) {
596
657
  output += `> **BM25-only mode** -- vector embeddings not yet available. Quality will improve after embedding completes.\n\n`
597
658
  }
598
659
 
660
+ if (filterRelaxed) {
661
+ output += `> **Filter relaxed.** Strict filter gave too few results. Showing broader matches.\n\n`
662
+ }
663
+
599
664
  if (topScore < 0.45) {
600
665
  output += `> **Low confidence.** Best score: ${topScore.toFixed(3)}. Try more specific keywords.\n\n`
601
666
  }
@@ -155,102 +155,77 @@ export const workspace_list = tool({
155
155
  },
156
156
  })
157
157
 
158
- // ── workspace.attach ────────────────────────────────────────────────────────
158
+ // ── workspace.forget ────────────────────────────────────────────────────────
159
159
 
160
- export const workspace_attach = tool({
161
- description: `Manually attach a file to workspace context as a single chunk. The file will be visible in context injection without needing read().`,
160
+ export const workspace_forget = tool({
161
+ description: `Remove chunks from workspace context to optimize context size and focus.
162
162
 
163
- args: {
164
- filePath: tool.schema.string().describe("Relative file path to attach (e.g. 'src/auth/login.ts')"),
165
- },
163
+ IMPORTANT: Regularly clean up workspace by removing irrelevant files or old search results.
164
+ This keeps context focused and prevents token budget overflow.
166
165
 
167
- async execute(args) {
168
- const projectRoot = process.cwd()
169
-
170
- // Read file content
171
- try {
172
- const fullPath = path.join(projectRoot, args.filePath)
173
- const content = await fs.readFile(fullPath, "utf-8")
174
-
175
- // Generate chunkId for manual attachment: "path:chunk-0"
176
- const chunkId = `${args.filePath}:chunk-0`
177
-
178
- // Check if already attached
179
- if (workspaceCache.has(args.filePath)) {
180
- const existing = workspaceCache.getChunksByPath(args.filePath)
181
- if (existing.length > 0) {
182
- const first = existing[0]
183
- const totalTokens = existing.reduce((sum, c) => sum + c.tokens, 0)
184
- return `File "${args.filePath}" is already in workspace (${existing.length} chunk${existing.length > 1 ? "s" : ""}).\nTokens: ${totalTokens.toLocaleString()} | Role: ${first.role} | Score: ${first.score?.toFixed(3) ?? "n/a"}`
185
- }
186
- }
166
+ Auto-detects what to remove based on input:
167
+ - Chunk ID: "src/auth.ts:chunk-5"
168
+ - File path: "docs/architecture.md" (removes ALL chunks)
169
+ - Search query: "authentication logic" (removes chunks from this search)
170
+ - Age: "5" (removes chunks older than 5 minutes)
187
171
 
188
- workspaceCache.attach({
189
- chunkId,
190
- path: args.filePath,
191
- content,
192
- chunkIndex: 0,
193
- role: "manual",
194
- attachedAt: Date.now(),
195
- attachedBy: "manual",
196
- })
197
-
198
- const entry = workspaceCache.get(chunkId)!
199
- return `Attached "${args.filePath}" to workspace as single chunk.\nChunkId: ${chunkId}\nTokens: ${entry.tokens.toLocaleString()}\nWorkspace total: ${workspaceCache.totalTokens.toLocaleString()} tokens (${workspaceCache.size} chunks)`
200
- } catch (error: any) {
201
- return `Failed to attach "${args.filePath}": ${error.message || String(error)}`
202
- }
203
- },
204
- })
205
-
206
- // ── workspace.detach ────────────────────────────────────────────────────────
207
-
208
- export const workspace_detach = tool({
209
- description: `Remove chunks from workspace context. Can detach by chunkId, by file path (removes ALL chunks of that file), by search query, or by age.`,
172
+ Examples:
173
+ - workspace_forget({ what: "docs/prd.md" })
174
+ - workspace_forget({ what: "5" }) // older than 5 min
175
+ - workspace_forget({ what: "src/auth.ts:chunk-3" })`,
210
176
 
211
177
  args: {
212
- chunkId: tool.schema.string().optional().describe("Specific chunk ID to remove (e.g. 'src/auth.ts:chunk-5')"),
213
- filePath: tool.schema.string().optional().describe("File path to remove (removes ALL chunks of that file)"),
214
- query: tool.schema.string().optional().describe("Remove all chunks attached by this search query"),
215
- olderThan: tool.schema.number().optional().describe("Remove chunks older than N minutes"),
178
+ what: tool.schema.string().describe("What to forget: chunk ID, file path, search query, or age in minutes"),
216
179
  },
217
180
 
218
181
  async execute(args) {
219
182
  let removed = 0
220
-
221
- if (args.chunkId) {
222
- // Detach specific chunk by chunkId
223
- const entry = workspaceCache.get(args.chunkId)
224
-
183
+
184
+ // Auto-detect what to remove
185
+ // 1. Check if it's a chunk ID (contains ":chunk-")
186
+ if (args.what.includes(":chunk-")) {
187
+ const entry = workspaceCache.get(args.what)
225
188
  if (!entry) {
226
- return `Chunk "${args.chunkId}" not found in workspace.`
189
+ return `Chunk "${args.what}" not found in workspace.`
227
190
  }
228
-
229
- removed = workspaceCache.detach(args.chunkId) ? 1 : 0
191
+ removed = workspaceCache.detach(args.what) ? 1 : 0
230
192
  if (removed === 0) {
231
- return `Failed to remove chunk "${args.chunkId}".`
193
+ return `Failed to remove chunk "${args.what}".`
232
194
  }
233
- } else if (args.filePath) {
234
- // Detach all chunks of a file
235
- const fileChunks = workspaceCache.getChunksByPath(args.filePath)
236
-
195
+ return `Removed chunk "${args.what}" from workspace.\nWorkspace: ${workspaceCache.size} chunks, ${workspaceCache.totalTokens.toLocaleString()} tokens`
196
+ }
197
+
198
+ // 2. Check if it's a number (age in minutes)
199
+ const ageMatch = args.what.match(/^(\d+)$/)
200
+ if (ageMatch) {
201
+ const minutes = parseInt(ageMatch[1], 10)
202
+ removed = workspaceCache.detachOlderThan(minutes * 60 * 1000)
203
+ return `Removed ${removed} chunk(s) older than ${minutes} minutes.\nWorkspace: ${workspaceCache.size} chunks, ${workspaceCache.totalTokens.toLocaleString()} tokens`
204
+ }
205
+
206
+ // 3. Check if it's a file path (has extension or common path prefixes)
207
+ if (
208
+ args.what.match(/\.(md|ts|js|go|py|tsx|jsx|rs|java|kt|swift|txt|yaml|json|yml|toml)$/i) ||
209
+ args.what.match(/^(src|docs|internal|pkg|lib|app|pages|components|api)\//i) ||
210
+ args.what.includes("/")
211
+ ) {
212
+ const fileChunks = workspaceCache.getChunksByPath(args.what)
237
213
  if (fileChunks.length === 0) {
238
- return `File "${args.filePath}" not found in workspace.`
214
+ return `File "${args.what}" not found in workspace.`
239
215
  }
240
-
241
- removed = workspaceCache.detachByPath(args.filePath)
216
+ removed = workspaceCache.detachByPath(args.what)
242
217
  if (removed === 0) {
243
- return `Failed to remove chunks from "${args.filePath}".`
218
+ return `Failed to remove chunks from "${args.what}".`
244
219
  }
245
- } else if (args.query) {
246
- removed = workspaceCache.detachByQuery(args.query)
247
- } else if (args.olderThan) {
248
- removed = workspaceCache.detachOlderThan(args.olderThan * 60 * 1000)
249
- } else {
250
- return `Specify chunkId, filePath, query, or olderThan to detach chunks.`
220
+ return `Removed ${removed} chunk(s) from "${args.what}".\nWorkspace: ${workspaceCache.size} chunks, ${workspaceCache.totalTokens.toLocaleString()} tokens`
251
221
  }
252
-
253
- return `Removed ${removed} chunk(s) from workspace.\nWorkspace: ${workspaceCache.size} chunks, ${workspaceCache.totalTokens.toLocaleString()} tokens`
222
+
223
+ // 4. Otherwise, treat as search query
224
+ removed = workspaceCache.detachByQuery(args.what)
225
+ if (removed === 0) {
226
+ return `No chunks found attached by query "${args.what}".\n\nTip: Use workspace_list() to see what's in workspace.`
227
+ }
228
+ return `Removed ${removed} chunk(s) from search "${args.what}".\nWorkspace: ${workspaceCache.size} chunks, ${workspaceCache.totalTokens.toLocaleString()} tokens`
254
229
  },
255
230
  })
256
231