pi-optchat 0.10.2 → 0.11.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.
@@ -0,0 +1,274 @@
1
+ import {
2
+ AssistantEntry, CompactionTask, defineExtension, defineTask, defineTool, GenerationTask, hook, InboxDoc, LiveDoc, ResetEntry, ROOT_CONVERSATION_ID,
3
+ section, ToolResultEntry, UserEntry, type Conversation, type ConversationId, type EntryId, type EntryRecord, type GenerationHooks, type Harness,
4
+ type HarnessOptions, type ToolExecutionResult,
5
+ } from '@earendil-works/pi-durable';
6
+ import type { Static, TSchema } from 'typebox';
7
+ import {
8
+ allowSearch, boundedMessage, cachePayload, completedExchange, CONTINUITY, decode, isImage, isMime, logMessage, memoryTools, refreshBudget, refreshedRun,
9
+ refreshPoint, SEARCH_DOC, searchTool, textContent, TURN_REFRESHED, VIEW_DOC, withView, type AnyMessage, type ImageContent, type Memory,
10
+ } from '../core/index.ts';
11
+
12
+ export type Context = Parameters<Harness['commit']>[1];
13
+ type Message = Parameters<GenerationHooks['beforeRequest']>[0]['messages'][number];
14
+ type Models = Exclude<HarnessOptions['models'], () => unknown>;
15
+
16
+ /** The entries that are the chat: other kinds (system prompt changes, resets, compactions, other extensions' own) stay out of memory. */
17
+ const CHAT = new Set([UserEntry.kind, AssistantEntry.kind, ToolResultEntry.kind]);
18
+ const chat = (entry: EntryRecord) => CHAT.has(entry.kind) ? entry.model ?? [] : [];
19
+ /** What memory logs of an entry: its chat, or a reset's note (`Conversation.reset(text)`, a tool's handoff) marked as one. Pi sends
20
+ * the note in the context it starts, which is logged only once it ends, so the model never sees it twice. */
21
+ const remembered = (entry: EntryRecord) => !ResetEntry.is(entry) ? chat(entry)
22
+ : (entry.model ?? []).map(note => ({ role: 'user' as const, content: `[Handoff note] ${textContent(note.content)}`, timestamp: note.timestamp }));
23
+ /** Each message OptChat logs from a Pi entry carries the entry's id, so the log itself says how far it has read Pi's transcript. */
24
+ const RECEIPT = 'pi:';
25
+ /** The answer that ends a run, whether it succeeded, failed or was aborted. */
26
+ const endsRun = (m: Message) => m.role === 'assistant' && m.stopReason !== 'toolUse';
27
+ const encoder = new TextEncoder();
28
+
29
+ /** The images memory names, as Pi keeps them: a user's and a tool's, but not zoom's, which are kept already. */
30
+ const keptImages = (message: AnyMessage) => message.role === 'user' || message.role === 'toolResult' && !('toolName' in message && message.toolName === 'zoom')
31
+ ? Array.isArray(message.content) ? message.content.filter(isImage) : [] : [];
32
+ /** Each distinct image by its base64, named as Pi's OptChat names it: the first 16 hex digits of the SHA-256 of its base64. Web
33
+ * Crypto is asynchronous and core's ImageRef is not, so images are named before core reads the names. */
34
+ async function imageNames(images: readonly ImageContent[]) {
35
+ const distinct = new Map(images.map(image => [image.data, image]));
36
+ return new Map(await Promise.all([...distinct.values()].map(async image => {
37
+ const hash = new Uint8Array(await crypto.subtle.digest('SHA-256', encoder.encode(image.data)), 0, 8);
38
+ return [image.data, { image, name: Array.from(hash, byte => byte.toString(16).padStart(2, '0')).join('') }] as const;
39
+ })));
40
+ }
41
+
42
+ /** One of core's memory tools as a Pi Durable tool, its arguments typed by its own schema. */
43
+ const durableTool = <P extends TSchema>(tool: {
44
+ name: string; description: string; parameters: P;
45
+ execute: (callId: string, args: Static<P>) => Promise<{ content: ToolExecutionResult['output'] }>;
46
+ }) => defineTool({
47
+ name: tool.name, description: tool.description, parameters: tool.parameters, replay: 'safe',
48
+ execute: async (args, api) => ({ output: (await tool.execute(api.callId, args)).content }),
49
+ });
50
+
51
+ export interface OptChatOptions {
52
+ memory: Memory;
53
+ /** The conversation OptChat remembers; Pi Durable's root by default. Other conversations that select the extension are left alone. */
54
+ conversation?: ConversationId;
55
+ /** The memory part of the system prompt: VIEW_DOC (plus CONTINUITY and SEARCH_DOC as set) by default. */
56
+ prompt?: string;
57
+ /** Offers the `search` tool. Off by default, as in Pi. */
58
+ search?: boolean;
59
+ /** The previous exchange is sent in full after the view when it is at most this many bytes; 0 never sends it. */
60
+ previousExchangeBytes?: number;
61
+ /** When a run's messages since the last refresh pass this share of the model's window, they move into memory and the run goes
62
+ * on with a fresh view, as Pi's Turn refresh threshold (#146). 0.3 by default; 0 is off. */
63
+ refreshAt?: number;
64
+ }
65
+
66
+ /** OptChat's memory for one Pi Durable conversation. Install `extension` in the registry Harness.open gets, then `attach` the opened
67
+ * Harness. Each request is built as OptChat builds it in Pi: the view, the previous exchange and the current context, so the model
68
+ * never sees Pi's older transcript. When the conversation goes idle, OptChat resets Pi's context and a background task logs what the
69
+ * reset ended into memory and builds its summaries; Pi Durable runs that task, and wakes the Durable Object for it after an eviction.
70
+ * Turn Pi's own compaction off (`settings.compaction.enabled: false`); the extension also declines any that starts. */
71
+ export function optchat({ memory, conversation: id = ROOT_CONVERSATION_ID, prompt, search = false, previousExchangeBytes = 16_000, refreshAt = 0.3 }: OptChatOptions) {
72
+ const attached = attachment('OptChat'), opened = attached.get;
73
+ /** The Pi entry logging resumes after: messages before the context's head are logged, the rest are sent as they are. On open it is
74
+ * the one before the newest entry in memory, which is read again: a restart may have cut it off between its records (an answer
75
+ * with tool calls makes several), so the first `saved` records it makes, which memory has, are skipped. The newest is the highest
76
+ * id, not the last in the log: a rebuild orders turns by their dates, which a clock can put out of order. */
77
+ let logged = 0, saved = 0;
78
+ const newest = memory.root.reduce((max, e) => e.receipt?.startsWith(RECEIPT) ? Math.max(max, Number(e.receipt.slice(RECEIPT.length))) : max, 0);
79
+ if (newest) { logged = newest - 1; saved = memory.root.filter(e => e.receipt === `${RECEIPT}${newest}`).length; }
80
+ const resumed = new Proxy(memory, {
81
+ get: (target, key) => key !== 'append' ? Reflect.get(target, key) : (...args: Parameters<Memory['append']>) => saved > 0 ? void saved-- : target.append(...args),
82
+ });
83
+ let logging = Promise.resolve();
84
+ /** What a context's requests share, fixed when its first one is built so they read the same cached prefix. */
85
+ let turn: { head: EntryId | undefined; view: string; previous: Message[] } | undefined;
86
+
87
+ const handle = async (ctx: Context): Promise<Conversation> => {
88
+ const conversation = await opened().harness.conversation(id, ctx);
89
+ if (!conversation) throw new Error(`No conversation ${id}.`);
90
+ return conversation;
91
+ };
92
+ /** Logs Pi's entries before `head` that memory doesn't have yet, one caller at a time. A call that fails (aborted, say) leaves
93
+ * the next one to carry on from the last entry it logged. */
94
+ const logUpTo = (conversation: Conversation, head: EntryId, ctx: Context) => {
95
+ const run = logging.then(async () => {
96
+ let cursor: Parameters<Conversation['entries']>[2];
97
+ do {
98
+ if (logged + 1 > head - 1) return;
99
+ const page = await conversation.entries({ order: 'ascending', minEntryId: logged + 1 as EntryId, maxEntryId: head - 1 as EntryId }, 256, cursor, ctx);
100
+ for (const entry of page.items) {
101
+ const messages = remembered(entry), names = await imageNames(messages.flatMap(keptImages));
102
+ // Kept before the message is logged, so the image its text names is there for zoom; one that can't be kept is only named.
103
+ // An image sent again, or read again after a restart, is written again under its name.
104
+ for (const { image, name } of names.values()) if (isMime(image.mimeType)) {
105
+ try { await memory.store.putImage(name, image.mimeType, decode(image.data)); }
106
+ catch (error) { console.error(`OptChat could not keep an image: ${error instanceof Error ? error.message : String(error)}`); }
107
+ }
108
+ // Once a save failed (also one queued before, failing during the awaits above), memory refuses appends: requests keep the
109
+ // view it has, and a restart logs the rest from Pi's transcript.
110
+ if (memory.saveError) return;
111
+ for (const message of messages) logMessage(resumed, message, { receipt: `${RECEIPT}${entry.id}`, imageRef: image => `[image ${names.get(image.data)?.name}]` });
112
+ logged = entry.id;
113
+ }
114
+ cursor = page.next;
115
+ } while (cursor);
116
+ });
117
+ logging = run.catch(() => {});
118
+ return run;
119
+ };
120
+ /** The last run before `head`, from Pi's transcript, if it was answered and is short enough to send whole. It is read from the
121
+ * entries, not the context's messages, which leave failed and aborted answers out and so would join a run to the one before.
122
+ * A text-only answer ends a run here even when steering followed it in the same Pi run. */
123
+ const previousBefore = async (conversation: Conversation, head: EntryId, ctx: Context) => {
124
+ if (!previousExchangeBytes) return [];
125
+ const [last] = (await conversation.entries({ order: 'descending', maxEntryId: head - 1 as EntryId }, 1, undefined, ctx)).items;
126
+ if (!last) return [];
127
+ const messages = (await conversation.context(ctx, { at: last.id })).entries.flatMap(chat);
128
+ const start = messages.slice(0, messages.findLastIndex(m => m.role === 'user')).findLastIndex(endsRun) + 1;
129
+ const run = messages.slice(start), names = await imageNames(run.filter(m => m.role === 'user').flatMap(keptImages));
130
+ const exchange = completedExchange(run, image => `[image ${names.get(image.data)?.name}]`);
131
+ const bytes = exchange.reduce((sum, m) => sum + encoder.encode(textContent(m.content)).length, 0);
132
+ return bytes <= previousExchangeBytes ? exchange : [];
133
+ };
134
+ const beforeRequest: GenerationHooks['beforeRequest'] = async ({ messages }, api, ctx) => {
135
+ if (api.conversationId !== id) return undefined;
136
+ const conversation = await handle(ctx);
137
+ const { head, entries } = await conversation.context(ctx);
138
+ if (!turn || turn.head !== head?.id) {
139
+ if (head) await logUpTo(conversation, head.id, ctx);
140
+ await memory.settle(ctx.abortSignal);
141
+ turn = { head: head?.id, view: memory.render(), previous: head ? await previousBefore(conversation, head.id, ctx) : [] };
142
+ }
143
+ const at = messages.findIndex(m => m.role !== 'system');
144
+ if (at < 0) return undefined;
145
+ // A tool result over 30K characters is sent as its head and tail, as in Pi.
146
+ const run = messages.slice(at).map(boundedMessage), shared = turn;
147
+ // `run` is the whole context, which can hold more than one run: inputs queued with no idle reset between them, or a reset's note
148
+ // first. The run being answered starts at its task, the last of the user messages after the newest finished answer; a refresh
149
+ // moves what comes before it into memory with the steps it cuts, and a steer before the cut too, as in Pi.
150
+ let task = run.findLastIndex(endsRun) + 1;
151
+ while (run[task + 1]?.role === 'user') task++;
152
+ const own = run.slice(task);
153
+ /** The run's answers and their entries. Pi Durable's messages are copies of its entries', so an answer is found by its first
154
+ * tool call's id, or its timestamp without one. */
155
+ const answer = (m: Message) => m.role === 'assistant' ? m.content.find(c => c.type === 'toolCall')?.id ?? m.timestamp : undefined;
156
+ const steps = entries.flatMap(entry => {
157
+ const sent = AssistantEntry.is(entry) && entry.model?.[0];
158
+ const i = sent ? own.findIndex(m => answer(m) === answer(sent)) : -1;
159
+ return i > 0 ? [{ entry, i }] : [];
160
+ });
161
+ /** Logs Pi's entries before `end`, rebuilding the view if that logged anything. False once a save failed: memory then lacks
162
+ * part of the run, so it is sent whole. */
163
+ const logThrough = async (end: EntryId) => {
164
+ const count = memory.root.length;
165
+ await logUpTo(conversation, end, ctx);
166
+ if (memory.saveError) return false;
167
+ if (memory.root.length !== count) { await memory.settle(ctx.abortSignal); shared.view = memory.render(); }
168
+ return true;
169
+ };
170
+ // Once a refresh has logged part of this context, an earlier run in it that has ended goes into memory whole, as the reset it
171
+ // missed would have done: a run queued behind a long one doesn't send it again.
172
+ const ended = task > 0 && logged > (head?.id ?? 0) && run[task - 1].role === 'assistant'
173
+ ? entries.find(entry => AssistantEntry.is(entry) && entry.model?.[0] && answer(entry.model[0]) === answer(run[task - 1])) : undefined;
174
+ const whole = ended && await logThrough(ended.id + 1 as EntryId) ? own : run;
175
+ // A refresh logs the run up to the answer it cuts at, so once any of its answers is in the log, the run is sent from the first
176
+ // answer after it, after a restart too; a restart in the middle of that logging leaves the rest of it to do.
177
+ let keep = 0;
178
+ const kept = steps.some(s => s.entry.id <= logged) ? steps.find(s => s.entry.id > logged) : undefined;
179
+ if (kept && await logThrough(kept.entry.id)) keep = kept.i;
180
+ const { model } = await conversation.agent(ctx);
181
+ const refresh = !memory.saveError && refreshPoint(own, keep, refreshBudget(model && api.models.getModel(model.provider, model.modelId)?.contextWindow, refreshAt));
182
+ const cut = refresh && steps.find(s => s.i === refresh.cut);
183
+ if (cut && await logThrough(cut.entry.id)) keep = cut.i;
184
+ const sent = keep ? refreshedRun(own, keep, TURN_REFRESHED) : whole;
185
+ return { messages: withView([...messages.slice(0, at), ...turn.previous, ...sent], turn.view) };
186
+ };
187
+
188
+ const Remember = defineTask<Record<string, never>, { phase: 'remember' }, null>({
189
+ name: 'optchat.remember', version: 1,
190
+ initial: () => ({ phase: 'remember' }),
191
+ phases: {
192
+ // Logs what the newest reset ended and waits until the next turn's view is built, or every summary owed has failed. A phase
193
+ // cut off by an eviction runs again from the top: logging skips what is in memory, and memory rebuilds what it owes on open.
194
+ remember: async (_task, runtime, ctx) => {
195
+ const conversation = await handle(ctx), { head } = await conversation.context(ctx);
196
+ if (head) await logUpTo(conversation, head.id, ctx);
197
+ await memory.settle(runtime.signal);
198
+ await runtime.commit(() => ({ status: 'terminal', outcome: { status: 'completed', result: null } }), ctx);
199
+ },
200
+ },
201
+ abort: (_task, runtime, ctx) => runtime.commit(() => ({ status: 'terminal', outcome: { status: 'aborted' } }), ctx),
202
+ });
203
+
204
+ /** Once the conversation is idle with a chat in its context, resets the context and starts remembering it, in one commit. */
205
+ const resetIfIdle = async () => {
206
+ const { harness, context } = opened();
207
+ const busy = async () => !!(await harness.snapshot(LiveDoc, id, context))?.run || !!(await harness.snapshot(InboxDoc, id, context))?.items.length;
208
+ if (await busy() || !(await (await handle(context)).context(context)).entries.some(e => chat(e).length)) return;
209
+ await harness.commit(async tx => {
210
+ // Checked again where it is atomic: an input that came in meanwhile keeps its context.
211
+ if ((await tx.doc(LiveDoc, id)).run || (await tx.doc(InboxDoc, id)).items.length) return;
212
+ await tx.appendEntry(id, { kind: ResetEntry.kind, head: 'self' });
213
+ await tx.createTask(Remember, {}, { ownership: { kind: 'conversation' }, conversationId: id, background: true });
214
+ }, context);
215
+ };
216
+ /** Checks after commits, one check at a time; commits during a check ask for one more, however many there were. */
217
+ let checking = false, again = false;
218
+ const schedule = async () => {
219
+ again = true;
220
+ if (checking) return;
221
+ checking = true;
222
+ while (again) {
223
+ again = false;
224
+ await resetIfIdle().catch(error => console.error(`OptChat could not reset the context: ${error instanceof Error ? error.message : String(error)}`));
225
+ }
226
+ checking = false;
227
+ };
228
+
229
+ const [zoom, date] = memoryTools(() => memory);
230
+ const tools = [durableTool(zoom), durableTool(date), ...search ? [durableTool(searchTool(() => memory))] : []];
231
+ const text = prompt ?? allowSearch(`${VIEW_DOC}${previousExchangeBytes ? CONTINUITY : ''}${search ? SEARCH_DOC : ''}`, search);
232
+
233
+ return {
234
+ extension: defineExtension({
235
+ name: 'optchat',
236
+ tools,
237
+ sections: [section('optchat', () => text, { tag: false })],
238
+ hooks: [hook(GenerationTask, { beforeRequest }), hook(CompactionTask, { beforeCompact: (_compaction, api) => api.conversationId === id ? { decline: true } : undefined })],
239
+ tasks: [Remember],
240
+ }),
241
+ /** Starts following the conversation, and resets it now if a run ended before a restart. Returns a function that stops. */
242
+ attach(harness: Harness, ctx: Context) {
243
+ attached.set(harness, ctx);
244
+ const stop = harness.subscribeCommits(() => void schedule());
245
+ void schedule();
246
+ return stop;
247
+ },
248
+ };
249
+ }
250
+
251
+ /** The Harness and context an extension's `attach` was given; `get` throws before it is attached, or after `clear`. */
252
+ export function attachment(name: string) {
253
+ let at: { harness: Harness; context: Context } | undefined;
254
+ return {
255
+ set: (harness: Harness, context: Context) => { at = { harness, context }; },
256
+ clear: () => { at = undefined; },
257
+ get: () => { if (!at) throw new Error(`${name} is not attached to a Harness yet.`); return at; },
258
+ };
259
+ }
260
+
261
+ /** Pi Durable's `Models` with OptChat's cache marks on Anthropic requests that carry a view: in blocks of 4 lines, so a view that
262
+ * grows at its end is read from the cache (pi-ai's own marks miss it). A request that sets its own `onPayload` is left alone, as
263
+ * the compactor's are. A Proxy, since `createModels()` returns a class instance whose methods a spread would lose. */
264
+ export function withCacheMarks<M extends Models>(models: M): M {
265
+ return new Proxy(models, {
266
+ get(target, key) {
267
+ const value: unknown = Reflect.get(target, key, target);
268
+ if (key !== 'streamSimple') return typeof value === 'function' ? value.bind(target) : value;
269
+ const stream: Models['streamSimple'] = (model, request, options) => target.streamSimple(model, request,
270
+ options?.onPayload || model.api !== 'anthropic-messages' ? options : { ...options, onPayload: payload => cachePayload(payload) });
271
+ return stream;
272
+ },
273
+ });
274
+ }
@@ -0,0 +1,162 @@
1
+ import type { SqliteDatabase, SqliteExecutor, SqliteValue } from '@earendil-works/pi-durable/storage/sqlite';
2
+ import { isMime, type Entry, type ImageStore, type ImportMode, type Mime, type Part, type Saved, type Store, type Summary } from '../core/index.ts';
3
+
4
+ /** A Durable Object's SQLite refuses a row over 2 MB. A message's text is stored as JSON in parts of at most this many UTF-16 units,
5
+ * so a part is at most ~1.5 MB of UTF-8 however the text is written, and a huge pasted email still fits. */
6
+ const PART = 512 * 1024;
7
+ export interface SqliteStoreOptions {
8
+ /** Where images are kept instead of `optchat_images`, such as an R2 bucket that keeps large binary data out of the database
9
+ * (its row size limit and storage cost). */
10
+ images?: ImageStore;
11
+ /** The generation to open, as `optchatImport` does for the one it builds. Default: the live one, whose load also deletes the
12
+ * rows of generations that are neither live nor being imported. */
13
+ generation?: number;
14
+ }
15
+
16
+ /** Log, tree and view rows carry their generation `g`: an import builds a new one beside the live one. `optchat_state` names the
17
+ * live one, the pending import's (`target`, NULL when none) and the last one handed out; the planned log is in `optchat_plan`.
18
+ * Images are shared. */
19
+ const TABLES = `CREATE TABLE IF NOT EXISTS optchat_log (g INTEGER NOT NULL, i INTEGER NOT NULL, part INTEGER NOT NULL, meta TEXT, text TEXT NOT NULL, PRIMARY KEY (g, i, part));
20
+ CREATE TABLE IF NOT EXISTS optchat_tree (g INTEGER NOT NULL, l INTEGER NOT NULL, i INTEGER NOT NULL, size INTEGER NOT NULL, text TEXT NOT NULL, PRIMARY KEY (g, l, i));
21
+ CREATE TABLE IF NOT EXISTS optchat_view (g INTEGER PRIMARY KEY, json TEXT NOT NULL);
22
+ CREATE TABLE IF NOT EXISTS optchat_images (name TEXT PRIMARY KEY, mime TEXT NOT NULL, data BLOB NOT NULL);
23
+ CREATE TABLE IF NOT EXISTS optchat_state (id INTEGER PRIMARY KEY CHECK (id = 0), live INTEGER NOT NULL, last INTEGER NOT NULL DEFAULT 0, target INTEGER, mode TEXT, total INTEGER);
24
+ CREATE TABLE IF NOT EXISTS optchat_plan (i INTEGER NOT NULL, part INTEGER NOT NULL, meta TEXT, text TEXT NOT NULL, PRIMARY KEY (i, part));
25
+ INSERT OR IGNORE INTO optchat_state (id, live) VALUES (0, 0);`;
26
+
27
+ /** Splits `text` every PART units, never between the two halves of a surrogate pair. */
28
+ function parts(text: string) {
29
+ const result: string[] = [];
30
+ for (let at = 0; at < text.length;) {
31
+ let end = Math.min(text.length, at + PART);
32
+ if (end < text.length && /[\ud800-\udbff]/.test(text[end - 1])) end--;
33
+ result.push(text.slice(at, end)); at = end;
34
+ }
35
+ return result;
36
+ }
37
+ /** An entry as rows of `(part, meta, text)`: its text as JSON in parts, its metadata on the first. */
38
+ const rows = ({ text, ...meta }: Entry) => parts(JSON.stringify(text)).map((chunk, part) => [part, part ? null : JSON.stringify(meta), chunk] as const);
39
+
40
+ /** A synchronous query on the same database, for the texts memory reads while it builds a view, zooms or searches. In a Durable
41
+ * Object: `(sql, ...params) => ctx.storage.sql.exec(sql, ...params)`; on Node, node:sqlite's `(sql, ...params) => db.prepare(sql).iterate(...params)`. */
42
+ export type Read = (sql: string, ...params: SqliteValue[]) => Iterable<Record<string, unknown>>;
43
+ /** Texts are stored as JSON, which keeps an unpaired surrogate that SQLite's TEXT would replace. A text that isn't there throws. */
44
+ function texts(rows: Iterable<Record<string, unknown>>, what: string) {
45
+ const found = [...rows].map(row => typeof row.text === 'string' ? row.text : '');
46
+ if (!found.length) throw new Error(`No ${what} in this store.`);
47
+ const text: unknown = JSON.parse(found.join(''));
48
+ if (typeof text !== 'string') throw new Error(`Invalid ${what} in this store.`);
49
+ return text;
50
+ }
51
+
52
+ /** OptChat's memory in any Chord `SqliteDatabase`, such as Node's (`NodeSqliteDatabase`) or a Durable Object's. Give it the
53
+ * database Pi Durable's storage uses, so both share one queue: in a Durable Object, the `DurableObjectSqliteDatabase` over
54
+ * `ctx.storage` (direct `ctx.storage.sql` writes could be rolled back with a Pi transaction). `read` reads texts from the same
55
+ * database synchronously; it only reads rows whose writes have finished, so it needs no queue. Its tables are `optchat_*`, beside
56
+ * Pi's `pi_*`. Memory loads only metadata and node sizes; texts stay here. Messages are split so no row passes a Durable Object's
57
+ * 2 MB limit; images go to `images` when given, otherwise to `optchat_images`, one row each. */
58
+ export class SqliteStore implements Store {
59
+ #g = 0;
60
+ constructor(private readonly db: SqliteDatabase, private readonly read: Read, private readonly options: SqliteStoreOptions = {}) {}
61
+ async load(): Promise<Saved> {
62
+ await this.db.exec(TABLES);
63
+ if (this.options.generation !== undefined) this.#g = this.options.generation;
64
+ else {
65
+ const state = await this.db.get<{ live: number; target: number | null }>('SELECT live, target FROM optchat_state');
66
+ this.#g = state?.live ?? 0;
67
+ // An old memory may still write to its generation until the object restarts after a switch, so old rows go only here.
68
+ await this.db.transaction(async tx => {
69
+ for (const table of ['log', 'tree', 'view']) await tx.run(`DELETE FROM optchat_${table} WHERE g NOT IN (?, ?)`, this.#g, state?.target ?? this.#g);
70
+ });
71
+ }
72
+ const log = await this.db.all<{ meta: string }>('SELECT meta FROM optchat_log WHERE g = ? AND part = 0 ORDER BY i', this.#g);
73
+ const tree = await this.db.all<{ l: number; i: number; size: number }>('SELECT l, i, size FROM optchat_tree WHERE g = ?', this.#g);
74
+ const view = await this.db.get<{ json: string }>('SELECT json FROM optchat_view WHERE g = ?', this.#g);
75
+ return { entries: log.map(row => JSON.parse(row.meta)), tree, view: view?.json };
76
+ }
77
+ text(i: number) { return texts(this.read('SELECT text FROM optchat_log WHERE g = ? AND i = ? ORDER BY part', this.#g, i), `message ${i}`); }
78
+ summary({ l, i }: Part) { return texts(this.read('SELECT text FROM optchat_tree WHERE g = ? AND l = ? AND i = ?', this.#g, l, i), `summary ${l}/${i}`); }
79
+ /** The primary key refuses a second message at an index, so a store that was not the last to append writes nothing. */
80
+ async append(entry: Entry) {
81
+ try {
82
+ await this.db.transaction(async tx => {
83
+ for (const row of rows(entry)) await tx.run('INSERT INTO optchat_log (g, i, part, meta, text) VALUES (?, ?, ?, ?, ?)', this.#g, entry.i, ...row);
84
+ });
85
+ } catch (error) {
86
+ if (await this.db.get('SELECT 1 FROM optchat_log WHERE g = ? AND i = ? AND part = 0', this.#g, entry.i))
87
+ throw new Error(`Someone else already saved message ${entry.i}; nothing was written.`, { cause: error });
88
+ throw error;
89
+ }
90
+ }
91
+ async appendNode(node: Summary) {
92
+ await this.db.run('INSERT OR REPLACE INTO optchat_tree (g, l, i, size, text) VALUES (?, ?, ?, ?, ?)', this.#g, node.l, node.i, node.size, JSON.stringify(node.text));
93
+ }
94
+ async saveView(view: string) {
95
+ await this.db.run('INSERT OR REPLACE INTO optchat_view (g, json) VALUES (?, ?)', this.#g, view);
96
+ }
97
+ async putImage(name: string, mimeType: Mime, data: Uint8Array) {
98
+ if (this.options.images) return this.options.images.putImage(name, mimeType, data);
99
+ await this.db.run('INSERT OR REPLACE INTO optchat_images (name, mime, data) VALUES (?, ?, ?)', name, mimeType, data);
100
+ }
101
+ async image(name: string) {
102
+ if (this.options.images) return this.options.images.image(name);
103
+ const row = await this.db.get<{ mime: string; data: Uint8Array }>('SELECT mime, data FROM optchat_images WHERE name = ?', name);
104
+ return row && isMime(row.mime) ? { mimeType: row.mime, data: row.data } : undefined;
105
+ }
106
+ }
107
+
108
+ /** A pending import: the generation it builds, and its plan's mode and length. */
109
+ export interface ImportJob { generation: number; mode: ImportMode; total: number }
110
+
111
+ /** The rows `optchatImport` keeps: which generation is live, the pending import and its plan. */
112
+ export function importTables(db: SqliteDatabase, read: Read) {
113
+ const state = async (tx: SqliteExecutor = db) => {
114
+ await tx.exec(TABLES);
115
+ const row = await tx.get<{ live: number; target: number | null; mode: ImportMode | null; total: number | null }>('SELECT live, target, mode, total FROM optchat_state');
116
+ const job: ImportJob | undefined = row?.target != null && row.mode && row.total != null ? { generation: row.target, mode: row.mode, total: row.total } : undefined;
117
+ return { live: row?.live ?? 0, job };
118
+ };
119
+ return {
120
+ state: () => state(),
121
+ /** Saves the plan, `entries` in order, made from generation `from`, and makes it the pending import of a new generation. A
122
+ * generation is never handed out twice, so a late call about a discarded one can't touch the next. Append's new generation starts with the live one's first `kept` messages, the nodes
123
+ * over them, and its view. Refused unless `from` is still live: an import went live since. */
124
+ stage: (from: number, mode: ImportMode, entries: Iterable<Entry>, kept: number) => db.transaction(async tx => {
125
+ const { live, job } = await state(tx);
126
+ if (job) throw new Error('An import is pending: let it finish, or discard it.');
127
+ if (live !== from) throw new Error('An import went live: restart before the next one.');
128
+ const generation = ((await tx.get<{ last: number }>('SELECT last FROM optchat_state'))?.last ?? live) + 1;
129
+ await tx.run('DELETE FROM optchat_plan');
130
+ let total = 0;
131
+ for (const entry of entries) { total++; for (const row of rows(entry)) await tx.run('INSERT INTO optchat_plan (i, part, meta, text) VALUES (?, ?, ?, ?)', entry.i, ...row); }
132
+ if (kept) {
133
+ // Nodes only over the messages copied: a message still being written isn't, and a node past the log would refuse to open.
134
+ // The view keeps its cached prefix; one that covers more than was copied is rebuilt.
135
+ await tx.run('INSERT INTO optchat_log (g, i, part, meta, text) SELECT ?, i, part, meta, text FROM optchat_log WHERE g = ? AND i < ?', generation, live, kept);
136
+ await tx.run(`INSERT INTO optchat_tree (g, l, i, size, text) SELECT ?, l, i, size, text FROM optchat_tree
137
+ WHERE g = ? AND ((i + 1) << l) <= (SELECT COUNT(*) FROM optchat_log WHERE g = ? AND part = 0)`, generation, live, generation);
138
+ await tx.run('INSERT INTO optchat_view (g, json) SELECT ?, json FROM optchat_view WHERE g = ?', generation, live);
139
+ }
140
+ await tx.run('UPDATE optchat_state SET last = ?, target = ?, mode = ?, total = ?', generation, generation, mode, total);
141
+ return { generation, mode, total };
142
+ }),
143
+ /** Planned message `i`, as saved; validated where it is used. */
144
+ planned(i: number): unknown {
145
+ const [first] = read('SELECT meta FROM optchat_plan WHERE i = ? AND part = 0', i);
146
+ if (typeof first?.meta !== 'string') throw new Error(`No planned message ${i} in this store.`);
147
+ return { ...JSON.parse(first.meta), text: texts(read('SELECT text FROM optchat_plan WHERE i = ? ORDER BY part', i), `planned message ${i}`) };
148
+ },
149
+ /** Makes `generation` live and drops its plan, in one transaction; refused if its import was discarded meanwhile. */
150
+ activate: (generation: number) => db.transaction(async tx => {
151
+ if ((await state(tx)).job?.generation !== generation) throw new Error('The import was discarded.');
152
+ await tx.run('UPDATE optchat_state SET live = target, target = NULL, mode = NULL, total = NULL');
153
+ await tx.run('DELETE FROM optchat_plan');
154
+ }),
155
+ /** Drops the pending import if it is still `generation`'s; its rows go when the live store next loads. */
156
+ discard: (generation: number) => db.transaction(async tx => {
157
+ if ((await state(tx)).job?.generation !== generation) return;
158
+ await tx.run('UPDATE optchat_state SET target = NULL, mode = NULL, total = NULL');
159
+ await tx.run('DELETE FROM optchat_plan');
160
+ }),
161
+ };
162
+ }
package/src/handoff.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import type { ModelRegistry } from '@earendil-works/pi-coding-agent';
2
2
  import type { AgentMessage } from '@earendil-works/pi-agent-core';
3
- import type { AssistantMessage } from '@earendil-works/pi-ai';
4
- import { reasoningFor, retryTransient, type ModelChoice } from './compactor.ts';
3
+ import { isRetryableAssistantError, type AssistantMessage } from '@earendil-works/pi-ai';
4
+ import { reasoningFor, type ModelChoice } from './compactor.ts';
5
+ import { retryTransient } from './core/compactor.ts';
5
6
  import { HANDOFF } from './prompts.ts';
6
7
  import { undelivered, type RunInfo } from './runs.ts';
7
8
  import { textContent } from './transcript.ts';
@@ -53,7 +54,7 @@ export function createHandoffSummarizer(registry: ModelRegistry, choice: () => M
53
54
  const reply = await retryTransient(() => registry.streamSimple(model, {
54
55
  systemPrompt: HANDOFF,
55
56
  messages: [{ role: 'user', timestamp: Date.now(), content: prefix + transcript.subarray(offset, end).toString('utf8') }],
56
- }, { reasoning: reasoningFor(model, selected.thinking), maxTokens, signal: AbortSignal.any([AbortSignal.timeout(300_000), ...signal ? [signal] : []]) }).result(), usage, retryDelays, signal);
57
+ }, { reasoning: reasoningFor(model, selected.thinking), maxTokens, signal: AbortSignal.any([AbortSignal.timeout(300_000), ...signal ? [signal] : []]) }).result(), usage, isRetryableAssistantError, retryDelays, signal);
57
58
  summary = handoffText(reply);
58
59
  offset = end;
59
60
  }
package/src/images.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import type { ImageContent } from '@earendil-works/pi-ai';
3
3
  import { resizeImage } from '@earendil-works/pi-coding-agent';
4
- import { isMime, type Store } from './store.ts';
4
+ import { isImage } from './core/images.ts';
5
+ import { isMime, type Store } from './core/store.ts';
5
6
 
6
7
  /** Kept images fit 2048 px on the long side and ~1.5 MB (2 MB as base64), re-encoded as JPEG 85 only when they don't already. */
7
8
  export const IMAGE_LIMITS = { maxWidth: 2048, maxHeight: 2048, maxBytes: 2 * 1024 * 1024, jpegQuality: 85 };
@@ -9,10 +10,6 @@ export const IMAGE_LIMITS = { maxWidth: 2048, maxHeight: 2048, maxBytes: 2 * 102
9
10
  const hash = (image: ImageContent) => createHash('sha256').update(image.data).digest('hex').slice(0, 16);
10
11
  /** How a message's image reads in memory: named by the image as the message holds it, before any shrinking, so it always gets the same name. */
11
12
  export const imageRef = (image: ImageContent) => `[image ${hash(image)}]`;
12
- const REF = /\[image ([0-9a-f]{16})\]/g;
13
-
14
- export const isImage = (part: unknown): part is ImageContent => typeof part === 'object' && part !== null
15
- && 'type' in part && part.type === 'image' && 'data' in part && typeof part.data === 'string' && 'mimeType' in part && typeof part.mimeType === 'string';
16
13
 
17
14
  /** Keeps each image of a message's content in the store, once per distinct image, shrunk to IMAGE_LIMITS. */
18
15
  export async function saveImages(store: Store, content: unknown) {
@@ -25,10 +22,3 @@ export async function saveImages(store: Store, content: unknown) {
25
22
  if (isMime(kept.mimeType)) await store.putImage(name, kept.mimeType, Buffer.from(kept.data, 'base64'));
26
23
  }
27
24
  }
28
-
29
- /** The kept images a text refers to, in order, each once; ones never kept are skipped. */
30
- export async function loadImages(store: Store, text: string): Promise<ImageContent[]> {
31
- const names = [...new Set(Array.from(text.matchAll(REF), match => match[1]))];
32
- return (await Promise.all(names.map(name => store.image(name))))
33
- .flatMap(kept => kept ? [{ type: 'image' as const, data: Buffer.from(kept.data).toString('base64'), mimeType: kept.mimeType }] : []);
34
- }
package/src/import/job.ts CHANGED
@@ -1,13 +1,15 @@
1
1
  import { existsSync, readFileSync, rmSync, cpSync, mkdirSync } from 'node:fs';
2
2
  import { join, relative, sep } from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
- import { Memory, bytes, isEntry, type Entry, type Compressor } from '../memory.ts';
4
+ import { Memory } from '../memory.ts';
5
+ import { isEntry, type Entry, type Compressor } from '../core/memory.ts';
6
+ import { deduplicate as dedupe, planImport, runImport as build, type ImportMode } from '../core/import.ts';
5
7
  import { atomicWrite } from '../store.ts';
6
- import { record } from '../cache.ts';
8
+ import { record } from '../core/cache.ts';
7
9
  import { copyKey, type ImportedEntry } from './sources.ts';
8
10
  import { DEFAULT_SETTINGS } from '../settings.ts';
9
11
 
10
- export type ImportMode = 'append' | 'rebuild';
12
+ export type { ImportMode };
11
13
  export interface ImportJob {
12
14
  id: string; mode: ImportMode; previous: string; target: string; created: string;
13
15
  added: number; skipped: number; total: number; inputBytes: number;
@@ -39,41 +41,14 @@ export function pendingImport(dir: string): ImportJob | undefined {
39
41
  return { id: j.id, mode: j.mode === 'append' ? 'append' : 'rebuild', previous: j.previous, target: `memories/${j.id}`,
40
42
  created: j.created, added: Number(j.added), skipped: Number(j.skipped), total: Number(j.total), inputBytes: Number(j.inputBytes) };
41
43
  }
42
- export function deduplicate(existing: readonly Entry[], incoming: readonly ImportedEntry[]) {
43
- const receipts = new Set(existing.map(e => e.receipt).filter(Boolean));
44
- const copies = new Set(existing.map(copyKey).filter(Boolean));
45
- const added: ImportedEntry[] = []; let skipped = 0;
46
- for (const entry of incoming) {
47
- if (!entry.receipt?.startsWith('import:')) throw new Error('Imported entry has no stable source identity.');
48
- const copy = copyKey(entry);
49
- if (receipts.has(entry.receipt) || copy && copies.has(copy)) { skipped++; continue; }
50
- receipts.add(entry.receipt); if (copy) copies.add(copy); added.push(entry);
51
- }
52
- return { added, skipped };
53
- }
54
- /** Imported conversations remain contiguous; existing native history is grouped by user turn. */
55
- export function chronological(entries: readonly ImportedEntry[]) {
56
- const groups = new Map<string, { date: string; entries: ImportedEntry[] }>();
57
- let native = 0;
58
- for (const entry of entries) {
59
- if (!entry.origin && entry.kind === 'user') native++;
60
- const id = entry.origin ? JSON.stringify([entry.origin.source, entry.origin.conversation]) : `native:${native}`;
61
- const group = groups.get(id) ?? { date: entry.date, entries: [] };
62
- group.date = group.date < entry.date ? group.date : entry.date;
63
- group.entries.push(entry); groups.set(id, group);
64
- }
65
- return [...groups.values()].sort((a, b) => a.date.localeCompare(b.date)).flatMap(g => g.entries);
66
- }
44
+ /** Pi also drops a Claude message copied into a forked session (`copyKey`). */
45
+ export const deduplicate = (existing: readonly Entry[], incoming: readonly ImportedEntry[]) => dedupe(existing, incoming, copyKey);
67
46
  /** Call only while holding the profile lock, after closing its old Memory worker. */
68
47
  export function prepareImport(dir: string, old: Memory, incoming: readonly ImportedEntry[], mode: ImportMode): ImportJob | undefined {
69
48
  if (pendingImport(dir)) throw new Error('Resume or discard the pending import first.');
70
- const { added, skipped } = deduplicate(old.root, incoming);
71
- if (!added.length) return undefined;
72
- const id = randomUUID(), target = `memories/${id}`, path = generationPath(dir, target);
73
- // Append copies existing summaries without changing their indices. Rebuild regenerates every node.
74
- const all = mode === 'rebuild' ? chronological([...old.root, ...added]) : [...old.root, ...chronological(added)];
75
- const entries: Entry[] = all.map((e, i) => ({ ...e, i, size: bytes(`${e.kind}: ${e.text}`) }));
76
- const kept = mode === 'append' ? old.root.length : 0;
49
+ const plan = planImport(old.entries(), incoming, mode, copyKey);
50
+ if (!plan) return undefined;
51
+ const { entries, kept } = plan, id = randomUUID(), target = `memories/${id}`, path = generationPath(dir, target);
77
52
  for (const sub of ['main', 'tree']) mkdirSync(join(path, sub), { recursive: true, mode: 0o700 });
78
53
  if (kept) atomicWrite(join(path, 'main', '000-import.jsonl'), entries.slice(0, kept).map(e => JSON.stringify(e)).join('\n') + '\n');
79
54
  atomicWrite(join(path, STAGED), entries.map(e => JSON.stringify(e)).join('\n') + '\n');
@@ -83,8 +58,7 @@ export function prepareImport(dir: string, old: Memory, incoming: readonly Impor
83
58
  if (existsSync(join(old.directory, 'view.json'))) cpSync(join(old.directory, 'view.json'), join(path, 'view.json'));
84
59
  }
85
60
  const job: ImportJob = { id, mode, previous: relative(dir, old.directory).split(sep).join('/') || '.', target,
86
- created: new Date().toISOString(), added: added.length, skipped, total: entries.length,
87
- inputBytes: (mode === 'rebuild' ? entries : added).reduce((n, e) => n + bytes(e.text), 0) };
61
+ created: new Date().toISOString(), added: plan.added, skipped: plan.skipped, total: entries.length, inputBytes: plan.inputBytes };
88
62
  atomicWrite(pendingFile(dir), JSON.stringify(job, null, 2));
89
63
  return job;
90
64
  }
@@ -104,22 +78,16 @@ export async function runImport(dir: string, compress: Compressor, signal: Abort
104
78
  if (memoryDirectory(dir) === path) { finish(); return job; }
105
79
  if (!existsSync(join(path, STAGED))) throw new Error('Import staging data is missing; original memory remains intact.');
106
80
  const plan: unknown[] = readFileSync(join(path, STAGED), 'utf8').split('\n').filter(Boolean).map(line => JSON.parse(line));
107
- // Every line must parse, and the plan must be whole and in order, so a damaged plan can't drop messages silently.
81
+ // Every line must parse, and the plan must be whole and in order, so a damaged plan is refused before anything is written.
108
82
  if (!plan.every(isEntry) || plan.length !== job.total || plan.some((e, i) => e.i !== i)) throw new Error('Import staging data is invalid; original memory remains intact.');
109
- const memory = await Memory.open(path, compress, () => {}, undefined, jobs);
110
- const report = () => progress({ messages: memory.root.length - memory.pending, total: plan.length, summaries: memory.tree.size, error: memory.lastError });
83
+ let memory: Memory | undefined;
84
+ const report = () => memory && progress({ messages: memory.root.length - memory.pending, total: plan.length, summaries: memory.tree.size, error: memory.lastError });
111
85
  const timer = setInterval(report, 500);
112
86
  try {
113
- if (memory.root.length > plan.length || memory.root.some((e, i) => e.text !== plan[i].text)) throw new Error('Import staging data does not match its log; original memory remains intact.');
114
- report();
115
- // Recipe §10: imported messages are compressed like any other. Each is logged once its node can start, as a burst of live
116
- // messages is, so up to `jobs` summaries run at once (the "Summaries at once" setting). Unlike a turn, an import waits through failures (its progress shows the error).
117
- for (const e of plan.slice(memory.root.length)) { await memory.settle(signal, 'ahead'); memory.append(e.kind, e.text, e.date, e.receipt, e.origin); }
118
- await memory.settle(signal, 'tree'); signal.throwIfAborted();
119
- // Close all writers before the single atomic pointer swap. The previous generation stays intact.
120
- await memory.close();
121
- atomicWrite(join(dir, 'imports', `${job.id}.json`), JSON.stringify({ ...job, completed: new Date().toISOString() }, null, 2));
122
- atomicWrite(join(dir, 'active-memory.json'), JSON.stringify(job.target));
87
+ await build({ entry: i => plan[i], total: job.total, open: async () => { memory = await Memory.open(path, compress, { warn: () => {}, jobs }); report(); return memory; }, activate: () => {
88
+ atomicWrite(join(dir, 'imports', `${job.id}.json`), JSON.stringify({ ...job, completed: new Date().toISOString() }, null, 2));
89
+ atomicWrite(join(dir, 'active-memory.json'), JSON.stringify(job.target));
90
+ } }, signal);
123
91
  finish(); report(); return job;
124
- } finally { clearInterval(timer); await memory.close(); }
92
+ } finally { clearInterval(timer); await memory?.close(); } // Also when `open` fails after opening: a throwing progress callback.
125
93
  }
@@ -7,11 +7,13 @@ import { execFile } from 'node:child_process';
7
7
  import { promisify } from 'node:util';
8
8
  import { createHash } from 'node:crypto';
9
9
  import { getAgentDir, parseSkillBlock } from '@earendil-works/pi-coding-agent';
10
- import { record } from '../cache.ts';
11
- import { bytes, type Entry, type Kind, type Origin } from '../memory.ts';
10
+ import { record } from '../core/cache.ts';
11
+ import { bytes, type Kind, type Origin } from '../core/memory.ts';
12
+ import type { ImportedEntry } from '../core/import.ts';
13
+ export type { ImportedEntry };
12
14
 
13
- export type Source = Origin['source'];
14
- export type ImportedEntry = Omit<Entry, 'i' | 'size'>;
15
+ /** The histories Pi's OptChat imports, as their entries' `origin.source`. */
16
+ export type Source = 'claude' | 'claude-memory' | 'codex' | 'pi' | 'chatgpt';
15
17
  export interface Conversation {
16
18
  source: Source; id: string; file: string; title: string; project: string; date: string; size: number;
17
19
  exported?: Record<string, unknown>;