UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

345 lines (344 loc) • 23.2 kB
import type { ExecutionEnv } from "../../internal/harness-types.js"; import { type HomeViewsCarrier } from "./read-deny-home.js"; /** The JS scanner's own output ceiling, in bytes of emitted content rows. Pinned to the * ripgrep leg's figure ({@link MAX_EXEC_OUTPUT_BYTES}, the exec pipe's rolling-tail bound) so the two * legs cannot disagree about how much one search may return; see the collection site in {@link jsGrep} * for why this leg bounds the HEAD where the pipe bounds the tail. */ export declare const JS_GREP_OUTPUT_MAX_BYTES: number; /** Directories never worth crawling — dependency/build/cache trees, PLUS the six VCS metadata * directories CC names explicitly (git/svn/mercurial/bazaar/jujutsu/sapling). See {@link buildIgnore}'s * own comment for why this list is the ENTIRE directory-skip story: there is deliberately no blanket * "any dot-prefixed name is invisible" rule beside it. */ export declare const DEFAULT_IGNORE_DIRS: ReadonlySet<string>; export interface GrepParams { /** Regular expression (ripgrep semantics) — CC 2.1.209 parity: the pattern is ALWAYS a regex; the * old `regex?: boolean` literal-by-default switch is retired (a legacy `regex` argument sent by an * old caller is tolerated and ignored at the tool layer, never a failure). */ pattern: string; /** Restrict the search to this sub-path (relative to root, or absolute within root). */ path?: string; /** Only search files matching this glob (e.g. `*.ts`, `*.{ts,tsx}`). CC parity: a * comma/whitespace-separated list (`"*.ts,*.tsx"`) is split into multiple globs (brace globs kept whole). */ glob?: string; /** CC `-o`: print only the matched (non-empty) parts of each matching line, one match per output * line (rg -o / --only-matching). content mode only; ignored otherwise (CC parity). With * `multiline` the JS fallback emits the matched SEGMENT's lines (rg -U -o shape). */ only_matching?: boolean; /** content: `path:line:text`. files_with_matches (default, CC 2.1.209): `path`. count: `path:count`. */ output_mode?: "content" | "files_with_matches" | "count"; /** Lines of context around a match (content mode only) — CC's `-C`. */ context?: number; /** Lines of context BEFORE each match (content mode) — CC's `-B`. Wins over `context` for the before-window. */ context_before?: number; /** Lines of context AFTER each match (content mode) — CC's `-A`. Wins over `context` for the after-window. */ context_after?: number; /** File type filter — CC's `type` (rg `--type`): "js", "py", "rust", "go", "ts", … The rg path uses rg's * built-in type table; the JS fallback maps common types to globs (unknown types are ignored with a note). */ type?: string; /** Case-insensitive match — CC's `-i`. */ ignore_case?: boolean; /** Match across line boundaries — CC's multiline `-U`. JS fallback approximates per-file. */ multiline?: boolean; /** Cap the number of output items (default 250) — CC's `head_limit`. */ head_limit?: number; /** Skip the first N output lines — CC's `offset` (pagination partner of head_limit). */ offset?: number; } /** CC 2.1.198 Grep parity: split the `glob` param into individual glob * tokens — whitespace-separated first; a token WITHOUT braces is further split on commas * (`"*.ts,*.tsx"` → two globs), while a brace token (`*.{ts,tsx}`) is kept whole (its commas are * alternation syntax, not separators). CC-verbatim splitting rules. */ export declare function splitGlobParam(glob: string): string[]; /** * POSIX-safe single-quote so an arbitrary model-supplied string can't break out of the shell command that * runs ripgrep. Wraps in single quotes (inside which the shell expands nothing — no `$()`, backtick, `;`, or * glob) and escapes any embedded single quote via the `'\''` idiom (close, escaped-quote, reopen). * Exported for direct injection testing. */ export declare function shellQuote(s: string): string; /** * The tokens of a `glob` argument that this engine CANNOT compile as glob syntax, i.e. the ones whose * metacharacter would silently degrade to a literal match. * * The harm this closes is specific and asymmetric. An unknown `type` degrades to searching EVERY file — * a SUPERSET of what was asked, wrong but self-evidently over-broad, and sema already discloses it with * a `[note: unknown type …]` line. An unparseable `glob` degrades the other way: `src/[ab.ts` stops * being a character class and becomes a request for a file literally NAMED `src/[ab.ts`, so the filter * silently narrows to (almost always) nothing and the search answers a clean "No matches." — a false * negative wearing the exact shape of a successful exhaustive search. This repo's standing verdict on * that class is: no real data ⇒ isError, never a degraded answer. * * CC hard-errors on the same input class rather than searching at all — its ripgrep stderr matcher * covers `error parsing glob` (alongside regex-parse / unrecognized-file-type / flag-parse errors) and * raises `Search failed — ripgrep rejected the pattern, glob, or file type without searching`. sema * rejects at the INPUT level instead of waiting for a stderr string, because sema has * two search engines (rg + the JS fallback) and only the rg one would ever produce that stderr: a * rejection that fires only when ripgrep happens to be installed is not a contract. The `type` leg * deliberately does NOT get this treatment (see the `typeNote` site) — over-broad-with-a-note is an * honest answer, silently-narrowed is not. */ export declare function invalidGlobTokens(glob: string): string[]; /** Lightweight `.gitignore` matcher (handles comments, negation, dir-only `/`, anchoring, `*`/`?`). */ export declare function gitignoreMatcher(content: string): (rel: string, isDir: boolean) => boolean; /** Build the combined ignore predicate (ignore-set ∪ .gitignore) for a JS-fallback walk. * Exported so repo-map walks with the SAME ignore semantics as grep/glob. * * CC's "keep it quiet" posture is NOT "every dot-prefixed name is invisible" — it is `--hidden` (show * everything dot-prefixed) plus an explicit VCS-directory exclusion list ({@link DEFAULT_IGNORE_DIRS}). * A blanket `base.startsWith(".")` rule here would make EVERY hidden file/dir invisible with no caveat * — `.github/`, `.claude/`, `.env.example` would not exist as far as Grep/Glob/repo-map are concerned, * not even as an honest "search skipped this" note, and an unflagged gap reads as exhaustive. So * {@link DEFAULT_IGNORE_DIRS} (dependency/build/cache trees + the 6 VCS metadata dirs) and `.gitignore` * are the entire directory-skip story, matching CC. */ export declare function buildIgnore(env: ExecutionEnv, root: string, signal?: AbortSignal): Promise<(rel: string, isDir: boolean) => boolean>; /** What a bounded walk actually saw — so consumers can be HONEST about incompleteness: silently * bounded results must never read as exhaustive. */ export interface WalkResult { /** Greppable text files (binary-extension and oversize files excluded). */ files: string[]; /** Name-only entries: binary-extension + over-{@link FILE_MAX_BYTES} files. Content search skips them * (grep semantics / memory bound), but globbing matches NAMES — these must still be visible to it * (previously `glob *.png` could never match anything). */ nameOnly: string[]; /** True when the walk hit its file-count or depth ceiling — results MAY be missing files. (Ceiling * ONLY; unreadable-dir and abort incompleteness live on their own fields below — use * {@link walkIsPartial} for the any-cause check.) */ incomplete: boolean; /** Non-binary files skipped by content search for being over {@link FILE_MAX_BYTES} (also in * `nameOnly`). Surfaced by grep so "No matches." can't silently mean "didn't look". */ skippedLarge: number; /** Directories whose listing FAILED (EACCES, transient I/O) — whole subtrees the walk never saw. */ skippedUnreadable: number; /** The walk was cut short by an abort — the result is a prefix, not a survey. */ aborted: boolean; /** CC 2.1.209 Glob parity: modification time (ms epoch) per walked path (files AND * name-only entries), captured from the listing that discovered them — so glob can sort by mtime * without a second stat pass. An env whose listings carry no numeric `mtimeMs` simply leaves * entries out of this map (consumers fall back to name order). */ mtimes: Map<string, number>; /** Entries withheld by the sensitive-path read deny list: PRUNE-ROOT count only * (a denied directory's descendants are not enumerated, so they cannot be counted — the note says * so). 0 when no deny judge was supplied or nothing matched. */ denyPruned: number; /** The deny patterns that pruned something, first-seen order (for the disclosure note). */ denyPatterns: string[]; } /** Any-cause incompleteness — a result built on this walk must not present itself as exhaustive. */ export declare function walkIsPartial(w: WalkResult): boolean; /** Bounded, ignore-aware recursive walk. Returns absolute file paths under `start`. Exported for * repo-map — same bounded/ignore-aware traversal as grep/glob, one source of truth. */ export declare function walk(env: ExecutionEnv, root: string, start: string, ignore: (rel: string, isDir: boolean) => boolean, signal?: AbortSignal, deny?: { matchPath(path: string): { pattern: string; } | null; } & HomeViewsCarrier): Promise<WalkResult>; /** The JS-leg withholding disclosure: an empty result must stay distinguishable from * searched-and-empty, so this note rides EVERY output shape when something was pruned. Distinct from * {@link walkCaveat}: pruning is deliberate withholding, not incompleteness. * The `[note:` prefix keeps it out of the tool layer's structured row extraction. */ export declare function denyWithheldNote(w: Pick<WalkResult, "denyPruned" | "denyPatterns">): string; /** Injectable execution boundaries for the JS-fallback scan (tests shrink them; callers use defaults). */ export interface JsGrepGuards { /** Wall-clock budget (ms) for matching a SINGLE file before the scan stops with a diagnosable error. */ budgetMs?: number; /** Longest line/content (chars) a gray-zone pattern may be matched against. */ longLineLimit?: number; /** Bytes of content rows this scan may accumulate before it stops collecting and * says so (default {@link JS_GREP_OUTPUT_MAX_BYTES}). A seam for the same reason the two above * are: the boundary is pinnable at a testable size instead of only at an 8MB harness. */ outputMaxBytes?: number; } /** What a traversal withheld under the sensitive-path read deny list, structured (the prose note is * the model-facing twin). Three shapes: `pruned_count` = the JS walker * counted its prune roots; `existence` = the rg leg's bounded probe CONFIRMED matching entries exist * under the scope (rg's own exclusions are silent, so the count is unknowable); `probe_failed` = * the probe itself failed — entries MAY have been withheld. */ export interface ReadDenyWithheld { kind: "pruned_count" | "existence" | "probe_failed"; count?: number; patterns?: readonly string[]; } /** The deny judge seam every search leg shares (structural subset of ReadDenyMatcher), with its placement's home views. */ export interface ReadDenyJudge extends HomeViewsCarrier { matchPath(path: string): { pattern: string; } | null; rgExclusionGlobs: ReadonlyArray<{ flag: "--glob" | "--iglob"; glob: string; }>; rgProbeGlobs: ReadonlyArray<{ flag: "--glob" | "--iglob"; glob: string; }>; /** read-deny.ts `ReadDenyMatcher.unexcludableUnder`: a scope where an entry the rg globs cannot exclude may lie. */ unexcludableUnder?(scope: string): boolean; } /** JS-fallback grep: ignore-aware walk + per-line scan, honoring output_mode / context / head_limit. * `deny` prunes the walk (note appended to every output shape); `denyOut` is the * structured twin — a sink because this function's dozen error returns predate the facts (the sink * is written the moment the walk lands, whatever the scan then returns). * * The SERVED rows ride back next to the text. This scanner knows every result's * identity natively (the walk hands it whole paths), so a row's `path` is that walked identity, * never a cut of the rendered line — the mis-split family the text re-parse carries cannot exist * here. The rows ARE the window `text` shows: post offset, post row cap, post byte ceiling, in the * same order. The unit is the RECORD, which is the unit the cap has always counted: one record is * one rendered line EXCEPT where the file's own name carries a newline, and then the one record * spans two physical lines — the same relation {@link formatRgRecords} states for the rg leg, and * precisely the case where a row's identity beats anything the text can be split into. * Every row carries a path (this leg emits no group-separator row — the one pathless shape * ripgrep's output can carry), and a no-match run's row set is `[]`, not absent. A typed * `Error (grep): …` return carries no rows at all: a refusal is not a row set. */ export declare function jsGrepDetailed(env: ExecutionEnv, root: string, p: GrepParams, signal?: AbortSignal, guards?: JsGrepGuards, deny?: ReadDenyJudge, denyOut?: { withheld?: ReadDenyWithheld; }): Promise<{ text: string; rows?: readonly GrepRow[]; }>; /** Text-only wrapper of {@link jsGrepDetailed} (the shape callers that never look at the served rows * want — the same relation {@link runGrep} has to {@link runGrepDetailed}). */ export declare function jsGrep(env: ExecutionEnv, root: string, p: GrepParams, signal?: AbortSignal, guards?: JsGrepGuards, deny?: ReadDenyJudge, denyOut?: { withheld?: ReadDenyWithheld; }): Promise<string>; /** Detect ripgrep once per env (cached). */ export declare function detectRipgrep(env: ExecutionEnv): Promise<boolean>; /** How a grep result was produced when the ripgrep leg could not complete normally. Either the * delivered text IS ripgrep's own partial output (`partial: true`), or ripgrep produced nothing * usable and the results came from the JS fallback scanner (`fallback: "js-scan"`). Absent on a * clean run (and on rg-less envs, where the JS scanner is the designed primary path, not a * degradation). */ export type GrepDegradation = { partial: true; reason: string; } | { fallback: "js-scan"; reason: string; }; /** Structured grep result: the model-facing text plus the degradation facts, so the tool layer can * ship them on the structured frame instead of leaving them prose-only. */ /** One SERVED result RECORD with its path taken from the engine's own knowledge, exactly the * window `text` shows (post cap/offset): ripgrep's `--null` path field ({@link parseRgRecords}) on * the rg legs, the walked file identity ({@link jsGrepDetailed}) on the JS-scanner legs. A record is * one rendered line except where a path carries a newline (see {@link formatRgRecords}), which is * also the case the text can no longer be split into records at all. The tool layer prefers these * over re-parsing `text` (whose `path:line:text` split mis-cuts a path that * itself contains `:digits:`). * * KNOWN EXCEPTION to "exactly the window `text` shows", stated because it is measurable: on the two * ripgrep PARTIAL legs (timeout / error-exit with output) the delivered text is fenced by * `delimitUntrusted`, which NEUTRALIZES fence markers inside the body, while the rows carry the * record bytes as ripgrep emitted them. A row's `text` there is therefore the un-neutralized form — * identity (`path`) is unaffected. Consume `text` for anything rendered; the rows' contract is * identity. Reconciling the two (defuse per record, or withhold rows where the text is fenced) is a * contract choice recorded for the backlog, not settled here. * * `path` is absent only where the record genuinely carries none — * ripgrep's `--` group separator and the unaccountable shapes listed on {@link RgRecord} — and a * row set carrying such a row sends the tool layer back to the text parse for identity. */ export interface GrepRow { path?: string; text: string; } export interface GrepRunResult { text: string; /** Served rows: present on BOTH engine legs — ripgrep's and the JS scanner's, including the * re-scan legs. Absent only when the JS scanner refused the search outright (a typed * `Error (grep): …` text): a refusal has no row set, truthfully, rather than an empty one. */ rows?: readonly GrepRow[]; degraded?: GrepDegradation; /** Deny-list withholding facts (see {@link ReadDenyWithheld}); absent = nothing * withheld / no deny judge in play. */ withheld?: ReadDenyWithheld; } /** The output modes the rg leg and {@link jsGrep} share. */ type GrepOutputMode = "content" | "files_with_matches" | "count"; /** * The rg leg's deny TRIPWIRE. rg's exclusion globs are spelled * from the pattern text and cannot express the win32 component-alias family (`.aws.` / `.aws ` — * trailing dots/spaces the win32 namespace strips), so a directory planted under an alias spelling * slips past the globs while the shared `matchPath` (two-view, read-deny.ts) correctly names it — * the JS leg pruned it, the rg leg returned its contents, and the same tree answered differently * depending on whether ripgrep was installed. * * FILTERING rg's text output line-by-line is not an option, on two counts: ripgrep prints filenames * verbatim, so a path component containing a NEWLINE splits one record across physical lines whose * fragments carry no `:line:` boundary — the filter keeps them and the deny-listed content passes (a * protection hole, not a precision residual); and pruning every line of a partial run fabricates a * `No matches.` row inside the structured card. So this function edits nothing: it JUDGES. Any deny * hit — or any record the format cannot account for — * trips, and the caller abandons the rg run for the JS scanner, whose walk prunes with the * authoritative judge and needs no path parsing at all. rg stays the fast path for the common case * (no guarded entries in the result); the moment a guarded spelling is involved, the engine that * cannot mis-parse it owns the answer. * * What it judges is ripgrep's OWN path field ({@link parseRgRecords}, `--null`), * not a prefix cut at a guessed separator. Trip conditions, all three modes alike: a record whose * path the deny judge names trips as `deny-hit`; a record with no path field at all (rg's binary-file * notice, a no-filename row, an unterminated tail, output from an env that ignored `--null`) is * unaccountable and trips as `ambiguous-record` — same abandonment, same reason code as before. * `dropIncompleteTail` mirrors {@link parseRgRecords}: on the partial legs the cut tail is dropped * BEFORE judging, because judging a record the caller will never receive can only cost an * unnecessary re-scan — every record that does reach the output is still judged, which is what the * rule is for. (A killed stream ending mid-record is an EXPECTED shape with a rule of its own; the * unaccountable arm is for records that survive that rule.) * `--` group separators and empty lines carry no path by construction and pass. The `:digits:` / * `-digits-` boundary family a guessing parser would have to reason about is gone: both of its * residuals (over-tripping on a cluster inside match TEXT, and the dedup key folding two denied files * whose own names carry a cluster) were artifacts of that decision, not of the judge. */ export declare function rgOutputDenyTripwire(stdout: string, mode: GrepOutputMode, judge: Pick<ReadDenyJudge, "matchPath">, opts?: { dropIncompleteTail?: boolean; base?: string; }): { trip: false; } | { trip: true; reason: "deny-hit" | "ambiguous-record"; pattern?: string; }; /** ripgrep grep: build flags from params, run, normalize to the same output as {@link jsGrep}. * Result-fidelity contract: output ripgrep DID produce is never silently replaced by a fallback * re-scan — partial results ship with a caveat; only a zero-output failure degrades to * {@link jsGrep}, and that degradation is declared in both text and structure. An external abort * propagates as the interruption it is (no fallback rerun). */ export declare function rgGrepDetailed(env: ExecutionEnv, root: string, p: GrepParams, signal?: AbortSignal, deny?: ReadDenyJudge): Promise<GrepRunResult>; /** grep dispatch: ripgrep when the env has it, else the hardened JS fallback. * Returns the degradation facts alongside the text so the tool layer can surface them structurally. */ export declare function runGrepDetailed(env: ExecutionEnv, root: string, p: GrepParams, signal?: AbortSignal, deny?: ReadDenyJudge): Promise<GrepRunResult>; /** Text-only wrapper of {@link runGrepDetailed}. */ export declare function runGrep(env: ExecutionEnv, root: string, p: GrepParams, signal?: AbortSignal): Promise<string>; /** glob: find files by name pattern, with optional sub-path scoping; returns RELATIVE paths. */ export declare function runGlob(env: ExecutionEnv, root: string, pattern: string, opts?: { path?: string; max?: number; }, signal?: AbortSignal): Promise<string>; /** Structured sibling of {@link runGlob}: same walk/match, plus the CC-shaped facts a * shell renders (`filenames`/`numFiles`/`truncated`/`durationMs`/`totalMatches`/`countIsComplete`) * without re-parsing the text. * * CC207 backlog P2-2 (CC 2.1.206:397761-398004 = 2.1.207:342454-342633, node-sliced verbatim): * · `totalMatches` — "Total number of matching files before truncation. A lower bound when * countIsComplete is false." (CC: `totalMatches: g.length`) * · `countIsComplete` — "Whether totalMatches is the exact total (true) or a floor because the * underlying search truncated its own output (false)." (CC: `countIsComplete: !m`, m = the rg * enumeration cut its own output; sema equivalent = the bounded walk hit its file/depth ceiling) * · `numFiles` — "Number of file paths returned (after any truncation)" (CC: `numFiles: u.length` * post-slice; was matchedAll.length here — pre-truncation total — fixed to the CC contract) * · `truncated` — CC: `y = m || g.length > n + r` (underlying-incomplete OR over the result cap). */ export declare function runGlobDetailed(env: ExecutionEnv, root: string, pattern: string, opts?: { path?: string; max?: number; deny?: ReadDenyJudge; }, signal?: AbortSignal): Promise<{ text: string; filenames: string[]; numFiles: number; truncated: boolean; durationMs: number; totalMatches: number; countIsComplete: boolean; error?: string; withheld?: ReadDenyWithheld; }>; export {};