# OptChat on Pi Durable

`pi-optchat/durable` runs OptChat's memory in a [Pi Durable](https://www.npmjs.com/package/@earendil-works/pi-durable) agent, such as one in a Cloudflare Durable Object behind the Agents SDK's `PiHarness`. It is `SqliteStore`, a `Store` over SQLite (in a Durable Object, the object's), a Pi Durable extension that builds each request from OptChat's view, and `optchatImport` for importing old history. The Worker, its prompts and tools stay in the app. It needs `@earendil-works/pi-durable` 1.4.x (an optional peer dependency; tested on 1.4.0) and imports nothing from Pi's terminal app or Node; `test/core-boundary.test.ts` keeps it that way.

## Wiring it up

```ts
const db = new DurableObjectSqliteDatabase(ctx.storage);         // the one Pi Durable's storage uses too
const read = (sql, ...params) => ctx.storage.sql.exec(sql, ...params); // synchronous reads of memory's texts
const memory = await openMemory(new SqliteStore(db, read), createCompressor({ model, retryable })); // or new SqliteStore(db, read, { images })
const memoryExtension = optchat({ memory });
registry.install(memoryExtension.extension);
const harness = await Harness.open(storage, { models: withCacheMarks(models), registry, onWake, settings: { compaction: { enabled: false } } }, context);
memoryExtension.attach(harness, context);
```

With the Agents SDK's `PiHarness` this goes in its `harness` factory, which supplies `storage`, `context` and `onWake` and resumes the Harness when an alarm wakes the object. A host without it passes an `onWake` that arms an alarm, and calls `harness.resume()` on open and on each alarm, or a task owed from before an eviction never runs.

`examples/durable-object` is a Worker built this way, importing `pi-optchat/core` and `pi-optchat/durable` as an app would (linked with `file:../..`, so run `npm run build` in the repo first: the package's `./core` and `./durable` are the built `dist/`), with the counters and endpoints its `proof.mjs` uses: it runs the Worker under `wrangler dev` with real model calls and checks a multi-turn chat, restarts and the cache.

## How it works

- **Each request** (`beforeRequest`, in the configured conversation only) is the system prompt, OptChat's view, the previous exchange and Pi's current context. The view and previous exchange are fixed for one context, so the requests of a tool loop share a cached prefix. Pi's older transcript is never sent. `afterView(ctx)`, if set, adds the app's own context for the turn right after the view, before the previous exchange and outside the view's cached prefix: fixed for the context like the view, asked again when a refresh rebuilds the view and after a restart (it may be called more than once per context, so keep it cheap), never logged. If it throws, the request goes without it rather than without the view.
- **Long runs** (`refreshAt`, 0.3 by default, 0 is off): once a run's messages since the last refresh pass that share of the model's window, the run up to its last answer is logged, the view is rebuilt, and the run goes on with its task and last step, as in Pi (#146). An earlier run still in the context, or a steer before the cut, moves into memory with the cut steps. The log's cursor says where the run is cut, so a restart sends the same request.
- **When the conversation is idle** (no run, nothing in the inbox, checked again inside the commit), the extension resets Pi's context and creates a background task, in one commit. The task logs what the reset ended into memory and waits for its summaries. Pi Durable runs the task and, after an eviction, its alarm wakes the object to finish it.
- **The log's cursor** is the log itself: each message logged from a Pi entry carries the receipt `pi:<entry id>`, written with the message. On open, the newest entry in memory is read again and the records it already has are skipped, so an entry cut off between its records (an answer with tool calls makes several) is completed. A restart logs nothing twice and summarizes nothing again. Only Pi's user, assistant and tool result entries are logged, plus a reset's note (`Conversation.reset(text)`, a tool's `handoff`) as `[Handoff note] …`, once the context it starts has ended. One endless chat with no resets of your own is still the recommended setup.
- **A failed save** stops memory's log: requests keep the view it has, and after a restart logging resumes from the receipts and Pi's transcript. Cloudflare resets the object itself only when a commit fails after the code ran (the output gate, which drops Pi's writes too); a write that throws at once, like `SQLITE_FULL` at the storage limit, doesn't, so the restart waits for idle eviction, a deploy, or the host's `ctx.abort()`, which the example calls from `warn`. `SQLITE_FULL` stops Pi's writes as well; the errors only memory's writes could hit (a row over 2 MB, a message index saved twice) are ruled out by the store's parts and by one Memory per database.
- **Compaction:** turn Pi's off; the extension also declines any that starts. OptChat's summaries replace it.
- **Large tool results** are sent as their head and tail (`boundedMessage`), capped as Pi does.
- **Images:** each image in a user message or tool result is named in memory's text as Pi's OptChat names it, `[image <16 hex digits>]` (SHA-256 of its base64, by Web Crypto), and kept in the store before the message is logged, so `zoom(id, 1)` returns it and the previous exchange names it instead of sending it again. OptChat doesn't resize images: Pi Durable's Photon processor (`createCloudflarePhotonImages()`) runs in Workers, but the memory it decodes in never shrinks again. Shrink photos when they arrive instead (Cloudflare Images, say), so Pi Durable's transcript and the model get the small copy too; Pi Durable keeps each transcript entry, base64 images included, in one row, so on Cloudflare a message's images must stay well under 2 MB anyway. `SqliteStore` keeps images in `optchat_images`; to keep large binary data out of the database (its row size limit and storage cost), give it `{ images }`, an `ImageStore` with `putImage` and `image`. The example keeps them in R2.
- **Tools:** `zoom` and `date` (plus `search` with `search: true`), replay-safe.
- **Cache marks:** `withCacheMarks(models)` adds OptChat's marks to Anthropic requests that carry a view, so a view that grows at its end is read from the cache. A request with its own `onPayload` is left alone.
- **Summaries of a group chat:** a chat that isn't one user talking to OptChat, such as a housing board with several people, gives `createCompressor` its own `system` prompt and names each speaker in the text, like "[Kari] …".
- **Imports** (`optchatImport`): `start(entries, 'append' | 'rebuild')` plans the import and saves the plan in `optchat_plan`; append keeps the log, its summaries and its view (so the cached prefix holds), rebuild orders everything by date and summarizes it all again. A Pi Durable task builds the new memory beside the live one, as a new generation of the same tables, and carries on after an eviction; it saves its progress every 50 summaries, so restarts during a long import don't park it. The chat goes on with the old memory meanwhile. Once the new one is complete it goes live in one transaction and `onActivated` is called: restart the object there, since the memory in RAM is still the old one: `ctx.abort()` once `ctx.storage.sync()` resolves, as the example does. Aborting at once drops the responses still waiting on the writes, such as that of the `start()` call itself when the import had nothing to summarize; a request that is mid-reply at the switch fails either way, and Pi Durable runs its turn again after the restart. On open, chat messages the old memory logged from Pi's transcript during the import are logged again; messages a host appended to memory directly meanwhile are not, so don't. The old generation's rows are deleted then too. `pending()` gives the job and its task's state; `discard()` stops it. A second import, or one after a failed save, is refused. Neither the plan nor the log's texts are held in RAM. Two memories summarize at once during an import: open the import's with fewer jobs (`jobs: 4`) to stay under the model's rate limit.
- **Storage:** tables `optchat_log`, `optchat_tree`, `optchat_view` (each row with its generation), `optchat_state`, `optchat_plan` and `optchat_images` beside Pi's `pi_*`, in the same database and queue. A message is stored in parts of 512K UTF-16 units, under the 2 MB row limit; an image is one row. Memory keeps only metadata and node sizes in RAM (about 5 MB of heap for a 16,000-message log, measured in workerd) and reads texts through `read` as it needs them, synchronously, so zoom, search and views stay synchronous.
