import * as fs from 'node:fs'; import type { CortexStore } from '../db/store.js'; import { createDigestCache, type DigestCache } from '../capture/digest.js'; /** * The read-ledger query (FR-6, Story 3.3): has this session already read this * file, and has it changed since? * * **Evidence in hand, never a proxy (AD-6).** `unchanged` is asserted only after * re-hashing the current bytes and matching them against the recorded digest. * `mtime` is recorded by Story 3.1 for reporting and is never read here — it is * the exact proxy AD-6 names, and a file restored from backup or checked out by * git carries a new mtime with identical content, while a same-second edit * carries the old one with different content. Ambiguity resolves to a **miss**: * every state that cannot produce its evidence answers `changed-since`, which * costs a re-read and can never license a false refund. * * **Change detection is scope-wide; "you already have this" is session-bound * (AD-16).** A digest recorded by a sibling or descendant session is a valid * fact about the file and is reported as one — but it is not refund-eligible, * and the rendered line attributes it to the agent that actually read it rather * than saying "you". * * **Inherited imprecision, stated rather than hidden.** Story 3.1 records the * digest when the spool batch is *flushed*, not when the read happened. A file * changed by something outside Cortex in that window recorded the changed bytes * and will read as `unchanged-since` here. This module cannot repair that; it * inherits it, and the bound is the flush interval. */ /** Exactly four, per FR-6. Qualifiers ride on `changed-since`; there is no fifth. */ export type ReadLedgerVerdict = 'unread' | 'unchanged-since' | 'changed-since' | 'edited-by-you-since'; /** * Why a `changed-since` could not be narrowed further. * * `missing` is AC #5's: the file is gone. `unverifiable` covers every state * where no comparison was possible — an `oversize` record (Story 3.1 stores * path and size but no `sha256` past the 2 MiB ceiling), a file that is now * oversize, a permission error, a path that is now a directory. Both are * *misses*: they report the safe direction, never `unchanged`. */ export type ReadLedgerQualifier = 'missing' | 'unverifiable'; export interface ReadLedgerRecorder { sessionId: string; agentId: string | null; agentType: string | null; } export interface ReadLedgerResult { /** The path exactly as asked, so a caller can correlate without re-deriving. */ path: string; /** The stored `(scope_key, path)` key half, or null when nothing was recorded. */ key: string | null; verdict: ReadLedgerVerdict; qualifier?: ReadLedgerQualifier; /** When the digest was recorded, ISO-8601. Null for `unread`. */ recordedAt: string | null; /** * AD-16. True only when the recording session is the requesting session or an * ancestor of it. A `false` here forbids the second person in any rendering. */ refundEligible: boolean; /** Present only when NOT refund-eligible — who actually read it. */ recordedBy?: ReadLedgerRecorder; /** * The recorded size of the file, when a digest exists. This is the *evidence* * an FR-8 credit is anchored to — the actual size of the actual file, which * is what separates a real saving from a counterfactual. */ byteSize?: number; } export interface ReadLedgerQuery { paths: string[]; /** The asking session. Its ancestry is the AD-16 eligibility set. */ sessionId: string; /** Defaults to the asking session's scope. */ scopeKey?: string | null; /** * Test seam only, and narrower than it looks: the *current* on-disk state is * what must be re-hashed, so this exists to make the hash observable, never to * supply a cached answer from a previous flush. */ digestCache?: DigestCache; /** * Answer "is this file unchanged" without answering "who read it". * * `knownUnchangedFiles` renders a scope-wide line that names no reader, so * the attribution lookup is pure waste — a `getSession` per candidate whose * result is discarded, on the B-1 path. It also removes the need for a * sentinel session id to be the thing that makes a read non-eligible: with * this set, eligibility is not consulted at all rather than happening to * evaluate false because no row carries an empty `session_id`. */ skipAttribution?: boolean; /** * Record an `offer:read` for every refund-eligible `unchanged-since`, so a * later read of that file books an *unrealized* saving (AC #6). * * **Opt-in, and off by default.** The agent-facing surfaces set it, because * those are the calls where Cortex actually tells an agent it already has the * content. Cortex's own internal probing — `knownUnchangedFiles`, which the * session brief runs on every SessionStart — must not, or the product would * manufacture offers to itself and then count the agent as having declined * them, inflating the exact number this exists to make honest. */ recordOffers?: boolean; } /** * How many paths one query may answer. * * Every Cortex surface is budgeted, and this one renders a line per file, so an * unbounded list is an unbounded surface. AC #7's ≤30 tokens is enforced * per-file; the cap is what keeps the total bounded too. Excess paths are * dropped rather than truncated mid-line, and the renderer says so — a silently * shortened answer to "have I read these 40 files" reads as "no" for the ones * that fell off, which is the wrong-answer direction AD-6 forbids. */ export declare const READ_LEDGER_MAX_PATHS = 20; /** AC #7. Asserted against the renderer, per file. */ export declare const READ_LEDGER_TOKENS_PER_FILE = 30; /** * Resolve what to hash. * * A relative input is resolved against the **scope root**, not `process.cwd()`. * The stored key is scope-root-relative, and cwd is whatever directory the CLI, * the MCP server or a hook happened to start in — Story 3.2 measured that exact * substitution silently relocating every key it touched. Both transports pass * an absolute path for this reason; the relative branch serves programmatic * callers and stays deterministic for them. With no scope root known there is * nothing better than cwd, and that is the degraded case, not the design. */ export declare function resolveOnDiskPath(inputPath: string, scopeRoot: string | null): string; /** * Files this scope has read that are **still unchanged**, most-read first * (FR-7, Story 3.4). * * **Scope-wide, not session-scoped, and that is forced.** `inject-header` ends * the session tree and creates a fresh primary on every SessionStart, so a * filter for "reads by the asking session" would leave this permanently empty * on the one surface that runs at session start. The caller must therefore * describe the files rather than the reader — Story 3.3 measured 163 primary * sessions against 9 subagents on this repo's live store and had to stop saying * `read by primary` for the same reason. This function returns paths; it never * asserts who read them. * * **The cost is bounded before it is paid, not discovered while paying it.** * "Unchanged" requires re-hashing (AC #2 of Story 3.3), this runs under B-1 * (session brief ≤150 ms p95), and a repository holds files of wildly different * sizes. So candidates are taken in `read_count` order, and `byte_size` — which * Story 3.1 already records — is accumulated against a ceiling *before* any * file is opened. A single 2 MiB candidate cannot consume the whole budget and * leave four cheap files unverified behind it. */ export interface KnownUnchangedOptions { /** Stop once this many unchanged files are found (AC #1 says up to five). */ limit?: number; /** How many rows to consider at all. Bounds the SQL, not the hashing. */ candidateLimit?: number; /** Total recorded bytes this call may hash before it stops looking. */ byteBudget?: number; } export declare const KNOWN_UNCHANGED_LIMIT = 5; export declare const KNOWN_UNCHANGED_CANDIDATES = 24; /** 1 MiB of hashing, well inside B-1 on the platforms measured. */ export declare const KNOWN_UNCHANGED_BYTE_BUDGET: number; export declare function knownUnchangedFiles(store: CortexStore, scopeKeys: string[], options?: KnownUnchangedOptions, /** * Seams, never used in production. The shared digest memo and the pre-hash * `statSync` have no observable difference from their absent counterparts — * building one cache per file and building one per walk return identical * answers — so without a seam a mutation removing either survives every * behavioural assertion. Story 3.3 added exactly this seam to * `queryReadLedger` after that mutation survived once; it was not extended * here, and the same mutation survived again. */ deps?: ReadLedgerDeps & { statSync: typeof fs.statSync; }): string[]; export interface ReadLedgerDeps { /** * Injected only so the per-query memo can be *observed*. Building one cache * per query and building one per path produce identical answers, so no * behavioural assertion can tell them apart — which is exactly how a mutation * removing the memo survived a suite that already tested memoisation, by * passing a cache in explicitly and never exercising the default. Production * always takes the default. Same reasoning as `writeDigestIndex`'s injected * `renameSync`: a mechanism with no observable difference needs a seam or it * regresses silently. */ createDigestCache: typeof createDigestCache; } export declare function queryReadLedger(store: CortexStore, query: ReadLedgerQuery, deps?: ReadLedgerDeps): ReadLedgerResult[]; /** * Make an author-supplied agent name safe to place inside a rendered verdict. * * This is a stored string reaching a renderer, so it takes the discipline * `buildNoteMemoryText`, `renderedAlternatives` and `inspect-memory` already * apply — but the grammar of *this* line makes two extra characters dangerous, * both measured: * * - **`;` forges a qualifier.** Qualifiers render as `(missing; read by …)`, so * an `agent_type` of `general-purpose; missing` produced * `(read by general-purpose; missing)` — a `missing` qualifier no probe ever * returned. Parentheses close and reopen the group for the same reason. * - **The second person defeats AC #6 outright.** An `agent_type` of `you` * rendered `(read by you)` on a read that was explicitly NOT the asker's — * the one sentence AD-16 forbids, with the suite green, because the test * supplies the name it then asserts against. Neutralised as whole tokens, so * an honest name like `youtube-indexer` is untouched. * * Control characters are stripped rather than collapsed: `\x1b[1A\x1b[2K` in a * name erases the *previous* file's verdict from a terminal, so one line's * attribution could hide another line's answer. */ export declare function sanitizeAgentLabel(value: string): string; /** * One line per file, each within AC #7's 30-token budget. * * The path is truncated from the LEFT when a line would exceed the budget: * `…/query/read-ledger.ts` still identifies the file, while a right-truncated * `src/query/read-le…` is ambiguous between siblings — and the verdict, which * is the answer, must never be what gets cut. */ export declare function renderReadLedgerLine(result: ReadLedgerResult): string; export declare function renderReadLedger(results: ReadLedgerResult[], requested?: number): string; //# sourceMappingURL=read-ledger.d.ts.map