|
| 1 | +import { sleep } from '@sim/utils/helpers' |
1 | 2 | import { isRecordLike } from '@sim/utils/object' |
2 | 3 | import type { ReadFileTextResponse } from 'sim/embed' |
3 | 4 | import { listCatalogTools } from '@/lib/catalog/application/list-tools' |
4 | 5 | import { readBlockCatalog } from '@/lib/catalog/application/read-block-catalog' |
| 6 | +import { compileLinearRegex, isPlainText, literalRegex } from '@/lib/core/security/linear-regex' |
5 | 7 | import { enginePrincipal } from '@/lib/mothership/agent-cli/engine-principal' |
6 | 8 | import { |
7 | 9 | type AgentCliEngine, |
@@ -46,6 +48,9 @@ const CATALOG_ALL = 100_000 |
46 | 48 | const MAX_FILES = 300 |
47 | 49 | const FILE_READ_CONCURRENCY = 5 |
48 | 50 | const MAX_BYTES_PER_FILE = 262_144 |
| 51 | +/** Bound scanning after materialization without charging backend read latency. */ |
| 52 | +const MAX_SCAN_TIME_MS = 5_000 |
| 53 | +const SCAN_YIELD_INTERVAL_MS = 10 |
49 | 54 | /** `workflow:<uuid>` — a prefixed form no world or resource ever prints as its path. */ |
50 | 55 | const PREFIX_SELECTOR = /^\w+:/ |
51 | 56 | const PLATFORM_SCOPES: ReadonlySet<Scope> = new Set<Scope>(['blocks', 'tools']) |
@@ -416,13 +421,12 @@ async function materializeWithin( |
416 | 421 | } |
417 | 422 |
|
418 | 423 | function compilePattern(raw: string, ignoreCase: boolean): (line: string) => boolean { |
419 | | - try { |
420 | | - const regex = new RegExp(raw, ignoreCase ? 'i' : '') |
421 | | - return (line) => regex.test(line) |
422 | | - } catch { |
423 | | - const needle = ignoreCase ? raw.toLowerCase() : raw |
424 | | - return (line) => (ignoreCase ? line.toLowerCase() : line).includes(needle) |
425 | | - } |
| 424 | + const regex = isPlainText(raw) |
| 425 | + ? literalRegex(raw, { ignoreCase }) |
| 426 | + : compileLinearRegex(raw, { ignoreCase }) |
| 427 | + if (regex) return (line) => regex.test(line) |
| 428 | + const needle = ignoreCase ? raw.toLowerCase() : raw |
| 429 | + return (line) => (ignoreCase ? line.toLowerCase() : line).includes(needle) |
426 | 430 | } |
427 | 431 |
|
428 | 432 | function clip(line: string): string { |
@@ -570,31 +574,51 @@ export const universalGrepCommand: AgentCliEngine = { |
570 | 574 | let total = 0 |
571 | 575 | let shownMatches = 0 |
572 | 576 | const perScope = new Map<Scope, number>() |
| 577 | + const scanStartedAt = performance.now() |
| 578 | + let lastYieldAt = scanStartedAt |
| 579 | + const checkScan = () => { |
| 580 | + runtime.signal?.throwIfAborted() |
| 581 | + const now = performance.now() |
| 582 | + if (now - scanStartedAt >= MAX_SCAN_TIME_MS) { |
| 583 | + throw new Error( |
| 584 | + 'Search incomplete: scanning exceeded the time budget. Narrow with --scope or --in.' |
| 585 | + ) |
| 586 | + } |
| 587 | + return now |
| 588 | + } |
573 | 589 | for (const resource of candidates) { |
574 | 590 | if (resource.text === null) continue |
575 | 591 | const lines = resource.text.split('\n') |
576 | | - const selected = new Set<number>() |
| 592 | + const selected = new Map<number, boolean>() |
| 593 | + const remainingLines = limit - out.length |
| 594 | + let nextContextLine = 0 |
577 | 595 | for (let i = 0; i < lines.length; i++) { |
| 596 | + if (checkScan() - lastYieldAt >= SCAN_YIELD_INTERVAL_MS) { |
| 597 | + await sleep(0) |
| 598 | + lastYieldAt = checkScan() |
| 599 | + } |
578 | 600 | if (!matches(lines[i])) continue |
579 | 601 | total++ |
580 | 602 | perScope.set(resource.scope, (perScope.get(resource.scope) ?? 0) + 1) |
581 | 603 | if (countOnly) continue |
582 | 604 | for ( |
583 | | - let j = Math.max(0, i - context.before); |
584 | | - j <= Math.min(lines.length - 1, i + context.after); |
| 605 | + let j = Math.max(nextContextLine, i - context.before); |
| 606 | + j <= Math.min(lines.length - 1, i + context.after) && selected.size < remainingLines; |
585 | 607 | j++ |
586 | 608 | ) { |
587 | | - selected.add(j) |
| 609 | + selected.set(j, false) |
| 610 | + nextContextLine = j + 1 |
588 | 611 | } |
| 612 | + if (selected.has(i)) selected.set(i, true) |
589 | 613 | } |
590 | 614 | if (countOnly || selected.size === 0 || out.length >= limit) continue |
591 | 615 | const header = `${resource.scope}/${resource.label}${resource.label === resource.id ? '' : ` (${resource.id})`}` |
592 | | - for (const i of [...selected].sort((a, b) => a - b)) { |
593 | | - if (out.length >= limit) break |
| 616 | + for (const [i, isMatch] of selected) { |
594 | 617 | out.push(`${header}:${i + 1}: ${clip(lines[i])}`) |
595 | | - if (matches(lines[i])) shownMatches++ |
| 618 | + if (isMatch) shownMatches++ |
596 | 619 | } |
597 | 620 | } |
| 621 | + checkScan() |
598 | 622 |
|
599 | 623 | if (countOnly) { |
600 | 624 | const breakdown = [...perScope.entries()].map(([s, n]) => `${s}=${n}`).join(' ') |
|
0 commit comments