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
356 lines
12 KiB
TypeScript
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)
|
|
}
|