@sema-agent/core
Version:
Stateless, task-oriented AI agent core
345 lines (344 loc) • 23.2 kB
TypeScript
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 {};