lossless-core 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -1,310 +1,1002 @@
1
1
  import { Static, TSchema, Type } from "typebox";
2
- //#region src/assembly.d.ts
3
- export interface AssemblyMsg {
4
- role: string;
5
- content?: unknown;
6
- /** AgentMessage UserMessage compatibility (Pi requires it on outgoing messages). */
7
- timestamp?: number;
2
+ /** Basename of the JSONL metrics log inside a sidecar directory. */
3
+ export declare const METRICS_FILE = "metrics.jsonl";
4
+ /** Size in bytes that triggers rotation (5 MiB); the full log is renamed aside. */
5
+ export declare const METRICS_MAX_BYTES: number;
6
+ /** Rotated generations kept beside the live log; older timestamped files are deleted. */
7
+ export declare const METRICS_KEEP_GENERATIONS = 3;
8
+ /** One metrics row: free-form fields plus an optional unix-ms stamp, session id, and kind. */
9
+ export interface MetricSinkRow extends Record<string, unknown> {
10
+ ts?: number;
11
+ session?: string;
12
+ kind?: string;
13
+ }
14
+ /** The live metrics file inside a sidecar directory. */
15
+ export declare function metricsFilePath(dir: string): string;
16
+ /** Rename an over-budget log to `<file>.<Date.now()>` and keep only the newest generations. Never throws. */
17
+ export declare function rotateMetricsIfNeeded(dir: string): void;
18
+ /** Append one JSONL row, stamping `ts` in unix ms when absent; creates the dir with mode 0700. Never throws. */
19
+ export declare function appendMetricRow(dir: string, row: Record<string, unknown>): void;
20
+ /** Read rotated generations oldest-first plus the live log, skipping missing files and malformed lines. */
21
+ export declare function readMetricRows(dir: string): unknown[];
22
+ /** Characters of a body's description that stay in the preview. The message row
23
+ * keeps every character, so this bounds what the summarizer reads, not what is
24
+ * retained. */
25
+ export declare const FILE_PREVIEW_CHARS = 1200;
26
+ export declare const FILE_KINDS: readonly ["json", "jsonl", "csv", "tsv", "code", "text"];
27
+ export type FileKind = (typeof FILE_KINDS)[number];
28
+ export declare function isFileKind(value: unknown): value is FileKind;
29
+ export interface FileDescriptor {
30
+ fileId: string;
31
+ path: string;
32
+ /** Characters retained in the message, not bytes on disk. */
33
+ bytes: number;
34
+ sha256: string;
35
+ kind: FileKind;
36
+ preview: string;
8
37
  }
9
- export interface AlignedEntry {
38
+ export declare function fileHandle(d: FileDescriptor): string;
39
+ export declare function filePreviewText(d: FileDescriptor): string;
40
+ export declare function describeFile(path: string, text: string): FileDescriptor;
41
+ /** What the body is, decided by structure rather than by extension alone: a
42
+ * `.ts` file holding a JSON array is JSON, and a `.txt` file holding JSON is
43
+ * JSON too. The parse is tried before the extension for that reason. */
44
+ export declare function kindOf(path: string, text: string): FileKind;
45
+ export declare function structureOf(kind: FileKind, path: string, text: string): string;
46
+ export declare function previewOf(kind: FileKind, path: string, text: string): string;
47
+ /** One row the pattern path scans. The text is the stored message, whole: the
48
+ * FTS index holds a prefix, and reading only that is the defect this path
49
+ * exists to fix. */
50
+ export interface ScannedRow {
51
+ rowid: number;
10
52
  entryId: string;
11
53
  role: string;
12
54
  text: string;
13
- /** Raw content blocks when `text` is not a faithful rendering of them
14
- * (`payloadOf`). Carried so ingest stores the same bytes at every call site. */
15
- payload?: string;
16
- fileHint?: {
17
- path: string;
18
- };
55
+ removed: boolean;
19
56
  }
20
- export declare const SYNTHETIC_MARKER = "[LCM session memory";
21
- export declare function tailKeyOf(m: AssemblyMsg | undefined): string;
22
- /** Characters the estimator counts for a context: the same rendering `cutIndexFor`
23
- * measures, so a calibration describes the quantity every threshold here is
24
- * compared against. */
25
- export declare function contextChars(messages: readonly AssemblyMsg[]): number;
26
- export declare function cutIndexFor(messages: readonly AssemblyMsg[], keepRecentTokens: number): number | null;
27
- export interface SyntheticMessage {
28
- role: "user";
29
- content: string;
57
+ /** A row that matched, with the first match's position and how many the row
58
+ * held. `partial` is set when the row was longer than the per-row cap, so a
59
+ * reader knows the scan stopped before the end of that row. */
60
+ export interface PatternHit {
61
+ row: ScannedRow;
62
+ matches: number;
63
+ at: number;
64
+ partial: boolean;
65
+ scannedChars: number;
66
+ }
67
+ export type PatternStop = "end" | "deadline" | "page";
68
+ export interface PatternScanOutcome {
69
+ hits: PatternHit[];
70
+ rows: number;
71
+ partialRows: number;
72
+ more: boolean;
73
+ stopped: PatternStop;
74
+ lastRowId?: number;
75
+ lastEntryId?: string;
76
+ }
77
+ export interface PatternScanOptions {
78
+ pattern: CompiledPattern;
79
+ offset: number;
80
+ limit: number;
81
+ /** Characters of one row the scan reads. Above the FTS prefix on purpose,
82
+ * below the size of an externalized body, and named on the hit when it bites. */
83
+ perRowChars: number;
84
+ deadlineAt: number;
85
+ now: () => number;
86
+ }
87
+ /** The characters a caller can hand a regular expression. A row longer than this
88
+ * is read up to the cap and the hit says so: a partial scan that looks complete
89
+ * is the same defect as an index that stops at 100,000 characters. */
90
+ export declare const PATTERN_ROW_CHARS = 200000;
91
+ /** How long one pattern call may scan. Long enough for a session's messages at
92
+ * 200,000 characters a row, short enough that a pathological pattern is a slow
93
+ * call rather than a hung session. */
94
+ export declare const PATTERN_DEADLINE_MS = 1000;
95
+ export declare const PATTERN_PAGE_ROWS = 200;
96
+ /** Matches counted in one row before the scan moves on. A row that mentions a
97
+ * path a thousand times is one hit line, and counting the thousandth costs the
98
+ * deadline that the next row needs. */
99
+ export declare const MAX_MATCHES_PER_ROW = 100;
100
+ export declare function scanRows(rows: Iterable<ScannedRow>, opts: PatternScanOptions): PatternScanOutcome;
101
+ /** A pattern this module compiled and validated, so a scan cannot receive a
102
+ * caller's own stateful `RegExp`. */
103
+ export type CompiledPattern = RegExp & {
104
+ readonly __brand: "CompiledPattern";
105
+ };
106
+ export type PatternCompileResult = {
107
+ ok: true;
108
+ pattern: CompiledPattern;
109
+ } | {
110
+ ok: false;
111
+ reason: string;
112
+ };
113
+ /** Compile a caller's pattern, with `g` for iteration and `i` unless the caller
114
+ * asked for case sensitivity, which is the reading the FTS path already has. A
115
+ * refusal is returned before any row is read. */
116
+ export declare function compilePattern(pattern: string, caseSensitive: boolean): PatternCompileResult;
117
+ export type LcmRole = "user" | "assistant" | "toolResult" | "custom";
118
+ /** What a stored summary's text is worth: `clean` came from an unclipped source
119
+ * of clean children, `clipped` lost source before the model saw it, `mechanical`
120
+ * is the deterministic truncate, and `derived` was built from a non-clean source.
121
+ * The value is durable so a failed ancestor stays discoverable after the child
122
+ * that made it stale has been repaired. */
123
+ export type SummaryQuality = "clean" | "clipped" | "mechanical" | "derived";
124
+ export declare function isSummaryQuality(value: unknown): value is SummaryQuality;
125
+ /** The level-3 ladder stores this prefix ahead of the mechanical truncate. It is
126
+ * a storage marker, so a store migration can recognise predated rows without
127
+ * running the summarizer. */
128
+ export declare const TRUNCATED_SUMMARY_PREFIX = "[lcm:truncated]";
129
+ /** Any role string Pi may emit (custom, branchSummary, bashExecution...) folds to the four stored roles. */
130
+ export declare function normalizeRole(r: string): LcmRole;
131
+ export interface StoredMessage {
132
+ id: number;
133
+ entryId: string;
134
+ role: LcmRole;
135
+ text: string;
136
+ tokens: number;
30
137
  timestamp: number;
138
+ /** JSON of the content blocks, present only when `text` is not a faithful
139
+ * rendering of them (a thinking block, an image, a signature). Absent means
140
+ * the derived text is the whole message. */
141
+ payload?: string;
142
+ /** Set when the entry has left the active branch. The bytes stay; search
143
+ * excludes the row unless a caller asks for it. */
144
+ removedAt?: number;
31
145
  }
32
- export type SummaryTier = "rich" | "terse" | "stub";
33
- export interface RenderableSummary {
146
+ export interface GrepHit {
147
+ entryId: string;
148
+ role: LcmRole;
149
+ text: string;
150
+ /** The entry has left the active branch; its text is retained. */
151
+ removed?: boolean;
152
+ coveringSummaryId?: number;
153
+ }
154
+ export interface SummaryNode {
34
155
  id: number;
156
+ kind: "leaf" | "condensed";
157
+ text: string;
158
+ tokens: number;
35
159
  depth: number;
36
160
  firstEntryId: string;
37
161
  lastEntryId: string;
38
- text: string;
162
+ createdAt: number;
163
+ quality: SummaryQuality;
164
+ /** A richer sibling text the ladder produced and did not store as primary,
165
+ * so the projection can serve more detail when the budget has room. */
39
166
  thoroughText?: string;
40
- tier: SummaryTier;
167
+ thoroughTokens?: number;
41
168
  }
42
- export declare const STUB_CHARS = 200;
43
- export type PlainSummary = Omit<RenderableSummary, "tier" | "thoroughText">;
44
- export declare function renderSummaries(summaries: readonly RenderableSummary[]): string;
45
- export declare function renderFrontier(nodes: readonly PlainSummary[]): string;
46
- export declare function buildSynthetic(summaries: readonly RenderableSummary[]): SyntheticMessage;
47
- //#endregion
48
- //#region src/commit-policy.d.ts
49
- export interface AppliedSummary {
169
+ export interface IntegritySnapshot {
170
+ ftsOk: boolean;
171
+ messageRows: number;
172
+ shapes: Array<{
173
+ id: number;
174
+ kind: "leaf" | "condensed";
175
+ depth: number;
176
+ messages: number;
177
+ children: number;
178
+ }>;
179
+ spans: Array<{
180
+ id: number;
181
+ first: string;
182
+ last: string;
183
+ firstRowid: number | null;
184
+ lastRowid: number | null;
185
+ }>;
186
+ duplicateSpans: Array<{
187
+ first: string;
188
+ last: string;
189
+ kind: string;
190
+ count: number;
191
+ }>;
192
+ orphanChildParents: number[];
193
+ deadRuns: Array<{
194
+ session: string | null;
195
+ rows: number[];
196
+ }>;
197
+ untrackedPending: number[];
198
+ foreignKeyViolations: number;
199
+ schemaVersion: number;
200
+ uncoveredMessages: number;
201
+ }
202
+ /** A fresh row, or the row that already owns the span; `existingTokens` comes
203
+ * from that row, so a caller reports the collision, not its intended write.
204
+ * `own` says whether the run that asked may count this row as its own and replace
205
+ * its text: a row it wrote, a committed row, or its own pending row. Another live
206
+ * run's pending row is none of those. */
207
+ export type InsertSummaryResult = {
50
208
  id: number;
51
- depth: number;
209
+ created: true;
210
+ own: true;
211
+ } | {
212
+ id: number;
213
+ created: false;
214
+ own: boolean;
215
+ existingTokens: number;
216
+ /** The colliding row's summary text. A caller whose view cannot read the row
217
+ * still has to answer for the span it was given, and this is that span's
218
+ * summary to reuse. */
219
+ existingText: string;
220
+ foreignLive: boolean;
221
+ };
222
+ /** What a caller hands `insertSummary`. `run` stamps the row pending for that run;
223
+ * without it the row is committed memory. */
224
+ export type InsertSummaryInput = {
225
+ kind: "leaf" | "condensed";
52
226
  text: string;
53
- thoroughText?: string;
227
+ tokens: number;
228
+ depth: number;
54
229
  firstEntryId: string;
55
230
  lastEntryId: string;
56
- tier: SummaryTier;
231
+ /** Defaults to `clean`: a caller that does not know the provenance quality
232
+ * must not invent a loss the store would then try to repair. */
233
+ quality?: SummaryQuality;
234
+ messageIds?: number[];
235
+ childSummaryIds?: number[];
236
+ fileIds?: string[];
237
+ thoroughText?: string;
238
+ run?: string;
239
+ };
240
+ /** What a compaction pass needs from the store: the committed memory plus, when
241
+ * the pass reads through `LcmStore.forRun`, the rows of its own run. `LcmStore`
242
+ * satisfies this with the committed-only meaning. */
243
+ export interface PassStore extends IngestStore {
244
+ /** The branch range a set of entry ids occupies: the oldest and the newest row
245
+ * the store holds among them, or null when it holds none. Pi's context array
246
+ * is not branch order (it hoists the newest compaction entry to the front),
247
+ * while a span is a row range, so the store, whose rows are appended in
248
+ * branch order, is what decides which end comes first. */
249
+ spanBounds(entryIds: readonly string[]): {
250
+ firstEntryId: string;
251
+ lastEntryId: string;
252
+ } | null;
253
+ uncoveredMessagesInSpan(firstEntryId: string, lastEntryId: string): StoredMessage[];
254
+ filesForMessages(ids: readonly number[]): Array<FileDescriptor & {
255
+ messageId: number;
256
+ }>;
257
+ frontier(firstEntryId: string, lastEntryId: string): SummaryNode[];
258
+ getSummary(id: number): SummaryNode | undefined;
259
+ parentSummaryIds(summaryId: number): number[];
260
+ fileIdsForSummaries(ids: readonly number[]): string[];
261
+ /** Stored messages in row order, so a pass can re-read the exact source a
262
+ * leaf's provenance names. */
263
+ messagesByIds(ids: readonly number[]): StoredMessage[];
264
+ insertSummary(s: InsertSummaryInput): InsertSummaryResult;
265
+ /** `quality` is optional so a caller rewriting text it did not derive keeps
266
+ * the stored verdict; a replacement that knows its source passes it. */
267
+ updateSummaryText(id: number, text: string, tokens: number, quality?: SummaryQuality): void;
268
+ setThoroughText(id: number, thoroughText: string): void;
57
269
  }
58
- export type AssemblyAction = "quiet" | "consider-apply" | "stable" | "recommit";
59
- export interface CommitState {
60
- cutCount: number;
61
- synthetic: SyntheticMessage;
62
- applied: AppliedSummary[];
63
- summaryId: number;
64
- appliedAtOccupancy: number;
65
- tailKey: string;
270
+ /** A node's provenance split into the two child types, each in the order the
271
+ * source ran: messages by row, child summaries by the row their span starts at. */
272
+ export interface SummarySources {
273
+ messages: number[];
274
+ summaries: number[];
66
275
  }
67
- export declare const DEFAULT_SWAP_RATIO = 0.7;
68
- export declare const DEFAULT_RECUT_RATIO = 0.85;
69
- export declare const WINDOW_SAFETY_CEILING = 0.9;
70
- /** Ceiling for the resolved recut fraction: a line above 1.0 is unreachable, so
71
- * the pin never re-cuts and the raw tail grows past the window while "stable"
72
- * is reported forever. Strictly above WINDOW_SAFETY_CEILING + RECUT_ABOVE_SWAP_MARGIN. */
73
- export declare const RECUT_CEILING = 0.95;
74
- export declare const REZONE_MARGIN = 1.18;
75
- export interface ThresholdConfig {
76
- swapAtRatio?: number;
77
- swapAtTokens?: number;
78
- recutAtRatio?: number;
79
- recutAtTokens?: number;
80
- smartZone?: number;
81
- }
82
- export type ZoneThresholds = ThresholdConfig;
83
- export interface ResolvedThresholds {
84
- swap: number;
85
- recut: number;
86
- clamped: boolean;
87
- }
88
- export declare function mergeZone(global: ThresholdConfig, zone?: ZoneThresholds): ThresholdConfig;
89
- export declare function thresholdConflicts(cfg: ThresholdConfig, scope?: string): string[];
90
- export declare function effectiveThresholds(cfg: ThresholdConfig, contextWindow: number): ResolvedThresholds;
91
- export declare function nextAction(state: CommitState | null, occupancy: number, swapRatio: number, recutRatio: number): AssemblyAction;
92
- export declare function cutMayReplace(previous: CommitState | null, newCutCount: number): boolean;
93
- //#endregion
94
- //#region src/metrics.d.ts
95
- export declare function metricsPath(): string;
96
- /** The metrics log, oldest generation first, so a window longer than one
97
- * generation still reads as one history. */
98
- export declare function metricsLogFiles(): string[];
99
- export declare function rotateIfNeeded(path: string, maxBytes: number): void;
100
- export declare function pruneGenerations(path: string, keep: number): void;
101
- export declare function appendMetric(record: MetricRecord): void;
102
- export interface UsageLike {
103
- input?: unknown;
104
- output?: unknown;
105
- cacheRead?: unknown;
106
- cacheWrite?: unknown;
107
- cost?: {
108
- input?: unknown;
109
- output?: unknown;
110
- total?: unknown;
111
- };
112
- }
113
- interface MetricBase {
114
- /** Session tag. Absent on rows written outside a session (`gc`, `import`). */
115
- session?: string;
276
+ /** Replace a node's text only if it still holds the text the caller read. The
277
+ * rich tier is cleared in the same statement: it described the old source. */
278
+ export interface SummaryTextCas {
279
+ id: number;
280
+ expectedText: string;
281
+ expectedTokens: number;
282
+ text: string;
283
+ tokens: number;
284
+ /** The verdict for the new text. A repair always writes `clean`. */
285
+ quality: SummaryQuality;
286
+ }
287
+ /** The store surface a heal run needs. It is a `PassStore` plus the committed
288
+ * scan and compare-and-swap write that a repair uses, so a heal cannot write
289
+ * through a run view whose pending rows the projection does not serve. */
290
+ export interface HealStore extends PassStore {
291
+ /** Committed nodes whose quality is not clean, or whose text carries the
292
+ * mechanical prefix as a fallback for imported and predated rows. */
293
+ taintedSummaries(prefix: string): SummaryNode[];
294
+ summarySources(summaryId: number): SummarySources;
295
+ casUpdateSummaryText(update: SummaryTextCas): boolean;
296
+ /** Mark a clean node stale because a child changed under it. A node already
297
+ * non-clean keeps its verdict: mechanical and clipped are facts about its own
298
+ * text, and derived is already the retry marker. */
299
+ markSummaryDerived(id: number): boolean;
116
300
  }
117
- export type MetricSpan = [string, string];
118
- export type SummaryStage = "leaf" | "condensed";
119
- export type CompactionStage = "blocking" | "async" | "direct";
120
- export type SummaryLevel = 1 | 2 | 3;
121
- /** One model's turn in a summarizer chain, in the order it was tried. */
122
- type SummarizerAttempt = {
123
- model: string;
124
- outcome: "ok" | "empty";
125
- } | {
126
- model: string;
127
- outcome: "threw";
128
- error: string;
129
- };
130
- export type UsageCounters = {
131
- input: number | null;
132
- output: number | null;
133
- cacheRead: number | null;
134
- cacheWrite: number | null;
135
- costInput: number | null;
136
- costOutput: number | null;
137
- costTotal: number | null;
138
- };
139
- /** The counters to put on a row, or `undefined` when the provider billed
140
- * nothing (an aborted call, or a provider that reports no usage). */
141
- export declare function billedCounters(usage: UsageLike | undefined): UsageCounters | undefined;
142
- /** One metrics row per measured assistant message: no numeric counter at all
143
- * (aborted turn, legacy row) yields `undefined`, so it does not count as a turn
144
- * in the report's cache-economics averages. */
145
- export declare function usageRecord(usage: UsageLike | undefined): UsageRow | undefined;
146
- export type SummarizerStage = "blocking" | "async";
147
- /** Why a summarizer call failed, as far as the provider's own text says. A
148
- * closed set, and `unknown` is a real answer: a classification that guesses is
149
- * worse than a bucket that says it could not tell, and the raw message stays on
150
- * the row either way. `capped` is not a provider failure: it is the pass giving
151
- * up after too many of them, and the category beside it says which one. */
152
- export type SummarizerFailureCategory = "auth" | "rate-limit" | "timeout" | "unavailable" | "length" | "empty" | "capped" | "unknown";
153
- /** `ok` answered with usable text, `empty` answered with none, `failure` was rejected. */
154
- export type SummarizerOutcome = "ok" | "empty" | "failure";
155
- export interface SummarizerUsageOptions {
156
- /** The resolved model key, as `modelKey` renders it. */
157
- model: string;
158
- stage?: SummarizerStage;
159
- outcome: SummarizerOutcome;
160
- /** The provider's own stop word, so `length` and `error` are told apart. */
161
- stopReason?: string;
162
- session?: string;
301
+ export interface IngestStore {
302
+ hasEntry(entryId: string): boolean;
303
+ insertMessage(m: {
304
+ entryId: string;
305
+ role: LcmRole;
306
+ text: string;
307
+ tokens: number;
308
+ timestamp: number;
309
+ payload?: string;
310
+ }): number;
311
+ insertFile(d: FileDescriptor, firstEntryId: string): void;
312
+ linkMessageFile(messageId: number, fileId: string): void;
163
313
  }
164
- export type SummarizerUsageRow = UsageCounters & {
165
- event: "lcm";
166
- kind: "summarizer-usage";
167
- model: string;
168
- stage?: SummarizerStage;
169
- outcome: SummarizerOutcome;
170
- stopReason?: string;
171
- session?: string;
172
- };
173
- /** One provider call the summarizer made. `usage` rows cannot carry it, since
174
- * those come from Pi's `message_end` for the session's own messages while the
175
- * summarizer talks to the model registry directly. */
176
- export declare function summarizerUsageRecord(usage: UsageLike | undefined, opts: SummarizerUsageOptions): SummarizerUsageRow | undefined;
177
- export type UsageRow = MetricBase & UsageCounters & {
178
- event: "usage";
179
- };
180
- interface LcmRowBase extends MetricBase {
181
- event: "lcm";
314
+ /** Characters of a message the full-text index covers. Storage is verbatim, so
315
+ * this bounds the index and not the row. */
316
+ export declare const FTS_PREFIX_CHARS = 100000;
317
+ /** First published schema: pre-publish development stores were wiped, so every
318
+ * store in the wild starts here and later generations count from this one.
319
+ * Generation 2 adds the summary quality verdict. */
320
+ export declare const SCHEMA_VERSION = 2;
321
+ /** A store left behind by an older build. A read-only open refuses it rather than
322
+ * migrating it: a search must not rewrite a file it is only reading, and a store
323
+ * that cannot answer this build's statements has to be a reported skip. */
324
+ export declare class StaleStoreError extends Error {
325
+ readonly generation: number;
326
+ constructor(generation: number);
182
327
  }
183
- export type ContextDecisionRow = LcmRowBase & {
184
- kind: "context-decision";
185
- action: string;
186
- occupancy: number;
187
- tokens: number;
188
- window: number;
189
- soft: number;
190
- commit: number;
191
- zone?: string;
192
- committed: boolean;
193
- /** Present when `tokens` is the estimator's count rather than Pi's: the
194
- * reported figure described a context other than the one on hand. */
195
- tokensEstimated?: true;
196
- };
197
- export type ProjectionOverBudgetRow = LcmRowBase & {
198
- kind: "projection-over-budget";
199
- nodes: number;
200
- renderedTokens: number;
201
- budgetTokens: number;
202
- };
203
- export type SwapAppliedRow = LcmRowBase & {
204
- kind: "swap-applied";
205
- summaryId: number;
206
- cutCount: number;
207
- distinctBoundary: boolean;
208
- appliedAtOccupancy: number;
209
- nodes: number;
210
- stubbed: number;
211
- renderedTokens: number;
212
- budgetTokens: number;
213
- };
214
- /**
215
- * A swap the policy did not commit. `tokens` is the occupancy that triggered
216
- * the bail; the kick-backoff writer names the entry it declined to kick instead.
217
- */
218
- export type SwapBlockedRow = LcmRowBase & {
219
- kind: "swap-blocked";
220
- reason: string;
221
- occupancy: number;
222
- tokens?: number;
223
- entryId?: string;
224
- };
225
- export type PiCompactionRow = LcmRowBase & {
226
- kind: "pi-compaction";
227
- entryId: string;
228
- tokens?: number;
229
- window?: number;
230
- };
231
- /** One calibration sample against Pi's own count: the characters this build
232
- * counted for the growth of the context, the tokens Pi reported for it, and the
233
- * ratio the samples now support. A null ratio is the answer "cannot say", which is
234
- * why the row carries the sample beside it. */
235
- export type EstimateCalibratedRow = LcmRowBase & {
236
- kind: "estimate-calibrated";
237
- chars: number;
238
- tokens: number;
328
+ /** What a store knows about a model rather than about a session: the token ratio
329
+ * measured for it against Pi's reported usage, and the context window a provider
330
+ * stated when it refused an over-window request. A null ratio is a measurement that
331
+ * disagree with itself, which is a state the estimator reads as "keep the
332
+ * constant" and the report names. A null window is a model no provider has
333
+ * corrected, which leaves Pi's belief in place. */
334
+ export interface ModelState {
239
335
  charsPerToken: number | null;
240
336
  samples: number;
241
- };
242
- /** A provider stated the real context window in an overflow error, which is the
243
- * one window number in this system that is not a belief. */
244
- export type WindowCorrectedRow = LcmRowBase & {
245
- kind: "window-corrected";
246
- model: string;
247
- window: number;
248
- source: string;
249
- };
250
- /** A refused request proved the window in use was too big without naming the
251
- * real one: the window for the next turn is floored at what the request carried,
252
- * and `window` is the stored correction when one exists. */
253
- export type WindowFloorRow = LcmRowBase & {
254
- kind: "window-floor";
255
- tokens: number;
256
- window: number | null;
257
- };
258
- export type ConfigProblemRow = LcmRowBase & {
259
- kind: "config-problem";
260
- problems: string[];
261
- };
262
- export type SummaryInputClippedRow = LcmRowBase & {
263
- kind: "summary-input-clipped";
264
- entryId: string;
265
- keptChars: number;
266
- droppedChars: number;
267
- };
268
- export type SummaryFallbackStoredRow = LcmRowBase & {
269
- kind: "summary-fallback-stored";
270
- stage: SummaryStage;
271
- span: MetricSpan;
272
- sourceChars: number;
273
- };
274
- export type SummaryStoredRow = LcmRowBase & {
275
- kind: "summary-stored";
276
- stage: SummaryStage;
277
- level: SummaryLevel;
278
- depth: number;
279
- tokens: number;
280
- summaryId: number;
281
- span: MetricSpan;
282
- };
283
- export type SummarySkippedCoveredRow = LcmRowBase & {
284
- kind: "summary-skipped-covered";
285
- stage: SummaryStage;
286
- span: MetricSpan;
287
- messages: number;
288
- };
289
- export type SummarySkippedExistingRow = LcmRowBase & {
290
- kind: "summary-skipped-existing";
291
- stage: SummaryStage;
292
- summaryId: number;
293
- depth: number;
294
- span: MetricSpan;
295
- attemptedTokens: number;
296
- existingTokens: number;
297
- };
298
- export type SummaryOversizedRow = LcmRowBase & {
299
- kind: "summary-oversized";
300
- stage: SummaryStage;
301
- depth: number;
337
+ contextWindow: number | null;
338
+ windowSource: string | null;
339
+ }
340
+ export declare const WINDOW_SOURCE_PROVIDER = "provider-error";
341
+ export declare class LcmStore {
342
+ private db;
343
+ private dbPath;
344
+ /** Opened for reading a past session's store: no migration ran, so `generation`
345
+ * is the file's own and a caller may have to skip it. */
346
+ private readOnly;
347
+ /** The schema generation this file carries, which read-only opens do not
348
+ * change. A writer holds `SCHEMA_VERSION` by the time its constructor returns. */
349
+ readonly generation: number;
350
+ /** True while a `transaction` callback is running, so a reentrant call joins
351
+ * the open transaction instead of nesting one, which SQLite rejects. */
352
+ private inTransaction;
353
+ /** Tokens of the runs this handle has open. Two runs can overlap inside one
354
+ * handle, because the blocking path stops waiting for the async pass, so a
355
+ * token names a run and not the handle. Whether a run this handle does not
356
+ * hold is still running is read from the `runs` table, never from here. */
357
+ private liveTokens;
358
+ private reapedInRun;
359
+ private stmts;
360
+ constructor(dbPath: string, opts?: {
361
+ readOnly?: boolean;
362
+ queryOnly?: boolean;
363
+ });
364
+ /** Close the handle: no run of this handle survives it, and the connection is
365
+ * released even when a run could not be abandoned, so a failure here cannot
366
+ * leave a connection and a set of invisible rows behind. */
367
+ close(): void;
368
+ /** Open a run: rows written with its token are pending until `commitRun`, and
369
+ * the row this writes is what lets another handle tell it from a run whose
370
+ * writer is gone. Reaps what such a run left, which this handle cannot be. */
371
+ openRun(opts?: {
372
+ session?: string;
373
+ }): {
374
+ token: string;
375
+ dropped: number;
376
+ };
377
+ /** True while any run is open whose writer is still there, this handle's or
378
+ * another process's, which is what an import has to wait for. */
379
+ hasLiveRun(): boolean;
380
+ /** Commit a run: drop its pending rows whose child no longer resolves, then flip
381
+ * the rest. One transaction, so a reader never sees a half-committed run, and
382
+ * idempotent for a token this handle no longer holds. */
383
+ commitRun(token: string): {
384
+ committed: number;
385
+ dropped: number;
386
+ };
387
+ /** Delete a run's pending rows and close it. Idempotent, so a `finally` can call
388
+ * it after a commit has already closed the run. */
389
+ abandonRun(token: string): number;
390
+ /** A view of this store for one run: the committed memory plus that run's own
391
+ * rows, which is what a pass must read to see its own writes. */
392
+ forRun(token: string): PassStore;
393
+ /** True while the run that wrote a row is still going somewhere, as far as this
394
+ * store can tell. A run this handle holds is live; a run written by this
395
+ * process is live, because a pid cannot separate two handles in one process and
396
+ * refusing to delete is the safe answer; a run written by another process is
397
+ * live while its pid is. A row whose run has no record at all was written
398
+ * without one, which no open path does, so nothing owns it. */
399
+ private isRunLive;
400
+ /** Delete what a run left behind whose writer is gone, and its record with it.
401
+ * A live owner's rows are never touched, whatever handle asks, which is what
402
+ * makes a second handle safe to open. No reader can see the rows this removes,
403
+ * and each one holds the span it claims. */
404
+ private reapPending;
405
+ /** Delete this run's pending rows whose child no longer exists, deepest first: a
406
+ * parent that loses a child must not leave a grandparent behind. A child can go
407
+ * away between the frontier read that selected it and this commit. */
408
+ private dropUnresolvable;
409
+ hasEntry(entryId: string): boolean;
410
+ insertMessage(m: {
411
+ entryId: string;
412
+ role: LcmRole;
413
+ text: string;
414
+ tokens: number;
415
+ timestamp: number;
416
+ payload?: string;
417
+ }): number;
418
+ /** One write transaction around a group of statements, so a compaction is
419
+ * atomic: the node row, its provenance rows and its file links either all
420
+ * land or none do. `node:sqlite` ships no transaction helper, so the
421
+ * statements are explicit. A reentrant call joins the open transaction: the
422
+ * caller that opened it owns the commit, so an inner throw rolls the whole
423
+ * group back rather than leaving a half-applied write. */
424
+ transaction<T>(fn: () => T): T;
425
+ insertSummary(s: InsertSummaryInput): InsertSummaryResult;
426
+ insertFile(d: FileDescriptor, firstEntryId: string): void;
427
+ linkMessageFile(messageId: number, fileId: string): void;
428
+ filesForMessages(ids: readonly number[]): Array<FileDescriptor & {
429
+ messageId: number;
430
+ }>;
431
+ fileIdsForMessages(ids: readonly number[]): string[];
432
+ fileIdsForSummaries(ids: readonly number[]): string[];
433
+ filesForSummaries(ids: readonly number[]): FileDescriptor[];
434
+ allFiles(): Array<FileDescriptor & {
435
+ entryIds: string[];
436
+ }>;
437
+ /** Replace a node's text in place: the span, provenance, and created_at stay.
438
+ * A caller that knows the new text's verdict passes it; a caller that does not
439
+ * leaves the stored verdict alone. */
440
+ updateSummaryText(id: number, text: string, tokens: number, quality?: SummaryQuality): void;
441
+ setThoroughText(id: number, thoroughText: string): void;
442
+ messagesInSpan(firstEntryId: string, lastEntryId: string): StoredMessage[];
443
+ spanBounds(entryIds: readonly string[]): {
444
+ firstEntryId: string;
445
+ lastEntryId: string;
446
+ } | null;
447
+ uncoveredMessagesInSpan(firstEntryId: string, lastEntryId: string, run?: string): StoredMessage[];
448
+ /** Minimal top-level cover: no summary's child, oldest first, dropping any node
449
+ * whose span sits inside the previous kept one, which is what keeps the cover
450
+ * non-nested: condensation's group span must be one no row already holds. */
451
+ frontier(firstEntryId: string, lastEntryId: string, run?: string): SummaryNode[];
452
+ grep(pattern: string, opts?: {
453
+ limit?: number;
454
+ offset?: number;
455
+ withinMessageIds?: ReadonlySet<number>;
456
+ includeRemoved?: boolean;
457
+ }): GrepHit[];
458
+ grepCount(pattern: string, opts?: {
459
+ withinMessageIds?: ReadonlySet<number>;
460
+ includeRemoved?: boolean;
461
+ }): number | undefined;
462
+ messagePage(afterRowId: number, limit: number, opts?: {
463
+ withinMessageIds?: ReadonlySet<number>;
464
+ includeRemoved?: boolean;
465
+ }): ScannedRow[];
466
+ coveringSummaryId(entryId: string): number | undefined;
467
+ messageByEntryId(entryId: string): StoredMessage | undefined;
468
+ parentSummaryIds(summaryId: number, run?: string): number[];
469
+ childSummaryIds(summaryId: number): number[];
470
+ taintedSummaries(prefix: string): SummaryNode[];
471
+ summarySources(summaryId: number): SummarySources;
472
+ /** One statement, so the text and its token count move together and a row
473
+ * another writer changed is reported as not updated rather than overwritten. */
474
+ casUpdateSummaryText(update: SummaryTextCas): boolean;
475
+ /** A clean node whose child changed becomes `derived`; a node that already
476
+ * carries a stronger verdict keeps it. Returns true when this call wrote. */
477
+ markSummaryDerived(id: number): boolean;
478
+ getSummary(id: number, run?: string): SummaryNode | undefined;
479
+ /** Bounded by a visited set, so a corrupt cycle cannot recurse forever. */
480
+ coveredMessageIds(summaryId: number, seen?: Set<number>): number[];
481
+ integritySnapshot(): IntegritySnapshot;
482
+ /** Restore the full-text index. The schema is re-applied first, because a
483
+ * dropped virtual table cannot be rebuilt, only recreated, and the DDL that
484
+ * knows how is the schema itself. Everything in it is `IF NOT EXISTS` apart
485
+ * from the two triggers, which are dropped and recreated on purpose. */
486
+ rebuildFts(): void;
487
+ deleteOrphanSpans(): number;
488
+ deleteOrphanChildren(): number;
489
+ reapDeadRuns(): number;
490
+ /** When one span is claimed twice, the row that carries provenance wins over
491
+ * one that cannot be expanded, and the newest wins between equals. */
492
+ dedupeSpans(): number;
493
+ /** Mark entries that left the active branch, or clear the mark when a
494
+ * navigation brings them back. Returns the rows the call actually changed, so
495
+ * a metric can tell a real change from a no-op reconciliation. */
496
+ setRemoved(entryIds: readonly string[], removedAt: number | null): number;
497
+ removedMessageCount(): number;
498
+ messagesByIds(ids: readonly number[]): StoredMessage[];
499
+ allMessages(): StoredMessage[];
500
+ entrySpan(): {
501
+ first: string;
502
+ last: string;
503
+ } | undefined;
504
+ coveredSize(summaryId: number): {
505
+ tokens: number;
506
+ messages: number;
507
+ };
508
+ allSummaries(): SummaryNode[];
509
+ messageCount(): number;
510
+ /** Number of stored messages whose entry id starts with `prefix`. Used by
511
+ * status reporting to count a family of rows (for example compaction marks
512
+ * and summaries) without scanning the table. */
513
+ entryCountWithPrefix(prefix: string): number;
514
+ backup(backupPath: string): void;
515
+ /** The calibration recorded for a model. `undefined` is a model nothing has
516
+ * sampled; a null ratio is one whose samples disagreed, which the caller reads
517
+ * as "keep the constant" rather than "no data". */
518
+ getModelState(modelKey: string): ModelState | undefined;
519
+ setModelState(modelKey: string, state: Pick<ModelState, "charsPerToken" | "samples">): void;
520
+ /** Records the window a provider stated. Its own statement rather than part of
521
+ * `setModelState`, because the two are learned at different moments: a later
522
+ * calibration must not clear a window, and a window must not touch samples. */
523
+ setModelWindow(modelKey: string, contextWindow: number, source: string): void;
524
+ stats(): {
525
+ messages: number;
526
+ summaries: number;
527
+ dbBytes: number;
528
+ payloadMessages: number;
529
+ payloadBytes: number;
530
+ files: number;
531
+ fileBytes: number;
532
+ removedMessages: number;
533
+ };
534
+ }
535
+ /** Reply when the caller has no store: nothing ingested yet, or the session is ephemeral. */
536
+ export declare const NO_STORE = "LCM: no stored history for this session yet (nothing has been ingested, or the session is ephemeral).";
537
+ /** Search scopes `lcm_grep` accepts: this session only, plus the past-session scopes. */
538
+ export declare const RECALL_SCOPES: readonly ["session", "sessions", "all_sessions"];
539
+ /** One of `RECALL_SCOPES`; `sessions` and `all_sessions` are equivalent and both read past sidecars. */
540
+ export type RecallScope = (typeof RECALL_SCOPES)[number];
541
+ /** What `lcm_expand` reads: a summary listing by id, or one message by entry id with an optional summary check. */
542
+ export type Address = {
543
+ readonly kind: "summary";
544
+ readonly id: number;
545
+ } | {
546
+ readonly kind: "entry";
547
+ readonly entryId: string;
548
+ readonly id?: number;
549
+ };
550
+ /** Past-search outcome: redacted text plus hit, scanned, and skipped session counts. */
551
+ export interface RecallPastSearchResult {
552
+ readonly text: string;
553
+ readonly hits: number;
554
+ readonly scanned: number;
555
+ readonly skipped: number;
556
+ }
557
+ /** Past-session open outcome: the read-only store and id, or why it could not be opened. */
558
+ export type OpenPastResult = {
559
+ readonly kind: "opened";
560
+ readonly sessionId: string;
561
+ readonly store: LcmStore;
562
+ } | {
563
+ readonly kind: "missing" | "unreadable";
564
+ };
565
+ /** Host seams the recall answers read through: store lookup, redactor, metrics recorder, and past-session hooks. */
566
+ export interface RecallToolDeps {
567
+ readonly storeFor: (sessionId: string | undefined) => LcmStore | undefined;
568
+ readonly redact: (text: string) => string;
569
+ readonly record?: (row: Record<string, unknown>) => void;
570
+ readonly sessionOf?: (sessionId: string) => string;
571
+ readonly isReader?: (sessionId: string) => boolean;
572
+ readonly searchPast?: (session: string, query: string, scope: Exclude<RecallScope, "session">, limit: unknown) => RecallPastSearchResult;
573
+ readonly openPast?: (session: string, pastSession: string) => OpenPastResult;
574
+ readonly pastSearchUnreachableText?: string;
575
+ readonly pastReadUnreachableText?: string;
576
+ }
577
+ /** Answer `lcm_grep`: the session `grepView`, or past sidecars when the scope names them; readers stay session-scoped. */
578
+ export declare function grepAnswer(deps: RecallToolDeps, callerSessionID: string, input: unknown): string;
579
+ /** Answer `lcm_describe`: the redacted cost and contents of one summary id, or why it missed. */
580
+ export declare function describeAnswer(deps: RecallToolDeps, callerSessionID: string, input: unknown): string;
581
+ /** Answer `lcm_expand`: list a summary or read one message, capped at `MAX_EXPAND_CHARS`; a foreign `session` id reads a past sidecar read-only. */
582
+ export declare function expandAnswer(deps: RecallToolDeps, callerSessionID: string, input: unknown): string;
583
+ /** Parse `id`/`entry_id` args into an `Address`: error text when malformed, undefined when neither is present. */
584
+ export declare function readAddress(args: Record<string, unknown>): Address | string | undefined;
585
+ /** Refusal text when an address names no stored summary or message, else undefined. */
586
+ export declare function addressProblem(store: LcmStore, address: Address): string | undefined;
587
+ /** Read a validated address with `char_offset` paging capped at `maxChars` characters. */
588
+ export declare function readAddressed(store: LcmStore, redact: (text: string) => string, address: Address, args: Record<string, unknown>, maxChars: number): {
589
+ ok: boolean;
590
+ text: string;
591
+ };
592
+ /** Classify an `lcm_expand` reply as hit, miss, refused, or error for metric rows. */
593
+ export declare function expandQueryOutcome(text: string): "hit" | "miss" | "refused" | "error";
594
+ /** Shared redaction seam for every harness adapter. Pi passes its live
595
+ * config getter so a cockpit toggle takes effect immediately; OpenCode
596
+ * passes its resolved plugin options (which today carry no flag, so the
597
+ * adapter always redacts, preserving the current ignore-flag behavior in
598
+ * one central place). Both shapes flow into core's `makeRedact`, so the
599
+ * flag check lives here, not at each call site. */
600
+ /** Flag carrier both adapters satisfy: unset redacts, `false` disables. */
601
+ export interface RedactConfig {
602
+ readonly redactSecrets?: boolean;
603
+ }
604
+ /** One adapter for `piConfig` (via getter) and `opencodeOpts` (via value):
605
+ * a config value or a getter for one, out a `(text) => string` redactor. */
606
+ export declare function makeRedactConfigAdapter(config: RedactConfig | (() => RedactConfig)): (text: string) => string;
607
+ /** One sidecar on disk, named by its file stem. */
608
+ export interface SidecarRef {
609
+ readonly id: string;
610
+ readonly path: string;
611
+ }
612
+ /** File suffix for sidecar databases. OpenCode uses `.sqlite`. */
613
+ export declare const SIDECAR_EXTENSION = ".sqlite";
614
+ /** Replace every character a session id cannot carry with `_`. */
615
+ export declare function sanitizeSidecarId(sessionId: string): string;
616
+ /** Create the sidecar directory when missing, then repair its mode. */
617
+ export declare function ensureSidecarDir(dir: string): void;
618
+ /** Sidecar file for a session id: sanitized stem plus extension, joined under the sidecar dir. */
619
+ export declare function sidecarPathFor(dir: string, sessionId: string, extension?: string): string;
620
+ /** Live sidecar refs newest-first (mtime, then id); skips non-files and one optionally excluded path. */
621
+ export declare function listSidecarRefs(dir: string, opts?: {
622
+ extension?: string;
623
+ excludePath?: string;
624
+ }): SidecarRef[];
625
+ /** Delete a sidecar database and its WAL and shared-memory files. */
626
+ export declare function dropSidecarFiles(path: string): void;
627
+ /** Where sidecars live and which extension names them; defaults to `.sqlite`. */
628
+ export interface SidecarStoresOptions {
629
+ readonly dir: string;
630
+ readonly extension?: string;
631
+ }
632
+ /** One SQLite sidecar per session, opened on first use and held for reuse. */
633
+ export declare class SidecarStores {
634
+ private readonly stores;
635
+ /** Sidecar directory this cache reads and writes. */
636
+ readonly dir: string;
637
+ /** File extension naming sidecar databases, including the leading dot. */
638
+ readonly extension: string;
639
+ /** Open the cache, creating the dir (mode 0700) when missing; stores open lazily. */
640
+ constructor(options: SidecarStoresOptions);
641
+ /** File path for one session id under this cache's dir and extension. */
642
+ pathFor(sessionId: string): string;
643
+ /** Open-once store for a session id, reused across calls until dropped or closed. */
644
+ for(sessionId: string): LcmStore;
645
+ /** Open store already held for a session id, if any, without opening one. */
646
+ peek(sessionId: string): LcmStore | undefined;
647
+ /** Session ids with an open store in this cache. */
648
+ ids(): string[];
649
+ /** Live sidecar refs newest-first, optionally excluding one session id. */
650
+ list(excludeSessionId?: string): SidecarRef[];
651
+ /** Close the held store, forget it, and delete its database plus WAL and SHM files. */
652
+ drop(sessionId: string): void;
653
+ /** Close every held store and forget them; files stay on disk. */
654
+ close(): void;
655
+ }
656
+ /** What one call was billed, in numbers, so a pass can sum its spend.
657
+ * `UsageLike` in `metrics.ts` is the tolerant reader of the same facts at the
658
+ * metrics boundary, and a `Usage` satisfies it. */
659
+ export interface Usage {
660
+ input: number;
661
+ output: number;
662
+ cacheRead: number;
663
+ cacheWrite: number;
664
+ /** A subset of `cacheWrite`, reported by hosts that split retention. */
665
+ cacheWrite1h?: number;
666
+ /** A subset of `output`, reported by hosts that expose the breakdown. */
667
+ reasoning?: number;
668
+ /** The host derived the counters from characters because the provider
669
+ * reported none. A cost beside it is a bounded estimate, not a bill. */
670
+ estimated?: true;
671
+ totalTokens: number;
672
+ cost: {
673
+ input: number;
674
+ output: number;
675
+ cacheRead: number;
676
+ cacheWrite: number;
677
+ total: number;
678
+ };
679
+ }
680
+ /** How core tells the operator something. A host with no UI is `none` rather
681
+ * than a dropped callback, so a caller has one branch and no optional to miss. */
682
+ export type Notices = {
683
+ readonly kind: "none";
684
+ } | {
685
+ readonly kind: "ui";
686
+ notify(text: string, level: "info" | "warning"): void;
687
+ };
688
+ /** One model the host can call. `key` is the spelling config zones and metric
689
+ * rows already use; `model` is the host's own handle, opaque here. */
690
+ export interface ModelRef<Model> {
691
+ readonly key: string;
692
+ readonly reasoning: boolean;
693
+ readonly model: Model;
694
+ }
695
+ export interface ModelRequest {
696
+ readonly provider: string;
697
+ readonly id: string;
698
+ }
699
+ export interface CompletionRequest {
700
+ readonly prompt: string;
701
+ readonly maxTokens: number;
702
+ }
703
+ /** What a call came back with. `stop` is the host's own word, kept verbatim for
704
+ * metric rows; `capped` is the host saying the answer was cut short, which is
705
+ * the one thing a caller must never store. */
706
+ export type ModelAnswer = {
707
+ outcome: "answer";
708
+ text: string;
709
+ stop: string;
710
+ usage: Usage | undefined;
711
+ } | {
712
+ outcome: "failed";
713
+ reason: string;
714
+ capped: boolean;
715
+ stop: string;
716
+ usage: Usage | undefined;
717
+ };
718
+ export interface ModelHost<Model> {
719
+ readonly notices: Notices;
720
+ session(): ModelRef<Model> | undefined;
721
+ find(requested: ModelRequest): ModelRef<Model> | undefined;
722
+ complete(model: ModelRef<Model>, request: CompletionRequest): Promise<ModelAnswer>;
723
+ }
724
+ /** A tool the reader may call. The schema is the host's, passed through rather
725
+ * than rebuilt. */
726
+ export interface ReaderTool {
727
+ name: string;
728
+ description: string;
729
+ parameters: TSchema;
730
+ }
731
+ export interface ReaderCall {
732
+ readonly id: string;
733
+ readonly name: string;
734
+ readonly arguments: Record<string, unknown>;
735
+ }
736
+ export interface ReaderResult {
737
+ readonly call: ReaderCall;
738
+ readonly text: string;
739
+ }
740
+ /** What one reader step produced. `reads` asks the caller to run the calls;
741
+ * `answered` means the reader is done, with whatever text it accumulated. */
742
+ export type ReaderReply = {
743
+ outcome: "reads";
744
+ calls: readonly ReaderCall[];
745
+ text: string;
746
+ usage: Usage | undefined;
747
+ } | {
748
+ outcome: "answered";
749
+ text: string;
750
+ usage: Usage | undefined;
751
+ } | {
752
+ outcome: "failed";
753
+ reason: string;
754
+ usage: Usage | undefined;
755
+ };
756
+ export interface ReaderRequest {
757
+ readonly systemPrompt: string;
758
+ readonly brief: string;
759
+ readonly tools: readonly ReaderTool[];
760
+ }
761
+ /** One reader conversation. The host owns the transcript: `next` appends the
762
+ * results it is handed, appends its own reply, and calls the model. */
763
+ export interface Reading {
764
+ next(results: readonly ReaderResult[]): Promise<ReaderReply>;
765
+ }
766
+ export interface Reader {
767
+ begin(request: ReaderRequest): Reading;
768
+ }
769
+ export declare function metricsPath(): string;
770
+ /** The metrics log, oldest generation first, so a window longer than one
771
+ * generation still reads as one history. */
772
+ export declare function metricsLogFiles(): string[];
773
+ export declare function rotateIfNeeded(path: string, maxBytes: number): void;
774
+ export declare function pruneGenerations(path: string, keep: number): void;
775
+ export declare function appendMetric(record: MetricRecord): void;
776
+ /** Where a row goes. A pass injects one sink so every row it causes reaches the
777
+ * same place, including the calls its completer makes. */
778
+ export type MetricsSink = (record: MetricRecord) => void;
779
+ export interface UsageLike {
780
+ input?: unknown;
781
+ output?: unknown;
782
+ cacheRead?: unknown;
783
+ cacheWrite?: unknown;
784
+ cost?: {
785
+ input?: unknown;
786
+ output?: unknown;
787
+ total?: unknown;
788
+ };
789
+ /** The host counted characters because the provider reported no usage. */
790
+ estimated?: unknown;
791
+ }
792
+ interface MetricBase {
793
+ /** Session tag. Absent on rows written outside a session (`gc`, `import`). */
794
+ session?: string;
795
+ }
796
+ export type MetricSpan = [string, string];
797
+ export type SummaryStage = "leaf" | "condensed";
798
+ export type CompactionStage = "blocking" | "async" | "direct" | "heal";
799
+ export type SummaryLevel = 1 | 2 | 3;
800
+ /** One model's turn in a summarizer chain, in the order it was tried. */
801
+ type SummarizerAttempt = {
802
+ model: string;
803
+ outcome: "ok" | "empty";
804
+ } | {
805
+ model: string;
806
+ outcome: "threw";
807
+ error: string;
808
+ };
809
+ export type UsageCounters = {
810
+ input: number | null;
811
+ output: number | null;
812
+ cacheRead: number | null;
813
+ cacheWrite: number | null;
814
+ costInput: number | null;
815
+ costOutput: number | null;
816
+ costTotal: number | null;
817
+ };
818
+ /** The counters to put on a row, or `undefined` when the provider billed
819
+ * nothing (an aborted call, or a provider that reports no usage). */
820
+ export declare function billedCounters(usage: UsageLike | undefined): UsageCounters | undefined;
821
+ /** One metrics row per measured assistant message: no numeric counter at all
822
+ * (aborted turn, legacy row) yields `undefined`, so it does not count as a turn
823
+ * in the report's cache-economics averages. */
824
+ export declare function usageRecord(usage: UsageLike | undefined): UsageRow | undefined;
825
+ /** The stage a completer ran under. Same vocabulary as `CompactionStage`, so a
826
+ * direct pass labels its completer rows and its engine rows alike. */
827
+ export type SummarizerStage = CompactionStage;
828
+ /** Why a summarizer call failed, as far as the provider's own text says. A
829
+ * closed set, and `unknown` is a real answer: a classification that guesses is
830
+ * worse than a bucket that says it could not tell, and the raw message stays on
831
+ * the row either way. `capped` is not a provider failure: it is the pass giving
832
+ * up after too many of them, and the category beside it says which one. */
833
+ export type SummarizerFailureCategory = "auth" | "rate-limit" | "timeout" | "unavailable" | "length" | "empty" | "capped" | "unknown";
834
+ /** `ok` answered with usable text, `empty` answered with none, `failure` was rejected. */
835
+ export type SummarizerOutcome = "ok" | "empty" | "failure";
836
+ export interface SummarizerUsageOptions {
837
+ /** The resolved model key, as `modelKey` renders it. */
838
+ model: string;
839
+ stage?: SummarizerStage;
840
+ outcome: SummarizerOutcome;
841
+ /** The provider's own stop word, so `length` and `error` are told apart. */
842
+ stopReason?: string;
843
+ session?: string;
844
+ }
845
+ export type SummarizerUsageRow = UsageCounters & {
846
+ event: "lcm";
847
+ kind: "summarizer-usage";
848
+ model: string;
849
+ stage?: SummarizerStage;
850
+ outcome: SummarizerOutcome;
851
+ stopReason?: string;
852
+ session?: string;
853
+ /** The counters were derived from characters, not reported by the provider. */
854
+ estimated?: true;
855
+ };
856
+ /** One provider call the summarizer made. `usage` rows cannot carry it, since
857
+ * those come from Pi's `message_end` for the session's own messages while the
858
+ * summarizer talks to the model registry directly. */
859
+ export declare function summarizerUsageRecord(usage: UsageLike | undefined, opts: SummarizerUsageOptions): SummarizerUsageRow | undefined;
860
+ export type UsageRow = MetricBase & UsageCounters & {
861
+ event: "usage";
862
+ };
863
+ interface LcmRowBase extends MetricBase {
864
+ event: "lcm";
865
+ }
866
+ export type ContextDecisionRow = LcmRowBase & {
867
+ kind: "context-decision";
868
+ action: string;
869
+ occupancy: number;
870
+ tokens: number;
871
+ window: number;
872
+ soft: number;
873
+ commit: number;
874
+ zone?: string;
875
+ committed: boolean;
876
+ /** Present when `tokens` is the estimator's count rather than Pi's: the
877
+ * reported figure described a context other than the one on hand. */
878
+ tokensEstimated?: true;
879
+ };
880
+ export type ProjectionOverBudgetRow = LcmRowBase & {
881
+ kind: "projection-over-budget";
882
+ nodes: number;
883
+ renderedTokens: number;
884
+ budgetTokens: number;
885
+ };
886
+ export type SwapAppliedRow = LcmRowBase & {
887
+ kind: "swap-applied";
888
+ summaryId: number;
889
+ cutCount: number;
890
+ distinctBoundary: boolean;
891
+ appliedAtOccupancy: number;
892
+ nodes: number;
893
+ stubbed: number;
894
+ renderedTokens: number;
895
+ budgetTokens: number;
896
+ };
897
+ /**
898
+ * A swap the policy did not commit. `tokens` is the occupancy that triggered
899
+ * the bail; the kick-backoff writer names the entry it declined to kick instead.
900
+ */
901
+ export type SwapBlockedRow = LcmRowBase & {
902
+ kind: "swap-blocked";
903
+ reason: string;
904
+ occupancy: number;
905
+ tokens?: number;
906
+ entryId?: string;
907
+ };
908
+ export type PiCompactionRow = LcmRowBase & {
909
+ kind: "pi-compaction";
910
+ entryId: string;
911
+ tokens?: number;
912
+ window?: number;
913
+ };
914
+ /** One calibration sample against Pi's own count: the characters this build
915
+ * counted for the growth of the context, the tokens Pi reported for it, and the
916
+ * ratio the samples now support. A null ratio is the answer "cannot say", which is
917
+ * why the row carries the sample beside it. */
918
+ export type EstimateCalibratedRow = LcmRowBase & {
919
+ kind: "estimate-calibrated";
920
+ chars: number;
921
+ tokens: number;
922
+ charsPerToken: number | null;
923
+ samples: number;
924
+ };
925
+ /** A provider stated the real context window in an overflow error, which is the
926
+ * one window number in this system that is not a belief. */
927
+ export type WindowCorrectedRow = LcmRowBase & {
928
+ kind: "window-corrected";
929
+ model: string;
930
+ window: number;
931
+ source: string;
932
+ };
933
+ /** A refused request proved the window in use was too big without naming the
934
+ * real one: the window for the next turn is floored at what the request carried,
935
+ * and `window` is the stored correction when one exists. */
936
+ export type WindowFloorRow = LcmRowBase & {
937
+ kind: "window-floor";
938
+ tokens: number;
939
+ window: number | null;
940
+ };
941
+ export type ConfigProblemRow = LcmRowBase & {
942
+ kind: "config-problem";
943
+ problems: string[];
944
+ };
945
+ export type SummaryInputClippedRow = LcmRowBase & {
946
+ kind: "summary-input-clipped";
947
+ entryId: string;
948
+ keptChars: number;
949
+ droppedChars: number;
950
+ };
951
+ export type SummaryFallbackStoredRow = LcmRowBase & {
952
+ kind: "summary-fallback-stored";
953
+ stage: SummaryStage;
954
+ span: MetricSpan;
955
+ sourceChars: number;
956
+ /** Absent on rows written before the node id rode along. */
957
+ summaryId?: number;
958
+ };
959
+ export type SummaryStoredRow = LcmRowBase & {
960
+ kind: "summary-stored";
961
+ stage: SummaryStage;
962
+ level: SummaryLevel;
963
+ /** Absent on rows written before quality was recorded. */
964
+ quality?: SummaryQuality;
965
+ depth: number;
966
+ tokens: number;
967
+ summaryId: number;
968
+ span: MetricSpan;
969
+ /** From just before the summarizer call to after the node row was written. */
970
+ durationMs: number;
971
+ };
972
+ export type SummarySkippedCoveredRow = LcmRowBase & {
973
+ kind: "summary-skipped-covered";
974
+ stage: SummaryStage;
975
+ span: MetricSpan;
976
+ messages: number;
977
+ };
978
+ export type SummarySkippedExistingRow = LcmRowBase & {
979
+ kind: "summary-skipped-existing";
980
+ stage: SummaryStage;
981
+ summaryId: number;
982
+ depth: number;
983
+ span: MetricSpan;
984
+ attemptedTokens: number;
985
+ existingTokens: number;
986
+ durationMs: number;
987
+ };
988
+ export type SummaryOversizedRow = LcmRowBase & {
989
+ kind: "summary-oversized";
990
+ stage: SummaryStage;
991
+ depth: number;
302
992
  tokens: number;
303
993
  targetTokens: number;
304
994
  sourceTokens: number;
305
995
  retried: boolean;
306
996
  level: SummaryLevel;
307
997
  span: MetricSpan;
998
+ /** Absent on rows written before the node id rode along. */
999
+ summaryId?: number;
308
1000
  };
309
1001
  export type SummaryReplacedRow = LcmRowBase & {
310
1002
  kind: "summary-replaced";
@@ -312,6 +1004,10 @@ export type SummaryReplacedRow = LcmRowBase & {
312
1004
  summaryId: number;
313
1005
  beforeTokens: number;
314
1006
  afterTokens: number;
1007
+ /** Absent when the row being displaced could not be read. */
1008
+ qualityBefore?: SummaryQuality;
1009
+ qualityAfter: SummaryQuality;
1010
+ durationMs: number;
315
1011
  };
316
1012
  export type SummaryFinalTruncatedRow = LcmRowBase & {
317
1013
  kind: "summary-final-truncated";
@@ -346,6 +1042,69 @@ export type CompactionCompleteRow = LcmRowBase & {
346
1042
  ingested: number;
347
1043
  span: MetricSpan;
348
1044
  };
1045
+ /** What one heal node did. `levelBefore` is 3 only when the stored text carries
1046
+ * the mechanical prefix; a clean-looking node derived from tainted children has
1047
+ * no level of its own, which is why the detection walks provenance. `levelAfter`
1048
+ * is null when nothing was stored, so a rejected level-3 answer leaves the old
1049
+ * text and no quality claim behind. */
1050
+ export type HealNodeOutcomeKind = "repaired" | "improved" | "failed" | "blocked" | "skipped" | "changed";
1051
+ /** Why a heal run stopped. `dry-run` reports a plan without a model call. */
1052
+ export type HealStopReason = "done" | "dry-run" | "max-nodes" | "max-calls" | "token-budget" | "llm-capped";
1053
+ export type HealStartRow = LcmRowBase & {
1054
+ kind: "heal-start";
1055
+ candidates: number;
1056
+ taintedNodes: number;
1057
+ maxNodes: number;
1058
+ maxCalls: number;
1059
+ maxTokens: number;
1060
+ targetTokens: number;
1061
+ /** The effective whole-node source window and the request cap that bounded it. */
1062
+ sourceTokens: number;
1063
+ callBudgetTokens: number;
1064
+ dryRun: boolean;
1065
+ };
1066
+ export type HealNodeRow = LcmRowBase & {
1067
+ kind: "heal-node";
1068
+ summaryId: number;
1069
+ depth: number;
1070
+ nodeKind: SummaryStage;
1071
+ tainted: boolean;
1072
+ qualityBefore: SummaryQuality;
1073
+ /** Present only when this run stored a replacement; a failed node keeps the
1074
+ * quality the store already holds. */
1075
+ qualityAfter?: SummaryQuality;
1076
+ levelBefore: SummaryLevel | null;
1077
+ levelAfter: SummaryLevel | null;
1078
+ beforeTokens: number;
1079
+ afterTokens: number | null;
1080
+ calls: number;
1081
+ outcome: HealNodeOutcomeKind;
1082
+ reason?: string;
1083
+ /** Source facts only. Source and summary text never reach the metrics log. */
1084
+ sourceTokens?: number;
1085
+ sourceChars?: number;
1086
+ sourceClipped?: boolean;
1087
+ };
1088
+ export type HealCompleteRow = LcmRowBase & {
1089
+ kind: "heal-complete";
1090
+ stop: HealStopReason;
1091
+ candidates: number;
1092
+ attempted: number;
1093
+ repaired: number;
1094
+ improved: number;
1095
+ failed: number;
1096
+ blocked: number;
1097
+ blockedSource: number;
1098
+ skipped: number;
1099
+ changedCount: number;
1100
+ notAttempted: number;
1101
+ calls: number;
1102
+ tokens: number;
1103
+ sourceTokens: number;
1104
+ callBudgetTokens: number;
1105
+ repairedIds: number[];
1106
+ improvedIds: number[];
1107
+ };
349
1108
  /** A summarizer call that answered with nothing, or that threw. An empty answer
350
1109
  * is its own reason, so only a throw needs the category that says why. */
351
1110
  export type SummarizerErrorRow = LcmRowBase & {
@@ -534,505 +1293,369 @@ export type RetrievalRow = LcmRowBase & {
534
1293
  steps: number;
535
1294
  retrievedTokens: number;
536
1295
  stop: string;
537
- reason?: string;
538
- scope?: "summary" | "entry";
539
- summaryId?: number;
540
- entryId?: string;
541
- } & Partial<UsageCounters>;
542
- export type ImportRow = MetricBase & {
543
- event: "lcm";
544
- kind: "import";
545
- db: string;
546
- } & ({
547
- refused: string;
548
- } | {
549
- messagesInserted: number;
550
- messagesSkipped: number;
551
- summariesInserted: number;
552
- summariesSkipped: number;
553
- });
554
- export type LcmMetricRecord = ContextDecisionRow | ProjectionOverBudgetRow | SwapAppliedRow | SwapBlockedRow | PiCompactionRow | EstimateCalibratedRow | WindowCorrectedRow | WindowFloorRow | ConfigProblemRow | SummaryInputClippedRow | SummaryFallbackStoredRow | SummaryStoredRow | SummarySkippedCoveredRow | SummarySkippedExistingRow | SummaryOversizedRow | SummaryReplacedRow | SummaryFinalTruncatedRow | CondensationStalledRow | CondensationReducedRow | CompactionCompleteRow | SummarizerErrorRow | SummarizerCappedRow | SummarizerFallbackRow | SummarizerModelRow | SummarizerUsageRow | CompactionErrorRow | CompactionAwaitedRow | CompactionQueuedRow | CompactionQueuedDroppedRow | CompactionTimeoutClearedRow | CompactionStaleRow | CompactionAbandonedRow | CompactionNoopRow | GcDeletedRow | StoreMigratedRow | BranchRemovedRow | FileExternalizedRow | LargeInlineRow | RecallRow | RetrievalRow | EvalRow | ImportRow;
555
- export type MetricRecord = UsageRow | LcmMetricRecord;
556
- //#endregion
557
- //#region src/files.d.ts
558
- /** Characters of a body's description that stay in the preview. The message row
559
- * keeps every character, so this bounds what the summarizer reads, not what is
560
- * retained. */
561
- export declare const FILE_PREVIEW_CHARS = 1200;
562
- export declare const FILE_KINDS: readonly ["json", "jsonl", "csv", "tsv", "code", "text"];
563
- export type FileKind = (typeof FILE_KINDS)[number];
564
- export declare function isFileKind(value: unknown): value is FileKind;
565
- export interface FileDescriptor {
566
- fileId: string;
567
- path: string;
568
- /** Characters retained in the message, not bytes on disk. */
569
- bytes: number;
570
- sha256: string;
571
- kind: FileKind;
572
- preview: string;
573
- }
574
- export declare function fileHandle(d: FileDescriptor): string;
575
- export declare function filePreviewText(d: FileDescriptor): string;
576
- export declare function describeFile(path: string, text: string): FileDescriptor;
577
- /** What the body is, decided by structure rather than by extension alone: a
578
- * `.ts` file holding a JSON array is JSON, and a `.txt` file holding JSON is
579
- * JSON too. The parse is tried before the extension for that reason. */
580
- export declare function kindOf(path: string, text: string): FileKind;
581
- export declare function structureOf(kind: FileKind, path: string, text: string): string;
582
- export declare function previewOf(kind: FileKind, path: string, text: string): string;
583
- //#endregion
584
- //#region src/pattern-scan.d.ts
585
- /** One row the pattern path scans. The text is the stored message, whole: the
586
- * FTS index holds a prefix, and reading only that is the defect this path
587
- * exists to fix. */
588
- export interface ScannedRow {
589
- rowid: number;
590
- entryId: string;
591
- role: string;
592
- text: string;
593
- removed: boolean;
594
- }
595
- /** A row that matched, with the first match's position and how many the row
596
- * held. `partial` is set when the row was longer than the per-row cap, so a
597
- * reader knows the scan stopped before the end of that row. */
598
- export interface PatternHit {
599
- row: ScannedRow;
600
- matches: number;
601
- at: number;
602
- partial: boolean;
603
- scannedChars: number;
604
- }
605
- export type PatternStop = "end" | "deadline" | "page";
606
- export interface PatternScanOutcome {
607
- hits: PatternHit[];
608
- rows: number;
609
- partialRows: number;
610
- more: boolean;
611
- stopped: PatternStop;
612
- lastRowId?: number;
613
- lastEntryId?: string;
614
- }
615
- export interface PatternScanOptions {
616
- pattern: CompiledPattern;
617
- offset: number;
618
- limit: number;
619
- /** Characters of one row the scan reads. Above the FTS prefix on purpose,
620
- * below the size of an externalized body, and named on the hit when it bites. */
621
- perRowChars: number;
622
- deadlineAt: number;
623
- now: () => number;
624
- }
625
- /** The characters a caller can hand a regular expression. A row longer than this
626
- * is read up to the cap and the hit says so: a partial scan that looks complete
627
- * is the same defect as an index that stops at 100,000 characters. */
628
- export declare const PATTERN_ROW_CHARS = 200000;
629
- /** How long one pattern call may scan. Long enough for a session's messages at
630
- * 200,000 characters a row, short enough that a pathological pattern is a slow
631
- * call rather than a hung session. */
632
- export declare const PATTERN_DEADLINE_MS = 1000;
633
- export declare const PATTERN_PAGE_ROWS = 200;
634
- /** Matches counted in one row before the scan moves on. A row that mentions a
635
- * path a thousand times is one hit line, and counting the thousandth costs the
636
- * deadline that the next row needs. */
637
- export declare const MAX_MATCHES_PER_ROW = 100;
638
- export declare function scanRows(rows: Iterable<ScannedRow>, opts: PatternScanOptions): PatternScanOutcome;
639
- /** A pattern this module compiled and validated, so a scan cannot receive a
640
- * caller's own stateful `RegExp`. */
641
- export type CompiledPattern = RegExp & {
642
- readonly __brand: "CompiledPattern";
643
- };
644
- export type PatternCompileResult = {
645
- ok: true;
646
- pattern: CompiledPattern;
1296
+ reason?: string;
1297
+ scope?: "summary" | "entry";
1298
+ summaryId?: number;
1299
+ entryId?: string;
1300
+ } & Partial<UsageCounters>;
1301
+ export type ImportRow = MetricBase & {
1302
+ event: "lcm";
1303
+ kind: "import";
1304
+ db: string;
1305
+ } & ({
1306
+ refused: string;
647
1307
  } | {
648
- ok: false;
649
- reason: string;
650
- };
651
- /** Compile a caller's pattern, with `g` for iteration and `i` unless the caller
652
- * asked for case sensitivity, which is the reading the FTS path already has. A
653
- * refusal is returned before any row is read. */
654
- export declare function compilePattern(pattern: string, caseSensitive: boolean): PatternCompileResult;
655
- //#endregion
656
- //#region src/store.d.ts
657
- export type LcmRole = "user" | "assistant" | "toolResult" | "custom";
658
- /** Any role string Pi may emit (custom, branchSummary, bashExecution...) folds to the four stored roles. */
659
- export declare function normalizeRole(r: string): LcmRole;
660
- export interface StoredMessage {
661
- id: number;
662
- entryId: string;
663
- role: LcmRole;
664
- text: string;
665
- tokens: number;
666
- timestamp: number;
667
- /** JSON of the content blocks, present only when `text` is not a faithful
668
- * rendering of them (a thinking block, an image, a signature). Absent means
669
- * the derived text is the whole message. */
670
- payload?: string;
671
- /** Set when the entry has left the active branch. The bytes stay; search
672
- * excludes the row unless a caller asks for it. */
673
- removedAt?: number;
1308
+ messagesInserted: number;
1309
+ messagesSkipped: number;
1310
+ summariesInserted: number;
1311
+ summariesSkipped: number;
1312
+ });
1313
+ export type LcmMetricRecord = ContextDecisionRow | ProjectionOverBudgetRow | SwapAppliedRow | SwapBlockedRow | PiCompactionRow | EstimateCalibratedRow | WindowCorrectedRow | WindowFloorRow | ConfigProblemRow | SummaryInputClippedRow | SummaryFallbackStoredRow | SummaryStoredRow | SummarySkippedCoveredRow | SummarySkippedExistingRow | SummaryOversizedRow | SummaryReplacedRow | SummaryFinalTruncatedRow | CondensationStalledRow | CondensationReducedRow | CompactionCompleteRow | HealStartRow | HealNodeRow | HealCompleteRow | SummarizerErrorRow | SummarizerCappedRow | SummarizerFallbackRow | SummarizerModelRow | SummarizerUsageRow | CompactionErrorRow | CompactionAwaitedRow | CompactionQueuedRow | CompactionQueuedDroppedRow | CompactionTimeoutClearedRow | CompactionStaleRow | CompactionAbandonedRow | CompactionNoopRow | GcDeletedRow | StoreMigratedRow | BranchRemovedRow | FileExternalizedRow | LargeInlineRow | RecallRow | RetrievalRow | EvalRow | ImportRow;
1314
+ export type MetricRecord = UsageRow | LcmMetricRecord;
1315
+ export interface LlmComplete {
1316
+ (systemPrompt: string, userText: string, maxTokens: number): Promise<string>;
1317
+ /** Where this completer's rows go. Absent means the metrics log. */
1318
+ readonly metrics?: MetricsSink;
674
1319
  }
675
- export interface GrepHit {
676
- entryId: string;
677
- role: LcmRole;
1320
+ export interface EscalationResult {
678
1321
  text: string;
679
- /** The entry has left the active branch; its text is retained. */
680
- removed?: boolean;
681
- coveringSummaryId?: number;
1322
+ level: 1 | 2 | 3;
1323
+ sourceTokens: number;
1324
+ retried: boolean;
1325
+ thoroughText?: string;
682
1326
  }
683
- export interface SummaryNode {
684
- id: number;
685
- kind: "leaf" | "condensed";
1327
+ export declare function summarizeWithEscalation(items: Array<{
1328
+ role: string;
686
1329
  text: string;
687
- tokens: number;
688
- depth: number;
689
- firstEntryId: string;
690
- lastEntryId: string;
691
- createdAt: number;
692
- /** A richer sibling text the ladder produced and did not store as primary,
693
- * so the projection can serve more detail when the budget has room. */
694
- thoroughText?: string;
695
- thoroughTokens?: number;
1330
+ }>, targetTokens: number, llm: LlmComplete, session?: string): Promise<EscalationResult>;
1331
+ /** Summarize with the terse rung only, for a source that is already a summary:
1332
+ * the thorough ladder would spend two provider calls re-summarizing text that
1333
+ * was made thorough once. Here the terse answer is the only ask, so a result
1334
+ * that does not reduce the source falls to the deterministic truncate. */
1335
+ export declare function summarizeTerse(items: Array<{
1336
+ role: string;
1337
+ text: string;
1338
+ }>, targetTokens: number, llm: LlmComplete, session?: string): Promise<EscalationResult>;
1339
+ export declare function deterministicTruncate(text: string, targetTokens: number): string;
1340
+ export interface SummarizerConfig {
1341
+ /** One model id, or an ordered fallback chain. `auto` is the session model. */
1342
+ summarizer?: string | string[];
1343
+ /** Tokens one request may cost, estimate plus output cap, before it is refused
1344
+ * rather than paid for. A backstop, not a throttle. */
1345
+ summarizerTokensPerCall?: number;
1346
+ /** Tokens one pass may spend across all its calls, counted from what the
1347
+ * provider reported. Reaching it finishes the pass mechanically. */
1348
+ summarizerTokensPerPass?: number;
696
1349
  }
697
- export interface IntegritySnapshot {
698
- ftsOk: boolean;
699
- messageRows: number;
700
- shapes: Array<{
701
- id: number;
702
- kind: "leaf" | "condensed";
703
- depth: number;
704
- messages: number;
705
- children: number;
706
- }>;
707
- spans: Array<{
708
- id: number;
709
- first: string;
710
- last: string;
711
- firstRowid: number | null;
712
- lastRowid: number | null;
713
- }>;
714
- duplicateSpans: Array<{
715
- first: string;
716
- last: string;
717
- kind: string;
718
- count: number;
719
- }>;
720
- orphanChildParents: number[];
721
- deadRuns: Array<{
722
- session: string | null;
723
- rows: number[];
724
- }>;
725
- untrackedPending: number[];
726
- foreignKeyViolations: number;
727
- schemaVersion: number;
728
- uncoveredMessages: number;
1350
+ /**
1351
+ * Thinking tokens are charged against the completion budget, so a reasoning
1352
+ * model can spend the whole cap before it emits any text. The headroom sits on
1353
+ * top of the caller's request, so the requested output size does not change.
1354
+ */
1355
+ export declare const REASONING_HEADROOM = 4096;
1356
+ export interface ResolvedChain<Model> {
1357
+ /** The models to try, in order and deduped. Empty when nothing resolves. */
1358
+ models: ModelRef<Model>[];
1359
+ /** Configured entries the host could not resolve, in order. */
1360
+ unresolved: string[];
1361
+ requested: string[];
729
1362
  }
730
- /** A fresh row, or the row that already owns the span; `existingTokens` comes
731
- * from that row, so a caller reports the collision, not its intended write.
732
- * `own` says whether the run that asked may count this row as its own and replace
733
- * its text: a row it wrote, a committed row, or its own pending row. Another live
734
- * run's pending row is none of those. */
735
- export type InsertSummaryResult = {
736
- id: number;
737
- created: true;
738
- own: true;
1363
+ /** Expand the configured chain: `auto` (the session model) goes last unless the
1364
+ * chain names it, so one bad entry still leaves the session able to summarize. */
1365
+ export declare function resolveModels<Model>(host: ModelHost<Model>, config: SummarizerConfig): ResolvedChain<Model>;
1366
+ /** The one rendering of a resolved chain. The level travels with the warning so
1367
+ * no caller can pick a different notify level for the same chain. */
1368
+ export type ChainReport = {
1369
+ level: "info";
1370
+ state: string;
739
1371
  } | {
740
- id: number;
741
- created: false;
742
- own: boolean;
743
- existingTokens: number;
744
- /** The colliding row's summary text. A caller whose view cannot read the row
745
- * still has to answer for the span it was given, and this is that span's
746
- * summary to reuse. */
747
- existingText: string;
748
- foreignLive: boolean;
1372
+ level: "warning";
1373
+ state: string;
1374
+ warning: string;
749
1375
  };
750
- /** What a caller hands `insertSummary`. `run` stamps the row pending for that run;
751
- * without it the row is committed memory. */
752
- export type InsertSummaryInput = {
753
- kind: "leaf" | "condensed";
754
- text: string;
755
- tokens: number;
756
- depth: number;
1376
+ export declare function describeSummarizerChain<Model>(chain: ResolvedChain<Model>): ChainReport;
1377
+ /** The span every call from one completer summarizes. The failure cap is per
1378
+ * span, so a completer is built with the span it will be asked about. */
1379
+ export interface SummarizedSpan {
757
1380
  firstEntryId: string;
758
1381
  lastEntryId: string;
759
- messageIds?: number[];
760
- childSummaryIds?: number[];
761
- fileIds?: string[];
762
- thoroughText?: string;
763
- run?: string;
764
- };
765
- /** What a compaction pass needs from the store: the committed memory plus, when
766
- * the pass reads through `LcmStore.forRun`, the rows of its own run. `LcmStore`
767
- * satisfies this with the committed-only meaning. */
768
- export interface PassStore extends IngestStore {
769
- /** The branch range a set of entry ids occupies: the oldest and the newest row
770
- * the store holds among them, or null when it holds none. Pi's context array
771
- * is not branch order (it hoists the newest compaction entry to the front),
772
- * while a span is a row range, so the store, whose rows are appended in
773
- * branch order, is what decides which end comes first. */
774
- spanBounds(entryIds: readonly string[]): {
775
- firstEntryId: string;
776
- lastEntryId: string;
777
- } | null;
778
- uncoveredMessagesInSpan(firstEntryId: string, lastEntryId: string): StoredMessage[];
779
- filesForMessages(ids: readonly number[]): Array<FileDescriptor & {
780
- messageId: number;
781
- }>;
782
- frontier(firstEntryId: string, lastEntryId: string): SummaryNode[];
783
- getSummary(id: number): SummaryNode | undefined;
784
- parentSummaryIds(summaryId: number): number[];
785
- fileIdsForSummaries(ids: readonly number[]): string[];
786
- insertSummary(s: InsertSummaryInput): InsertSummaryResult;
787
- updateSummaryText(id: number, text: string, tokens: number): void;
788
- setThoroughText(id: number, thoroughText: string): void;
789
1382
  }
790
- export interface IngestStore {
791
- hasEntry(entryId: string): boolean;
792
- insertMessage(m: {
793
- entryId: string;
794
- role: LcmRole;
795
- text: string;
796
- tokens: number;
797
- timestamp: number;
798
- payload?: string;
799
- }): number;
800
- insertFile(d: FileDescriptor, firstEntryId: string): void;
801
- linkMessageFile(messageId: number, fileId: string): void;
1383
+ export interface MakeLlmOptions {
1384
+ session: string;
1385
+ stage?: SummarizerStage;
1386
+ span: SummarizedSpan;
1387
+ /** Where every row this completer writes goes. Defaults to the metrics log. */
1388
+ sink?: MetricsSink;
1389
+ }
1390
+ /** Failed walks in a row before a pass stops calling the model and lets the
1391
+ * ladder's deterministic truncate finish the span. One is too eager, because a
1392
+ * single transport blip would turn a whole span mechanical; past three, another
1393
+ * call carries no new information about the provider and costs its latency. */
1394
+ export declare const SUMMARIZER_FAILURE_CAP = 3;
1395
+ /** What one request may cost before it is refused instead of paid for. It exists
1396
+ * for a pathological chunk rather than for a normal one. */
1397
+ export declare const SUMMARIZER_CALL_TOKENS = 60000;
1398
+ /** What one pass may spend across all of its calls. */
1399
+ export declare const SUMMARIZER_PASS_TOKENS = 250000;
1400
+ export declare function retriableCategory(category: SummarizerFailureCategory): boolean;
1401
+ /** How long a span whose calls were capped is left alone. Shorter than the kick
1402
+ * backoff (`KICK_BACKOFF_MS`) on purpose: this guards a retry inside one turn,
1403
+ * it is not the breaker that stops re-kicks across turns. */
1404
+ export declare const FAILED_SPAN_TTL_MS = 60000;
1405
+ /** What a failed span is identified by: the two ends a caller asked about and
1406
+ * the chain it asked. A different model is a different attempt, not the same
1407
+ * failure, so the chain is part of the key. */
1408
+ export declare function spanFingerprint(span: SummarizedSpan, chain: readonly string[]): string;
1409
+ /**
1410
+ * A call that reached no usable answer. `category` is what the provider's own
1411
+ * text says: a best-effort read that answers `unknown` rather than guessing, and
1412
+ * `capped` when the caller should stop asking rather than the provider failing.
1413
+ */
1414
+ export declare class SummarizerFailure extends Error {
1415
+ readonly category: SummarizerFailureCategory;
1416
+ constructor(message: string, category: SummarizerFailureCategory);
1417
+ }
1418
+ /** Which bucket a provider's error text falls in. A 5xx or a dropped connection
1419
+ * is `unavailable` before it is `timeout`, because the gateway's own timeout is
1420
+ * a request that never reached a model. */
1421
+ export declare function classifyFailure(text: string | undefined): SummarizerFailureCategory;
1422
+ export declare function makeLlm<Model>(host: ModelHost<Model>, config: SummarizerConfig, opts: MakeLlmOptions): LlmComplete | null;
1423
+ /** Sessions share the process, so each one gets to see the config it runs, and
1424
+ * a session that starts fresh should not inherit a refusal from the last one. */
1425
+ export declare function resetSummarizerState(): void;
1426
+ /** Tokens one bounded repair run may spend across all of its calls. A heal
1427
+ * re-derives nodes a pass already paid for, so it carries its own budget. */
1428
+ export declare const SUMMARIZER_HEAL_TOKENS = 120000;
1429
+ /** Tokens one repaired node targets when the caller names none. It matches
1430
+ * the heal engine's own fallback, so an unresolved option still repairs. */
1431
+ export declare const DEFAULT_SUMMARY_TOKENS = 1500;
1432
+ export interface SummarizerOptions {
1433
+ /** One model id, or an ordered fallback chain. `auto` is the session model. */
1434
+ readonly summarizer?: string | string[];
1435
+ /** Tokens one request may cost, estimate plus output cap, before it is
1436
+ * refused rather than paid for. */
1437
+ readonly summarizerTokensPerCall?: number;
1438
+ /** Tokens one pass may spend across all of its calls. */
1439
+ readonly summarizerTokensPerPass?: number;
1440
+ /** Tokens one on-demand heal run may spend across all of its calls. */
1441
+ readonly summarizerTokensPerHeal?: number;
1442
+ }
1443
+ /** The engine config for these options. An unresolved budget keeps the core
1444
+ * default, which is also what a direct caller that passes no options gets. */
1445
+ export declare function summarizerConfig(options: SummarizerOptions): SummarizerConfig;
1446
+ export interface HealBudgetOptions {
1447
+ /** Tokens one repaired node targets. */
1448
+ readonly summaryTokens?: number;
1449
+ /** Estimated tokens one heal node may read as source. Unset derives the
1450
+ * engine default; this is the explicit override for large sources. */
1451
+ readonly healSourceTokens?: number;
1452
+ /** Tokens one summarizer request may cost. */
1453
+ readonly summarizerTokensPerCall?: number;
1454
+ }
1455
+ /** Calibrated heal budgets in tokens: node target, per-call cap, and optional source window. */
1456
+ export interface HealBudgets {
1457
+ readonly targetTokens: number;
1458
+ readonly callBudgetTokens: number;
1459
+ readonly sourceTokens?: number;
1460
+ }
1461
+ /** The calibrated heal target and source window. A null ratio keeps the
1462
+ * configured reading, which is what an uncalibrated host passes. */
1463
+ export declare function healBudgets(options?: HealBudgetOptions, charsPerToken?: number | null): HealBudgets;
1464
+ export declare const CHARS_PER_TOKEN = 4;
1465
+ /** Samples below this count say nothing: with two points any line fits, and the
1466
+ * first turns of a session are exactly where the overhead has not cancelled. */
1467
+ export declare const CALIBRATION_MIN_SAMPLES = 3;
1468
+ /** Samples kept in memory. A cap keeps the median describing the recent turns of
1469
+ * the session rather than its whole history, which is what a model switch inside
1470
+ * one session would otherwise be unable to move. */
1471
+ export declare const CALIBRATION_SAMPLE_CAP = 8;
1472
+ /** One observation of the same session: how many characters this build counted in
1473
+ * the messages, and how many tokens Pi reported for the request that carried
1474
+ * them. Sampling differences rather than totals is what cancels the system prompt
1475
+ * and tool definitions, which this build never counts. */
1476
+ export interface EstimateSample {
1477
+ chars: number;
1478
+ tokens: number;
1479
+ }
1480
+ export declare function estimateTokens(text: string): number;
1481
+ export declare function tokensFromChars(chars: number): number;
1482
+ /** A reported count below this share of the estimate describes a context other
1483
+ * than the one on hand: Pi reports its last measurement, and after a resume that
1484
+ * measurement belongs to the previous process. The estimator's own error is
1485
+ * bounded by the calibration band, which is a factor of two in either direction
1486
+ * around the constant, so anything below a quarter is a different turn rather
1487
+ * than a miscount. A reported count too high only compacts early, which is why
1488
+ * this looks one way. */
1489
+ export declare const STALE_REPORT_SHARE = 0.25;
1490
+ export declare function calibrate(samples: readonly EstimateSample[]): number | null;
1491
+ /** A configured token budget in the units `estimateTokens` counts. The estimator
1492
+ * counts chars / 4, so a model whose real ratio is 6 needs 1.5 times the
1493
+ * configured number of estimated tokens accumulated before it has kept the
1494
+ * context the user asked for. Without a ratio the budget is left alone, which is
1495
+ * the constant's own reading of it. */
1496
+ export declare function scaledBudget(tokens: number, charsPerToken: number | null): number;
1497
+ /** A window outside this band is a misread rather than a model: no provider
1498
+ * serves a context under a thousand tokens or over a hundred million, and a
1499
+ * number outside it means a pattern caught the request count, a status code, or
1500
+ * something else that was not a limit. A refused parse is the floor path, which
1501
+ * costs one conservative turn instead of a wrong permanent window. */
1502
+ export declare const WINDOW_MIN = 1000;
1503
+ export declare const WINDOW_MAX = 100000000;
1504
+ /** The window in tokens a provider stated in an overflow message, or null when
1505
+ * the message does not name one. Null is an answer: the caller keeps Pi's belief
1506
+ * and arms the one-shot floor, which is all a body like "400/413 status code (no
1507
+ * body)" can support. */
1508
+ export declare function windowFromError(errorMessage: string | undefined): number | null;
1509
+ export declare const WINDOW_PATTERN_EXAMPLES: readonly string[];
1510
+ /** Either provider spelling hosts use: OpenCode names `providerID`, Pi names
1511
+ * `provider`. The key ignores any other field, including an OpenCode variant,
1512
+ * because a variant changes settings and not the model itself. */
1513
+ export interface ModelIdentity {
1514
+ readonly providerID?: string;
1515
+ readonly provider?: string;
1516
+ readonly id?: string;
1517
+ }
1518
+ /** A ref wrapper hosts pass around: the model beside its computed key. Only
1519
+ * the nested model matters for the spelling. */
1520
+ export interface ModelKeyRef {
1521
+ readonly model?: ModelIdentity | undefined;
1522
+ readonly key?: string;
1523
+ readonly reasoning?: boolean;
1524
+ }
1525
+ /** The spelling config zones and metric rows use: `provider/id`. A wrapper
1526
+ * with a `model` field (a core `ModelRef` or a Pi context) is unwrapped, so a
1527
+ * direct model and a ref to one spell the same. Missing or empty parts spell
1528
+ * empty, the way Pi names no model before one is active. */
1529
+ export declare function modelKey(input: ModelIdentity | ModelKeyRef | undefined | null): string;
1530
+ /** The fields a window lookup matches on. An OpenCode `ModelRef` carries an
1531
+ * optional variant besides these, which still satisfies this shape. */
1532
+ export interface WindowModel {
1533
+ readonly providerID: string;
1534
+ readonly id: string;
802
1535
  }
803
- /** Characters of a message the full-text index covers. Storage is verbatim, so
804
- * this bounds the index and not the row. */
805
- export declare const FTS_PREFIX_CHARS = 100000;
806
- /** First published schema: pre-publish development stores were wiped, so every
807
- * store in the wild starts here and later generations count from this one. */
808
- export declare const SCHEMA_VERSION = 1;
809
- /** A store left behind by an older build. A read-only open refuses it rather than
810
- * migrating it: a search must not rewrite a file it is only reading, and a store
811
- * that cannot answer this build's statements has to be a reported skip. */
812
- export declare class StaleStoreError extends Error {
813
- readonly generation: number;
814
- constructor(generation: number);
1536
+ /** One catalogue entry reduced to what the lookup reads. */
1537
+ export interface WindowCatalogEntry {
1538
+ readonly providerID: string;
1539
+ readonly id: string;
1540
+ readonly limit: {
1541
+ readonly context: number;
1542
+ };
815
1543
  }
816
- /** What a store knows about a model rather than about a session: the token ratio
817
- * measured for it against Pi's reported usage, and the context window a provider
818
- * stated when it refused an over-window request. A null ratio is a measurement that
819
- * disagree with itself, which is a state the estimator reads as "keep the
820
- * constant" and the report names. A null window is a model no provider has
821
- * corrected, which leaves Pi's belief in place. */
822
- export interface ModelState {
823
- charsPerToken: number | null;
824
- samples: number;
825
- contextWindow: number | null;
826
- windowSource: string | null;
1544
+ /** The catalogue source the lookup reads. It is the `list` half of the host
1545
+ * model domain; the synchronous `get` stays host side. */
1546
+ export interface WindowListDomain {
1547
+ list(input?: unknown): Promise<unknown>;
1548
+ }
1549
+ /** The context the lookup reads the catalogue from. Only the optional model
1550
+ * domain matters, so any host context with that shape satisfies it. */
1551
+ export interface WindowLookupContext {
1552
+ readonly model?: WindowListDomain | undefined;
1553
+ }
1554
+ /** The model window lookup both assembly seams read. */
1555
+ export type WindowOf<M extends WindowModel = WindowModel> = (model: M) => Promise<number>;
1556
+ /** The model window both assembly seams read. The context projection measures
1557
+ * occupancy against it, and the blocking checkpoint reads the same figure for
1558
+ * its metric rows, so the catalogue is asked once for the plugin lifetime. A
1559
+ * catalogue that failed once is asked again on the next request: the host may
1560
+ * still be starting. */
1561
+ export declare function createWindowLookup<M extends WindowModel = WindowModel>(ctx: WindowLookupContext, fallback: number): WindowOf<M>;
1562
+ export interface AssemblyMsg {
1563
+ role: string;
1564
+ content?: unknown;
1565
+ /** AgentMessage UserMessage compatibility (Pi requires it on outgoing messages). */
1566
+ timestamp?: number;
827
1567
  }
828
- export declare const WINDOW_SOURCE_PROVIDER = "provider-error";
829
- export declare class LcmStore {
830
- private db;
831
- private dbPath;
832
- /** Opened for reading a past session's store: no migration ran, so `generation`
833
- * is the file's own and a caller may have to skip it. */
834
- private readOnly;
835
- /** The schema generation this file carries, which read-only opens do not
836
- * change. A writer holds `SCHEMA_VERSION` by the time its constructor returns. */
837
- readonly generation: number;
838
- /** True while a `transaction` callback is running, so a reentrant call joins
839
- * the open transaction instead of nesting one, which SQLite rejects. */
840
- private inTransaction;
841
- /** Tokens of the runs this handle has open. Two runs can overlap inside one
842
- * handle, because the blocking path stops waiting for the async pass, so a
843
- * token names a run and not the handle. Whether a run this handle does not
844
- * hold is still running is read from the `runs` table, never from here. */
845
- private liveTokens;
846
- private reapedInRun;
847
- private stmts;
848
- constructor(dbPath: string, opts?: {
849
- readOnly?: boolean;
850
- queryOnly?: boolean;
851
- });
852
- /** Close the handle: no run of this handle survives it, and the connection is
853
- * released even when a run could not be abandoned, so a failure here cannot
854
- * leave a connection and a set of invisible rows behind. */
855
- close(): void;
856
- /** Open a run: rows written with its token are pending until `commitRun`, and
857
- * the row this writes is what lets another handle tell it from a run whose
858
- * writer is gone. Reaps what such a run left, which this handle cannot be. */
859
- openRun(opts?: {
860
- session?: string;
861
- }): {
862
- token: string;
863
- dropped: number;
864
- };
865
- /** True while any run is open whose writer is still there, this handle's or
866
- * another process's, which is what an import has to wait for. */
867
- hasLiveRun(): boolean;
868
- /** Commit a run: drop its pending rows whose child no longer resolves, then flip
869
- * the rest. One transaction, so a reader never sees a half-committed run, and
870
- * idempotent for a token this handle no longer holds. */
871
- commitRun(token: string): {
872
- committed: number;
873
- dropped: number;
874
- };
875
- /** Delete a run's pending rows and close it. Idempotent, so a `finally` can call
876
- * it after a commit has already closed the run. */
877
- abandonRun(token: string): number;
878
- /** A view of this store for one run: the committed memory plus that run's own
879
- * rows, which is what a pass must read to see its own writes. */
880
- forRun(token: string): PassStore;
881
- /** True while the run that wrote a row is still going somewhere, as far as this
882
- * store can tell. A run this handle holds is live; a run written by this
883
- * process is live, because a pid cannot separate two handles in one process and
884
- * refusing to delete is the safe answer; a run written by another process is
885
- * live while its pid is. A row whose run has no record at all was written
886
- * without one, which no open path does, so nothing owns it. */
887
- private isRunLive;
888
- /** Delete what a run left behind whose writer is gone, and its record with it.
889
- * A live owner's rows are never touched, whatever handle asks, which is what
890
- * makes a second handle safe to open. No reader can see the rows this removes,
891
- * and each one holds the span it claims. */
892
- private reapPending;
893
- /** Delete this run's pending rows whose child no longer exists, deepest first: a
894
- * parent that loses a child must not leave a grandparent behind. A child can go
895
- * away between the frontier read that selected it and this commit. */
896
- private dropUnresolvable;
897
- hasEntry(entryId: string): boolean;
898
- insertMessage(m: {
899
- entryId: string;
900
- role: LcmRole;
901
- text: string;
902
- tokens: number;
903
- timestamp: number;
904
- payload?: string;
905
- }): number;
906
- /** One write transaction around a group of statements, so a compaction is
907
- * atomic: the node row, its provenance rows and its file links either all
908
- * land or none do. `node:sqlite` ships no transaction helper, so the
909
- * statements are explicit. A reentrant call joins the open transaction: the
910
- * caller that opened it owns the commit, so an inner throw rolls the whole
911
- * group back rather than leaving a half-applied write. */
912
- transaction<T>(fn: () => T): T;
913
- insertSummary(s: InsertSummaryInput): InsertSummaryResult;
914
- insertFile(d: FileDescriptor, firstEntryId: string): void;
915
- linkMessageFile(messageId: number, fileId: string): void;
916
- filesForMessages(ids: readonly number[]): Array<FileDescriptor & {
917
- messageId: number;
918
- }>;
919
- fileIdsForMessages(ids: readonly number[]): string[];
920
- fileIdsForSummaries(ids: readonly number[]): string[];
921
- filesForSummaries(ids: readonly number[]): FileDescriptor[];
922
- allFiles(): Array<FileDescriptor & {
923
- entryIds: string[];
924
- }>;
925
- /** Replace a node's text in place: the span, provenance, and created_at stay. */
926
- updateSummaryText(id: number, text: string, tokens: number): void;
927
- setThoroughText(id: number, thoroughText: string): void;
928
- messagesInSpan(firstEntryId: string, lastEntryId: string): StoredMessage[];
929
- spanBounds(entryIds: readonly string[]): {
930
- firstEntryId: string;
931
- lastEntryId: string;
932
- } | null;
933
- uncoveredMessagesInSpan(firstEntryId: string, lastEntryId: string, run?: string): StoredMessage[];
934
- /** Minimal top-level cover: no summary's child, oldest first, dropping any node
935
- * whose span sits inside the previous kept one, which is what keeps the cover
936
- * non-nested: condensation's group span must be one no row already holds. */
937
- frontier(firstEntryId: string, lastEntryId: string, run?: string): SummaryNode[];
938
- grep(pattern: string, opts?: {
939
- limit?: number;
940
- offset?: number;
941
- withinMessageIds?: ReadonlySet<number>;
942
- includeRemoved?: boolean;
943
- }): GrepHit[];
944
- grepCount(pattern: string, opts?: {
945
- withinMessageIds?: ReadonlySet<number>;
946
- includeRemoved?: boolean;
947
- }): number | undefined;
948
- messagePage(afterRowId: number, limit: number, opts?: {
949
- withinMessageIds?: ReadonlySet<number>;
950
- includeRemoved?: boolean;
951
- }): ScannedRow[];
952
- coveringSummaryId(entryId: string): number | undefined;
953
- messageByEntryId(entryId: string): StoredMessage | undefined;
954
- parentSummaryIds(summaryId: number, run?: string): number[];
955
- childSummaryIds(summaryId: number): number[];
956
- getSummary(id: number, run?: string): SummaryNode | undefined;
957
- /** Bounded by a visited set, so a corrupt cycle cannot recurse forever. */
958
- coveredMessageIds(summaryId: number, seen?: Set<number>): number[];
959
- integritySnapshot(): IntegritySnapshot;
960
- /** Restore the full-text index. The schema is re-applied first, because a
961
- * dropped virtual table cannot be rebuilt, only recreated, and the DDL that
962
- * knows how is the schema itself. Everything in it is `IF NOT EXISTS` apart
963
- * from the two triggers, which are dropped and recreated on purpose. */
964
- rebuildFts(): void;
965
- deleteOrphanSpans(): number;
966
- deleteOrphanChildren(): number;
967
- reapDeadRuns(): number;
968
- /** When one span is claimed twice, the row that carries provenance wins over
969
- * one that cannot be expanded, and the newest wins between equals. */
970
- dedupeSpans(): number;
971
- /** Mark entries that left the active branch, or clear the mark when a
972
- * navigation brings them back. Returns the rows the call actually changed, so
973
- * a metric can tell a real change from a no-op reconciliation. */
974
- setRemoved(entryIds: readonly string[], removedAt: number | null): number;
975
- removedMessageCount(): number;
976
- messagesByIds(ids: number[]): StoredMessage[];
977
- allMessages(): StoredMessage[];
978
- entrySpan(): {
979
- first: string;
980
- last: string;
981
- } | undefined;
982
- coveredSize(summaryId: number): {
983
- tokens: number;
984
- messages: number;
985
- };
986
- allSummaries(): SummaryNode[];
987
- messageCount(): number;
988
- backup(backupPath: string): void;
989
- /** The calibration recorded for a model. `undefined` is a model nothing has
990
- * sampled; a null ratio is one whose samples disagreed, which the caller reads
991
- * as "keep the constant" rather than "no data". */
992
- getModelState(modelKey: string): ModelState | undefined;
993
- setModelState(modelKey: string, state: Pick<ModelState, "charsPerToken" | "samples">): void;
994
- /** Records the window a provider stated. Its own statement rather than part of
995
- * `setModelState`, because the two are learned at different moments: a later
996
- * calibration must not clear a window, and a window must not touch samples. */
997
- setModelWindow(modelKey: string, contextWindow: number, source: string): void;
998
- stats(): {
999
- messages: number;
1000
- summaries: number;
1001
- dbBytes: number;
1002
- payloadMessages: number;
1003
- payloadBytes: number;
1004
- files: number;
1005
- fileBytes: number;
1006
- removedMessages: number;
1568
+ export interface AlignedEntry {
1569
+ entryId: string;
1570
+ role: string;
1571
+ text: string;
1572
+ /** Raw content blocks when `text` is not a faithful rendering of them
1573
+ * (`payloadOf`). Carried so ingest stores the same bytes at every call site. */
1574
+ payload?: string;
1575
+ fileHint?: {
1576
+ path: string;
1007
1577
  };
1008
1578
  }
1009
- //#endregion
1010
- //#region src/summarize.d.ts
1011
- export interface LlmComplete {
1012
- (systemPrompt: string, userText: string, maxTokens: number): Promise<string>;
1579
+ export declare const SYNTHETIC_MARKER = "[LCM session memory";
1580
+ export declare function tailKeyOf(m: AssemblyMsg | undefined): string;
1581
+ /** Characters the estimator counts for a context: the same rendering `cutIndexFor`
1582
+ * measures, so a calibration describes the quantity every threshold here is
1583
+ * compared against. */
1584
+ export declare function contextChars(messages: readonly AssemblyMsg[]): number;
1585
+ export declare function cutIndexFor(messages: readonly AssemblyMsg[], keepRecentTokens: number): number | null;
1586
+ export interface SyntheticMessage {
1587
+ role: "user";
1588
+ content: string;
1589
+ timestamp: number;
1013
1590
  }
1014
- export interface EscalationResult {
1591
+ export type SummaryTier = "rich" | "terse" | "stub";
1592
+ export interface RenderableSummary {
1593
+ id: number;
1594
+ depth: number;
1595
+ firstEntryId: string;
1596
+ lastEntryId: string;
1015
1597
  text: string;
1016
- level: 1 | 2 | 3;
1017
- sourceTokens: number;
1018
- retried: boolean;
1019
1598
  thoroughText?: string;
1599
+ tier: SummaryTier;
1020
1600
  }
1021
- export declare function summarizeWithEscalation(items: Array<{
1022
- role: string;
1023
- text: string;
1024
- }>, targetTokens: number, llm: LlmComplete, session?: string): Promise<EscalationResult>;
1025
- /** Summarize with the terse rung only, for a source that is already a summary:
1026
- * the thorough ladder would spend two provider calls re-summarizing text that
1027
- * was made thorough once. Here the terse answer is the only ask, so a result
1028
- * that does not reduce the source falls to the deterministic truncate. */
1029
- export declare function summarizeTerse(items: Array<{
1030
- role: string;
1601
+ export declare const STUB_CHARS = 200;
1602
+ export type PlainSummary = Omit<RenderableSummary, "tier" | "thoroughText">;
1603
+ export declare function renderSummaries(summaries: readonly RenderableSummary[]): string;
1604
+ export declare function renderFrontier(nodes: readonly PlainSummary[]): string;
1605
+ export declare function buildSynthetic(summaries: readonly RenderableSummary[]): SyntheticMessage;
1606
+ export interface AppliedSummary {
1607
+ id: number;
1608
+ depth: number;
1031
1609
  text: string;
1032
- }>, targetTokens: number, llm: LlmComplete, session?: string): Promise<EscalationResult>;
1033
- export declare function deterministicTruncate(text: string, targetTokens: number): string;
1034
- //#endregion
1035
- //#region src/compaction-engine.d.ts
1610
+ thoroughText?: string;
1611
+ firstEntryId: string;
1612
+ lastEntryId: string;
1613
+ tier: SummaryTier;
1614
+ }
1615
+ export type AssemblyAction = "quiet" | "consider-apply" | "stable" | "recommit";
1616
+ export interface CommitState {
1617
+ cutCount: number;
1618
+ synthetic: SyntheticMessage;
1619
+ applied: AppliedSummary[];
1620
+ summaryId: number;
1621
+ appliedAtOccupancy: number;
1622
+ tailKey: string;
1623
+ }
1624
+ export declare const DEFAULT_SWAP_RATIO = 0.7;
1625
+ export declare const DEFAULT_RECUT_RATIO = 0.85;
1626
+ export declare const WINDOW_SAFETY_CEILING = 0.9;
1627
+ /** Ceiling for the resolved recut fraction: a line above 1.0 is unreachable, so
1628
+ * the pin never re-cuts and the raw tail grows past the window while "stable"
1629
+ * is reported forever. Strictly above WINDOW_SAFETY_CEILING + RECUT_ABOVE_SWAP_MARGIN. */
1630
+ export declare const RECUT_CEILING = 0.95;
1631
+ export declare const REZONE_MARGIN = 1.18;
1632
+ /** One model's thresholds. A token field takes precedence over its ratio
1633
+ * counterpart when both are set. */
1634
+ export interface ThresholdConfig {
1635
+ /** Swap when the request reaches this share of the model window. */
1636
+ swapAtRatio?: number;
1637
+ /** Swap when the request reaches this many tokens; takes precedence over
1638
+ * `swapAtRatio`. */
1639
+ swapAtTokens?: number;
1640
+ /** Re-cut when the request reaches this share of the model window. */
1641
+ recutAtRatio?: number;
1642
+ /** Re-cut when the request reaches this many tokens; takes precedence over
1643
+ * `recutAtRatio`. */
1644
+ recutAtTokens?: number;
1645
+ /** Swap at this absolute token count, deriving the recut from it. */
1646
+ smartZone?: number;
1647
+ }
1648
+ export type ZoneThresholds = ThresholdConfig;
1649
+ export interface ResolvedThresholds {
1650
+ swap: number;
1651
+ recut: number;
1652
+ clamped: boolean;
1653
+ }
1654
+ export declare function mergeZone(global: ThresholdConfig, zone?: ZoneThresholds): ThresholdConfig;
1655
+ export declare function thresholdConflicts(cfg: ThresholdConfig, scope?: string): string[];
1656
+ export declare function effectiveThresholds(cfg: ThresholdConfig, contextWindow: number): ResolvedThresholds;
1657
+ export declare function nextAction(state: CommitState | null, occupancy: number, swapRatio: number, recutRatio: number): AssemblyAction;
1658
+ export declare function cutMayReplace(previous: CommitState | null, newCutCount: number): boolean;
1036
1659
  /** Deepest condensed node the DAG may grow. With MAX_DAG_FANOUT children this
1037
1660
  * covers 8^8 leaf chunks, so it is a structural assert, not a tuning knob: a
1038
1661
  * pass that cannot meet its budget reports `condensation-stalled` rather than
@@ -1081,7 +1704,7 @@ export declare function runCompaction(store: PassStore, input: CompactionInput,
1081
1704
  * fallback event cannot be attributed to a session. */
1082
1705
  session?: string;
1083
1706
  maxDepth?: number;
1084
- stage?: "blocking" | "async" | "direct";
1707
+ stage?: CompactionStage;
1085
1708
  }): Promise<CompactionOutcome>;
1086
1709
  /** The next group to condense: the oldest run of adjacent same-depth nodes, at
1087
1710
  * the shallowest depth with a run of two or more, capped at MAX_DAG_FANOUT.
@@ -1090,8 +1713,6 @@ export declare function runCompaction(store: PassStore, input: CompactionInput,
1090
1713
  export declare function condensationGroup(frontier: readonly SummaryNode[], opts?: {
1091
1714
  maxDepth?: number;
1092
1715
  }): SummaryNode[] | undefined;
1093
- //#endregion
1094
- //#region src/context-policy.d.ts
1095
1716
  export interface ContextPolicyInput {
1096
1717
  rawMessages: readonly AssemblyMsg[];
1097
1718
  getCtxEntries: () => readonly AlignedEntry[];
@@ -1120,299 +1741,105 @@ export type BailReason = "nothing-to-age" | "no-boundary" | "tail-exceeds-entrie
1120
1741
  export type NonEmpty<T> = readonly [T, ...T[]];
1121
1742
  export interface ContextPolicyBase {
1122
1743
  commitState: CommitState | null;
1123
- metrics: LcmMetricRecord[];
1124
- pinnedModelKey: string;
1125
- pinnedWindow: number;
1126
- clampNotified: boolean;
1127
- clampWarning?: string;
1128
- }
1129
- export type ContextPolicyResult = ContextPolicyBase & ({
1130
- outcome: "quiet";
1131
- } | {
1132
- outcome: "reapply";
1133
- messages: AssemblyMsg[];
1134
- } | {
1135
- outcome: "swap";
1136
- messages: AssemblyMsg[];
1137
- } | {
1138
- outcome: "bail";
1139
- reason: BailReason;
1140
- messages?: AssemblyMsg[];
1141
- } | {
1142
- outcome: "kick";
1143
- reason: "summary-missing";
1144
- aged: NonEmpty<AlignedEntry>;
1145
- /** The span this kick is about, in the order the store resolves one. The
1146
- * adapter keys its latch on it, so the latch and the coverage check
1147
- * cannot disagree about which boundary is missing. */
1148
- span: {
1149
- firstEntryId: string;
1150
- lastEntryId: string;
1151
- };
1152
- messages?: AssemblyMsg[];
1153
- });
1154
- export interface ContextPolicyDeps {
1155
- thresholds: (cfg: ThresholdConfig, window: number) => ResolvedThresholds;
1156
- nextAction: (state: CommitState | null, occupancy: number, swap: number, recut: number) => AssemblyAction;
1157
- cutIndexFor: (msgs: readonly AssemblyMsg[], keepRecentTokens: number) => number | null;
1158
- cutMayReplace: (previous: CommitState | null, newCutCount: number) => boolean;
1159
- frontier: (firstEntryId: string, lastEntryId: string) => SummaryNode[];
1160
- /** The aged set's own ends as a span: branch order, and only entries the store
1161
- * holds. Pi's context array is not branch order (it hoists the newest
1162
- * compaction entry to the front), and a span whose ends are inverted resolves
1163
- * to nothing, so this answers with the end pair a store query can use. Null
1164
- * when the store holds none of the aged entries, which is a state rather than
1165
- * a kick. */
1166
- spanBounds: (aged: NonEmpty<AlignedEntry>) => {
1167
- firstEntryId: string;
1168
- lastEntryId: string;
1169
- } | null;
1170
- buildSynthetic: (summaries: readonly RenderableSummary[]) => CommitState["synthetic"];
1171
- /** The mask node text passes through on its way to a model. The store keeps
1172
- * the bytes, so every read seam takes this rather than storing a masked
1173
- * copy; `makeRedact` is the provider a real caller passes. */
1174
- redact: (text: string) => string;
1175
- }
1176
- export interface FailedBoundary {
1177
- attempts: number;
1178
- lastAt: number;
1179
- }
1180
- export declare const KICK_MAX_ATTEMPTS = 3;
1181
- export declare const KICK_BACKOFF_MS: number;
1182
- export declare function shouldKickBoundary(latch: FailedBoundary | undefined, now: number): boolean;
1183
- export declare function applyContextPolicy(input: ContextPolicyInput, deps: ContextPolicyDeps): ContextPolicyResult;
1184
- export interface CompactionBounds {
1185
- startEntryId?: string;
1186
- firstKeptEntryId: string;
1187
- }
1188
- /** Blocking-regime span (session_before_compact), computed the way Pi's
1189
- * prepareCompaction does: branch entries from the previous compaction's first
1190
- * kept entry up to but excluding the new one, minus compaction rows. Entry ids,
1191
- * not message text, so bash and branch-summary rows align. An unknown
1192
- * firstKeptEntryId yields an empty span, so the caller falls back to Pi. */
1193
- export declare function resolveCompactionSpan(branchMsgs: readonly AlignedEntry[], bounds: CompactionBounds): AlignedEntry[];
1194
- //#endregion
1195
- //#region src/integrity.d.ts
1196
- export type FindingKind = "fts-index" | "summary-span" | "summary-shape" | "orphan-child" | "duplicate-span" | "foreign-key" | "schema-version" | "dead-run" | "coverage";
1197
- export interface Finding {
1198
- kind: FindingKind;
1199
- /** `violation` is an invariant the DAG depends on; `coverage` is a count. */
1200
- severity: "violation" | "info";
1201
- count: number;
1202
- detail: string;
1203
- sample: string[];
1204
- }
1205
- export interface IntegrityReport {
1206
- /** How many checks ran, so "no findings" cannot be confused with "no checks". */
1207
- checks: number;
1208
- findings: Finding[];
1209
- }
1210
- export declare function checkIntegrity(store: LcmStore): IntegrityReport;
1211
- /** Fix what can be fixed without guessing: the derived index, rows whose span no
1212
- * longer exists, nodes whose child no longer resolves, rows a run whose writer is
1213
- * gone left behind, and predated duplicate spans. An inverted span is left
1214
- * alone, because which end is right needs the session file. */
1215
- export declare function repairIntegrity(store: LcmStore): string[];
1216
- //#endregion
1217
- //#region src/host.d.ts
1218
- /** What one call was billed, in numbers, so a pass can sum its spend.
1219
- * `UsageLike` in `metrics.ts` is the tolerant reader of the same facts at the
1220
- * metrics boundary, and a `Usage` satisfies it. */
1221
- export interface Usage {
1222
- input: number;
1223
- output: number;
1224
- cacheRead: number;
1225
- cacheWrite: number;
1226
- /** A subset of `cacheWrite`, reported by hosts that split retention. */
1227
- cacheWrite1h?: number;
1228
- /** A subset of `output`, reported by hosts that expose the breakdown. */
1229
- reasoning?: number;
1230
- totalTokens: number;
1231
- cost: {
1232
- input: number;
1233
- output: number;
1234
- cacheRead: number;
1235
- cacheWrite: number;
1236
- total: number;
1237
- };
1238
- }
1239
- /** How core tells the operator something. A host with no UI is `none` rather
1240
- * than a dropped callback, so a caller has one branch and no optional to miss. */
1241
- export type Notices = {
1242
- readonly kind: "none";
1243
- } | {
1244
- readonly kind: "ui";
1245
- notify(text: string, level: "info" | "warning"): void;
1246
- };
1247
- /** One model the host can call. `key` is the spelling config zones and metric
1248
- * rows already use; `model` is the host's own handle, opaque here. */
1249
- export interface ModelRef<Model> {
1250
- readonly key: string;
1251
- readonly reasoning: boolean;
1252
- readonly model: Model;
1253
- }
1254
- export interface ModelRequest {
1255
- readonly provider: string;
1256
- readonly id: string;
1257
- }
1258
- export interface CompletionRequest {
1259
- readonly prompt: string;
1260
- readonly maxTokens: number;
1261
- }
1262
- /** What a call came back with. `stop` is the host's own word, kept verbatim for
1263
- * metric rows; `capped` is the host saying the answer was cut short, which is
1264
- * the one thing a caller must never store. */
1265
- export type ModelAnswer = {
1266
- outcome: "answer";
1267
- text: string;
1268
- stop: string;
1269
- usage: Usage | undefined;
1270
- } | {
1271
- outcome: "failed";
1272
- reason: string;
1273
- capped: boolean;
1274
- stop: string;
1275
- usage: Usage | undefined;
1276
- };
1277
- export interface ModelHost<Model> {
1278
- readonly notices: Notices;
1279
- session(): ModelRef<Model> | undefined;
1280
- find(requested: ModelRequest): ModelRef<Model> | undefined;
1281
- complete(model: ModelRef<Model>, request: CompletionRequest): Promise<ModelAnswer>;
1282
- }
1283
- /** A tool the reader may call. The schema is the host's, passed through rather
1284
- * than rebuilt. */
1285
- export interface ReaderTool {
1286
- name: string;
1287
- description: string;
1288
- parameters: TSchema;
1289
- }
1290
- export interface ReaderCall {
1291
- readonly id: string;
1292
- readonly name: string;
1293
- readonly arguments: Record<string, unknown>;
1294
- }
1295
- export interface ReaderResult {
1296
- readonly call: ReaderCall;
1297
- readonly text: string;
1744
+ metrics: LcmMetricRecord[];
1745
+ pinnedModelKey: string;
1746
+ pinnedWindow: number;
1747
+ clampNotified: boolean;
1748
+ clampWarning?: string;
1298
1749
  }
1299
- /** What one reader step produced. `reads` asks the caller to run the calls;
1300
- * `answered` means the reader is done, with whatever text it accumulated. */
1301
- export type ReaderReply = {
1302
- outcome: "reads";
1303
- calls: readonly ReaderCall[];
1304
- text: string;
1305
- usage: Usage | undefined;
1750
+ export type ContextPolicyResult = ContextPolicyBase & ({
1751
+ outcome: "quiet";
1306
1752
  } | {
1307
- outcome: "answered";
1308
- text: string;
1309
- usage: Usage | undefined;
1753
+ outcome: "reapply";
1754
+ messages: AssemblyMsg[];
1310
1755
  } | {
1311
- outcome: "failed";
1312
- reason: string;
1313
- usage: Usage | undefined;
1314
- };
1315
- export interface ReaderRequest {
1316
- readonly systemPrompt: string;
1317
- readonly brief: string;
1318
- readonly tools: readonly ReaderTool[];
1319
- }
1320
- /** One reader conversation. The host owns the transcript: `next` appends the
1321
- * results it is handed, appends its own reply, and calls the model. */
1322
- export interface Reading {
1323
- next(results: readonly ReaderResult[]): Promise<ReaderReply>;
1324
- }
1325
- export interface Reader {
1326
- begin(request: ReaderRequest): Reading;
1327
- }
1328
- //#endregion
1329
- //#region src/llm.d.ts
1330
- export interface SummarizerConfig {
1331
- /** One model id, or an ordered fallback chain. `auto` is the session model. */
1332
- summarizer?: string | string[];
1333
- /** Tokens one request may cost, estimate plus output cap, before it is refused
1334
- * rather than paid for. A backstop, not a throttle. */
1335
- summarizerTokensPerCall?: number;
1336
- /** Tokens one pass may spend across all its calls, counted from what the
1337
- * provider reported. Reaching it finishes the pass mechanically. */
1338
- summarizerTokensPerPass?: number;
1756
+ outcome: "swap";
1757
+ messages: AssemblyMsg[];
1758
+ } | {
1759
+ outcome: "bail";
1760
+ reason: BailReason;
1761
+ messages?: AssemblyMsg[];
1762
+ } | {
1763
+ outcome: "kick";
1764
+ reason: "summary-missing";
1765
+ aged: NonEmpty<AlignedEntry>;
1766
+ /** The span this kick is about, in the order the store resolves one. The
1767
+ * adapter keys its latch on it, so the latch and the coverage check
1768
+ * cannot disagree about which boundary is missing. */
1769
+ span: {
1770
+ firstEntryId: string;
1771
+ lastEntryId: string;
1772
+ };
1773
+ messages?: AssemblyMsg[];
1774
+ });
1775
+ export interface ContextPolicyDeps {
1776
+ thresholds: (cfg: ThresholdConfig, window: number) => ResolvedThresholds;
1777
+ nextAction: (state: CommitState | null, occupancy: number, swap: number, recut: number) => AssemblyAction;
1778
+ cutIndexFor: (msgs: readonly AssemblyMsg[], keepRecentTokens: number) => number | null;
1779
+ cutMayReplace: (previous: CommitState | null, newCutCount: number) => boolean;
1780
+ frontier: (firstEntryId: string, lastEntryId: string) => SummaryNode[];
1781
+ /** The aged set's own ends as a span: branch order, and only entries the store
1782
+ * holds. Pi's context array is not branch order (it hoists the newest
1783
+ * compaction entry to the front), and a span whose ends are inverted resolves
1784
+ * to nothing, so this answers with the end pair a store query can use. Null
1785
+ * when the store holds none of the aged entries, which is a state rather than
1786
+ * a kick. */
1787
+ spanBounds: (aged: NonEmpty<AlignedEntry>) => {
1788
+ firstEntryId: string;
1789
+ lastEntryId: string;
1790
+ } | null;
1791
+ buildSynthetic: (summaries: readonly RenderableSummary[]) => CommitState["synthetic"];
1792
+ /** The mask node text passes through on its way to a model. The store keeps
1793
+ * the bytes, so every read seam takes this rather than storing a masked
1794
+ * copy; `makeRedact` is the provider a real caller passes. */
1795
+ redact: (text: string) => string;
1339
1796
  }
1340
- /**
1341
- * Thinking tokens are charged against the completion budget, so a reasoning
1342
- * model can spend the whole cap before it emits any text. The headroom sits on
1343
- * top of the caller's request, so the requested output size does not change.
1344
- */
1345
- export declare const REASONING_HEADROOM = 4096;
1346
- export interface ResolvedChain<Model> {
1347
- /** The models to try, in order and deduped. Empty when nothing resolves. */
1348
- models: ModelRef<Model>[];
1349
- /** Configured entries the host could not resolve, in order. */
1350
- unresolved: string[];
1351
- requested: string[];
1797
+ export interface FailedBoundary {
1798
+ attempts: number;
1799
+ lastAt: number;
1352
1800
  }
1353
- /** Expand the configured chain: `auto` (the session model) goes last unless the
1354
- * chain names it, so one bad entry still leaves the session able to summarize. */
1355
- export declare function resolveModels<Model>(host: ModelHost<Model>, config: SummarizerConfig): ResolvedChain<Model>;
1356
- /** The one rendering of a resolved chain. The level travels with the warning so
1357
- * no caller can pick a different notify level for the same chain. */
1358
- export type ChainReport = {
1359
- level: "info";
1360
- state: string;
1361
- } | {
1362
- level: "warning";
1363
- state: string;
1364
- warning: string;
1801
+ export declare const KICK_MAX_ATTEMPTS = 3;
1802
+ export declare const KICK_BACKOFF_MS: number;
1803
+ export declare function shouldKickBoundary(latch: FailedBoundary | undefined, now: number): boolean;
1804
+ export declare function applyContextPolicy(input: ContextPolicyInput, deps: ContextPolicyDeps): ContextPolicyResult;
1805
+ /** The frontier as the projection renders it: every node redacted on its way to
1806
+ * a model, and the degradation ladder walked only until the render fits the
1807
+ * summary budget. The blocking compaction hook calls this too, so the text it
1808
+ * hands the host is the text the projection would have sent. */
1809
+ export declare function renderableFrontier(frontier: readonly SummaryNode[], summaryTokens: number, redact: (text: string) => string): {
1810
+ applied: AppliedSummary[];
1811
+ renderedTokens: number;
1365
1812
  };
1366
- export declare function describeSummarizerChain<Model>(chain: ResolvedChain<Model>): ChainReport;
1367
- /** The span every call from one completer summarizes. The failure cap is per
1368
- * span, so a completer is built with the span it will be asked about. */
1369
- export interface SummarizedSpan {
1370
- firstEntryId: string;
1371
- lastEntryId: string;
1813
+ export interface CompactionBounds {
1814
+ startEntryId?: string;
1815
+ firstKeptEntryId: string;
1372
1816
  }
1373
- export interface MakeLlmOptions {
1374
- session: string;
1375
- stage?: SummarizerStage;
1376
- span: SummarizedSpan;
1817
+ /** Blocking-regime span (session_before_compact), computed the way Pi's
1818
+ * prepareCompaction does: branch entries from the previous compaction's first
1819
+ * kept entry up to but excluding the new one, minus compaction rows. Entry ids,
1820
+ * not message text, so bash and branch-summary rows align. An unknown
1821
+ * firstKeptEntryId yields an empty span, so the caller falls back to Pi. */
1822
+ export declare function resolveCompactionSpan(branchMsgs: readonly AlignedEntry[], bounds: CompactionBounds): AlignedEntry[];
1823
+ export type FindingKind = "fts-index" | "summary-span" | "summary-shape" | "orphan-child" | "duplicate-span" | "foreign-key" | "schema-version" | "dead-run" | "coverage";
1824
+ export interface Finding {
1825
+ kind: FindingKind;
1826
+ /** `violation` is an invariant the DAG depends on; `coverage` is a count. */
1827
+ severity: "violation" | "info";
1828
+ count: number;
1829
+ detail: string;
1830
+ sample: string[];
1377
1831
  }
1378
- /** Failed walks in a row before a pass stops calling the model and lets the
1379
- * ladder's deterministic truncate finish the span. One is too eager, because a
1380
- * single transport blip would turn a whole span mechanical; past three, another
1381
- * call carries no new information about the provider and costs its latency. */
1382
- export declare const SUMMARIZER_FAILURE_CAP = 3;
1383
- /** What one request may cost before it is refused instead of paid for. It exists
1384
- * for a pathological chunk rather than for a normal one. */
1385
- export declare const SUMMARIZER_CALL_TOKENS = 60000;
1386
- /** What one pass may spend across all of its calls. */
1387
- export declare const SUMMARIZER_PASS_TOKENS = 250000;
1388
- export declare function retriableCategory(category: SummarizerFailureCategory): boolean;
1389
- /** How long a span whose calls were capped is left alone. Shorter than the kick
1390
- * backoff (`KICK_BACKOFF_MS`) on purpose: this guards a retry inside one turn,
1391
- * it is not the breaker that stops re-kicks across turns. */
1392
- export declare const FAILED_SPAN_TTL_MS = 60000;
1393
- /** What a failed span is identified by: the two ends a caller asked about and
1394
- * the chain it asked. A different model is a different attempt, not the same
1395
- * failure, so the chain is part of the key. */
1396
- export declare function spanFingerprint(span: SummarizedSpan, chain: readonly string[]): string;
1397
- /**
1398
- * A call that reached no usable answer. `category` is what the provider's own
1399
- * text says: a best-effort read that answers `unknown` rather than guessing, and
1400
- * `capped` when the caller should stop asking rather than the provider failing.
1401
- */
1402
- export declare class SummarizerFailure extends Error {
1403
- readonly category: SummarizerFailureCategory;
1404
- constructor(message: string, category: SummarizerFailureCategory);
1832
+ export interface IntegrityReport {
1833
+ /** How many checks ran, so "no findings" cannot be confused with "no checks". */
1834
+ checks: number;
1835
+ findings: Finding[];
1405
1836
  }
1406
- /** Which bucket a provider's error text falls in. A 5xx or a dropped connection
1407
- * is `unavailable` before it is `timeout`, because the gateway's own timeout is
1408
- * a request that never reached a model. */
1409
- export declare function classifyFailure(text: string | undefined): SummarizerFailureCategory;
1410
- export declare function makeLlm<Model>(host: ModelHost<Model>, config: SummarizerConfig, opts: MakeLlmOptions): ((systemPrompt: string, userText: string, maxTokens: number) => Promise<string>) | null;
1411
- /** Sessions share the process, so each one gets to see the config it runs, and
1412
- * a session that starts fresh should not inherit a refusal from the last one. */
1413
- export declare function resetSummarizerState(): void;
1414
- //#endregion
1415
- //#region src/report.d.ts
1837
+ export declare function checkIntegrity(store: LcmStore): IntegrityReport;
1838
+ /** Fix what can be fixed without guessing: the derived index, rows whose span no
1839
+ * longer exists, nodes whose child no longer resolves, rows a run whose writer is
1840
+ * gone left behind, and predated duplicate spans. An inverted span is left
1841
+ * alone, because which end is right needs the session file. */
1842
+ export declare function repairIntegrity(store: LcmStore): string[];
1416
1843
  type Row = MetricRecord;
1417
1844
  type KeysOf<T> = T extends unknown ? keyof T : never;
1418
1845
  type ValueOf<T, K> = T extends unknown ? (K extends keyof T ? T[K] : never) : never;
@@ -1435,8 +1862,6 @@ export declare function depthStats(summaries: readonly {
1435
1862
  tokens: number;
1436
1863
  }[]): DepthStat[];
1437
1864
  export declare function buildReport(events: readonly MetricsEvent[]): string;
1438
- //#endregion
1439
- //#region src/diagnose.d.ts
1440
1865
  export interface FactConfig {
1441
1866
  swapAtTokens?: number;
1442
1867
  recutAtTokens?: number;
@@ -1523,43 +1948,6 @@ export declare function hostVersion(hostPackage: string, from: string | URL): st
1523
1948
  * or named something else is not the answer, so a wrong anchor fails the name
1524
1949
  * check instead of reporting a stranger's version. */
1525
1950
  export declare function packageVersionNear(from: string, name: string): string | undefined;
1526
- //#endregion
1527
- //#region src/estimate-tokens.d.ts
1528
- export declare const CHARS_PER_TOKEN = 4;
1529
- /** Samples below this count say nothing: with two points any line fits, and the
1530
- * first turns of a session are exactly where the overhead has not cancelled. */
1531
- export declare const CALIBRATION_MIN_SAMPLES = 3;
1532
- /** Samples kept in memory. A cap keeps the median describing the recent turns of
1533
- * the session rather than its whole history, which is what a model switch inside
1534
- * one session would otherwise be unable to move. */
1535
- export declare const CALIBRATION_SAMPLE_CAP = 8;
1536
- /** One observation of the same session: how many characters this build counted in
1537
- * the messages, and how many tokens Pi reported for the request that carried
1538
- * them. Sampling differences rather than totals is what cancels the system prompt
1539
- * and tool definitions, which this build never counts. */
1540
- export interface EstimateSample {
1541
- chars: number;
1542
- tokens: number;
1543
- }
1544
- export declare function estimateTokens(text: string): number;
1545
- export declare function tokensFromChars(chars: number): number;
1546
- /** A reported count below this share of the estimate describes a context other
1547
- * than the one on hand: Pi reports its last measurement, and after a resume that
1548
- * measurement belongs to the previous process. The estimator's own error is
1549
- * bounded by the calibration band, which is a factor of two in either direction
1550
- * around the constant, so anything below a quarter is a different turn rather
1551
- * than a miscount. A reported count too high only compacts early, which is why
1552
- * this looks one way. */
1553
- export declare const STALE_REPORT_SHARE = 0.25;
1554
- export declare function calibrate(samples: readonly EstimateSample[]): number | null;
1555
- /** A configured token budget in the units `estimateTokens` counts. The estimator
1556
- * counts chars / 4, so a model whose real ratio is 6 needs 1.5 times the
1557
- * configured number of estimated tokens accumulated before it has kept the
1558
- * context the user asked for. Without a ratio the budget is left alone, which is
1559
- * the constant's own reading of it. */
1560
- export declare function scaledBudget(tokens: number, charsPerToken: number | null): number;
1561
- //#endregion
1562
- //#region src/eval.d.ts
1563
1951
  /** The seed CI runs. Fixed, so a change in the answer is a change in the engine
1564
1952
  * rather than a change in the fixture. */
1565
1953
  export declare const SMALL_SEED = 20260915;
@@ -1629,8 +2017,6 @@ export interface EvalRunOptions {
1629
2017
  }
1630
2018
  export declare function runEval(opts: EvalOptions, run: EvalRunOptions): Promise<EvalResult>;
1631
2019
  export declare function formatEvalTable(results: readonly EvalResult[]): string;
1632
- //#endregion
1633
- //#region src/export.d.ts
1634
2020
  /** The format this build writes, and the only one it reads. A range reads as a
1635
2021
  * compatibility promise while the row readers ignore the number, and the store,
1636
2022
  * not the file, holds the truth: a file that does not fit is re-exported. */
@@ -1663,6 +2049,8 @@ export interface ExportSummaryRow {
1663
2049
  depth: number;
1664
2050
  firstEntryId: string;
1665
2051
  lastEntryId: string;
2052
+ /** Absent in files written before the quality verdict existed. */
2053
+ quality?: SummaryQuality;
1666
2054
  messageEntryIds: string[];
1667
2055
  childOrdinals: number[];
1668
2056
  }
@@ -1683,12 +2071,158 @@ export declare function exportTranscript(store: LcmStore): {
1683
2071
  summaries: SummaryNode[];
1684
2072
  toJSONL: () => string;
1685
2073
  };
1686
- //#endregion
1687
- //#region src/format.d.ts
1688
2074
  export declare function fmtTokens(n: number): string;
1689
2075
  export declare function fmtBytes(n: number): string;
1690
- //#endregion
1691
- //#region src/home-paths.d.ts
2076
+ export declare function fmtDuration(ms: number): string;
2077
+ /** The mechanical marker shared with the store migration and import fallback.
2078
+ * Detection prefers the quality verdict and keeps this as the legacy signal. */
2079
+ export declare const HEAL_TRUNCATED_PREFIX = "[lcm:truncated]";
2080
+ export declare const HEAL_TARGET_TOKENS = 1500;
2081
+ /** The source-to-summary ratio the default window follows. A heal reads more
2082
+ * than it writes, but a repair that cannot see the source cannot be clean. */
2083
+ export declare const HEAL_SOURCE_TARGET_FACTOR = 8;
2084
+ export declare const HEAL_SOURCE_MIN_TOKENS = 3000;
2085
+ export declare const HEAL_SOURCE_MAX_TOKENS = 24000;
2086
+ /** One repair may read a source larger than its window, but only as a bounded
2087
+ * map-reduce: the fanout matches the DAG's maximum child count, so a windowed
2088
+ * repair cannot grow calls without a limit. */
2089
+ export declare const HEAL_MAX_WINDOWS_PER_NODE = 8;
2090
+ /** Room for the system prompt and the summary instruction under a call cap. */
2091
+ export declare const HEAL_PROMPT_TOKENS = 1024;
2092
+ /** Kept for callers that still name the old constant. The value is the floor,
2093
+ * not a fixed per-message clip. */
2094
+ export declare const HEAL_LEAF_CHUNK_TOKENS = 3000;
2095
+ export declare const HEAL_MAX_NODES = 32;
2096
+ export declare const HEAL_TOKEN_BUDGET = 250000;
2097
+ export interface HealSourceBudgetInput {
2098
+ targetTokens?: number;
2099
+ /** An explicit source window. The derived default is `8 * targetTokens`,
2100
+ * clamped to the source floor and ceiling and then to the call budget. */
2101
+ sourceTokens?: number;
2102
+ callBudgetTokens?: number;
2103
+ }
2104
+ export interface HealInput {
2105
+ /** Session tag for every engine row and every ladder call this run makes. */
2106
+ session?: string;
2107
+ /** Tokens one repaired node targets. A result above twice this is rejected. */
2108
+ targetTokens?: number;
2109
+ /** Estimated tokens one map window may read. Defaults to `8 * targetTokens`,
2110
+ * clamped to 3,000 through 24,000 and to the request budget. A larger leaf
2111
+ * source uses up to eight windows and one reduce; it is never clipped to a
2112
+ * per-message slice. */
2113
+ sourceTokens?: number;
2114
+ /** @deprecated Use `sourceTokens`. Kept so a caller from the first heal
2115
+ * release keeps working; it now means the same per-window budget. */
2116
+ leafChunkTokens?: number;
2117
+ /** Tokens one summarizer request may cost. The source window is clamped so
2118
+ * its prompt, output, and reserve fit this cap. */
2119
+ callBudgetTokens?: number;
2120
+ /** Nodes this run may attempt. Ancestors only run after their children. */
2121
+ maxNodes?: number;
2122
+ /** Model calls this run may make. Defaults to twice `maxNodes`. */
2123
+ maxCalls?: number;
2124
+ /** Estimated tokens this run may spend. The provider's own budget still runs. */
2125
+ maxTokens?: number;
2126
+ /** Report the plan and make no model call. */
2127
+ dryRun?: boolean;
2128
+ /** Masks text on its way to the model and into the replacement node. */
2129
+ redact?: (text: string) => string;
2130
+ /** Override for tests and imports; the deterministic prefix is the default. */
2131
+ truncatedPrefix?: string;
2132
+ }
2133
+ export interface HealCandidate {
2134
+ id: number;
2135
+ nodeKind: "leaf" | "condensed";
2136
+ depth: number;
2137
+ quality: SummaryQuality;
2138
+ /** The node's own text is the deterministic truncate, either by verdict or by
2139
+ * the prefix fallback. */
2140
+ mechanical: boolean;
2141
+ /** The node is a seed: its quality is not clean, or its text carries the
2142
+ * mechanical prefix. */
2143
+ tainted: boolean;
2144
+ /** Candidate parents, in id order. A dry run reports the dependency shape
2145
+ * through these edges; repair order is always child before parent. */
2146
+ parents: number[];
2147
+ }
2148
+ export interface HealNodeResult {
2149
+ id: number;
2150
+ nodeKind: "leaf" | "condensed";
2151
+ depth: number;
2152
+ tainted: boolean;
2153
+ qualityBefore: SummaryQuality;
2154
+ /** The quality this run stored, or absent when the old row stands. A failed
2155
+ * node keeps its persisted verdict, which is what the next run seeds on. */
2156
+ qualityAfter?: SummaryQuality;
2157
+ outcome: HealNodeOutcomeKind;
2158
+ reason?: string;
2159
+ /** 3 when the text is the deterministic truncate, null otherwise. */
2160
+ levelBefore: SummaryLevel | null;
2161
+ /** The level stored by this run, or null when the old text stands. */
2162
+ levelAfter: SummaryLevel | null;
2163
+ beforeTokens: number;
2164
+ afterTokens: number | null;
2165
+ calls: number;
2166
+ /** Source facts for the attempted node. They never include source text. */
2167
+ sourceTokens?: number;
2168
+ sourceChars?: number;
2169
+ sourceClipped?: boolean;
2170
+ }
2171
+ export interface HealCounts {
2172
+ candidates: number;
2173
+ tainted: number;
2174
+ attempted: number;
2175
+ /** Nodes whose new text is clean. An improved node is not a repair. */
2176
+ repaired: number;
2177
+ /** Nodes whose text changed but remains clipped or derived. */
2178
+ improved: number;
2179
+ failed: number;
2180
+ /** Dependency-blocked plus source-blocked nodes. */
2181
+ blocked: number;
2182
+ /** The source-blocked subset of `blocked`. */
2183
+ blockedSource: number;
2184
+ skipped: number;
2185
+ changed: number;
2186
+ notAttempted: number;
2187
+ }
2188
+ export interface HealOutcome {
2189
+ dryRun: boolean;
2190
+ stop: HealStopReason;
2191
+ /** The effective map window for this run, in estimated tokens. */
2192
+ sourceTokens: number;
2193
+ callBudgetTokens: number;
2194
+ candidates: HealCandidate[];
2195
+ nodes: HealNodeResult[];
2196
+ repaired: number[];
2197
+ improved: number[];
2198
+ failed: number[];
2199
+ blocked: number[];
2200
+ /** The subset of `blocked` whose source exceeded the window. */
2201
+ blockedSource: number[];
2202
+ skipped: number[];
2203
+ changed: number[];
2204
+ notAttempted: number[];
2205
+ calls: number;
2206
+ tokens: number;
2207
+ counts: HealCounts;
2208
+ }
2209
+ /** The source window a repair is allowed to read, in estimated tokens.
2210
+ *
2211
+ * The default is a multiple of the summary target, so the one quality knob a
2212
+ * user already tunes moves the source with it. A fixed source window has the
2213
+ * failure this policy exists to remove: every larger input clips at the same
2214
+ * place forever. The window is still bounded by the request cap, so a large
2215
+ * source cannot turn into a prompt the provider will refuse.
2216
+ */
2217
+ export declare function healSourceBudget(input?: HealSourceBudgetInput): number;
2218
+ /**
2219
+ * Re-derive mechanical summaries from the source they still point at, then
2220
+ * recompute every ancestor whose child changed. The messages table is never
2221
+ * written: a leaf is rebuilt from its exact provenance rows, a condensed node
2222
+ * from its exact children, and only a node whose text still matches the read
2223
+ * that produced the replacement is written at all.
2224
+ */
2225
+ export declare function runHeal(store: HealStore, input: HealInput, llm: LlmComplete): Promise<HealOutcome>;
1692
2226
  /** Escape hatch for a live session that carries a test signal for another
1693
2227
  * reason, so the guard can never brick one. */
1694
2228
  export declare const ALLOW_REAL_HOME_FLAG = "LCM_ALLOW_REAL_HOME";
@@ -1697,8 +2231,6 @@ export declare function isUnderTempDir(path: string): boolean;
1697
2231
  * guard covers them all instead of one per call site. A test run refuses a path
1698
2232
  * outside its temp home rather than touching the real config. */
1699
2233
  export declare function lcmHomePath(...segments: string[]): string;
1700
- //#endregion
1701
- //#region src/import.d.ts
1702
2234
  export interface ImportResult {
1703
2235
  messagesInserted: number;
1704
2236
  messagesSkipped: number;
@@ -1719,8 +2251,6 @@ export type ParsedExport = {
1719
2251
  * the caller can report a line number. */
1720
2252
  export declare function parseExport(text: string): ParsedExport;
1721
2253
  export declare function importTranscript(store: LcmStore, rows: readonly ExportRow[]): ImportResult;
1722
- //#endregion
1723
- //#region src/ingest.d.ts
1724
2254
  export interface IngestibleEntry {
1725
2255
  entryId: string;
1726
2256
  role: LcmRole;
@@ -1789,23 +2319,6 @@ export declare function ingestEntries(store: IngestStore, entries: readonly Inge
1789
2319
  largeFileChars: number;
1790
2320
  };
1791
2321
  }): IngestStats;
1792
- //#endregion
1793
- //#region src/overflow-window.d.ts
1794
- /** A window outside this band is a misread rather than a model: no provider
1795
- * serves a context under a thousand tokens or over a hundred million, and a
1796
- * number outside it means a pattern caught the request count, a status code, or
1797
- * something else that was not a limit. A refused parse is the floor path, which
1798
- * costs one conservative turn instead of a wrong permanent window. */
1799
- export declare const WINDOW_MIN = 1000;
1800
- export declare const WINDOW_MAX = 100000000;
1801
- /** The window in tokens a provider stated in an overflow message, or null when
1802
- * the message does not name one. Null is an answer: the caller keeps Pi's belief
1803
- * and arms the one-shot floor, which is all a body like "400/413 status code (no
1804
- * body)" can support. */
1805
- export declare function windowFromError(errorMessage: string | undefined): number | null;
1806
- export declare const WINDOW_PATTERN_EXAMPLES: readonly string[];
1807
- //#endregion
1808
- //#region src/recall-view.d.ts
1809
2322
  export declare const GREP_PAGE_HITS = 20;
1810
2323
  export type ViewResult = {
1811
2324
  ok: true;
@@ -1827,6 +2340,8 @@ export declare function grepView(store: LcmStore, redact: (text: string) => stri
1827
2340
  * names. A page cannot express where a clock ran out. */
1828
2341
  after?: string;
1829
2342
  now?: () => number;
2343
+ /** Tool the continuation marker names. Defaults to `lcm_expand_query`. */
2344
+ expandTool?: string;
1830
2345
  }): ViewResult;
1831
2346
  export declare function describeView(store: LcmStore, redact: (text: string) => string, id: number): ViewResult;
1832
2347
  /** List the summary's messages, or read one of them as a window of characters.
@@ -1847,8 +2362,6 @@ export type ExpandParams = {
1847
2362
  };
1848
2363
  export declare function expandView(store: LcmStore, redact: (text: string) => string, params: ExpandParams): ViewResult;
1849
2364
  export declare function historyManifest(store: LcmStore, limit: number): string;
1850
- //#endregion
1851
- //#region src/redact.d.ts
1852
2365
  /** Redactor that re-reads the flag on every call, so a cockpit toggle takes
1853
2366
  * effect immediately: a getter, because patchConfig replaces the config object
1854
2367
  * and closing over it would pin the session-start value. */
@@ -1856,8 +2369,6 @@ export declare function makeRedact(getConfig: () => {
1856
2369
  redactSecrets?: boolean;
1857
2370
  }): (text: string) => string;
1858
2371
  export declare function redactSecrets(text: string): string;
1859
- //#endregion
1860
- //#region src/retention.d.ts
1861
2372
  export declare const SCRATCH_MAX_AGE_MS: number;
1862
2373
  export interface DbFootprint {
1863
2374
  path: string;
@@ -1880,8 +2391,6 @@ export declare function retentionCandidates(opts: {
1880
2391
  now: number;
1881
2392
  scratchMaxAgeMs: number;
1882
2393
  }): GcCandidate[];
1883
- //#endregion
1884
- //#region src/retrieval.d.ts
1885
2394
  export declare const RETRIEVAL_STEPS = 6;
1886
2395
  export declare const MANIFEST_NODES = 20;
1887
2396
  export declare const MAX_EXPAND_CHARS = 20000;
@@ -1927,8 +2436,6 @@ export type RetrievalRun = (RetrievalBase & {
1927
2436
  reason: string;
1928
2437
  });
1929
2438
  export declare function runRetrieval(store: LcmStore, request: RetrievalRequest, reader: Reader): Promise<RetrievalRun>;
1930
- //#endregion
1931
- //#region src/sessions.d.ts
1932
2439
  /** Most recent past sessions one scan considers. A cap, not a promise: the
1933
2440
  * deadline below is what bounds the work. */
1934
2441
  export declare const SESSION_SCAN_LIMIT = 20;
@@ -2015,5 +2522,5 @@ export declare function sessionsForScope(opts: {
2015
2522
  excludeHash?: string;
2016
2523
  limit?: number;
2017
2524
  }): SessionRef[];
2018
- //#endregion
2525
+ export {};
2019
2526
  //# sourceMappingURL=index.d.mts.map