UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

529 lines (528 loc) • 40 kB
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; }