@sema-agent/core
Version:
Stateless, task-oriented AI agent core
529 lines (528 loc) • 40 kB
TypeScript
import { Type } from "typebox";
import type { ExecutionEnv } from "../../internal/harness-types.js";
import { type FsViolation, type ReadFileState } from "./safety.js";
import { type DecodedTextFile } from "./encoding.js";
import { type HomeViewsCarrier } from "./read-deny-home.js";
import { type ImageDownsampler } from "../../core/mcp.js";
import { type EngineNotice } from "../../core/types.js";
/**
* The "hand" tool band — built per task over an injected {@link ExecutionEnv} and a per-task
* {@link ReadFileState}, closure-captured (the codebase tool idiom; no `ToolExecuteContext` growth).
* Every edit/write enforces the CC-ported safety invariants (read-before-edit, content-hash staleness,
* edit uniqueness, write-back) plus rootPath containment. The band is the file tools
* read/edit/write/grep/glob plus a shell: `bash` (effect:write, full shell) for normal tasks, or
* `bash_readonly` (effect:read, allowlisted) under the verifier read-only boundary.
*/
/** Byte cap on a single read (CC `MAX_OUTPUT_SIZE = 0.25*1024*1024`). A DEFAULT whole-file read over
* this is refused with guidance; an explicit offset/limit slice is served, which keeps
* read-before-edit satisfiable on large files. */
export declare const MAX_READ_BYTES: number;
/** Hard MEMORY upper bound on a sliced (explicit offset/limit) read. The offset/limit
* escape hatch bypasses the whole-file byte cap above, but the reader still loads the ENTIRE file into
* memory (`readBinaryFile`) before slicing by line — so without this cap, an explicit `offset:1,limit:1`
* on a multi-GB file is an OOM lever the model can pull. This bounds the bytes loaded into MEMORY, not
* the output (the output stays bounded by the token cap): files over this are refused even with an
* explicit slice, with guidance to stream a portion via bash (`sed -n` / `head -c`) instead, which
* never loads the whole file. */
export declare const SLICED_READ_MAX_BYTES: number;
/** The byte cap Edit refuses ABOVE (CC's own constant, verbatim). CC stats the target before touching
* it and refuses with a formatted-size message when it exceeds this. Without the gate, Edit goes
* straight to a whole-file `readBinaryFile` (twice: the `old_string:""` full-overwrite branch and the
* normal find/replace branch), so a multi-GB target is an unguarded OOM lever an ordinary Edit call
* can pull. The shape is Read's: a pre-read `env.fileInfo` stat plus a post-read TOCTOU recheck
* ({@link SLICED_READ_MAX_BYTES} above). */
export declare const MAX_EDIT_BYTES: number;
/** `Edit`'s human-readable byte-count formatter (CC `pl()` verbatim: bytes below 1KB, else
* KB/MB/GB with one decimal, trailing ".0" stripped). */
export declare function formatByteSize(n: number): string;
/** Node's own string-length ceiling (`buffer.constants.MAX_STRING_LENGTH` — ~512MiB on V8/Node 24)
* sits BELOW `MAX_EDIT_BYTES` (1GiB, CC's constant, which was never cross-checked against a host
* engine's actual limit). A file whose size lands between the two thresholds passes Edit's byte-count
* gate (both the pre-read `fileInfo` stat and the post-read TOCTOU recheck) and still crashes
* `decodeTextBytes`'s `Buffer#toString` with an uncaught `RangeError` — no race needed, reachable
* through the `old_string:""` branch's documented "no read-before-edit gate" path. The model would
* then see a bare, unactionable "Cannot create a string longer than…" instead of the "too large to
* edit" guidance the byte gate exists to provide. A numeric threshold pinned to today's engine
* constant would be fragile across Node/V8 versions (and would do nothing for Read's own decode call
* if a future engine lowered the ceiling further), so this catches ANY decode failure, at whatever
* byte count actually broke, and reports it exactly like the byte-count gate would have. */
export declare function decodeEditBytes(bytes: Uint8Array, path: string): {
ok: true;
value: DecodedTextFile;
} | {
ok: false;
message: string;
};
/** The edit size-ceiling refusal. One definition for every gate that can hit it (the pre-read stat, the
* post-read TOCTOU recheck, the binary-body recheck, and the decode failure above): a size refusal the
* model reads differently depending on which gate fired reads as several different limits. */
export declare function tooLargeToEditMessage(path: string, bytes: number): string;
/** The truncated-UTF-16 repair instruction, parameterized by the tool that refused. The condition is one
* condition — an odd-length UTF-16 body cannot be decoded — reached from Edit, Write and NotebookEdit. */
export declare function truncatedUtf16BodyMessage(tool: string, path: string): string;
/** Why a bash_readonly refusal is terminal rather than escalated. Appended by every arm of that refusal
* family; the arms differ in what they found, never in what the model can do about it. */
export declare const BASH_READONLY_CONFINEMENT_NOTE = "bash_readonly is confined to the workspace roots; it has no approval path, so the call is refused rather than escalated.";
/** The readFileState hash coordinate for a file that was JUST WRITTEN via
* `encodeTextForFile`: decode the EXACT payload handed to `env.writeFile`, i.e. what the next
* re-read's `decodeTextBytes` will produce. A blanket `normalizeFileText` is WRONG on the
* encode-preserving Edit lanes: a leading U+FEFF in the new text is stripped by the next read only
* when the target is plain no-BOM utf8 (it becomes the file's BOM); behind a preserved utf8-BOM or
* the always-emitted utf16le BOM it SURVIVES as body text, so stripping it here would invert the
* false-stale defect for those targets. Round-tripping the real bytes sidesteps the case analysis
* and stays correct if the encoding rules ever change. */
export declare function persistedTextOf(encoded: string | Uint8Array): string;
/**
* One APPLIED edit as the Edit tool's `details` card records it: the RESOLVED old/new strings
* (post quote/escape resolution, post deletion-newline widening) in the normalized originalFile
* coordinate. This is the card shape a transcript replay must re-apply, so the application step is a
* single source shared by the Edit tool's own apply loop and the replay — two spellings of "apply"
* could silently drift (e.g. one expanding `$&` and one not) and the replay's recomputed hash would
* refuse edits the tool actually made.
*/
export interface RecordedEdit {
oldString: string;
newString: string;
replaceAll: boolean;
}
/**
* Apply ONE recorded edit mechanically (no matching layers, no validation: the recording site
* already resolved and validated the strings; a replay consumer that cannot prove `oldString` is
* present must fall back to retracting its record, never guess here).
*
* Two load-bearing details, both shared with the Edit tool's historical inline apply:
* - Function-form replacement: the callback's return value is NOT interpreted as a replacement
* pattern, so `$&`/`$$`/`` $` ``/`$'` in `newString` are written literally (the replaceAll
* split/join path is literal by construction).
* - `oldString === ""` is the whole-content OVERWRITE shape: the Edit tool's whitespace-only-file
* overwrite lane records `{oldString: "", newString: <full new text>}` after REPLACING the file's
* content wholesale (fs-write.ts, the `old_string:""` existing-file branch). The tool's own apply
* loop never reaches this arm (checkEditMatch refuses an empty old_string before application), so
* it exists for the replay consumer alone — a plain `replace("", ...)` would PREPEND instead.
*/
export declare function applyRecordedEdit(text: string, edit: RecordedEdit): string;
/** Render the read-before-edit refusal, appending the slice/bash escape hatch when the target is over
* the Read byte cap — a bare "read it first" is un-followable there (a default whole-file Read
* refuses such files). Best-effort: an un-stattable file keeps the bare CC-verbatim refusal.
*
* `fallbackHint` is a caller-supplied parenthetical for the case NO branch below diagnosed anything
* (all three writing tools pass {@link READ_REFUSED_ESCAPE_HINT} — the Read-refuses-this-file
* deadlock, which by construction leaves nothing to stat). It is NOT appended beside a sharper hint:
* both sharper shapes describe files Read handled successfully, so the encoding-deadlock sentence
* would be false there — and stacking two escape routes on one refusal buries the one that applies. */
export declare function notReadRefusalText(env: ExecutionEnv, toolName: string, key: string, v: Pick<FsViolation, "code" | "message" | "partialView">, signal?: AbortSignal, fallbackHint?: string): Promise<string>;
/** The no-downsampler cap (`MAX_IMAGE_READ_BYTES`, core/image-admission.ts) is a DELIBERATE bound and does not move — but its refusal must not
* read like a hard ceiling. Whether the higher cap is reachable depends on an OPTIONAL dependency the
* caller can install, and only this package knows that, so the refusal names the move instead of
* leaving the caller to guess that the limit is negotiable. Appended ONLY on the no-downsampler
* branch: with sharp already resolved the hint would be false advice. */
export declare const NO_DOWNSAMPLER_IMAGE_CAP_HINT = "Install the optional \"sharp\" dependency (declared in this package's optionalDependencies) to read images up to 67108864 bytes \u2014 it downsamples them to fit the API image limit.";
/** Read-image downsampler seam: `undefined` → auto-detect sharp; `false` → force-disabled (deterministic
* no-sharp path, used by tests + envs that must not touch native deps); a function → injected. */
export type ReadImageDownsamplerOption = ImageDownsampler | false | undefined;
/** Resolve (and memoize) the auto-detected sharp downsampler, process-wide. Shared accessor for the two
* consumers of the module-private {@link autoDownsampler} cache. */
export declare function resolveAutoDownsampler(): Promise<ImageDownsampler | undefined>;
/** Output-token cap as a char proxy (CC's 25000, at ~4 chars/token). Caps the shown body so 2000 very
* long lines cannot blow the context budget.
*
* Only the 25000 NUMBER is CC's. CC does NOT use a flat chars/4 proxy — it pre-filters with a
* PER-EXTENSION chars-per-token coefficient (json/jsonl = 2, half the 4 assumed here) and then
* confirms with a REAL tokenizer; its budget is also overridable per call and by a feature gate,
* neither of which has a sema lane. Consequence, stated rather than hidden: on token-dense text
* (JSON/CJK/base64) this proxy UNDER-counts, so sema serves a page CC would have cut further; on
* token-sparse text it over-counts and paginates earlier than CC. A real tokenizer here would need a
* provider-neutral counting seam (BYOM — no single vocabulary), which is why the proxy stands; the
* divergence is a fidelity delta, not a correctness one (the partial-view marker always states the
* served window truthfully). */
export declare const MAX_READ_OUTPUT_CHARS = 100000;
export declare const BASH_DEFAULT_TIMEOUT_SEC = 120;
export declare const BASH_MAX_TIMEOUT_SEC = 600;
export declare const BASH_DEFAULT_TIMEOUT_MS: number;
export declare const BASH_MAX_TIMEOUT_MS: number;
/**
* The smallest timeout that can be asked for in ms without the request being destroyed on the way in.
*
* The shell's internal unit is SECONDS while every face of this tool speaks milliseconds, so each ms
* value passes through `Math.max(1, Math.round(ms / 1000))`. That expression has a PLATEAU: every input
* from 1ms up to 1499ms — plus zero and every negative number — comes out as the same 1-second budget.
* Inside the plateau the number a caller wrote is not approximated, it is erased, and nothing said so:
* an operator who wrote 120 meaning two minutes got a shell where every command died after one second,
* and the timeout text then reported `timeout limit: 1s` in the same voice it uses for a real budget.
* A model reading that concludes the environment imposes a one-second limit and abandons every real
* command it was asked to run.
*
* So the plateau is refused rather than entered silently, and the refusal splits by cause: a positive
* value under a second is almost certainly a SECONDS number in a milliseconds slot (the units are three
* orders of magnitude apart), while a value between one and one-and-a-half seconds is a real
* milliseconds request whose remainder simply cannot survive — it must be spelled as the exact 1000 it
* would become, or raised past the plateau. Zero and negatives have no reading at all.
*/
export declare const MIN_BASH_TIMEOUT_MS = 1000;
/** The first value ABOVE the collapse plateau: `Math.round(1500 / 1000)` is the first result that is not
* the 1-second floor. Between {@link MIN_BASH_TIMEOUT_MS} (exclusive) and this, every request lands on
* the floor with its remainder discarded. */
export declare const BASH_TIMEOUT_PLATEAU_END_MS = 1500;
/**
* A host-supplied failure `message` is untrusted as a CARRIER OF FACTS: an execution env whose adapter
* hands back an empty (or whitespace-only) string turns any text built as `Error (X): ${message}` into a
* bare `Error (X):` — a card with a red badge and nothing to act on, which is exactly what a model (and
* a person) reads as "the engine broke and won't say how". Use this wherever the message IS the whole
* fact; where it only decorates a sentence the engine wrote itself, interpolate it directly.
*/
export declare function envErrorDetail(message: string | undefined): string;
/**
* The `timeout` parameter's model-facing description, rendered from the SAME caps the runtime enforces
* and the SAME plateau the refusal applies — one text for both shell legs so a boundary can never be
* stated on one card and enforced on the other. `withDefault` includes the default budget (the
* read-only leg's historical wording; the full `bash` leg states its default in the tool description).
*/
export declare function bashTimeoutParamDescription(caps: {
defaultMs: number;
maxMs: number;
}, withDefault: boolean): string;
/**
* The model-facing `timeout` argument's validity rule — returns the refusal text when the value cannot
* be honored, `undefined` when it can. Shared by both shell legs (`bash` and `bash_readonly`: one rule,
* both entry points). Absent is always fine — that is the request to use the configured default.
*
* A refusal rather than a clamp: clamping a plateau request produced a one-second budget the caller
* never asked for and then reported it back as the caller's own limit (see {@link MIN_BASH_TIMEOUT_MS}).
*/
export declare function bashTimeoutArgRefusal(timeoutMs: number | undefined, caps: {
defaultMs: number;
maxMs: number;
}): string | undefined;
/**
* The SINGLE authority for the Bash `timeout` parameter's two numbers: the default budget applied
* when the model omits `timeout`, and the engine ceiling requests are clamped to. The 120s/600s
* constants themselves are CC-same; what this resolver adds on top is configurability.
*
* Resolution, per leg (options over env over constant — the standing sema discipline: "deployments
* configure the tool, not the process"; env stays as the CC-shaped fallback for process-level tuning):
* defaultMs = opts.bashDefaultTimeoutMs ?? env BASH_DEFAULT_TIMEOUT_MS ?? {@link BASH_DEFAULT_TIMEOUT_MS}
* maxMs = Math.max(opts.bashMaxTimeoutMs ?? env BASH_MAX_TIMEOUT_MS ?? {@link BASH_MAX_TIMEOUT_MS}, defaultMs)
* The outer `Math.max` is CC's own only-widen semantic (its env max resolves through the same form): a
* ceiling below the resolved default is a NARROWING intent = invalid — it is ignored and the resolved
* default floors the cap. Invalid values (non-finite / <= 0) are discarded at every leg, options
* included, exactly like CC's env read.
*
* Readable seam: a shell/host rendering its OWN copy of the Bash tool description MUST
* read the true values from here instead of holding a sibling constant — the engine's description/param
* text and its runtime clamp both interpolate this function's output (resolved once per tool instance),
* so a self-held "600s" only matches by coincidence and forks silently the moment a deployment
* configures either knob.
*/
export declare function resolveBashTimeoutCaps(opts?: {
bashDefaultTimeoutMs?: number;
bashMaxTimeoutMs?: number;
/** Structured seat for the discard announcements below — wired, it REPLACES their `console.warn`
* (same ledger, same lines as `message`); absent, the console line prints verbatim. Threaded from
* the toolkit assembly (ultimately `RunnerDeps.onNotice`); never a global. */
onNotice?: (n: EngineNotice) => void;
}): {
defaultMs: number;
maxMs: number;
};
/** Test seam: the announcement de-dupes per sink (console arm per process), so a test that asserts
* the line must be able to re-arm the ledger. Re-arms BOTH arms (a WeakMap has no clear — it is
* re-minted). Never called by production code. */
export declare function __resetBashTimeoutAnnouncements(): void;
/** The seconds view of a resolved {@link resolveBashTimeoutCaps} pair — the shell's INTERNAL unit
* stays seconds while every model-facing surface stays ms. One rounding
* point, same Math.round form as the model-supplied timeout's own ms→s conversion, floored at 1s. */
export declare function bashTimeoutCapsSec(caps: {
defaultMs: number;
maxMs: number;
}): {
defaultSec: number;
maxSec: number;
};
/** Read per call (a deployment steers it without a restart); the one shared resolver holds the grammar,
* the `[OUTPUT_BUDGET_MIN_CHARS, ceiling]` range and the loud outlet for a value it could not honor. */
export declare function bashMaxOutputChars(): number;
export declare const FILE_PATH_PARAMS: {
file_path: Type.TString;
};
/**
* Write's pre-validation argument repair — CC 2.1.296 `orr` (the Write tool's `coerceInput`, cli296.js:247143),
* the two rules that matter under a non-strict schema: `path` is read as `file_path` when `file_path` is absent
* and `path` is a string; exactly one of `file_text` / `file_content` is read as `content` when `content` is
* absent and the value is a string. Anything else — both content aliases, a present `file_path`, a non-string
* value — is left to the schema, which refuses it by naming the parameter. The repaired key REPLACES the alias
* (it is deleted), so no second spelling rides on into the gates. Idempotent, and the very same object for a
* call that needs no repair (the loop's "prepared arguments changed" test is a reference test).
*/
export declare function coerceWriteArguments(args: unknown): unknown;
/** Honest head+tail truncation to {@link bashMaxOutputChars}: the middle is dropped with a real-total
* marker, because the head AND tail both matter for a shell. Kept as the inline shape even beside the
* file-backed recovery below — unlike a background task's poll-for-the-latest-tail interaction, a
* foreground command's head can carry status a bare tail-keep would drop. Shared by runShell
* (foreground) and TaskOutput (background poll). Delegates its arithmetic to the shared
* {@link clipWithFilePointer} but never exercises its pointer arm — no caller here passes a path. */
export declare function clipShellOutput(s: string): string;
/**
* A foreground command's captured stdout/stderr are ALREADY fully in memory here (the env delivered
* them whole — only `clipShellOutput` above decides how much of that the model's turn actually sees).
* What this closes is that the omitted middle would otherwise be discarded, forcing a blind full
* re-run to recover output that was, in fact, captured. "Whole" means whatever THIS layer received,
* not a guarantee about what the process originally wrote: the env's own rolling-tail cap
* (`MAX_EXEC_OUTPUT_BYTES`, exec-output-tail.ts) can already have dropped the head before delivery on
* a truly enormous single command — this file is a faithful copy
* of the received bytes either way, and still carries that cap's own `markTruncated()` marker when it
* fired, so nothing here overclaims completeness beyond what the text itself already discloses. Purely
* ADDITIVE: the inline head+tail framing above is untouched; this only gives the model a second, explicit
* route to the bytes that framing dropped. Writes BOTH streams (not just the one that overflowed) to ONE
* combined file — same shape TaskOutput's A5 mechanism uses (`stdout + stderr`, single pointer) — so the
* model gets one path regardless of which stream tripped the clip. Uses `env.createTempFile`/`writeFile`/
* `canonicalPath` — the standard `FileSystem` primitives every {@link ExecutionEnv} backend implements —
* NOT the Node env's internal exec spool, so this works uniformly across backends, not just the Node adapter.
*
* Deliberately NOT wired into the Read-tool root-fence exemption (unlike TaskOutput's A5 file): that
* exemption is keyed off a TaskRegistry `background_bash` row with a live lifecycle (owner/scope/session
* visibility, membership dies with the row) — a one-shot foreground command has no such row, and building
* one purely to carry a read-exemption would be a parallel, un-owned lifecycle with no natural teardown.
* `bash` itself is NOT root-fenced at the command-text level (only Read/Edit/Write/Grep/Glob enforce path
* containment on their explicit path arguments), so the model already has a direct route to the file: a
* later `bash` call — the message text says so explicitly rather than implying Read will work, and picks
* an example command the CALLING tool variant can actually run (see {@link shellRecoveryHint}: `sed -n`
* for full `bash`, an allowlisted `tail -c` for `bash_readonly`). Creation/write failure degrades
* silently to no file (the inline clip is unaffected either way), matching the background lane's own
* degrade posture.
*/
export declare function writeShellOverflowFile(env: ExecutionEnv, stdout: string, stderr: string): Promise<string | undefined>;
/**
* Recognise the engine's OWN shell overflow recovery files, so `bash_readonly` can keep honouring the
* `tail -c … <file>` command its result trailer advertises.
*
* NOT a `Set` of the exact paths one tool instance minted: the trailer lives in the transcript, not in
* the instance, and the hands toolkit is rebuilt on every task preparation — a later run replaying the
* advertised command would meet a tool with no memory of it and be refused, i.e. the engine refusing
* its own advice, and (in the read-only band, whose Read tool is fenced by the same roots) the only
* route to the truncated output.
*
* Judged by SHAPE instead, which survives any rebuild, with the grant kept pinned to the engine's own
* temp area on three counts that must ALL hold:
* 1. the basename is the engine's mint spelling ({@link isShellOverflowFileName}) — carrying the
* backend's unguessable unique token, so another session's recovery file cannot be named blind;
* 2. the file sits inside the area the backend hands out temp paths from, learned by minting a probe
* temp directory through the SAME backend (`createTempDir`) and taking its parent, then removing the
* probe. Learned rather than assumed because the area is the backend's business — this file must not
* grow an `os.tmpdir()` assumption that a non-Node {@link ExecutionEnv} would not honour;
* 3. its depth under that area is the mint's own — the area itself, or one directory below it (the Node
* backend puts each temp file in a fresh `mkdtemp` directory). So a deep path that merely happens to
* live under the temp area is not covered either.
* Every failure to learn the area (backend refuses, probe unresolvable) yields `false`: no exemption, i.e.
* the refusal stands. The probe runs at most once per fence and only for a path that already passed (1),
* so an ordinary refusal never touches the filesystem.
*
* What this does NOT grant: any path outside the temp area (a `/etc/passwd`, a user file, a sibling
* project) — those fail (2) no matter what they are named; and any neighbour inside the temp area that is
* not named like a mint — those fail (1). The narrowing this trades away against the exact-path Set is
* precisely "a file in the engine's temp area, named with the engine's prefix AND a valid unique token
* the caller would have to already know".
*/
export declare function createShellOverflowSpoolFence(env: ExecutionEnv): (path: string) => Promise<boolean>;
/**
* The recovery-file trailer's example command must be one the
* CALLING tool can actually run. `sed` is not in {@link import("./bash-readonly-classifier.js").BASH_READONLY_DEFAULT_ALLOW} and `bash_readonly`
* forbids the operators a pipe/redirect would need, so a `bash_readonly` command that overflows gets a
* single-command, allowlisted alternative instead (`tail -c`, byte-bounded so it doesn't depend on line
* shape) — strictly less precise than `sed -n 'START,ENDp'` (no arbitrary mid-file range), but actually
* executable. Full `bash` keeps the precise form.
*/
export declare function shellRecoveryHint(path: string, readOnly: boolean | undefined): string;
/** CC 2.1.198 live-verbatim Edit/Write success trailer — appended to every Edit/Write success message
* (NOT NotebookEdit — CC ships it without the trailer). */
export declare const FILE_STATE_TRAILER = " (file state is current in your context \u2014 no need to Read it back)";
export declare const CWD_SENTINEL = "__cc_cwd_9f2c1b__";
/**
* Take the engine's own cwd epilogue out of a finished stderr, and report the working directory the
* command left behind (the payload of the LAST span).
*
* EVERY span, and the LAST one — not the first, and not just one. A command that turns on shell tracing
* produces more than one (the shell echoes the epilogue's own source, marker and all, before running
* it), and under a one-pair rule the FIRST pair was that echo, whose payload is the unexpanded
* `' "$(pwd)" '`; a `cd` that plainly worked was then dropped while the real record stayed in the stderr
* a person reads. An opener with no close is NOT ours to eat — the command's own text is left exactly
* as it arrived.
*
* TWO CALLERS, ONE RULE, which is why the marker is optional. The foreground result path KNOWS the
* marker it minted and passes it (exact, unambiguous). A backgrounded command's spool is assembled by a
* different lane entirely — the shell row outlives the run that launched it and never had the marker —
* so it passes none and the marker is recovered per span from the sentinel plus its hex suffix.
*/
export declare function stripCwdSentinel(stderr: string, marker?: string): {
stderr: string;
cwd: string | undefined;
};
/** Convert a model-supplied millisecond timeout to our internal seconds, clamped. Exported for the
* cc-parity unit (model sees ms, internal stays seconds — no ×1000 error).
* ⚠️ This returns the EFFECTIVE budget, not the request — capping at a call site destroys the
* caller's number before it can be disclosed. The Bash legs deliberately do NOT use it (they pass the
* uncapped conversion into runShell, which caps once and reports the true request); use this only
* where the post-ceiling value is what you mean.
* The clamp bounds are not the bare constants — pass the caller's resolved caps
* ({@link resolveBashTimeoutCaps}) to clamp against a configured deployment; omitted ⇒ resolve fresh
* (zero-config = the historical 120s/600s, byte-compat).
* ⚠️ This is the RAW converter and NOT the authority on what the tool accepts: it still folds the whole
* collapse plateau (see {@link MIN_BASH_TIMEOUT_MS}) onto a 1-second budget, while the shell legs refuse
* those inputs outright via {@link bashTimeoutArgRefusal}. A host rendering its own view of what a given
* request would cost must consult the refusal first, or it will advertise a budget the engine declines
* to run. No production caller inside the engine reaches this function. */
export declare function msTimeoutToSec(timeoutMs: number | undefined, caps?: {
defaultMs: number;
maxMs: number;
}): number;
/** `.ipynb` redirect (CC FileEditTool parity): a raw text edit/overwrite corrupts the
* notebook JSON, so the text-edit face refuses and redirects to the NotebookEdit tool (built in
* src/tools/fs/fs-write.ts, mounted with the rest of the hands band). */
export declare function ipynbRedirect(toolName: string, path: string): string | undefined;
/** Count the lines of text content. `split("\n")` yields a trailing "" for newline-terminated content
* (e.g. "a\nb\n" → ["a","b",""]); that empty fragment is NOT a real line, so drop one when the content
* ends in "\n". Behavior is identical for content WITHOUT a trailing newline. Shared by read_file's
* `total`, edit_file/write_file's "N lines" report, and the persisted ReadFileState.totalLines. */
export declare function countLines(s: string): number;
/** CC 2.1.204 VERBATIM: the reminder served when a DEFAULT whole-file Read hits a startup-seeded,
* unchanged file. `filePath` is the CANONICAL key (CC passes the resolved full path, matching the
* `Contents of <path>` header its context seeding emits — deployments seeding files should title the
* injected block the same way so the back-reference lands). Locked verbatim by test: the BODY and
* close tag stay CC-verbatim; the open tag alone is rendered by the mint home and carries the session
* mark when one is threaded (the recorded wire-form divergence). */
export declare function seededFileUnchangedReminder(filePath: string, mark?: string): string;
/**
* True when `resultText` is one of the Read tool's TWO dedup markers (the ordinary
* unchanged-since-last-read stub below, or {@link seededFileUnchangedReminder}) rather than a genuine
* content transmission. A consumer deciding "is this file's real content present in THIS message"
* (e.g. auto-compaction's kept-tail visibility scan) must treat a stub hit as absence — the marker
* POINTS AT an earlier read that may itself no longer be in context.
*
* Both emitters ALSO carry a structured marker (`details.type === "file_unchanged"`,
* fs-read.ts `fileUnchangedResult`) — consumers with access to the tool result's `details` should
* branch on that instead of this prose match (which can false-positive on file CONTENT that quotes
* either sentence). This predicate stays for text-only surfaces (compaction sees rendered text).
*/
export declare function isReadDedupStubResult(resultText: string): boolean;
/** Pre-seed the hands' read state for a file whose FULL, disk-verbatim text was injected into the
* model's context at startup (CC 2.1.204 `seededFromContext` seeding of the memory preload; sema:
* ProjectMemoryLoad.seededFiles → prepare-task).
* `content` MUST be the file's exact disk text at load time — the Read tool's seeded dedup treats a
* hash match as "unchanged"; a truncated/annotated variant must NOT be seeded (CC exempts those via
* `isPartialView`; sema's contract is simply "don't seed partials"). `key` is the CANONICAL path
* (resolveKey output), so it collides correctly with real Read/Edit entries. Note the CC-faithful
* side effect: a seeded file passes read-before-edit (the model legitimately has the full text). */
export declare function seedReadFileStateFromContext(state: ReadFileState, key: string, content: string): void;
/**
* Seed the hands' read state from a WHOLE-file content the session transcript recorded on an earlier
* turn (the selection rules live in core/runner/session-file-state-replay.ts).
*
* Same minting as {@link seedReadFileStateFromContext} and the same normalization reason, with three
* deliberate differences, each of which is a claim this seed cannot honestly make:
* - `lastReadAt` is the RECORDED moment, not now. It is what the changed-files detector compares an
* mtime against, so an honest old stamp keeps a between-turns modification detectable; stamping
* "now" would hide exactly the change the model needs told about.
* - no `view`, so the Read dedup stub can never fire off this entry. The dedup answers a re-read
* with "unchanged since you last read it" — true only while the earlier read's body is still IN
* the context, which a continuation (or a compaction inside it) cannot promise.
* - no `seededFromContext`: that flag means the text lives in the system-prompt lane and survives
* compaction. This text lives in the transcript and does not.
*/
export declare function seedReadFileStateFromTranscript(state: ReadFileState, key: string, content: string, lastReadAt: number): void;
/**
* Copy a SESSION-inherited read-file state into a new task's map, under the new task's
* own containment judgment (CC parity: readFileState is session-scoped — minted once per REPL session,
* mutated across turns, with no turn-boundary or abort clear site; sema's per-task map inherits the
* previous turn's live state instead).
*
* Two rules, each a claim inheritance cannot honestly skip:
* - **Every key passes the CURRENT task's own key judgment** (`judgeKey` = the same resolveKey
* containment the transcript-replay seed uses: write roots ∪ read roots, resolved read face). A
* root or face narrowed between the turns must narrow what the state remembers, exactly as the
* replay leg already judges — an unjudged copy would smuggle out-of-face entries into the task
* (and into its checkpoints).
* - **`seededFromContext` entries do not inherit.** That flag claims the file's text lives in the
* system-prompt lane OF THIS TASK — a claim only this task's own startup seeding can make.
* A still-declared file is re-seeded the same turn (no loss); a no-longer-declared one must not
* keep answering reads with an "already in your context" reminder that is no longer true.
*
* Entries are copied by REFERENCE (ReadEntry values are never mutated in place — every write-back
* `set`s a fresh object), so recorded facts (view / isPartialView / lastReadAt) ride verbatim: the
* dedup stub and the partial-view refusal keep working across turns exactly as inside one task.
*/
export declare function inheritSessionReadFileState(target: ReadFileState, inherited: ReadFileState, judgeKey: (recordedKey: string) => Promise<string | undefined>): Promise<void>;
/**
* CC parity: a landed compaction summarizes old Read results OUT of the context, so every non-seeded
* read-state entry must be dropped — CC snapshots then
* `readFileState.clear()`s at the same spot. Without this, the Read dedup stub above answers a re-read of
* an unchanged file with "content omitted to save context" pointing at content that no longer exists
* anywhere in the context (the model works from hallucinated memory or has to vary offset/limit to escape).
* Two deliberate divergences from CC's bare clear(), each from sema's own structure:
* - SEEDED entries survive: their content lives in the system-prompt lane, which compaction never touches,
* so their dedup remains CORRECT (CC re-seeds the same family right after its clear — equivalent end state).
* - Files re-attached WHOLE into the summary re-register (content genuinely re-entered the context — same
* principle as CC re-registering restored file attachments); a TRUNCATED attachment does not (a partial
* view must not answer a whole-file read with a stub).
* The read-before-edit gate tightens as a consequence, matching CC: after a compaction the model must
* re-Read a summarized-away file before editing it, instead of blind-editing from a stale recollection.
*
* A THIRD category besides seeded/re-attached: a file whose most recent Read is still visible
* verbatim in the compaction's KEPT tail is deliberately left OUT of
* `attachedComplete` (re-attaching it would just duplicate content already in context), but its
* existing read-state entry must survive unmodified for the SAME reason a seeded entry does: the
* model's view of it did not change. Wiping it breaks two things for exactly these files — the
* read-before-edit gate would reject an Edit the model could visibly justify from its own kept-tail
* Read, and a next Read would miss the dedup stub and retransmit the whole file, recreating the exact
* waste this dedup exists to remove. `preserveKeys` is the caller's kept-tail set
* (see `maybeCompact`'s `candidateFiles` computation) — passed as keys already in `state`'s own
* coordinate (canonicalized the same way the Read/Edit/Write tools populate it), so a plain lookup
* suffices; no re-hash/re-register needed since the entry's content genuinely has not changed.
*/
/**
* The SECOND way a transmission leaves the model's view, and why this function is named for context
* SHRINK rather than compaction: the request-layer stale-result pass replaces an old tool result's
* content with a cleared marker — no summary, no re-attachment, nothing changed in the session — and
* for a Read result the effect on the entry is identical: the body its dedup stub points at is no
* longer anywhere the model can see, so the entry must retire with it.
*
* Selection is by the TRANSMISSION, not by path — an entry a LATER Read re-transmitted carries that
* later call's id and so survives a clear of the earlier one, which a path-keyed retirement could not
* tell apart. KNOWN IMPRECISION: a call id is not unique over a run (the clear ledger keys occurrences
* by id + message timestamp + ordinal for that reason) and the finer key is not reachable at the Read
* mint, which does not know the timestamp of the message its result will land in. A reused id can
* therefore retire one entry too many, costing a re-transmission and a tightened read-before-edit gate
* — the same tightening the summarized arm accepts wholesale. The converse is not reachable: an entry
* carries the id of the last Read that transmitted it, so if that result was cleared its id is in the list.
*/
export type ReadFileStateShrink =
/** A landed compaction: `attachedComplete` re-entered the context whole; `preserveKeys` stayed visible in the kept tail. */
{
kind: "summarized";
attachedComplete: ReadonlyArray<{
path: string;
content: string;
}>;
preserveKeys?: ReadonlyArray<string>;
}
/** A request-layer stale-result clear: these tool calls' results are markers now. Nothing re-entered. */
| {
kind: "tool-results-cleared";
clearedToolCallIds: ReadonlyArray<string>;
};
export declare function applyContextShrinkToReadFileState(state: ReadFileState, shrink: ReadFileStateShrink): void;
/** CC 2.1.198 verbatim skeleton: the ENOENT error body for a
* missing Read/Edit target — `File does not exist. Note: your current working directory is <cwd>.`
* plus a `Did you mean <sibling>?` correction when the parent directory holds a near-name (case
* variant / same stem different extension — the high-frequency self-heal path). Best-effort: a
* listDir failure just omits the suggestion. */
export declare function enoentMessage(env: ExecutionEnv, key: string, cwd: string, signal?: AbortSignal, readDeny?: {
matchPath(path: string): {
pattern: string;
} | null;
} & HomeViewsCarrier): Promise<string>;
/** Per-task mutable working directory shared by the shell and the path-taking fs tools.
* Holds the RAW path (never canonicalized): bash `cd` updates `current`, and the fs tools resolve relative
* paths against it. Containment is still enforced per-op by resolveKey (canonicalize + within), so a `cd`
* through a symlink out of root cannot carry across a relative fs path outside. */
export interface CwdRef {
current: string;
}