chore: import upstream snapshot with attribution
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,355 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user