pi-optchat 0.10.2 → 0.11.0

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.
@@ -0,0 +1,84 @@
1
+ import { bytes, isEntry, type Entry, type Memory } from './memory.ts';
2
+
3
+ /** What planning reads of a message: no text, so a host can plan from metadata alone. */
4
+ type Meta = Pick<Entry, 'kind' | 'date' | 'receipt' | 'origin'>;
5
+
6
+ /** Append keeps the existing log and its summaries and adds the new messages after them; rebuild orders everything by date and
7
+ * summarizes it all again. Either way the host builds the result on a staging store and switches to it only once it is complete. */
8
+ export type ImportMode = 'append' | 'rebuild';
9
+ /** A message to import, before memory numbers it. Its `receipt` starts with `import:` and is stable per source message, so
10
+ * importing the same history again adds nothing. */
11
+ export type ImportedEntry = Omit<Entry, 'i'>;
12
+
13
+ /** Drops what memory already has: the same receipt, or the same `copyOf` key, the host's second identity for one message
14
+ * (Pi: a Claude message copied into a forked session). */
15
+ export function deduplicate<E extends Meta>(existing: readonly E[], incoming: readonly ImportedEntry[],
16
+ copyOf: (entry: E | ImportedEntry) => string | undefined = () => undefined) {
17
+ const receipts = new Set(existing.map(e => e.receipt).filter(Boolean));
18
+ const copies = new Set(existing.map(copyOf).filter(Boolean));
19
+ const added: ImportedEntry[] = []; let skipped = 0;
20
+ for (const entry of incoming) {
21
+ if (!entry.receipt?.startsWith('import:')) throw new Error('Imported entry has no stable source identity.');
22
+ const copy = copyOf(entry);
23
+ if (receipts.has(entry.receipt) || copy && copies.has(copy)) { skipped++; continue; }
24
+ receipts.add(entry.receipt); if (copy) copies.add(copy); added.push(entry);
25
+ }
26
+ return { added, skipped };
27
+ }
28
+
29
+ /** Imported conversations remain contiguous; existing native history is grouped by user turn. An imported message without an
30
+ * origin stands alone: in a native turn it would move that turn, and a host's cursor in the log with it. */
31
+ export function chronological<E extends Meta>(entries: readonly E[]) {
32
+ const groups = new Map<string, { date: string; entries: E[] }>();
33
+ let native = 0;
34
+ for (const entry of entries) {
35
+ const imported = entry.receipt?.startsWith('import:') ? entry.receipt : undefined;
36
+ if (!entry.origin && !imported && entry.kind === 'user') native++;
37
+ const id = entry.origin ? JSON.stringify([entry.origin.source, entry.origin.conversation]) : imported ?? `native:${native}`;
38
+ const group = groups.get(id) ?? { date: entry.date, entries: [] };
39
+ group.date = group.date < entry.date ? group.date : entry.date;
40
+ group.entries.push(entry); groups.set(id, group);
41
+ }
42
+ return [...groups.values()].sort((a, b) => a.date.localeCompare(b.date)).flatMap(g => g.entries);
43
+ }
44
+
45
+ /** The whole log an import builds, or undefined when nothing is new. Its first `kept` entries are the existing log as it is (append),
46
+ * so the host seeds the staging store with them and the existing summaries and view; rebuild keeps none. */
47
+ export function planImport(existing: readonly Entry[], incoming: readonly ImportedEntry[], mode: ImportMode,
48
+ copyOf?: (entry: ImportedEntry) => string | undefined) {
49
+ const { added, skipped } = deduplicate(existing, incoming, copyOf);
50
+ if (!added.length) return undefined;
51
+ const all = mode === 'rebuild' ? chronological([...existing, ...added]) : [...existing, ...chronological(added)];
52
+ const entries: Entry[] = all.map((e, i) => ({ ...e, i }));
53
+ return { mode, entries, kept: mode === 'append' ? existing.length : 0, added: added.length, skipped,
54
+ inputBytes: (mode === 'rebuild' ? entries : added).reduce((n, e) => n + bytes(e.text), 0) };
55
+ }
56
+
57
+ /** Builds the staging memory `open` gives from the plan, `entry(i)` for each of its `total` messages, and calls `activate` once its
58
+ * whole tree is built and it is closed. It continues from what the memory already logged, so a paused or crashed import resumes. A
59
+ * planned message that isn't a valid entry at its index, or a memory that isn't a start of the plan, stops it before the switch;
60
+ * `entry` is read as needed, so a host need not hold the plan in RAM.
61
+ * The live memory must take no messages from `planImport` until `activate`, or the switch drops them: queue them and log them after. */
62
+ export async function runImport({ entry, total, open, activate }: { entry: (i: number) => unknown; total: number;
63
+ open: () => Promise<Memory>; activate: () => void | Promise<void> }, signal: AbortSignal) {
64
+ const planned = (i: number) => {
65
+ const e = entry(i);
66
+ if (!isEntry(e) || e.i !== i) throw new Error('Import staging data is invalid; original memory remains intact.');
67
+ return e;
68
+ };
69
+ if (!Number.isSafeInteger(total) || total < 0) throw new Error('Import staging data is invalid; original memory remains intact.');
70
+ const memory = await open();
71
+ try {
72
+ if (memory.root.length > total || memory.root.some((_, i) => memory.text(i) !== planned(i).text)) throw new Error('Import staging data does not match its log; original memory remains intact.');
73
+ // Recipe §10: imported messages are compressed like any other. Each is logged once its node can start, as a burst of live
74
+ // messages is, so up to `jobs` summaries run at once. Unlike a turn, an import waits through failures (`lastError` says why).
75
+ for (let i = memory.root.length; i < total; i++) { await memory.settle(signal, 'ahead'); const e = planned(i); memory.append(e.kind, e.text, e.date, e.receipt, e.origin); }
76
+ await memory.settle(signal, 'tree'); signal.throwIfAborted();
77
+ // Close all writers before the switch. The previous memory stays intact.
78
+ await memory.close();
79
+ // A store that writes behind reports a failed write only here: switching would drop the messages it lost.
80
+ if (memory.saveError !== undefined) throw new Error(`Import could not be saved: ${memory.saveError}; original memory remains intact.`);
81
+ signal.throwIfAborted();
82
+ await activate();
83
+ } finally { await memory.close(); }
84
+ }
@@ -0,0 +1,13 @@
1
+ /** OptChat's memory for any host: the log, the summary tree and its views, zoom/date/search, the compactor, cache marks and
2
+ * memory's prompts. It imports nothing from Pi or Node at runtime: the host gives it a `Store` and the compactor's model.
3
+ * Only what a host needs is exported here; Pi's own code imports the modules directly. */
4
+ export { isEntry, Memory, openMemory, Refused, VIEW, type Compression, type Compressor, type Entry, type Kind, type MemoryOptions, type Origin, type Part, type Summary } from './memory.ts';
5
+ export { isMime, type ImageStore, type Lease, type Mime, type Saved, type Store } from './store.ts';
6
+ export { ACCEPTED_BYTES, createCompressor, type CompactorModel, type CompressorOptions, type Retryable } from './compactor.ts';
7
+ export { cachePayload } from './cache.ts';
8
+ export { allowSearch, COMPACT, CONTINUITY, SEARCH_DOC, TURN_REFRESHED, VIEW_DOC } from './prompts.ts';
9
+ export { boundedMessage, completedExchange, logMessage, refreshBudget, refreshedRun, refreshPoint, textContent, withView, type AnyMessage, type ImageRef } from './messages.ts';
10
+ export { memoryTools, searchTool } from './tools.ts';
11
+ export { decode, isImage, loadImages } from './images.ts';
12
+ export type { ImageContent } from '@earendil-works/pi-ai';
13
+ export { chronological, deduplicate, planImport, runImport, type ImportedEntry, type ImportMode } from './import.ts';
@@ -0,0 +1,460 @@
1
+ import type { Saved, Store } from './store.ts';
2
+
3
+ export const NODE = 512;
4
+ export const VIEW = 128_000;
5
+ export const CAP = 30_000;
6
+ /** The most characters of one message a leaf sends the compactor, so a huge paste can't overflow it; zoom still pages through all of it. */
7
+ export const SOURCE = 100_000;
8
+ /** The most characters one zoom into a message returns, so a page stays under the tool output CAP instead of losing its middle. */
9
+ export const PAGE = 25_000;
10
+ export const KINDS = ['user', 'talk', 'tool', 'echo', 'note', 'work'] as const;
11
+ export type Kind = typeof KINDS[number];
12
+ /** Marks an entry as imported history: the summarizer treats it as historical, not as a new request. `source` is a label the host
13
+ * chooses, such as "claude" or "email". */
14
+ export interface Origin { source: string; conversation: string; message: string; title: string; project?: string }
15
+ /** A message as stores save it. */
16
+ export interface Entry { i: number; kind: Kind; text: string; date: string; receipt?: string; origin?: Origin }
17
+ /** A message as memory keeps it in RAM: its text stays in the store, and `Memory.text(i)` reads it. */
18
+ export type Message = Omit<Entry, 'text'>;
19
+ export interface Part { l: number; i: number }
20
+ /** A summary node as stores save it. `size` is its line's bytes in a view. */
21
+ export interface Summary extends Part { text: string; size: number }
22
+ /** A summary node as memory keeps it in RAM: `Memory.summary(part)` reads its text from the store. */
23
+ export type Node = Omit<Summary, 'text'>;
24
+ /** `part` is the node to build: a message's leaf, or the merge of its two halves. `call` is the tool call a tool result's leaf
25
+ * answers, raw, since its own line is often not built yet (#153). */
26
+ export interface Compression { context: string; source: string; part: Part; historical?: boolean; call?: { i: number; text: string } }
27
+ export type Compressor = (input: Compression, signal: AbortSignal) => Promise<string>;
28
+ /** A compressor throws this when the model itself gives no line for an input (a refusal, an empty reply, 5 cut-off replies):
29
+ * asked again it fails alike, so the node keeps the start of its source instead of being retried forever (#193). */
30
+ export class Refused extends Error {}
31
+ const key = ({ l, i }: Part) => l * 2 ** 40 + i;
32
+ const UNBUILT = '(not summarized yet: zoom it)';
33
+ const CLIPPED = '(not summarized: zoom it)';
34
+ export const start = ({ l, i }: Part) => i * 2 ** l;
35
+ export const end = (part: Part) => start(part) + 2 ** part.l;
36
+ // ignoreBOM keeps a leading U+FEFF, as Buffer did.
37
+ const encoder = new TextEncoder(), decoder = new TextDecoder('utf-8', { ignoreBOM: true });
38
+ export const bytes = (s: string) => encoder.encode(s).length;
39
+ const UNBUILT_BYTES = bytes(UNBUILT);
40
+ export const flat = (s: string) => s.replace(/[\r\n]+/g, ' ');
41
+ /** The first NODE bytes, without a split character at the end. */
42
+ export const cut = (s: string) => decoder.decode(encoder.encode(s).subarray(0, NODE)).replace(/\uFFFD+$/, '');
43
+ const lineBytes = (s: string) => bytes(flat(s));
44
+ const notice = (omitted: number) => `\n[${omitted} characters omitted; head and tail retained]\n`;
45
+ const VIEW_OPEN = '<chat>\n';
46
+ const VIEW_FRAME_BYTES = bytes(`${VIEW_OPEN}</chat>`);
47
+ export const isView = (text: string) => text.startsWith(VIEW_OPEN);
48
+ export function cap(text: string, limit = CAP) {
49
+ if (text.length <= limit) return text;
50
+ const half = Math.floor((limit - notice(text.length).length) / 2);
51
+ const head = text.slice(0, /[\ud800-\udbff]/.test(text[half - 1]) ? half - 1 : half);
52
+ const tail = text.slice(/[\udc00-\udfff]/.test(text[text.length - half]) ? text.length - half + 1 : text.length - half);
53
+ return head + notice(text.length - head.length - tail.length) + tail;
54
+ }
55
+ /** The index of the view's most due pair of sibling lines whose parent is built, or -1 (recipe §3.2). A pair is as due as
56
+ * long ago it ended, in its own line size; equal pairs go oldest first. This is the order of Taelin's rollback push. */
57
+ export function mostDue(view: readonly Part[], total: number, built: (part: Part) => boolean) {
58
+ let best = -1, due = -Infinity;
59
+ for (let j = 0; j + 1 < view.length; j++) {
60
+ const a = view[j], b = view[j + 1];
61
+ if (a.l !== b.l || a.i % 2 || b.i !== a.i + 1) continue;
62
+ const age = (total - (end(b) - 1)) / 2 ** a.l;
63
+ if (age > due && built({ l: a.l + 1, i: a.i / 2 })) { best = j; due = age; }
64
+ }
65
+ return best;
66
+ }
67
+ /** A view (recipe §3.2): lines that tile the log, oldest first. It grows a line per message and, once over `high` bytes or when
68
+ * forced, merges its most due pairs in one batch down to `low`, merging what it can at each change until then. */
69
+ class Sawtooth {
70
+ readonly parts: Part[] = [];
71
+ private measured = 0;
72
+ merging = false;
73
+ constructor(private readonly high: number, private readonly low: number, private readonly node: (part: Part) => Node | undefined) {}
74
+ private lineSize(part: Part) { return bytes(`${start(part)}+${2 ** part.l}|\n`) + (this.node(part)?.size ?? UNBUILT_BYTES); }
75
+ get size() { return VIEW_FRAME_BYTES + this.measured + (this.parts.length ? 0 : 1); } // render() keeps a blank line in an empty view.
76
+ push(part: Part) { this.parts.push(part); this.measured += this.lineSize(part); }
77
+ /** The line covering message `at`. The lines tile the log in order, so a binary search finds it. */
78
+ covering(at: number): Part | undefined {
79
+ for (let lo = 0, hi = this.parts.length - 1; lo <= hi;) {
80
+ const mid = (lo + hi) >> 1, p = this.parts[mid];
81
+ if (end(p) <= at) lo = mid + 1;
82
+ else if (start(p) > at) hi = mid - 1;
83
+ else return p;
84
+ }
85
+ }
86
+ /** A message got its node. Its line is usually still shown, but after a damaged tree file a saved parent can already hide it. */
87
+ built(i: number) { if (this.covering(i)?.l === 0) this.measured += this.node({ l: 0, i })!.size - UNBUILT_BYTES; }
88
+ /** Returns whether any lines merged. */
89
+ fit(total: number, force = false) {
90
+ const lines = this.parts.length;
91
+ if (force || this.size > this.high) this.merging = true;
92
+ for (let best: number; this.merging && this.size > this.low && (best = mostDue(this.parts, total, part => !!this.node(part))) >= 0;) {
93
+ const a = this.parts[best], b = this.parts[best + 1], parent = { l: a.l + 1, i: a.i / 2 };
94
+ this.measured += this.lineSize(parent) - this.lineSize(a) - this.lineSize(b);
95
+ this.parts.splice(best, 2, parent);
96
+ }
97
+ if (this.size <= this.low) this.merging = false;
98
+ return this.parts.length < lines;
99
+ }
100
+ }
101
+ function object(value: unknown): value is Record<string, unknown> {
102
+ return typeof value === 'object' && value !== null;
103
+ }
104
+ export function isEntry(value: unknown): value is Entry {
105
+ return isMessage(value) && typeof value.text === 'string';
106
+ }
107
+ /** A saved message, with its text or without it. */
108
+ function isMessage(value: unknown): value is Message & { text?: string } {
109
+ return object(value) && Number.isSafeInteger(value.i) && typeof value.kind === 'string'
110
+ && KINDS.some(kind => kind === value.kind) && typeof value.date === 'string' && (value.text === undefined || typeof value.text === 'string');
111
+ }
112
+ export function isSummary(value: unknown): value is Summary {
113
+ return object(value) && Number.isSafeInteger(value.l) && Number.isSafeInteger(value.i) && typeof value.text === 'string';
114
+ }
115
+ /** A saved node without its text. Its line size is measured from the text, or, from a store that loads no texts, is the size
116
+ * memory measured when it wrote the node. */
117
+ function savedNode(value: unknown): Node | undefined {
118
+ if (!object(value) || typeof value.l !== 'number' || typeof value.i !== 'number' || !Number.isSafeInteger(value.l) || !Number.isSafeInteger(value.i)) return;
119
+ const size = typeof value.text === 'string' ? lineBytes(value.text) : value.size;
120
+ return typeof size === 'number' && Number.isSafeInteger(size) ? { l: value.l, i: value.i, size } : undefined;
121
+ }
122
+
123
+ /** The log is authoritative. The tree and the view follow the recipe: the view grows a line per message and, once it passes
124
+ * `budget`, merges its most due pairs in one batch down to half of it, so between batches it only grows at its end.
125
+ * Compactions get their own view (recipe §4): the chat's merged further, to between an eighth and a quarter of the budget,
126
+ * and again whenever the chat's merges, so parallel compactions read it from the cache. */
127
+ export class Memory {
128
+ readonly root: Message[] = [];
129
+ readonly tree = new Map<number, Node>();
130
+ /** Texts whose store write hasn't finished (a store that writes behind can't read them yet) or was skipped after a failed save,
131
+ * by message index and by node. */
132
+ private readonly unsaved = { texts: new Map<number, string>(), summaries: new Map<number, string>() };
133
+ private readonly listeners = new Set<() => void>();
134
+ private readonly controller = new AbortController();
135
+ private readonly busy = new Map<number, Promise<void>>();
136
+ private readonly retryAt = new Map<number, number>();
137
+ private readonly reported = new Set<number>();
138
+ /** Messages without their node, and merges whose halves are built, oldest first, so the scheduler never scans the tree for work (recipe §4). */
139
+ private readonly unbuilt = new Set<number>();
140
+ private readonly merges = new Map<number, Part>();
141
+ private retryTimer?: ReturnType<typeof setTimeout>;
142
+ /** The most summaries owed at once since none were last owed. */
143
+ private peak = 0;
144
+ private scheduled = false;
145
+ private stopped = false;
146
+ private readonly chat: Sawtooth;
147
+ private readonly compaction: Sawtooth;
148
+ /** The last store write still running, if any. Once an append fails, later writes are skipped and appends throw, as after a failed file write. */
149
+ private writing?: Promise<void>;
150
+ private failed?: string;
151
+ readonly store: Store;
152
+ /** Why a message's append failed in the store, if one did: from then on nothing more is saved, yet `settle` and `close` still
153
+ * resolve, so a host whose store writes behind checks this to learn its log lost its tail. */
154
+ get saveError() { return this.failed; }
155
+ lastError?: string;
156
+
157
+ /** `saved` is what `store.load()` gave (see `openMemory`). `jobs`: summaries built at once, and how many unbuilt lines a new
158
+ * message's node waits behind. Pi's chat and imports pass the "Summaries at once" setting. */
159
+ constructor({ store, saved }: { store: Store; saved: Saved }, private readonly compress: Compressor,
160
+ private readonly warn: (s: string) => void = console.error,
161
+ readonly budget = VIEW, private readonly jobs = 8, private readonly retryMs = 10_000) {
162
+ this.chat = new Sawtooth(budget, budget / 2, part => this.node(part));
163
+ this.compaction = new Sawtooth(budget / 4, budget / 8, part => this.node(part));
164
+ this.store = store;
165
+ const log = saved.entries;
166
+ const entries = log.every(isMessage) ? log.sort((a, b) => a.i - b.i) : undefined;
167
+ if (!entries || entries.some((entry, i) => entry.i !== i))
168
+ throw new Error('Invalid/noncontiguous OptChat log; refusing to change it.');
169
+ for (const { i, kind, date, receipt, origin } of entries) this.root.push({ i, kind, date, ...(receipt ? { receipt } : {}), ...(origin ? { origin } : {}) });
170
+ for (const value of saved.tree) {
171
+ const node = savedNode(value);
172
+ if (!node || node.l < 0 || node.i < 0 || end(node) > this.root.length) throw new Error('Invalid OptChat summary record.');
173
+ this.tree.set(key(node), node);
174
+ }
175
+ for (const node of this.tree.values()) this.queueParent(node);
176
+ for (let i = 0; i < this.root.length; i++) if (!this.node({ l: 0, i })) this.unbuilt.add(i);
177
+ // Refolding the log gives different views than the live ones, and every cache entry would miss, so the views are saved.
178
+ const loaded = this.load(saved.view);
179
+ for (let i = loaded; i < this.root.length; i++) { this.push(i); this.fit(i + 1); }
180
+ this.fit(); this.save(); this.schedule();
181
+ }
182
+ append(kind: Kind, text: string, date = new Date().toISOString(), receipt?: string, origin?: Origin) {
183
+ if (this.stopped) throw new Error('Memory is closed.');
184
+ if (this.failed) throw new Error(`Memory could not be saved: ${this.failed}`);
185
+ const message: Message = { i: this.root.length, kind, date, ...(receipt ? { receipt } : {}), ...(origin ? { origin } : {}) };
186
+ const entry: Entry = { ...message, text };
187
+ void this.enqueue(this.keeping(this.unsaved.texts, entry.i, text, () => this.store.append(entry)), error => {
188
+ this.failed ??= error instanceof Error ? error.message : String(error);
189
+ this.warn(`Memory could not be saved: ${this.failed}`);
190
+ });
191
+ this.root.push(message); this.push(entry.i); if (this.fit()) this.save(); this.schedule();
192
+ return entry;
193
+ }
194
+ node(part: Part) { return this.tree.get(key(part)); }
195
+ /** Message `i`'s text, from the store. */
196
+ text(i: number) { return this.unsaved.texts.get(i) ?? this.store.text(i); }
197
+ /** Every message with its text, read from the store: for imports, which plan from the whole log. */
198
+ entries(): Entry[] { return this.root.map(m => ({ ...m, text: this.text(m.i) })); }
199
+ /** A built node's text, from the store. */
200
+ summary(part: Part) { return this.node(part) ? this.unsaved.summaries.get(key(part)) ?? this.store.summary(part) : undefined; }
201
+ private line(part: Part) { return this.summary(part) ?? UNBUILT; }
202
+ /** `write`, with `text` kept in `unsaved` until the write has finished. */
203
+ private keeping(unsaved: Map<number, string>, id: number, text: string, write: () => Promise<void> | undefined) {
204
+ unsaved.set(id, text);
205
+ const saved = () => { unsaved.delete(id); };
206
+ return () => { const done = write(); if (done) return done.then(saved); saved(); return undefined; };
207
+ }
208
+ /** Runs a store write at once when none is running, so a store that finishes its writes as it returns (FileStore) throws here
209
+ * as before; otherwise after the running ones, so writes land in the order Memory makes them. Returns the queued write, settled
210
+ * through `failed`, unless it finished at once. */
211
+ private enqueue(write: () => Promise<void> | undefined, failed: (error: unknown) => void) {
212
+ const run = () => this.failed ? undefined : write();
213
+ const started = this.writing ? this.writing.then(run) : run();
214
+ if (!started) return;
215
+ const settled = started.catch(failed);
216
+ const tail: Promise<void> = settled.catch(() => {}).then(() => { if (this.writing === tail) this.writing = undefined; });
217
+ this.writing = tail;
218
+ return settled;
219
+ }
220
+ /** Takes the saved views if they still tile the log from the start, and returns how many messages they cover. */
221
+ private load(view: string | undefined) {
222
+ if (view === undefined) return 0;
223
+ let saved: unknown;
224
+ try { saved = JSON.parse(view); } catch { saved = undefined; }
225
+ const chat = this.tiling(saved);
226
+ if (!chat) { this.warn('Rebuilt the memory view: the saved one does not match the log.'); return 0; }
227
+ for (const part of chat.parts) this.chat.push(part);
228
+ this.chat.merging = chat.merging;
229
+ // An older view.json has only the chat's view: the compactions' is refolded from it, a cold prefix once.
230
+ const compaction = object(saved) ? this.tiling(saved.compaction) : undefined;
231
+ if (compaction?.covered === chat.covered) { for (const part of compaction.parts) this.compaction.push(part); this.compaction.merging = compaction.merging; }
232
+ else { for (const part of chat.parts) this.compaction.push(part); this.compaction.fit(chat.covered, true); }
233
+ return chat.covered;
234
+ }
235
+ /** A saved view, `{ parts, merging }` or an older bare list of parts, if it tiles the log from the start. */
236
+ private tiling(saved: unknown) {
237
+ const merging = object(saved) && saved.merging === true;
238
+ if (object(saved) && typeof saved.merging === 'boolean') saved = saved.parts;
239
+ if (!Array.isArray(saved)) return;
240
+ const parts: Part[] = [];
241
+ let covered = 0;
242
+ for (const value of saved) {
243
+ const [l, i] = Array.isArray(value) ? value : [];
244
+ const part = { l, i };
245
+ if (!Number.isSafeInteger(l) || !Number.isSafeInteger(i) || l < 0 || start(part) !== covered || end(part) > this.root.length || (l > 0 && !this.node(part)))
246
+ return;
247
+ parts.push(part); covered = end(part);
248
+ }
249
+ return { parts, merging, covered };
250
+ }
251
+ /** Saved when either view merges or changes batch state: a view that only grew is the saved one plus later messages, as `load`
252
+ * replays it. The log stays authoritative, so a failed save only warns. */
253
+ private save() {
254
+ const failed = (error: unknown) => { this.warn(`Could not save the memory view: ${error instanceof Error ? error.message : String(error)}`); };
255
+ const saved = ({ parts, merging }: Sawtooth) => ({ parts: parts.map(p => [p.l, p.i]), merging });
256
+ try { void this.enqueue(() => this.store.saveView(JSON.stringify({ ...saved(this.chat), compaction: saved(this.compaction) })), failed); }
257
+ catch (error) { failed(error); }
258
+ }
259
+ private push(i: number) {
260
+ const part = { l: 0, i };
261
+ this.chat.push(part); this.compaction.push(part);
262
+ if (!this.node(part)) this.unbuilt.add(i);
263
+ }
264
+ get view(): readonly Part[] { return this.chat.parts; }
265
+ render() { return `${VIEW_OPEN}${this.view.map(p => `${start(p)}+${2 ** p.l}|${flat(this.line(p))}`).join('\n')}\n</chat>`; }
266
+ get ready() { return this.view.every(p => this.node(p)); }
267
+ get pending() { return this.unbuilt.size; }
268
+ get active() { return this.busy.size; }
269
+ get size() { return this.chat.size; }
270
+ /** Summaries built out of the backlog since it was last empty, and when the next failed one is retried. */
271
+ progress(now = Date.now()) {
272
+ const due = Math.min(...[...this.retryAt.values()].filter(t => t > now));
273
+ return { done: this.peak - this.owed(), total: this.peak, retryIn: Number.isFinite(due) ? due - now : undefined };
274
+ }
275
+ private owed() { return this.expectedNodes() - this.tree.size; }
276
+ onChange(listener: () => void) { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; }
277
+ private emit() { for (const listener of [...this.listeners]) listener(); }
278
+ /** The view line covering message `at`. */
279
+ covering(at: number) { return this.chat.covering(at); }
280
+ /** Returns whether either view, or its batch state, changed. */
281
+ private fit(total = this.root.length) {
282
+ const merging = [this.chat.merging, this.compaction.merging];
283
+ const merged = this.chat.fit(total);
284
+ const compacted = this.compaction.fit(total, merged);
285
+ const owed = this.owed();
286
+ this.peak = owed > 0 ? Math.max(this.peak, owed) : 0;
287
+ this.emit();
288
+ return merged || compacted || merging[0] !== this.chat.merging || merging[1] !== this.compaction.merging;
289
+ }
290
+ private schedule() {
291
+ if (this.scheduled || this.stopped) return;
292
+ this.scheduled = true;
293
+ queueMicrotask(() => { this.scheduled = false; this.pump(); });
294
+ }
295
+ private pump() {
296
+ if (this.stopped) return;
297
+ // One clock reading: skipping a part and arming its retry timer must agree on what is due.
298
+ const now = Date.now();
299
+ const run = (part: Part) => {
300
+ const id = key(part);
301
+ if (this.busy.has(id) || (this.retryAt.get(id) ?? 0) > now) return;
302
+ const promise = this.build(part).catch(error => {
303
+ if (this.stopped) return;
304
+ this.lastError = error instanceof Error ? error.message : String(error);
305
+ if (!this.reported.has(id)) { this.reported.add(id); this.warn(`Compactor ${start(part)}+${2 ** part.l}: ${this.lastError}`); }
306
+ this.retryAt.set(id, Date.now() + this.retryMs);
307
+ if (!this.retryTimer) this.retryTimer = setTimeout(() => { this.retryTimer = undefined; this.schedule(); }, this.retryMs);
308
+ // Schedule before telling waiters, so a turn never mistakes the gap before the next pump for a stall.
309
+ }).finally(() => { this.busy.delete(id); this.schedule(); this.emit(); });
310
+ this.busy.set(id, promise);
311
+ };
312
+ // A message's node starts once fewer than `jobs` lines before it are unbuilt; a merge, once both halves are built. They start
313
+ // in the order of the context they end at, a merge first at a tie, as Unii's do (#194): messages first left the merges waiting
314
+ // through a backlog, so the views ran over budget and changed at every node built, and compactions read about 60% from the cache.
315
+ const ready: Part[] = [];
316
+ for (const i of this.unbuilt) { if (ready.length === this.jobs) break; ready.push({ l: 0, i }); }
317
+ const at = (part: Part) => part.l ? end(part) : part.i;
318
+ for (const part of [...ready, ...this.merges.values()].sort((a, b) => at(a) - at(b) || b.l - a.l)) {
319
+ if (this.busy.size >= this.jobs) return;
320
+ run(part);
321
+ }
322
+ // A later failure can have a later deadline than the timer installed by the first.
323
+ const deadlines = [...this.retryAt.values()].filter(t => t > now);
324
+ if (deadlines.length && !this.retryTimer) this.retryTimer = setTimeout(() => { this.retryTimer = undefined; this.schedule(); }, Math.max(1, Math.min(...deadlines) - now));
325
+ if (this.stalled) this.emit();
326
+ }
327
+ /** Nothing is being built, nothing more can start, and something failed: only a retry can make progress. */
328
+ private get stalled() { return this.busy.size === 0 && !this.scheduled && this.retryAt.size > 0; }
329
+ private async build(part: Part) {
330
+ const source = part.l === 0 ? `${this.root[part.i].kind}: ${this.text(part.i)}`
331
+ : [0, 1].map(offset => this.line({ l: part.l - 1, i: 2 * part.i + offset })).join('\n');
332
+ const mergeSource = part.l > 0 ? [0, 1].map(offset => flat(this.line({ l: part.l - 1, i: 2 * part.i + offset }))).join('\n') : source;
333
+ const call = part.l === 0 ? this.callOf(part.i) : undefined;
334
+ let text = source;
335
+ if (bytes(source) > NODE) try {
336
+ text = (await this.compress({ context: this.compactionView(part), source: cap(mergeSource, SOURCE), part,
337
+ historical: this.root.slice(start(part), end(part)).some(entry => !!entry.origin),
338
+ ...(call !== undefined ? { call: { i: call, text: cap(`tool: ${this.text(call)}`, 4000) } } : {}) }, this.controller.signal)).trim();
339
+ if (!text) throw new Refused('Compactor returned an empty summary.');
340
+ } catch (error) {
341
+ if (!(error instanceof Refused) || this.stopped) throw error;
342
+ text = cut(`${CLIPPED} ${flat(mergeSource)}`);
343
+ this.warn(`Compactor ${start(part)}+${2 ** part.l}: ${error.message} (kept its start instead)`);
344
+ }
345
+ if (this.stopped) return;
346
+ const node = { ...part, text, size: lineBytes(text) };
347
+ // A failed node write fails the build, which is retried.
348
+ const written = this.enqueue(this.keeping(this.unsaved.summaries, key(part), text, () => this.store.appendNode(node)), error => { throw error; });
349
+ if (written) await written;
350
+ this.tree.set(key(part), { ...part, size: node.size }); this.retryAt.delete(key(part)); this.merges.delete(key(part)); this.queueParent(part);
351
+ if (!part.l) { this.unbuilt.delete(part.i); this.chat.built(part.i); this.compaction.built(part.i); }
352
+ if (!this.retryAt.size) this.lastError = undefined;
353
+ if (this.fit()) this.save();
354
+ }
355
+ /** The tool call a result answers: a run of calls is followed by their results in the same order. A result that doesn't
356
+ * start with the call's tool name (an `Agent error`, an odd import) has none. */
357
+ private callOf(i: number) {
358
+ if (this.root[i].kind !== 'echo') return;
359
+ let results = i;
360
+ while (this.root[results - 1]?.kind === 'echo') results--;
361
+ let calls = results;
362
+ while (this.root[calls - 1]?.kind === 'tool') calls--;
363
+ const call = calls + i - results;
364
+ return call < results && this.text(i).startsWith(`${this.text(call).split(' ', 1)[0]}:`) ? call : undefined;
365
+ }
366
+ /** The compactions' view up to the node. A line still being built shows as a placeholder instead of ending the view: an A/B on
367
+ * real turns (#101) found that cutting there also hid the built lines after it, such as an echo's own tool call. */
368
+ private compactionView(part: Part) {
369
+ const boundary = part.l === 0 ? start(part) : end(part), lines: string[] = [];
370
+ for (const p of this.compaction.parts) { if (end(p) > boundary) break; lines.push(`${start(p)}+${2 ** p.l}|${flat(this.line(p))}`); }
371
+ return `${VIEW_OPEN}${lines.join('\n')}\n</chat>`;
372
+ }
373
+ private queueParent({ l, i }: Part) {
374
+ const parent = { l: l + 1, i: Math.floor(i / 2) };
375
+ if (!this.node(parent) && this.node({ l, i: i % 2 ? i - 1 : i + 1 })) this.merges.set(key(parent), parent);
376
+ }
377
+ /** A 'turn' waits until every part of the view is built, or until everything pending has failed: it then goes on with placeholders
378
+ * for the missing lines, `lastError` says why, and the failed nodes are still retried in the background. 'tree' waits for every
379
+ * node, through failures. As in the recipe, the view may run over budget until pending merges land.
380
+ * 'ahead' (imports) waits until a new message's node would start at once. Due merges count too: messages go first, so a steady
381
+ * stream of them would otherwise take every worker and the views could never merge. */
382
+ async settle(signal?: AbortSignal, until: 'turn' | 'tree' | 'ahead' = 'turn'): Promise<void> {
383
+ const done = () => until === 'tree' ? this.ready && this.busy.size === 0 && this.tree.size === this.expectedNodes()
384
+ : until === 'ahead' ? this.unbuilt.size + this.merges.size < this.jobs
385
+ : this.ready || this.stalled;
386
+ if (done()) return;
387
+ if (this.stopped || signal?.aborted) throw new Error('Memory wait cancelled.');
388
+ await new Promise<void>((resolve, reject) => {
389
+ const cleanup = () => { this.listeners.delete(check); signal?.removeEventListener('abort', abort); };
390
+ const abort = () => { cleanup(); reject(new Error('Memory wait cancelled.')); };
391
+ const check = () => { if (this.stopped) abort(); else if (done()) { cleanup(); resolve(); } };
392
+ this.listeners.add(check); signal?.addEventListener('abort', abort, { once: true }); check();
393
+ });
394
+ }
395
+ private expectedNodes() {
396
+ let count = 0;
397
+ for (let n = this.root.length; n > 0; n = Math.floor(n / 2)) count += n;
398
+ return count;
399
+ }
400
+ /** n = 1 gives the message whole, or, when it is longer than `limit` or `offset` is given, the page of up to `limit` characters from `offset`.
401
+ * Paging a message that fits in one page is allowed, but the page says so. */
402
+ zoom(id: number, n: number, offset?: number, limit?: number) {
403
+ if (!Number.isSafeInteger(id) || id < 0 || !Number.isSafeInteger(n) || n < 1 || !Number.isInteger(Math.log2(n)) || id % n || id + n > this.root.length)
404
+ throw new Error(`No line ${id}+${n}: n is a power of 2, id a multiple of n. The tree's roots: ${this.roots() || 'none, the chat is empty'}.`);
405
+ if (n === 1) {
406
+ const { kind } = this.root[id], text = this.text(id), page = Math.min(limit ?? PAGE, PAGE);
407
+ if (!Number.isSafeInteger(page) || page < 1) throw new Error('limit must be a positive integer.');
408
+ if (offset === undefined && text.length <= page) return `${id}+1|${kind}: ${text}`;
409
+ if (offset !== undefined && (!Number.isSafeInteger(offset) || offset < 0 || offset >= Math.max(1, text.length)))
410
+ throw new Error(`Message ${id} has ${text.length} characters; offset must be 0 to ${Math.max(0, text.length - 1)}.`);
411
+ // Never split a surrogate pair: a page starts on its first half and ends after its second.
412
+ const from = /[\udc00-\udfff]/.test(text[offset ?? 0] ?? '') ? (offset ?? 0) - 1 : offset ?? 0;
413
+ let to = Math.min(text.length, from + page);
414
+ // A one-unit page on a pair takes the whole pair, so the next offset always moves forward.
415
+ if (to < text.length && /[\ud800-\udbff]/.test(text[to - 1])) to += to - 1 === from ? 1 : -1;
416
+ const hint = text.length <= PAGE ? `\n[note: this message is only ${text.length.toLocaleString('en-US')} characters and fits in one zoom; offset/limit are for messages over ${PAGE.toLocaleString('en-US')}]` : '';
417
+ return `${id}+1|${kind}: ${text.slice(from, to)}\n[showing characters ${from}-${to} of ${text.length}${to < text.length ? `; next page: offset ${to}` : ''}]${hint}`;
418
+ }
419
+ if (offset !== undefined) throw new Error('offset and limit page one message: use them with n = 1.');
420
+ const l = Math.log2(n) - 1, i = 2 * id / n;
421
+ // A half not built yet shows as in the view, so the other half still comes back.
422
+ return [0, 1].map(offset => {
423
+ const part = { l, i: i + offset };
424
+ return `${start(part)}+${2 ** l}|${flat(this.line(part))}`;
425
+ }).join('\n');
426
+ }
427
+ /** The largest whole lines covering the chat, oldest first: one per power of two in the message count. */
428
+ private roots() {
429
+ const lines: string[] = [];
430
+ for (let id = 0, n = 2 ** Math.floor(Math.log2(this.root.length || 1)); n >= 1; n /= 2)
431
+ if (id + n <= this.root.length) { lines.push(`${id}+${n}`); id += n; }
432
+ return lines.join(', ');
433
+ }
434
+ /** Original messages containing `text`, ignoring case, newest first, older than message `before`. Never summaries,
435
+ * nor the logged zoom/search calls and results, which only copy memory. */
436
+ search(text: string, before = this.root.length) {
437
+ const needle = text.toLowerCase(), hits: Message[] = [];
438
+ for (let i = Math.min(before, this.root.length) - 1; i >= 0; i--) {
439
+ const message = this.root[i], body = this.text(i);
440
+ if ((message.kind === 'tool' || message.kind === 'echo') && /^(zoom|search)[ :]/.test(body)) continue;
441
+ if (body.toLowerCase().includes(needle)) hits.push(message);
442
+ }
443
+ return hits;
444
+ }
445
+ date(id: number) {
446
+ if (!Number.isSafeInteger(id) || !this.root[id]) throw new Error(`No message ${id}.`);
447
+ return new Date(this.root[id].date).toString();
448
+ }
449
+ async close() {
450
+ this.stopped = true; this.controller.abort(); clearTimeout(this.retryTimer);
451
+ this.emit(); await Promise.allSettled(this.busy.values()); await this.writing;
452
+ }
453
+ }
454
+
455
+ /** `jobs`: summaries built at once (8). `retryMs`: how long a failed summary waits before it is tried again. */
456
+ export interface MemoryOptions { warn?: (s: string) => void; budget?: number; jobs?: number; retryMs?: number }
457
+ /** Loads a store and opens its memory. */
458
+ export async function openMemory(store: Store, compress: Compressor, { warn, budget, jobs, retryMs }: MemoryOptions = {}) {
459
+ return new Memory({ store, saved: await store.load() }, compress, warn, budget, jobs, retryMs);
460
+ }