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.
- package/README.md +4 -2
- package/docs/core.md +38 -0
- package/docs/durable.md +35 -0
- package/docs/recipe-differences.md +1 -1
- package/docs/storage.md +2 -2
- package/docs/victor-recipe.md +4 -4
- package/package.json +38 -12
- package/src/agent-view.ts +1 -1
- package/src/agents.ts +8 -5
- package/src/browser.ts +10 -6
- package/src/compactor.ts +10 -97
- package/src/core/compactor.ts +119 -0
- package/src/core/images.ts +24 -0
- package/src/core/import.ts +84 -0
- package/src/core/index.ts +13 -0
- package/src/core/memory.ts +460 -0
- package/src/core/messages.ts +108 -0
- package/src/core/prompts.ts +153 -0
- package/src/core/store.ts +48 -0
- package/src/core/tools.ts +64 -0
- package/src/durable/import.ts +114 -0
- package/src/durable/index.ts +5 -0
- package/src/durable/optchat.ts +274 -0
- package/src/durable/store.ts +162 -0
- package/src/handoff.ts +4 -3
- package/src/images.ts +2 -12
- package/src/import/job.ts +19 -51
- package/src/import/sources.ts +6 -4
- package/src/import/ui.ts +3 -3
- package/src/inbox.ts +1 -1
- package/src/index.ts +24 -36
- package/src/memory.ts +18 -416
- package/src/profiles.ts +3 -2
- package/src/prompts.ts +1 -149
- package/src/runs.ts +1 -1
- package/src/settings.ts +3 -2
- package/src/store.ts +16 -42
- package/src/tools.ts +5 -62
- package/src/transcript.ts +14 -97
- package/src/usage.ts +1 -1
- package/src/window-bridge.ts +1 -1
- /package/src/{cache.ts → core/cache.ts} +0 -0
package/README.md
CHANGED
|
@@ -21,7 +21,7 @@ pi install npm:pi-web-access # optional, for web search and page fetching
|
|
|
21
21
|
|
|
22
22
|
Or from GitHub: `pi install git:github.com/jonaslsaa/pi-optchat`.
|
|
23
23
|
|
|
24
|
-
Requirements: Pi 1.
|
|
24
|
+
Requirements: Pi 1.2 or later (tested on 1.4.0), Node.js 22.19+, and Git. Tested on macOS; the offline tests also run on Linux and Windows.
|
|
25
25
|
|
|
26
26
|
Web access is not bundled. Subagents load the Pi extensions you have installed (except pi-optchat itself), so installing `pi-web-access` gives web tools to the main agent and every subagent.
|
|
27
27
|
|
|
@@ -213,6 +213,8 @@ Profile data lives in `~/.optchat/profiles/<name>/` (override the root with `OPT
|
|
|
213
213
|
|
|
214
214
|
Git checkpoints and the pluggable `Store` seam (for memory on another machine): [docs/storage.md](docs/storage.md).
|
|
215
215
|
|
|
216
|
+
OptChat's memory also runs outside Pi, for example in a Cloudflare Durable Object: `import ... from 'pi-optchat/core'`. See [docs/core.md](docs/core.md); `pi-optchat/durable` wires it into Pi Durable, see [docs/durable.md](docs/durable.md).
|
|
217
|
+
|
|
216
218
|
## Windows
|
|
217
219
|
|
|
218
220
|
The same install and commands work on Windows, and the offline tests run there too. What differs:
|
|
@@ -242,7 +244,7 @@ OptChat keeps the recipe's numbers: 512-byte lines, binary merges, the 128,000
|
|
|
242
244
|
|
|
243
245
|
| Area | Recipe | OptChat | Why |
|
|
244
246
|
| --- | --- | --- | --- |
|
|
245
|
-
| Prompts | The original and 2026-10-08 prompts | The same, lightly adapted,
|
|
247
|
+
| Prompts | The original and 2026-10-08 prompts | The same, lightly adapted, in `src/core/prompts.ts` and `src/prompts.ts`: reports start `[id]`, a note on `[OptChat status]`, "don't poll slow things", the size limit as a ceiling rather than a target | Fit OptChat's tools; the size wording gave less filler with no recall loss (#129) |
|
|
246
248
|
| Compactor instructions | Gets your instructions | Doesn't | An A/B found they added nothing (#112) |
|
|
247
249
|
| Summary size | Strict 512 bytes | Asked for 512, keeps up to 640 without a retry | Saves retries for a little view space |
|
|
248
250
|
| Memory view | Sawtooth, 128,000 → 64,000 bytes | Same, saved to `view.json` with the compactions' view and reloaded as they were | A restart keeps both prompt prefixes cached |
|
package/docs/core.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# OptChat core
|
|
2
|
+
|
|
3
|
+
`pi-optchat/core` is OptChat's memory without Pi: the log, the summary tree and its views, zoom/date/search, the compactor, cache marks and memory's prompts. It is meant for hosts such as Pi Durable in a Cloudflare Durable Object. The Pi extension (`pi-optchat`) is built on it and behaves exactly as before.
|
|
4
|
+
|
|
5
|
+
Core imports nothing from Pi or Node at runtime: only `typebox` (the tools' schemas) and pi-ai's types. `test/core-boundary.test.ts` keeps it that way. Pi's packages are optional peer dependencies, so installing `pi-optchat` outside Pi pulls in none of them.
|
|
6
|
+
|
|
7
|
+
It ships as TypeScript, like the extension, so the host needs a bundler (Wrangler, esbuild, tsx). A host that type-checks with `tsc` needs `allowImportingTsExtensions`.
|
|
8
|
+
|
|
9
|
+
## What a host supplies
|
|
10
|
+
|
|
11
|
+
- **A `Store`:** `load`, `append`, `appendNode`, `saveView`, `putImage` and `image`, sync or async, plus `text` and `summary`, which must be synchronous. Memory loads only metadata when it opens, reads texts through `text`/`summary` as it needs them, and issues its writes in order. Pi's is `FileStore` (`src/store.ts`); Pi Durable's is `SqliteStore` (`pi-optchat/durable`, see [durable.md](durable.md)).
|
|
12
|
+
- **The compactor's model:** `createCompressor({ model, retryable })`, where `model()` returns `{ model, reasoning, stream }` for each call (pi-ai's `Models.streamSimple` or one with its shape) and `retryable` is pi-ai's `isRetryableAssistantError`.
|
|
13
|
+
- **Image names**, if it keeps images: `logMessage` and `textContent` take an `imageRef` that names an image `[image <16 hex digits>]`; `loadImages` reads those back from the store.
|
|
14
|
+
|
|
15
|
+
## What it exposes
|
|
16
|
+
|
|
17
|
+
`src/core/index.ts` lists the exports by name: only these are public.
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { createCompressor, logMessage, memoryTools, openMemory, withView } from 'pi-optchat/core';
|
|
21
|
+
|
|
22
|
+
const memory = await openMemory(store, createCompressor({ model: () => ({ model, stream: models.streamSimple.bind(models) }), retryable }));
|
|
23
|
+
logMessage(memory, message); // each message of a turn, as the recipe's kinds
|
|
24
|
+
await memory.settle(); // until the view's lines are built
|
|
25
|
+
const request = withView(run, memory.render()); // the view goes before the user's words
|
|
26
|
+
const [zoom, date] = memoryTools(() => memory); // VIEW_DOC tells the model how to use them
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
- `openMemory(store, compress, { warn, budget, jobs, retryMs })`, and `Memory`: `append`, `text`, `render`, `settle(signal?, 'turn' | 'tree' | 'ahead')`, `zoom`, `search`, `date`, `onChange`, `close`, plus `root`, `tree`, `view`, `pending`, `lastError`, `saveError`.
|
|
30
|
+
- `Store`, `Saved`, `Lease`, `ImageStore` (a store's `putImage` and `image`, for keeping images elsewhere), `Mime`, `isMime`, and the record types `Entry`, `Summary`, `Origin`, `Kind`. An entry with an `origin` is imported history, which the summarizer treats as historical; `origin.source` is any label the host picks ("email").
|
|
31
|
+
- `createCompressor({ model, retryable, onUsage?, accepted?, retryDelays?, system? })` (`system`: the compactor's prompt, `COMPACT` by default), `ACCEPTED_BYTES`.
|
|
32
|
+
- `logMessage(memory, message, { kind?, receipt?, imageRef? })` (every entry it appends gets `receipt`, so a host can use it as a cursor), `textContent`, `withView`, `boundedMessage`, `completedExchange`, and the mid-turn refresh helpers (`refreshBudget`, `refreshPoint`, `refreshedRun`).
|
|
33
|
+
- `memoryTools(memory, runs?)` (zoom and date; `runs` adds zooming into a subagent's chat), `searchTool`, `loadImages`, `isImage`, `decode`.
|
|
34
|
+
- `cachePayload` (Anthropic: the view's cache marks, as a request's `onPayload`).
|
|
35
|
+
- Imports: `planImport(existing, incoming, 'append' | 'rebuild', copyOf?)` dedupes by `receipt` (each starts with `import:`) and orders the new history by date: append keeps the existing log as the plan's first `kept` entries, rebuild orders everything and summarizes it again. `runImport({ entry, total, open, activate }, signal)` builds the memory `open` gives from the plan's `total` entries, read one at a time through `entry(i)` so the plan need not stay in RAM, resuming where it stopped, and calls `activate` once the whole tree is built and the memory is closed. The host keeps the plan, seeds the staging store (append: the kept entries, the summaries and the saved view), and switches to it in `activate`. Until then the live memory must take no messages, or the switch drops them: queue them and log them after. `deduplicate` alone tells what would be new, and `chronological` is the order rebuild uses. `pi-optchat/durable`'s `optchatImport` does all this on SQLite.
|
|
36
|
+
- Prompts: `COMPACT`, `VIEW_DOC`, `CONTINUITY`, `SEARCH_DOC`, `allowSearch`, `TURN_REFRESHED`.
|
|
37
|
+
|
|
38
|
+
Not in core: the turn itself (when to snapshot the view, refresh mid-turn, replay the previous exchange). Each host does it with the helpers above: Pi in `src/index.ts`, Pi Durable in `src/durable/optchat.ts`.
|
package/docs/durable.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# OptChat on Pi Durable
|
|
2
|
+
|
|
3
|
+
`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.
|
|
4
|
+
|
|
5
|
+
## Wiring it up
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
const db = new DurableObjectSqliteDatabase(ctx.storage); // the one Pi Durable's storage uses too
|
|
9
|
+
const read = (sql, ...params) => ctx.storage.sql.exec(sql, ...params); // synchronous reads of memory's texts
|
|
10
|
+
const memory = await openMemory(new SqliteStore(db, read), createCompressor({ model, retryable })); // or new SqliteStore(db, read, { images })
|
|
11
|
+
const memoryExtension = optchat({ memory });
|
|
12
|
+
registry.install(memoryExtension.extension);
|
|
13
|
+
const harness = await Harness.open(storage, { models: withCacheMarks(models), registry, onWake, settings: { compaction: { enabled: false } } }, context);
|
|
14
|
+
memoryExtension.attach(harness, context);
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
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.
|
|
18
|
+
|
|
19
|
+
`examples/durable-object` is a Worker built this way, importing `pi-optchat/core` and `pi-optchat/durable` as an app would (linked with `file:../..`), 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.
|
|
20
|
+
|
|
21
|
+
## How it works
|
|
22
|
+
|
|
23
|
+
- **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.
|
|
24
|
+
- **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.
|
|
25
|
+
- **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.
|
|
26
|
+
- **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.
|
|
27
|
+
- **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.
|
|
28
|
+
- **Compaction:** turn Pi's off; the extension also declines any that starts. OptChat's summaries replace it.
|
|
29
|
+
- **Large tool results** are sent as their head and tail (`boundedMessage`), capped as Pi does.
|
|
30
|
+
- **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.
|
|
31
|
+
- **Tools:** `zoom` and `date` (plus `search` with `search: true`), replay-safe.
|
|
32
|
+
- **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.
|
|
33
|
+
- **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] …".
|
|
34
|
+
- **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.
|
|
35
|
+
- **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.
|
|
@@ -4,7 +4,7 @@ The README has the summary table. This page has the detail: what changed in each
|
|
|
4
4
|
|
|
5
5
|
## Prompts
|
|
6
6
|
|
|
7
|
-
`src/prompts.ts` holds
|
|
7
|
+
`src/core/prompts.ts` holds memory's prompts (the compactor's and the view doc from the recipe, the compaction task and its retry, the import note) and `src/prompts.ts` Pi's (the main agent's, the subagent instruction paragraphs, the handoff prompts), each with a comment on where it goes. Tool descriptions stay with their tools. The recipe's prompts are lightly adapted:
|
|
8
8
|
|
|
9
9
|
- **Main agent.** The turn rules are the revised recipe's (2026-10-08) word for word, with three changes:
|
|
10
10
|
- its paragraph on computers is left out;
|
package/docs/storage.md
CHANGED
|
@@ -12,10 +12,10 @@ An import builds a new memory generation (`memories/<id>/`) and switches `active
|
|
|
12
12
|
|
|
13
13
|
## The `Store` seam
|
|
14
14
|
|
|
15
|
-
Memory reaches disk through one seam, `Store` in `src/store.ts
|
|
15
|
+
Memory reaches disk through one seam, `Store` in `src/core/store.ts` (Pi's `FileStore` is in `src/store.ts`): the log, summary nodes, saved view and images, which is what would travel between machines. The saved view (`view.json`) holds the chat's view and the compactions' view, each with its batch state; an older one without the compactions' view refolds it from the chat's, once.
|
|
16
16
|
|
|
17
17
|
- Runs, recovery journals, imports and their pointer, usage, settings and `AGENTS.md` stay local files.
|
|
18
|
-
- `FileStore` (the layout in the README)
|
|
18
|
+
- Pi uses `FileStore` (the layout in the README). `pi-optchat/durable` has `SqliteStore`, for a Durable Object's SQLite ([durable.md](durable.md)). Another, such as Postgres, would implement the same few operations.
|
|
19
19
|
- Its lease (take, heartbeat, release) is optional: without one, OptChat keeps its machine-local profile lock.
|
|
20
20
|
- Import records (`imports/*.json`, the staging generations and the `active-memory.json` pointer) are per machine, so a second machine with the same `~/.claude` or Codex history on disk may offer those sessions for import again.
|
|
21
21
|
|
package/docs/victor-recipe.md
CHANGED
|
@@ -6,13 +6,13 @@ Source: https://gist.github.com/VictorTaelin/91837951a5ce5b38f341ec1ba1df6449
|
|
|
6
6
|
|
|
7
7
|
The implementation was compared with the recipe fetched on 2026-10-04. The reference content SHA-256 was `8f6997e8944d85e4df53b5704bf7c4e393e4da361071181e9cc2f7d9d1b6e430`.
|
|
8
8
|
|
|
9
|
-
The source remains upstream rather than duplicating the full article here. Its prompt strings, from the original and the 2026-10-08 revision, are in `src/prompts.ts` with the small adaptations listed in `docs/recipe-differences.md`, and attribution in `THIRD_PARTY_NOTICES.md`.
|
|
9
|
+
The source remains upstream rather than duplicating the full article here. Its prompt strings, from the original and the 2026-10-08 revision, are in `src/core/prompts.ts` and `src/prompts.ts` with the small adaptations listed in `docs/recipe-differences.md`, and attribution in `THIRD_PARTY_NOTICES.md`.
|
|
10
10
|
|
|
11
11
|
## Implementation mapping
|
|
12
12
|
|
|
13
|
-
- `src/memory.ts`: append-only log, binary summary tree, compression scheduling (§4 of the 2026-10-08 revision: a message's summary starts once fewer than 8 lines before it are unbuilt, a merge once both halves are built, kept in a queue so the tree is never scanned for work), the compactions' own view (§4: the view merged further to 16,000-32,000 bytes, ending at the node; unlike the recipe, a line not built yet shows as a placeholder instead of ending it, so a summary still sees the built lines after it), the view (§3.2: the most due pair, measured from its last message, merges in one batch from 128,000 down to 64,000 bytes, and the view is saved to `view.json` rather than refolded at start), zoom/date, and the opt-in text search over original messages (`src/tools.ts` has the tool; not in the recipe, off by default).
|
|
14
|
-
- `src/compactor.ts`: contextual compression and size retries, with the 2026-10-08 revision's task (a 512-dash ruler for the size) and its "Too long" retry, verbatim. The compactions' view shows each line under its `id+n|` head, as the task's line ids need, so a reply that copies a head has it removed.
|
|
15
|
-
- `src/cache.ts`: Anthropic cache marks: the view in blocks of 4 lines, one mark on the last whole block and one at the request's end, as in recipe §3.3 (a compaction leaves its end unmarked, as Unii does: its task is never sent again), plus two marks 20 and 40 blocks before the last. Anthropic looks back only 20 blocks from a mark, so with the recipe's one view mark a turn that added more than 80 lines, such as 40 tool calls and their results, made the next turn rewrite the whole view. OpenAI requests get no marks: GPT-5.6 and GPT-6 Luna reject the recipe's `prompt_cache_breakpoint` with a 400 (found by @aaaxn), so they rely on implicit prefix caching. OptChat doesn't set `reasoning.context` either: GPT-5.6 already defaults to the recipe's `"all_turns"`, and OpenAI documents it only for GPT-5.6 and GPT-6.1 Sol.
|
|
13
|
+
- `src/core/memory.ts`: append-only log, binary summary tree, compression scheduling (§4 of the 2026-10-08 revision: a message's summary starts once fewer than 8 lines before it are unbuilt, a merge once both halves are built, kept in a queue so the tree is never scanned for work), the compactions' own view (§4: the view merged further to 16,000-32,000 bytes, ending at the node; unlike the recipe, a line not built yet shows as a placeholder instead of ending it, so a summary still sees the built lines after it), the view (§3.2: the most due pair, measured from its last message, merges in one batch from 128,000 down to 64,000 bytes, and the view is saved to `view.json` rather than refolded at start), zoom/date, and the opt-in text search over original messages (`src/core/tools.ts` has the tool; not in the recipe, off by default).
|
|
14
|
+
- `src/core/compactor.ts`: contextual compression and size retries, with the 2026-10-08 revision's task (a 512-dash ruler for the size) and its "Too long" retry, verbatim. The compactions' view shows each line under its `id+n|` head, as the task's line ids need, so a reply that copies a head has it removed.
|
|
15
|
+
- `src/core/cache.ts`: Anthropic cache marks: the view in blocks of 4 lines, one mark on the last whole block and one at the request's end, as in recipe §3.3 (a compaction leaves its end unmarked, as Unii does: its task is never sent again), plus two marks 20 and 40 blocks before the last. Anthropic looks back only 20 blocks from a mark, so with the recipe's one view mark a turn that added more than 80 lines, such as 40 tool calls and their results, made the next turn rewrite the whole view. OpenAI requests get no marks: GPT-5.6 and GPT-6 Luna reject the recipe's `prompt_cache_breakpoint` with a 400 (found by @aaaxn), so they rely on implicit prefix caching. OptChat doesn't set `reasoning.context` either: GPT-5.6 already defaults to the recipe's `"all_turns"`, and OpenAI documents it only for GPT-5.6 and GPT-6.1 Sol.
|
|
16
16
|
- `src/transcript.ts`: fresh context per parent run, current-run tool loop retained. The previous completed exchange is also retained in full text (left out if over 16,000 bytes by default), an intentional addition to the summary-only recipe for conversational continuity.
|
|
17
17
|
- `src/agents.ts`: asynchronous Pi SDK children and automatic completion reports.
|
|
18
18
|
- `src/settings.ts`: per-profile settings for the departures from the recipe. Defaults are the recipe's (no memory search), except the previous exchange (on), the summary size tolerance (640 bytes, against the recipe's strict 512) and the turn refresh threshold (30%, where the recipe has none). Grouped subagent reports are off by default, as in the revised recipe, which reports each subagent on its own.
|
package/package.json
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-optchat",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.1",
|
|
4
4
|
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": "./src/index.ts",
|
|
7
|
+
"./core": "./src/core/index.ts",
|
|
8
|
+
"./durable": "./src/durable/index.ts",
|
|
9
|
+
"./package.json": "./package.json"
|
|
10
|
+
},
|
|
5
11
|
"pi": {
|
|
6
12
|
"extensions": [
|
|
7
13
|
"./src/index.ts"
|
|
@@ -14,22 +20,42 @@
|
|
|
14
20
|
"test:live": "tsx test/live.ts"
|
|
15
21
|
},
|
|
16
22
|
"devDependencies": {
|
|
17
|
-
"@earendil-works/
|
|
18
|
-
"@earendil-works/pi-
|
|
19
|
-
"@earendil-works/pi-
|
|
20
|
-
"@earendil-works/pi-
|
|
21
|
-
"@
|
|
22
|
-
"
|
|
23
|
+
"@earendil-works/chord": "1.4.0",
|
|
24
|
+
"@earendil-works/pi-agent-core": "1.4.0",
|
|
25
|
+
"@earendil-works/pi-ai": "1.4.0",
|
|
26
|
+
"@earendil-works/pi-coding-agent": "1.4.0",
|
|
27
|
+
"@earendil-works/pi-durable": "1.4.0",
|
|
28
|
+
"@earendil-works/pi-tui": "1.4.0",
|
|
29
|
+
"@types/node": "^22.20.5",
|
|
30
|
+
"tsx": "^4.23.15",
|
|
23
31
|
"typebox": "1.3.27",
|
|
24
|
-
"typescript": "^
|
|
32
|
+
"typescript": "^7.0.2"
|
|
25
33
|
},
|
|
26
34
|
"peerDependencies": {
|
|
27
|
-
"@earendil-works/pi-agent-core": "
|
|
28
|
-
"@earendil-works/pi-ai": "
|
|
29
|
-
"@earendil-works/pi-coding-agent": "
|
|
30
|
-
"@earendil-works/pi-
|
|
35
|
+
"@earendil-works/pi-agent-core": ">=1.2",
|
|
36
|
+
"@earendil-works/pi-ai": ">=1.2",
|
|
37
|
+
"@earendil-works/pi-coding-agent": ">=1.2",
|
|
38
|
+
"@earendil-works/pi-durable": "~1.4.0",
|
|
39
|
+
"@earendil-works/pi-tui": ">=1.2",
|
|
31
40
|
"typebox": "*"
|
|
32
41
|
},
|
|
42
|
+
"peerDependenciesMeta": {
|
|
43
|
+
"@earendil-works/pi-agent-core": {
|
|
44
|
+
"optional": true
|
|
45
|
+
},
|
|
46
|
+
"@earendil-works/pi-ai": {
|
|
47
|
+
"optional": true
|
|
48
|
+
},
|
|
49
|
+
"@earendil-works/pi-coding-agent": {
|
|
50
|
+
"optional": true
|
|
51
|
+
},
|
|
52
|
+
"@earendil-works/pi-durable": {
|
|
53
|
+
"optional": true
|
|
54
|
+
},
|
|
55
|
+
"@earendil-works/pi-tui": {
|
|
56
|
+
"optional": true
|
|
57
|
+
}
|
|
58
|
+
},
|
|
33
59
|
"description": "OptChat memory for Pi: persistent profiles, zoomable history, background agents, and conversation imports.",
|
|
34
60
|
"keywords": [
|
|
35
61
|
"pi-package",
|
package/src/agent-view.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type { AgentMessage } from '@earendil-works/pi-agent-core';
|
|
|
8
8
|
import type { Children } from './agents.ts';
|
|
9
9
|
import { isHandingOff, type RunInfo } from './runs.ts';
|
|
10
10
|
import { textContent } from './transcript.ts';
|
|
11
|
-
import { record } from './cache.ts';
|
|
11
|
+
import { record } from './core/cache.ts';
|
|
12
12
|
import { agentLabel, elapsed, fit, oneLine } from './inspector.ts';
|
|
13
13
|
import { isReport } from './report-message.ts';
|
|
14
14
|
|
package/src/agents.ts
CHANGED
|
@@ -6,16 +6,19 @@ import { createAgentSession, DefaultResourceLoader, SessionManager, SettingsMana
|
|
|
6
6
|
import * as sdk from '@earendil-works/pi-coding-agent';
|
|
7
7
|
import type { AgentMessage } from '@earendil-works/pi-agent-core';
|
|
8
8
|
import { StringEnum, type Api, type Model } from '@earendil-works/pi-ai';
|
|
9
|
-
import { allowSearch,
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
9
|
+
import { allowSearch, SEARCH_DOC, VIEW_DOC } from './core/prompts.ts';
|
|
10
|
+
import { windowHandoff, CONNECTED, cutOffChildren, delegation, NO_DELEGATION, NOT_CONNECTED, RESTARTED, STEERABLE, SUBAGENT } from './prompts.ts';
|
|
11
|
+
import { memoryTools } from './tools.ts';
|
|
12
|
+
import { searchTool } from './core/tools.ts';
|
|
13
|
+
import { VIEW } from './core/memory.ts';
|
|
14
|
+
import type { Memory } from './memory.ts';
|
|
12
15
|
import type { ModelChoice } from './compactor.ts';
|
|
13
|
-
import { cachePayload } from './cache.ts';
|
|
16
|
+
import { cachePayload } from './core/cache.ts';
|
|
14
17
|
import { CUT_OFF, reportHead, RunHistory, isRunning, transition, sessionMessages, savedSession, undelivered, sentText, type Guidance, type RunInfo, type RunState, type FinishReason } from './runs.ts';
|
|
15
18
|
import { UsageLedger } from './usage.ts';
|
|
16
19
|
import { fullChat, refreshFraction, textContent } from './transcript.ts';
|
|
17
20
|
import { Type, type Static } from 'typebox';
|
|
18
|
-
import { result } from './tools.ts';
|
|
21
|
+
import { result } from './core/tools.ts';
|
|
19
22
|
import { handoffText, withUndelivered, type HandoffEvidence } from './handoff.ts';
|
|
20
23
|
import { DEFAULT_SETTINGS, type Settings } from './settings.ts';
|
|
21
24
|
import { THINKING } from './profiles.ts';
|
package/src/browser.ts
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
2
|
import { atomicWrite } from './store.ts';
|
|
3
|
-
import {
|
|
3
|
+
import type { Memory } from './memory.ts';
|
|
4
|
+
import type { Source } from './import/sources.ts';
|
|
4
5
|
|
|
5
6
|
// One self-contained, read-only HTML snapshot of a profile's memory.
|
|
7
|
+
// Each import source's name in the page; a source without one shows its label as is.
|
|
8
|
+
const SOURCES = { claude: 'Claude Code', 'claude-memory': 'Claude Code memory', codex: 'Codex', pi: 'Pi / OMP', chatgpt: 'ChatGPT' } satisfies Record<Source, string>;
|
|
9
|
+
|
|
6
10
|
export function exportBrowser(memory: Memory, profile: string, directory = memory.directory) {
|
|
7
11
|
const data = JSON.stringify({
|
|
8
|
-
profile, budget: memory.budget, size: memory.size, view: memory.view,
|
|
9
|
-
root: memory.root.map(({ i, kind,
|
|
10
|
-
tree: [...memory.tree.values()].map(({ l, i
|
|
12
|
+
profile, sources: SOURCES, budget: memory.budget, size: memory.size, view: memory.view,
|
|
13
|
+
root: memory.root.map(({ i, kind, date, origin }) => ({ i, kind, text: memory.text(i), date, ...(origin ? { origin: { source: origin.source, title: origin.title } } : {}) })),
|
|
14
|
+
tree: [...memory.tree.values()].map(({ l, i }) => ({ l, i, text: memory.summary({ l, i }) })),
|
|
11
15
|
}).replace(/</g, '\\u003c');
|
|
12
16
|
const path = join(directory, 'memory.html'); atomicWrite(path, PAGE.replace('__DATA__', () => data)); return path;
|
|
13
17
|
}
|
|
@@ -82,7 +86,7 @@ function when(a, b) {
|
|
|
82
86
|
if (lo === hi) return day(x) + ', ' + time(x);
|
|
83
87
|
return x.toDateString() === y.toDateString() ? day(x) + ', ' + time(x) + '–' + time(y) : day(x) + ' ' + time(x) + ' – ' + day(y) + ' ' + time(y);
|
|
84
88
|
}
|
|
85
|
-
const SOURCE =
|
|
89
|
+
const SOURCE = D.sources;
|
|
86
90
|
// Imported text starts with a "[Historical …]" line for the model; the meta line already says it.
|
|
87
91
|
const body = e => { const j = e.origin && e.text.startsWith('[Historical ') ? e.text.indexOf(']\n') : -1; return j < 0 ? e.text : e.text.slice(j + 2); };
|
|
88
92
|
const short = (s, n) => { s = s.replace(/\s+/g, ' ').trim(); return s.length > n ? s.slice(0, n - 1) + '…' : s; };
|
|
@@ -103,7 +107,7 @@ function mix(a, b) {
|
|
|
103
107
|
function head(e) {
|
|
104
108
|
const [label] = who(e), h = $('div', 'who');
|
|
105
109
|
h.append($('b', null, label), ' · ' + when(e.i, e.i));
|
|
106
|
-
if (e.origin) h.append(' · imported from ' + SOURCE[e.origin.source] + (e.origin.title ? ': ' + short(e.origin.title, 60) : ''));
|
|
110
|
+
if (e.origin) h.append(' · imported from ' + (Object.hasOwn(SOURCE, e.origin.source) ? SOURCE[e.origin.source] : e.origin.source) + (e.origin.title ? ': ' + short(e.origin.title, 60) : ''));
|
|
107
111
|
return h;
|
|
108
112
|
}
|
|
109
113
|
function message(e) {
|
package/src/compactor.ts
CHANGED
|
@@ -1,11 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { clampThinkingLevel, isRetryableAssistantError, type Api, type AssistantMessage, type Message, type Model } from '@earendil-works/pi-ai';
|
|
1
|
+
import { clampThinkingLevel, isRetryableAssistantError, type Api, type Model } from '@earendil-works/pi-ai';
|
|
3
2
|
import type { ThinkingLevel } from '@earendil-works/pi-agent-core';
|
|
4
3
|
import type { ModelRegistry } from '@earendil-works/pi-coding-agent';
|
|
5
|
-
import {
|
|
6
|
-
import { bytes, cut, Refused, type Compressor } from './memory.ts';
|
|
7
|
-
import { cachePayload, splitView } from './cache.ts';
|
|
8
|
-
import { DEFAULT_SETTINGS } from './settings.ts';
|
|
4
|
+
import { createCompressor as compressor, type CompressorOptions } from './core/compactor.ts';
|
|
9
5
|
|
|
10
6
|
export interface ModelChoice { provider: string; model: string; thinking: ThinkingLevel }
|
|
11
7
|
/** A level the model can't take would be sent as no level, which Sonnet 5.5 runs at high effort; Pi's own sessions clamp the same way. */
|
|
@@ -13,96 +9,13 @@ export const reasoningFor = (model: Model<Api>, level: ThinkingLevel) => {
|
|
|
13
9
|
const thinking = clampThinkingLevel(model, level);
|
|
14
10
|
return thinking === 'off' ? undefined : thinking;
|
|
15
11
|
};
|
|
16
|
-
const WARM_MS = 4 * 60_000; // Anthropic's short cache lives 5 minutes from its last use.
|
|
17
12
|
|
|
18
|
-
/**
|
|
19
|
-
*
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
for (let state = warm.get(prefix) ?? priming(); state !== undefined; state = warm.get(prefix) ?? priming()) {
|
|
25
|
-
if (typeof state === 'number') { if (Date.now() - state < WARM_MS) break; warm.delete(prefix); continue; }
|
|
26
|
-
// A cancelled waiter leaves at once instead of waiting for someone else's primer.
|
|
27
|
-
signal.throwIfAborted();
|
|
28
|
-
let wake = () => {};
|
|
29
|
-
const aborted = new Promise<void>(resolve => { wake = resolve; });
|
|
30
|
-
signal.addEventListener('abort', wake, { once: true });
|
|
31
|
-
await Promise.race([state, aborted]);
|
|
32
|
-
signal.removeEventListener('abort', wake);
|
|
33
|
-
signal.throwIfAborted();
|
|
34
|
-
}
|
|
35
|
-
let release = () => {};
|
|
36
|
-
const pending = warm.has(prefix) ? undefined : new Promise<void>(resolve => { release = resolve; });
|
|
37
|
-
if (pending) warm.set(prefix, pending);
|
|
38
|
-
return (ok: boolean) => {
|
|
39
|
-
if (ok) { for (const [k, at] of warm) if (typeof at === 'number' && Date.now() - at >= WARM_MS) warm.delete(k); warm.set(prefix, Date.now()); }
|
|
40
|
-
else if (warm.get(prefix) === pending) warm.delete(prefix);
|
|
41
|
-
release();
|
|
42
|
-
};
|
|
43
|
-
};
|
|
44
|
-
}
|
|
45
|
-
/** Pi's agent loop retries a dropped connection itself; streamSimple doesn't, so a transient error is asked again before it surfaces. */
|
|
46
|
-
export async function retryTransient(ask: () => Promise<AssistantMessage>, onReply: (reply: AssistantMessage) => void,
|
|
47
|
-
delays: readonly number[] = [1000, 2000], signal?: AbortSignal) {
|
|
48
|
-
for (let retry = 0; ; retry++) {
|
|
49
|
-
const reply = await ask();
|
|
50
|
-
onReply(reply);
|
|
51
|
-
if (retry >= delays.length || !isRetryableAssistantError(reply)) return reply;
|
|
52
|
-
await sleep(delays[retry], undefined, { signal });
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** The model is asked for 512 bytes; `accepted` is the longest line kept without a retry (the profile's summary size tolerance). */
|
|
57
|
-
export function createCompressor(registry: ModelRegistry, choice: () => ModelChoice,
|
|
58
|
-
onUsage: (message: AssistantMessage) => void = () => {}, accepted = () => DEFAULT_SETTINGS.summaryAcceptBytes,
|
|
59
|
-
retryDelays?: readonly number[]): Compressor {
|
|
60
|
-
const gate = primeFirst();
|
|
61
|
-
return async (input, signal) => {
|
|
62
|
-
const selected = choice();
|
|
63
|
-
const model = registry.find(selected.provider, selected.model);
|
|
13
|
+
/** The profile's compactor: `choice` is read for each call, through Pi's registry and its keys. `accepted` is the profile's
|
|
14
|
+
* summary size tolerance. */
|
|
15
|
+
export const createCompressor = (registry: ModelRegistry, choice: () => ModelChoice,
|
|
16
|
+
options: Omit<CompressorOptions, 'model' | 'retryable'> = {}) => compressor({ ...options, retryable: isRetryableAssistantError,
|
|
17
|
+
model: () => {
|
|
18
|
+
const selected = choice(), model = registry.find(selected.provider, selected.model);
|
|
64
19
|
if (!model) throw new Error(`Compactor model unavailable: ${selected.provider}/${selected.model}. Use /optchat model.`);
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
// OpenAI joins text blocks with nothing between them, so the task starts its own paragraph after the view.
|
|
68
|
-
const messages: Message[] = [{ role: 'user', content: [{ type: 'text', text: input.context }, { type: 'text', text: `\n\n${step}` }], timestamp: Date.now() }];
|
|
69
|
-
const view = splitView(input.context);
|
|
70
|
-
const prefix = model.api === 'anthropic-messages' && view.length > 1 ? `${model.provider}/${model.id}/${thinking ?? 'off'}\n${view.slice(0, -1).join('')}` : undefined;
|
|
71
|
-
const tries: string[] = [];
|
|
72
|
-
const ask = async () => {
|
|
73
|
-
const warmed = prefix ? await gate(prefix, signal) : () => {};
|
|
74
|
-
try {
|
|
75
|
-
const stream = registry.streamSimple(model, { systemPrompt: COMPACT, messages }, {
|
|
76
|
-
// A shared session id is the OpenAI prompt-cache key; SSE because over a websocket Codex would chain unrelated parallel calls on one cached connection.
|
|
77
|
-
sessionId: 'optchat-compactor', transport: 'sse',
|
|
78
|
-
reasoning: thinking, signal, cacheRetention: 'short',
|
|
79
|
-
onPayload: payload => model.api === 'anthropic-messages' ? cachePayload(payload, { end: false }) : payload,
|
|
80
|
-
});
|
|
81
|
-
// The cache entry is usable once the model starts answering.
|
|
82
|
-
for await (const event of stream) if (event.type !== 'start') { warmed(event.type !== 'error'); break; }
|
|
83
|
-
return await stream.result();
|
|
84
|
-
} finally { warmed(false); }
|
|
85
|
-
};
|
|
86
|
-
const answer = () => retryTransient(ask, onUsage, retryDelays, signal);
|
|
87
|
-
for (let attempt = 0; attempt < 5; attempt++) {
|
|
88
|
-
const reply = await answer();
|
|
89
|
-
// A stop reason means the model answered: a refusal or a content filter, which asking again repeats.
|
|
90
|
-
// A provider's own 'error' stop (Mistral) is a failure on its side, not an answer: a plain error, tried again later.
|
|
91
|
-
if (reply.stopReason === 'error' && reply.rawStopReason !== undefined && reply.rawStopReason !== 'error' && !isRetryableAssistantError(reply))
|
|
92
|
-
throw new Refused(reply.errorMessage ?? `Compactor stopped: ${reply.rawStopReason}`);
|
|
93
|
-
if (reply.stopReason === 'error' || reply.stopReason === 'aborted') throw new Error(reply.errorMessage ?? `Compactor ${reply.stopReason}`);
|
|
94
|
-
// A reply cut off at the token limit, or one that calls a tool, is no summary: ask again.
|
|
95
|
-
if (reply.stopReason === 'length' || reply.content.some(c => c.type === 'toolCall')) continue;
|
|
96
|
-
// The compactions' view shows each line under its id+n| head, which a line can copy.
|
|
97
|
-
const line = reply.content.filter(c => c.type === 'text').map(c => c.text).join('').trim().replace(/^\d+\+\d+\|\s*/, '');
|
|
98
|
-
if (!line) throw new Refused('Compactor returned no text.');
|
|
99
|
-
tries.push(line);
|
|
100
|
-
// A merge of two short lines can come back nearly as big as both, so a line must also shrink what it replaces.
|
|
101
|
-
if (bytes(line) <= accepted() && bytes(line) < bytes(input.source)) break;
|
|
102
|
-
messages.push(reply);
|
|
103
|
-
messages.push({ role: 'user', content: tooLong(bytes(line), cut(line)), timestamp: Date.now() });
|
|
104
|
-
}
|
|
105
|
-
if (!tries.length) throw new Refused('Compactor returned no whole line in 5 tries.');
|
|
106
|
-
return tries.reduce((a, b) => bytes(a) <= bytes(b) ? a : b);
|
|
107
|
-
};
|
|
108
|
-
}
|
|
20
|
+
return { model, reasoning: reasoningFor(model, selected.thinking), stream: (model, context, options) => registry.streamSimple(model, context, options) };
|
|
21
|
+
} });
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { Api, AssistantMessage, Message, Model, Models, SimpleStreamOptions } from '@earendil-works/pi-ai';
|
|
2
|
+
import { COMPACT, compaction, IMPORT_GUIDANCE, tooLong } from './prompts.ts';
|
|
3
|
+
import { bytes, cut, NODE, Refused, type Compressor } from './memory.ts';
|
|
4
|
+
import { cachePayload, splitView } from './cache.ts';
|
|
5
|
+
|
|
6
|
+
/** The longest line kept without asking again, unless the host sets its own (Pi's "Summary size tolerance"). */
|
|
7
|
+
export const ACCEPTED_BYTES = NODE * 1.25;
|
|
8
|
+
const WARM_MS = 4 * 60_000; // Anthropic's short cache lives 5 minutes from its last use.
|
|
9
|
+
|
|
10
|
+
/** Parallel calls can't read a cache entry that isn't written yet, so one call primes a cold prefix and the rest wait until it answers.
|
|
11
|
+
* Parallel compactions end their views at different messages, so a call also waits for a primer of a shorter prefix of its own view. */
|
|
12
|
+
function primeFirst() {
|
|
13
|
+
const warm = new Map<string, number | Promise<void>>();
|
|
14
|
+
return async (prefix: string, signal: AbortSignal) => {
|
|
15
|
+
const priming = () => { for (const [k, state] of warm) if (typeof state !== 'number' && prefix.startsWith(k)) return state; };
|
|
16
|
+
for (let state = warm.get(prefix) ?? priming(); state !== undefined; state = warm.get(prefix) ?? priming()) {
|
|
17
|
+
if (typeof state === 'number') { if (Date.now() - state < WARM_MS) break; warm.delete(prefix); continue; }
|
|
18
|
+
// A cancelled waiter leaves at once instead of waiting for someone else's primer.
|
|
19
|
+
signal.throwIfAborted();
|
|
20
|
+
let wake = () => {};
|
|
21
|
+
const aborted = new Promise<void>(resolve => { wake = resolve; });
|
|
22
|
+
signal.addEventListener('abort', wake, { once: true });
|
|
23
|
+
await Promise.race([state, aborted]);
|
|
24
|
+
signal.removeEventListener('abort', wake);
|
|
25
|
+
signal.throwIfAborted();
|
|
26
|
+
}
|
|
27
|
+
let release = () => {};
|
|
28
|
+
const pending = warm.has(prefix) ? undefined : new Promise<void>(resolve => { release = resolve; });
|
|
29
|
+
if (pending) warm.set(prefix, pending);
|
|
30
|
+
return (ok: boolean) => {
|
|
31
|
+
if (ok) { for (const [k, at] of warm) if (typeof at === 'number' && Date.now() - at >= WARM_MS) warm.delete(k); warm.set(prefix, Date.now()); }
|
|
32
|
+
else if (warm.get(prefix) === pending) warm.delete(prefix);
|
|
33
|
+
release();
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
const sleep = (ms: number, signal?: AbortSignal) => new Promise<void>((resolve, reject) => {
|
|
38
|
+
signal?.throwIfAborted();
|
|
39
|
+
const abort = () => { clearTimeout(timer); reject(signal?.reason); };
|
|
40
|
+
const timer = setTimeout(() => { signal?.removeEventListener('abort', abort); resolve(); }, ms);
|
|
41
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
42
|
+
});
|
|
43
|
+
/** Whether a failed reply is worth asking again: pi-ai's `isRetryableAssistantError`, passed in by the host. */
|
|
44
|
+
export type Retryable = (reply: AssistantMessage) => boolean;
|
|
45
|
+
/** Pi's agent loop retries a dropped connection itself; streamSimple doesn't, so a transient error is asked again before it surfaces. */
|
|
46
|
+
export async function retryTransient(ask: () => Promise<AssistantMessage>, onReply: (reply: AssistantMessage) => void, retryable: Retryable,
|
|
47
|
+
delays: readonly number[] = [1000, 2000], signal?: AbortSignal) {
|
|
48
|
+
for (let retry = 0; ; retry++) {
|
|
49
|
+
const reply = await ask();
|
|
50
|
+
onReply(reply);
|
|
51
|
+
if (retry >= delays.length || !retryable(reply)) return reply;
|
|
52
|
+
await sleep(delays[retry], signal);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The compactor's model for one call, as the host resolves it: `stream` is pi-ai's `Models.streamSimple`, or Pi's
|
|
57
|
+
* `ModelRegistry.streamSimple`, which has its shape. */
|
|
58
|
+
export interface CompactorModel { model: Model<Api>; reasoning?: SimpleStreamOptions['reasoning']; stream: Models['streamSimple'] }
|
|
59
|
+
export interface CompressorOptions {
|
|
60
|
+
/** Resolves the model for each call, so a settings change applies to the next summary. Throws if it is unavailable. */
|
|
61
|
+
model: () => CompactorModel;
|
|
62
|
+
retryable: Retryable;
|
|
63
|
+
onUsage?: (reply: AssistantMessage) => void;
|
|
64
|
+
/** The longest line kept without a retry; ACCEPTED_BYTES unless the host has a setting for it. */
|
|
65
|
+
accepted?: () => number;
|
|
66
|
+
retryDelays?: readonly number[];
|
|
67
|
+
/** The compactor's system prompt: COMPACT, unless the host's chat isn't one user and OptChat. */
|
|
68
|
+
system?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Builds summary lines with the model `model` resolves. The model is asked for 512 bytes. */
|
|
72
|
+
export function createCompressor({ model: resolve, retryable, onUsage = () => {}, accepted = () => ACCEPTED_BYTES, retryDelays,
|
|
73
|
+
system = COMPACT }: CompressorOptions): Compressor {
|
|
74
|
+
const gate = primeFirst();
|
|
75
|
+
return async (input, signal) => {
|
|
76
|
+
const { model, reasoning: thinking, stream: streamSimple } = resolve();
|
|
77
|
+
const step = `${input.historical ? IMPORT_GUIDANCE + '\n\n' : ''}${compaction(input)}`;
|
|
78
|
+
// OpenAI joins text blocks with nothing between them, so the task starts its own paragraph after the view.
|
|
79
|
+
const messages: Message[] = [{ role: 'user', content: [{ type: 'text', text: input.context }, { type: 'text', text: `\n\n${step}` }], timestamp: Date.now() }];
|
|
80
|
+
const view = splitView(input.context);
|
|
81
|
+
const prefix = model.api === 'anthropic-messages' && view.length > 1 ? `${model.provider}/${model.id}/${thinking ?? 'off'}\n${view.slice(0, -1).join('')}` : undefined;
|
|
82
|
+
const tries: string[] = [];
|
|
83
|
+
const ask = async () => {
|
|
84
|
+
const warmed = prefix ? await gate(prefix, signal) : () => {};
|
|
85
|
+
try {
|
|
86
|
+
const stream = streamSimple(model, { systemPrompt: system, messages }, {
|
|
87
|
+
// A shared session id is the OpenAI prompt-cache key; SSE because over a websocket Codex would chain unrelated parallel calls on one cached connection.
|
|
88
|
+
sessionId: 'optchat-compactor', transport: 'sse',
|
|
89
|
+
reasoning: thinking, signal, cacheRetention: 'short',
|
|
90
|
+
onPayload: payload => model.api === 'anthropic-messages' ? cachePayload(payload, { end: false }) : payload,
|
|
91
|
+
});
|
|
92
|
+
// The cache entry is usable once the model starts answering.
|
|
93
|
+
for await (const event of stream) if (event.type !== 'start') { warmed(event.type !== 'error'); break; }
|
|
94
|
+
return await stream.result();
|
|
95
|
+
} finally { warmed(false); }
|
|
96
|
+
};
|
|
97
|
+
const answer = () => retryTransient(ask, onUsage, retryable, retryDelays, signal);
|
|
98
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
99
|
+
const reply = await answer();
|
|
100
|
+
// A stop reason means the model answered: a refusal or a content filter, which asking again repeats.
|
|
101
|
+
// A provider's own 'error' stop (Mistral) is a failure on its side, not an answer: a plain error, tried again later.
|
|
102
|
+
if (reply.stopReason === 'error' && reply.rawStopReason !== undefined && reply.rawStopReason !== 'error' && !retryable(reply))
|
|
103
|
+
throw new Refused(reply.errorMessage ?? `Compactor stopped: ${reply.rawStopReason}`);
|
|
104
|
+
if (reply.stopReason === 'error' || reply.stopReason === 'aborted') throw new Error(reply.errorMessage ?? `Compactor ${reply.stopReason}`);
|
|
105
|
+
// A reply cut off at the token limit, or one that calls a tool, is no summary: ask again.
|
|
106
|
+
if (reply.stopReason === 'length' || reply.content.some(c => c.type === 'toolCall')) continue;
|
|
107
|
+
// The compactions' view shows each line under its id+n| head, which a line can copy.
|
|
108
|
+
const line = reply.content.filter(c => c.type === 'text').map(c => c.text).join('').trim().replace(/^\d+\+\d+\|\s*/, '');
|
|
109
|
+
if (!line) throw new Refused('Compactor returned no text.');
|
|
110
|
+
tries.push(line);
|
|
111
|
+
// A merge of two short lines can come back nearly as big as both, so a line must also shrink what it replaces.
|
|
112
|
+
if (bytes(line) <= accepted() && bytes(line) < bytes(input.source)) break;
|
|
113
|
+
messages.push(reply);
|
|
114
|
+
messages.push({ role: 'user', content: tooLong(bytes(line), cut(line)), timestamp: Date.now() });
|
|
115
|
+
}
|
|
116
|
+
if (!tries.length) throw new Refused('Compactor returned no whole line in 5 tries.');
|
|
117
|
+
return tries.reduce((a, b) => bytes(a) <= bytes(b) ? a : b);
|
|
118
|
+
};
|
|
119
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ImageContent } from '@earendil-works/pi-ai';
|
|
2
|
+
import type { Store } from './store.ts';
|
|
3
|
+
|
|
4
|
+
/** How a message's image reads in memory: `[image <16 hex digits>]`, named by the host (Pi: a hash of the image as sent). */
|
|
5
|
+
const REF = /\[image ([0-9a-f]{16})\]/g;
|
|
6
|
+
|
|
7
|
+
export const isImage = (part: unknown): part is ImageContent => typeof part === 'object' && part !== null
|
|
8
|
+
&& 'type' in part && part.type === 'image' && 'data' in part && typeof part.data === 'string' && 'mimeType' in part && typeof part.mimeType === 'string';
|
|
9
|
+
|
|
10
|
+
/** Base64 in chunks: one String.fromCharCode call per image would pass the engine's argument limit. */
|
|
11
|
+
const base64 = (data: Uint8Array) => {
|
|
12
|
+
let binary = '';
|
|
13
|
+
for (let i = 0; i < data.length; i += 0x8000) binary += String.fromCharCode(...data.subarray(i, i + 0x8000));
|
|
14
|
+
return btoa(binary);
|
|
15
|
+
};
|
|
16
|
+
/** An image's base64 as bytes. */
|
|
17
|
+
export const decode = (data: string) => Uint8Array.from(atob(data), char => char.charCodeAt(0));
|
|
18
|
+
|
|
19
|
+
/** The kept images a text refers to, in order, each once; ones never kept are skipped. */
|
|
20
|
+
export async function loadImages(store: Store, text: string): Promise<ImageContent[]> {
|
|
21
|
+
const names = [...new Set(Array.from(text.matchAll(REF), match => match[1]))];
|
|
22
|
+
return (await Promise.all(names.map(name => store.image(name))))
|
|
23
|
+
.flatMap(kept => kept ? [{ type: 'image' as const, data: base64(kept.data), mimeType: kept.mimeType }] : []);
|
|
24
|
+
}
|