/** * Durable session records — what makes session history outlive the raw event * log it was rebuilt from. * * THE PROBLEM. `events.ndjson` is an analytics sink with an analytics sink's * retention: 50 MB / 30 days, truncated oldest-first (core/collector/ * store-retention.ts). Once session history is *built on* that file * (core/session-record.ts), the sweep silently deletes history — a session a * user can read today is gone next month, with no way to tell "never existed" * from "swept". A record the user can open is a product surface; a byte cap on * an append-only debug log is not the right lifetime for it. * * THE FIX. At session end we fold the record once and write a compacted copy to * `~/.sapiom/harness/records/.json`. It is bounded (see the * caps below), it is one file per conversation, and it survives every sweep of * the ndjson because it isn't in it. The ndjson goes back to being what it is: * the raw analytics sink. * * WHY NOT UNDER `//`, which the ticket suggested and * which already has a retention sweep: that directory is deleted the moment a * session's pty exits (server/index.ts's "exited" handler calls * removeGeneratedSessionDir — every file in it is regenerated on the next * launch), and whatever survives that is swept 7 days after it goes stale. * Both lifetimes are *shorter* than the 30 days of events the archive exists to * outlive, so a record written there would die sooner than the log it was meant * to survive. Records get their own root and their own policy instead. * * COMPACTION is lossy, on purpose, and says so. Tool inputs and tool results * are the bulk of the bytes (`tool.call` was ~80% of observed event volume) and * the least useful part of a record read weeks later, so they are clipped hard; * prompts and assistant text — the conversation — are clipped only at the * collector's own field cap, so in practice they are kept whole. Every loss * lands in `SessionRecord.limitations` (`compacted-archive`, * `dropped-early-turns`) and the UI renders it as prose, per this epic's rule * that a reconstruction never gets to imply it is complete. * * SIZE CEILING, stated exactly: a record is compacted to at most * {@link RECORD_MAX_BYTES} by clipping fields and then dropping whole turns * oldest-first — but never below one turn. A single turn with hundreds of tool * calls can therefore exceed the per-record cap; the store's total-bytes cap * ({@link RECORDS_MAX_TOTAL_BYTES}, enforced against real file sizes) is the * hard bound, and one outsized record just crowds out older ones sooner. */ import type { SessionRecord } from "../shared/types.js"; /** Tool input, clipped hard: a 40 KB Edit payload is not what makes a * months-old record worth keeping, and it is most of its bytes. */ export declare const RECORDS_MAX_TOOL_INPUT_CHARS = 512; /** Tool result, same reasoning. The collector already caps these at 16 KB. */ export declare const RECORDS_MAX_TOOL_RESPONSE_CHARS = 512; /** Prompts and assistant text — the conversation itself. Set at the * collector's own field cap (normalizer.ts's MAX_FIELD_LENGTH), so this * clips nothing the log didn't already clip. Kept as our own constant * because the archive's contract is "the conversation survives whole". */ export declare const RECORDS_MAX_TEXT_CHARS = 4000; /** Per-record byte ceiling — see the module header for the one case that * legitimately exceeds it. ~64 KB holds a long session comfortably once tool * payloads are clipped. */ export declare const RECORD_MAX_BYTES: number; /** Whole-store byte ceiling. A third of the raw sink's 50 MB, for two orders * of magnitude more sessions: at 64 KB worst-case per record it guarantees * 256 archived conversations, and at realistic sizes holds thousands. */ export declare const RECORDS_MAX_TOTAL_BYTES: number; /** Age ceiling. Deliberately far longer than the events' 30 days — the whole * point of the archive is to outlive them — while still bounded, so an * install that runs for years doesn't accumulate forever. */ export declare const RECORDS_MAX_AGE_MS: number; /** * A record that has been through the archive: identical to a {@link * SessionRecord} except that `archivedAt` is known to be set. The distinction * is load-bearing for the reader, which compares that stamp against the event * log to decide which of the two sources still holds the whole conversation. */ export type ArchivedSessionRecord = SessionRecord & { archivedAt: string; }; /** What the archive knows about one stored record without reading it again. */ export interface ArchivedRecordSummary { /** Primary id the record is filed under (the conversation's first session). */ harnessSessionId: string; /** Every id this record answers to: primary, merged sessions, agent session. */ keys: string[]; /** Human turns in the CONVERSATION (not necessarily in the stored turns — * see `SessionRecord.turnCount`). */ turnCount: number; /** ISO-8601 of when the record was archived. */ archivedAt: string; /** Size of the file on disk. */ bytes: number; /** Absolute path of the record file. */ file: string; } export interface RecordArchiveOptions { /** Root the record files live in. Required — the caller resolves it from the * server's state root so an isolated boot never writes to the real home. */ root: string; maxRecordBytes?: number; maxTotalBytes?: number; maxAgeMs?: number; /** Injectable clock (epoch ms) for tests. */ now?: () => number; /** Diagnostics sink. Defaults to console.error. */ onError?: (err: unknown) => void; } export interface RecordArchive { /** * Compact `record` and write it, replacing any earlier archive of the same * conversation. Returns what was written, or null when there was nothing * worth archiving (a record with no turns) or the write failed — never * throws: failing to archive must not take a session's exit down with it. */ write(record: SessionRecord): Promise; /** * The archived record for `id` — a harnessSessionId (primary or merged) or * the agent's own session id — or null when there is none. Never throws; a * corrupt file reads as absent. */ read(id: string): Promise; /** True when {@link read} would find a record, without reading the file. */ has(id: string): Promise; /** Every stored record's summary, newest first. */ list(): Promise; /** * Enforce the age and total-size caps, oldest-first. Returns the primary ids * removed. Best-effort per file: one undeletable record doesn't abort the * rest. */ sweep(): Promise; } export interface CompactionOptions { maxBytes?: number; maxToolInputChars?: number; maxToolResponseChars?: number; maxTextChars?: number; /** ISO-8601 stamp to record as `archivedAt`. Defaults to now. */ archivedAt?: string; } /** * The bounded, archivable form of a record: fields clipped, oldest turns * dropped if it still doesn't fit, and every loss declared in `limitations`. * * Pure and idempotent — compacting an already-compacted record clips nothing * further and drops nothing further, which matters because a re-archive * (a session that ends twice, a backfill) must not shrink a record a little * more each time. * * `turnCount` and `eventCount` deliberately keep their pre-compaction values: * they describe the conversation that happened, not the excerpt that survived. * A history row's turn count therefore doesn't change when its record is * archived, and `dropped-early-turns` is what tells the reader that `turns` * holds fewer than `turnCount` of them. */ export declare function compactSessionRecord(record: SessionRecord, options?: CompactionOptions): ArchivedSessionRecord; export declare function createRecordArchive(options: RecordArchiveOptions): RecordArchive; export interface BackfillOptions { /** Conversations present in the event log, newest activity first. */ conversationIds: () => Promise; /** Fold a conversation from the events alone (never from the archive). */ readFromEvents: (harnessSessionId: string) => Promise; archive: Pick; /** A live session is still writing events; archiving it now would store a * half-finished record and pre-empt the archive its exit will write. */ isLiveSession?: (harnessSessionId: string) => boolean; /** Ceiling on how many conversations one pass archives. */ maxRecords?: number; } /** Limit startup disk writes. Any remainder waits for a later maintenance * pass; retention must preserve the source events until backfill completes. */ export declare const RECORDS_BACKFILL_MAX = 200; /** * Archive conversations the event log still holds but the archive doesn't. * * Two things need this, and neither is covered by archiving at session end: * sessions that ended without one (the harness force-killed, the machine * rebooted), and every session that ended *before* this feature existed — whose * history would otherwise disappear at its 30-day mark with the archive looking * on. Idempotent: a conversation already archived is skipped, so the steady * state after the first pass is "nothing to do". * * Returns the ids it archived and whether the pass finished. An incomplete * pass or a read/write failure must prevent retention from deleting sources. */ export declare function backfillSessionRecords(options: BackfillOptions): Promise<{ archived: string[]; complete: boolean; }>; //# sourceMappingURL=record-archive.d.ts.map