Files
simstudioai--sim/apps/sim/lib/copilot/vfs/operations.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

356 lines
12 KiB
TypeScript

import micromatch from 'micromatch'
export interface GrepMatch {
path: string
line: number
content: string
}
export type GrepOutputMode = 'content' | 'files_with_matches' | 'count'
export interface GrepOptions {
maxResults?: number
outputMode?: GrepOutputMode
ignoreCase?: boolean
lineNumbers?: boolean
context?: number
}
export interface GrepCountEntry {
path: string
count: number
}
/**
* Thrown when a single-file content grep (see `WorkspaceVFS.grepFile`) hits an
* expected, user-facing condition: the path is not a single workspace file, the
* file has no searchable text (image/binary), or it exceeds the inline read cap.
* The grep handler surfaces the message verbatim instead of treating it as an
* internal failure. Defined here (rather than in `workspace-vfs.ts`) so the
* handler can reference it without pulling in the VFS module's heavy deps.
*/
export class WorkspaceFileGrepError extends Error {
readonly code = 'WORKSPACE_FILE_GREP' as const
constructor(message: string) {
super(message)
this.name = 'WorkspaceFileGrepError'
}
}
/**
* True when file content is one of `readFileRecord`'s non-text placeholders
* (binary, unparseable, or over the inline read cap) — these carry no searchable
* content, so grepping them should report the placeholder instead.
*/
function isNonGreppablePlaceholder(content: string, totalLines: number): boolean {
if (totalLines !== 1) return false
return /^\[(File too large|Image too large|Document too large|Could not parse|Binary file|Compiled artifact too large)/.test(
content.trim()
)
}
/**
* Run a single-file content grep over an already-resolved file read result,
* shared by workspace-file grep (`WorkspaceVFS.grepFile`) and chat-upload grep.
* Throws {@link WorkspaceFileGrepError} when the file has no searchable text
* (image/binary attachment) or is a size/parse placeholder; otherwise greps the
* text with the standard {@link grep} engine over a one-entry map keyed by
* `path`. `readHint` is the path to suggest in the "use read(...)" message.
*/
export function grepReadResult(
path: string,
result: { content: string; totalLines: number; attachment?: unknown },
pattern: string,
readHint: string,
options?: GrepOptions
): GrepMatch[] | string[] | GrepCountEntry[] {
if (result.attachment) {
throw new WorkspaceFileGrepError(
`Cannot grep "${path}" — it has no searchable text (image/binary). Use read("${readHint}") to view it.`
)
}
if (isNonGreppablePlaceholder(result.content, result.totalLines)) {
throw new WorkspaceFileGrepError(result.content)
}
return grep(new Map([[path, result.content]]), pattern, undefined, options)
}
export interface ReadResult {
content: string
totalLines: number
}
/**
* Micromatch options tuned to match the prior in-house glob: `bash: false` so a single `*`
* never crosses path slashes (required for `files` + star + `meta.json` style paths). `nobrace`
* and `noext` disable brace and extglob expansion like the old builder. Uses `micromatch` for
* well-tested `**` and edge cases instead of a custom `RegExp`.
*/
const VFS_GLOB_OPTIONS: micromatch.Options = {
bash: false,
dot: false,
windows: false,
nobrace: true,
noext: true,
}
/**
* Splits VFS text into lines for line-oriented grep. Strips a trailing CR so Windows-style
* CRLF payloads still match patterns anchored at line end (`$`).
*/
function splitLinesForGrep(content: string): string[] {
return content.split('\n').map((line) => line.replace(/\r$/, ''))
}
/**
* Returns true when `filePath` is `scope` or a descendant path (`scope/...`). If `scope` contains
* `*` or `?`, filters with micromatch `isMatch` and {@link VFS_GLOB_OPTIONS}. Other characters
* (including `[`, `{`, spaces) use directory-prefix logic so literal VFS path segments are not
* parsed as glob syntax. Trailing slashes are stripped so `files/` and `files` both scope under
* `files/...`.
*
* Exported so the lazy VFS can resolve exactly the lazy artifacts a scoped grep will consider,
* keeping "what we materialize" identical to "what grep filters in".
*/
export function pathWithinGrepScope(filePath: string, scope: string): boolean {
const scopeUsesStarOrQuestionGlob = /[*?]/.test(scope)
if (scopeUsesStarOrQuestionGlob) {
return micromatch.isMatch(filePath, scope, VFS_GLOB_OPTIONS)
}
const base = scope.replace(/\/+$/, '')
if (base === '') {
return true
}
return filePath === base || filePath.startsWith(`${base}/`)
}
/**
* Regex search over VFS file contents using ECMAScript `RegExp` syntax.
* `content` and `count` are line-oriented (split on newline, CR stripped per line).
* `files_with_matches` tests the entire file string once, so multiline patterns can match there
* but not in line modes.
*/
export function grep(
files: Map<string, string>,
pattern: string,
path?: string,
opts?: GrepOptions
): GrepMatch[] | string[] | GrepCountEntry[] {
const maxResults = opts?.maxResults ?? 100
const outputMode = opts?.outputMode ?? 'content'
const ignoreCase = opts?.ignoreCase ?? false
const showLineNumbers = opts?.lineNumbers ?? true
const contextLines = opts?.context ?? 0
const flags = ignoreCase ? 'gi' : 'g'
let regex: RegExp
try {
regex = new RegExp(pattern, flags)
} catch {
return []
}
if (outputMode === 'files_with_matches') {
const matchingFiles: string[] = []
for (const [filePath, content] of files) {
if (path && !pathWithinGrepScope(filePath, path)) continue
regex.lastIndex = 0
if (regex.test(content)) {
matchingFiles.push(filePath)
if (matchingFiles.length >= maxResults) break
}
}
return matchingFiles
}
if (outputMode === 'count') {
const counts: GrepCountEntry[] = []
for (const [filePath, content] of files) {
if (path && !pathWithinGrepScope(filePath, path)) continue
const lines = splitLinesForGrep(content)
let count = 0
for (const line of lines) {
regex.lastIndex = 0
if (regex.test(line)) count++
}
if (count > 0) {
counts.push({ path: filePath, count })
if (counts.length >= maxResults) break
}
}
return counts
}
// Default: 'content' mode
const matches: GrepMatch[] = []
for (const [filePath, content] of files) {
if (path && !pathWithinGrepScope(filePath, path)) continue
const lines = splitLinesForGrep(content)
for (let i = 0; i < lines.length; i++) {
regex.lastIndex = 0
if (regex.test(lines[i])) {
if (contextLines > 0) {
const start = Math.max(0, i - contextLines)
const end = Math.min(lines.length - 1, i + contextLines)
for (let j = start; j <= end; j++) {
matches.push({
path: filePath,
line: showLineNumbers ? j + 1 : 0,
content: lines[j],
})
}
} else {
matches.push({
path: filePath,
line: showLineNumbers ? i + 1 : 0,
content: lines[i],
})
}
if (matches.length >= maxResults) return matches
}
}
}
return matches
}
/**
* Glob pattern matching against VFS file paths and virtual directories using `micromatch`
* with {@link VFS_GLOB_OPTIONS} (path-aware `*` and `?`, `**`, no brace or extglob expansion).
* Returns matching file keys and virtual directory prefixes.
*/
export function glob(files: Map<string, string>, pattern: string): string[] {
const result = new Set<string>()
const directories = new Set<string>()
for (const filePath of files.keys()) {
if (filePath.endsWith('/.folder')) {
directories.add(filePath.slice(0, -'/.folder'.length))
continue
}
const parts = filePath.split('/')
for (let i = 1; i < parts.length; i++) {
directories.add(parts.slice(0, i).join('/'))
}
}
for (const filePath of files.keys()) {
if (filePath.endsWith('/.folder')) continue
if (micromatch.isMatch(filePath, pattern, VFS_GLOB_OPTIONS)) {
result.add(filePath)
}
}
for (const dir of directories) {
if (micromatch.isMatch(dir, pattern, VFS_GLOB_OPTIONS)) {
result.add(dir)
}
}
return Array.from(result).sort()
}
/**
* Read a VFS file's content, optionally with offset and limit.
* Returns null if the file does not exist.
*/
export function read(
files: Map<string, string>,
path: string,
offset?: number,
limit?: number
): ReadResult | null {
let content = files.get(path)
// Fallback: normalize Unicode and retry for encoding mismatches
if (content === undefined) {
const normalized = path.normalize('NFC')
content = files.get(normalized)
if (content === undefined) {
for (const [key, value] of files) {
if (key.normalize('NFC') === normalized) {
content = value
break
}
}
}
}
if (content === undefined) return null
const lines = content.split('\n')
const totalLines = lines.length
if (offset !== undefined || limit !== undefined) {
const rawStart = Number.isFinite(offset) ? (offset as number) : 0
const start = Math.max(0, Math.min(totalLines, rawStart))
const rawEnd = limit !== undefined ? start + Math.max(0, limit) : totalLines
const end = Math.max(start, Math.min(totalLines, rawEnd))
return {
content: lines.slice(start, end).join('\n'),
totalLines,
}
}
return { content, totalLines }
}
/**
* Find VFS paths similar to a missing path.
*
* Handles two cases:
* 1. Wrong filename: `components/blocks/gmail.json` → `gmail_v2.json`
* Matches by filename stem similarity within the same directory.
* 2. Wrong directory: `workflows/Untitled/state.json` → `Untitled Workflow`
* Matches by parent directory name similarity with the same filename.
*/
export function suggestSimilar(files: Map<string, string>, missingPath: string, max = 5): string[] {
const segments = missingPath.split('/')
const filename = segments[segments.length - 1].toLowerCase()
const fileStem = filename.replace(/\.[^.]+$/, '')
const parentDir = segments.length >= 2 ? segments[segments.length - 2].toLowerCase() : ''
const topDir = segments.length >= 1 ? `${segments[0]}/` : ''
const scored: Array<{ path: string; score: number }> = []
for (const vfsPath of files.keys()) {
const vfsSegments = vfsPath.split('/')
const vfsFilename = vfsSegments[vfsSegments.length - 1].toLowerCase()
const vfsStem = vfsFilename.replace(/\.[^.]+$/, '')
const vfsParentDir =
vfsSegments.length >= 2 ? vfsSegments[vfsSegments.length - 2].toLowerCase() : ''
const sameTopDir = topDir && vfsPath.startsWith(topDir)
// Same filename, different directory — the directory name is wrong.
// e.g. workflows/Untitled/state.json vs workflows/Untitled Workflow/state.json
if (vfsFilename === filename && vfsParentDir !== parentDir && sameTopDir) {
if (vfsParentDir.includes(parentDir) || parentDir.includes(vfsParentDir)) {
scored.push({ path: vfsPath, score: 95 })
continue
}
}
// Same directory, different filename — the filename is wrong.
const sameDir =
segments.length === vfsSegments.length &&
segments.slice(0, -1).join('/') === vfsSegments.slice(0, -1).join('/')
if (sameDir) {
if (vfsStem === fileStem) {
scored.push({ path: vfsPath, score: 100 })
} else if (vfsStem.includes(fileStem) || fileStem.includes(vfsStem)) {
scored.push({ path: vfsPath, score: 80 })
} else if (vfsFilename.includes(fileStem.replace(/[_-]/g, ''))) {
scored.push({ path: vfsPath, score: 60 })
}
} else if (sameTopDir && vfsStem === fileStem) {
// Same top-level directory and matching stem but different depth/parent
scored.push({ path: vfsPath, score: 50 })
}
}
scored.sort((a, b) => b.score - a.score)
return scored.slice(0, max).map((s) => s.path)
}