/** * Context Pruning — the "pointer for volume" half of the context-budget work * (plan 功能 1, item #49). * * `v2-offload.ts` governs a result AT THE MOMENT it is produced; it can do * nothing about history that already entered the context window. This layer * rides the SAME `session.hook("context")` seam the request layer uses and, once * a session crosses a fraction of its model's window, replaces the BODY of * settled messages with a one-line POINTER that keeps the recoverable address * (a `tm_fetch` handle, a child session id, or an explicit "re-run to recover"). * * The rules that make this safe rather than destructive: * * - **Only settled messages are touched.** The newest message is always kept, * a message naming a still-running child (`state="running"`) is kept, and a * message carrying a bare `ses_…` id with no settled marker is kept — an * uncollected child is still-running work, not finished work. * - **The hard-protection list is never cut** (`isProtectedMessage`): the reply * skeleton, GOAL/ACCEPTANCE, provenance (`正文来源=`), system messages and the * board note. A `tm_fetch` handle is NOT a protected message — it is a * protected STRING: the message may be pruned, but the stub must carry the * handle verbatim, which is exactly the "keep the pointer, drop the body" * contract. * - **Nothing leaves that cannot be recovered.** A message carrying no handle * of its own is offloaded to the run store BEFORE its body is replaced, so * the stub carries a full `tm_fetch` handle (`pruneOffload`, default on). A * failed offload falls back to today's echo note, one message at a time — * losing a body is data loss, losing an offload is a counter. A stub with no * handle to offer still carries the ADDRESSES the body named (board paths, * `file:line`, URLs; at most three, deduplicated, in document order), because a * pointer the reader can act on is the whole point of keeping one — and that * scan is SINGLE-PASS and linear in the body length, not three global regexes * over the whole text (which went quadratic on a long word run). * - **A user pattern outranks the budget** (`pruneProtectPatterns`): a message * whose text matches any compiled entry is never pruned, and the matches are * counted (`protectMatched`) instead of silently changing the saving figure. * - **The threshold is derived, never hard-coded.** `TM_PRUNE_AT_PERCENT` * (default 70) of `limit.context`, the same denominator the desktop's own * usage meter reads. No `limit.context` → no prune, counted `no_limit`. * - **Never throws, never rewrites an untouched message.** A failure allows * the request through and is counted; a message outside the plan is left * byte-exact. * - **Idempotent.** The stub carries a sentinel, so a host that replays the * hook in-process cannot prune the same message twice. */ import type { TeamScope } from "./v2-scope.js"; import type { V2Context, V2Registration } from "./v2-types.js"; /** The marker every stub carries. Its presence means "already pruned". */ export declare const PRUNE_SENTINEL = "[\u5DF2\u88C1\u526A \u00B7"; export interface PruneConfig { enabled: boolean; /** Percent of `limit.context` at which pruning starts (clamped 40–90). */ atPercent: number; /** Percent of `limit.context` kept verbatim at the tail (clamped 0–90). */ keepTailPercent: number; /** * User-supplied protection regexes (`pruneProtectPatterns`, comma separated). * A message whose text matches ANY of them is never pruned. Empty = off, and * that is the byte-identical path: the list is consulted only when non-empty. */ protectPatterns: RegExp[]; } /** * Compile the comma-separated pattern list. One invalid pattern loses only * itself (a typo must not disable the ones next to it, and must never throw into * the request path); an empty / absent value compiles to `[]` = off. */ export declare function parseProtectPatterns(raw: unknown): RegExp[]; export declare function resolvePruneConfig(config?: { prune: string; pruneAtPercent: number; pruneKeepTailPercent: number; pruneProtectPatterns?: string; }): PruneConfig; interface TextSlot { container: Record; key: string; text: string; } /** * Every text slot in a message, across the shapes the host has actually used: * `{info:{role},parts:[{type:"text",text}]}` (v1-ish), `{role,parts:[…]}`, * `{role,content:[{type:"text",text}]}` (2.0.20), the flat * `{id,time,type,text}` (2.0.18), and the 2.0.24 set a live probe measured — * `{role:"tool",content:[{type:"tool-result",result}]}` (the body is under * `result`, there is no `text`), `{type:"reasoning",text}`, and * `{type:"tool-call",input}` (the arguments are the body; #64). Recognition * is BY SHAPE, never by host version (the same discipline * `normaliseContextMessages` follows). An unrecognised shape yields no slots and * is therefore never touched. */ export declare function textSlots(msg: unknown): TextSlot[]; /** * How many parts in a message carry a `type` we do not recognise. A non-zero * count beside a small `used` is the signature of a renamed shape — the exact * failure that made pruning a no-op on 2.0.24 while every counter read healthy. * A KNOWN-but-textless part (`media`) is not counted: it is not a renamed * shape, and counting it would drown the signal in attachment noise. */ export declare function unknownPartCount(msg: unknown): number; export declare function messagesUnknownParts(messages: unknown): number; export declare function messageText(msg: unknown): string; export declare function messageTokens(msg: unknown): number; export declare function messagesTokens(messages: unknown): number; /** * A message that must never be pruned. The handle check is deliberately ABSENT: * a handle-bearing message is prunable, and `renderPruneStub` carries the handle * verbatim — the pointer survives even when the body does not. */ export declare function isProtectedMessage(msg: unknown): boolean; export interface PruneHandle { ref: string; accessToken: string; expireAt: number; /** Set when the wiring recognised this body and handed back an EXISTING handle. */ reused?: boolean; } /** 地址行最多留几条——再多就不是「地址」,而是正文摘抄。 */ export declare const PRUNE_ADDRESS_MAX = 3; /** 地址行的前缀;整行只在真的抽到地址时出现。 */ export declare const PRUNE_ADDRESS_PREFIX = "\u5730\u5740\uFF1A"; /** * 单个 token 的字符上限。 超过它的不是地址,是正文——这条上限也是性能闸门: * 长词串整段跳过,永远不会进到正则里。 */ export declare const PRUNE_ADDRESS_TOKEN_MAX = 2048; /** * The addresses a pruned body carried, in DOCUMENT ORDER, deduplicated, at most * `PRUNE_ADDRESS_MAX`. Pure, and it only ever yields the address itself — never a * fragment of the body around it, because the stub is a pointer, not a summary. * * LINEAR BY CONSTRUCTION (this is a performance contract, not a style note): the * text is walked ONCE, token by token, and every pattern is anchored to a single * token. The previous version ran three GLOBAL regexes over the whole body and * blanked each URL match by rebuilding the string — quadratic on a long word run * (`"a".repeat(200000)` was hundreds of seconds) and O(k·n) with many URLs. A * token longer than `PRUNE_ADDRESS_TOKEN_MAX` is skipped without touching a regex. * * A longer address that CONTAINS a shorter one wins: `…/v2-prune.ts:120` is the * pointer a reader can use, while `…/v2-prune.ts` would send them hunting for the * line the message was about. */ export declare function extractAddresses(text: string): string[]; export interface PruneStubInput { kind: "handle" | "child" | "echo" | "offload"; originalTokens: number; handle?: PruneHandle; childId?: string; childState?: string; /** Addresses found in the body — rendered as one `地址:` line, or omitted whole. */ addresses?: string[]; } /** * The replacement text. It must be self-explanatory: a reader has to know what * used to be here and where to get it back. The handle / child id is copied * VERBATIM — a stub that dropped the address would be evidence destruction. */ export declare function renderPruneStub(entry: PruneStubInput): string; export interface PrunePlanEntry { index: number; originalTokens: number; stub: string; kind: PruneStubInput["kind"]; } export interface PrunePlan { prune: PrunePlanEntry[]; protectedCount: number; /** Messages a user-supplied `pruneProtectPatterns` entry kept (a subset of the kept ones). */ protectMatched: number; keptCount: number; savedTokens: number; } export interface PrunePlanOptions { /** Target size: stop pruning once `used - saved <= budgetTokens`. */ budgetTokens?: number; /** Newest messages whose cumulative tokens fit here are kept verbatim. */ keepTailTokens?: number; /** User patterns: any hit keeps that message, whatever the budget says. */ protectPatterns?: RegExp[]; } /** * Decide which messages to prune, oldest first, until the request fits the * budget. Pure: it reads the messages and returns a plan; nothing is mutated. */ export declare function prunePlan(messages: unknown, opts?: PrunePlanOptions): PrunePlan; /** Apply a plan in place: the first text slot becomes the stub, the rest blank. */ export declare function applyPrunePlan(messages: unknown, plan: PrunePlan): void; export interface PruneReport { enabled: boolean; atPercent: number; keepTailPercent: number; checked: number; prunedMessages: number; prunedTokens: number; skippedProtected: number; below: number; noLimit: number; noMessages: number; foreignSkipped: number; threw: number; /** Bodies stored in the run store before their message was replaced. */ offloaded: number; /** Handle reused for a body an earlier request already stored (no second write). */ reused: number; /** Messages whose offload failed (or returned no handle) → echo stub. */ offloadFailed: number; /** Requests whose post-prune size still exceeds the budget (counted, never re-pruned). */ overBudget: number; /** Bodies stored for a request whose plan never landed — payloads with no pointer. */ orphans: number; /** Parts whose `type` we did not recognise — a renamed shape shows up here. */ unknownParts: number; /** Compiled `pruneProtectPatterns` entries (a value that failed to parse is not counted here). */ protectPatterns: number; /** Messages a protect pattern kept out of the plan this process. */ protectMatched: number; lastPercent: number; lastUsed: number; lastLimit: number; } export interface PruneInput { config?: PruneConfig; scope?: TeamScope; /** Resolve the context window for a session; null = no denominator → no prune. */ modelLimitOf?: (sessionID: string, messages: unknown[]) => Promise | number | null; /** Per-decision trajectory line (counters alone are invisible in a CLI run). */ onEvent?: (row: Record) => void; /** * Store one message body and hand back its handle. Absent = today's * behaviour (echo stub, body not kept) — the wiring passes it only while * `pruneOffload` is on. */ offload?: (text: string) => Promise; } export interface PruneLayer { registrations: V2Registration[]; report: PruneReport; } export declare function applyV2ContextPrune(ctx: V2Context, input?: PruneInput): Promise; export {}; //# sourceMappingURL=v2-prune.d.ts.map