pi-optchat 0.10.1 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/subagents.md +5 -1
- package/docs/victor-recipe.md +4 -4
- package/package.json +38 -12
- package/src/agent-view.ts +5 -6
- package/src/agents.ts +19 -12
- package/src/browser.ts +11 -7
- package/src/compactor.ts +10 -97
- package/src/connected-window.ts +4 -4
- 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 +26 -38
- package/src/inspector.ts +3 -1
- package/src/memory.ts +18 -416
- package/src/profiles.ts +3 -2
- package/src/prompts.ts +1 -149
- package/src/report-message.ts +5 -3
- package/src/runs.ts +6 -1
- package/src/settings.ts +3 -2
- package/src/store.ts +16 -42
- package/src/title.ts +3 -2
- package/src/tools.ts +5 -62
- package/src/transcript.ts +18 -101
- package/src/usage.ts +1 -1
- package/src/window-bridge.ts +9 -6
- /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 (`ctx.abort()`, as the example does), since the memory in RAM is still the old one. 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/subagents.md
CHANGED
|
@@ -14,6 +14,10 @@ Your MCP servers (`~/.pi/agent/mcp.json`, the project's `.pi/mcp.json`) work in
|
|
|
14
14
|
- If MCP is off in the main session (`--no-mcp`, `-builtin:mcp` in settings, or an extension that replaces `/mcp`), it is off in subagents too.
|
|
15
15
|
- Each subagent opens its own server connections (stdio servers start once per subagent) and closes them when it ends.
|
|
16
16
|
|
|
17
|
+
## Titles
|
|
18
|
+
|
|
19
|
+
A spawn task may carry a `title`, a short label the agent writes for you. The Agents page, the agent view, the agent picker of a second terminal and an attached window's tab show it instead of the task's first line, and the child's reports start `[id · title]`. A task without one shows its first line, as before. The title is saved with the run, so it survives `/reload`.
|
|
20
|
+
|
|
17
21
|
## Model and thinking level
|
|
18
22
|
|
|
19
23
|
Each task runs the profile's subagent model (`/optchat agents model`), unless the spawn sets that task's `model` (`provider/id`, as in `/model`) or `thinking`. The agent sets them only when you ask for them.
|
|
@@ -25,7 +29,7 @@ Each task runs the profile's subagent model (`/optchat agents model`), unless th
|
|
|
25
29
|
|
|
26
30
|
## Reports
|
|
27
31
|
|
|
28
|
-
Each child reports to its parent as soon as it finishes, as one message starting `[id]
|
|
32
|
+
Each child reports to its parent as soon as it finishes, as one message starting `[id]` (`[id · title]` with a title). The parent stays alive to receive reports; it never polls.
|
|
29
33
|
|
|
30
34
|
- A failed child reports its error text.
|
|
31
35
|
- A child whose request aborts with no stop (a provider abort) still reports `Task aborted: ...`.
|
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.0",
|
|
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,8 +8,9 @@ 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';
|
|
12
|
-
import { elapsed, fit, oneLine } from './inspector.ts';
|
|
11
|
+
import { record } from './core/cache.ts';
|
|
12
|
+
import { agentLabel, elapsed, fit, oneLine } from './inspector.ts';
|
|
13
|
+
import { isReport } from './report-message.ts';
|
|
13
14
|
|
|
14
15
|
export type ViewTone = 'accent' | 'muted' | 'dim' | 'error' | 'warning' | 'success' | 'borderMuted';
|
|
15
16
|
type ToolResult = Parameters<ToolExecutionComponent['updateResult']>[0];
|
|
@@ -23,14 +24,12 @@ const builtIns: Record<string, (cwd: string) => Renderers> = {
|
|
|
23
24
|
grep: createGrepToolDefinition, find: createFindToolDefinition, ls: createLsToolDefinition,
|
|
24
25
|
};
|
|
25
26
|
|
|
26
|
-
/** Reports from the child's own agents start with their id, e.g. "[1a2b3c4d] ...". */
|
|
27
|
-
const REPORT = /^\[[0-9a-f]{8}\] /;
|
|
28
27
|
/** Connected windows prefix the main agent's guidance with this line. */
|
|
29
28
|
const MAIN_PREFIX = '[Main agent guidance]\n';
|
|
30
29
|
|
|
31
30
|
/** Who a user-role message in the child's conversation came from. Runs saved before senders were recorded count as the user's. */
|
|
32
31
|
export function senderOf(text: string, guidance: Guidance = []): 'user' | 'manager' | 'report' {
|
|
33
|
-
if (
|
|
32
|
+
if (isReport(text)) return 'report';
|
|
34
33
|
if (text.startsWith(MAIN_PREFIX)) return 'manager';
|
|
35
34
|
return guidance.findLast(g => g.text === text)?.from === 'manager' ? 'manager' : 'user';
|
|
36
35
|
}
|
|
@@ -270,7 +269,7 @@ export class AgentView implements Component, Focusable {
|
|
|
270
269
|
const left = ` ${dot} ${color('accent', run.id)} `;
|
|
271
270
|
const room = width - visibleWidth(left) - visibleWidth(right) - 3;
|
|
272
271
|
if (room < 8) return truncateToWidth(`${left}${color('dim', right)}`, width);
|
|
273
|
-
const task = fit(
|
|
272
|
+
const task = fit(agentLabel(run), room);
|
|
274
273
|
return `${left}${task}${' '.repeat(Math.max(2, width - visibleWidth(left) - visibleWidth(task) - visibleWidth(right) - 1))}${color('dim', right)} `;
|
|
275
274
|
}
|
|
276
275
|
render(width: number): string[] {
|
package/src/agents.ts
CHANGED
|
@@ -6,19 +6,23 @@ 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';
|
|
14
|
-
import { CUT_OFF, RunHistory, isRunning, transition, sessionMessages, savedSession, undelivered, sentText, type Guidance, type RunInfo, type RunState, type FinishReason } from './runs.ts';
|
|
16
|
+
import { cachePayload } from './core/cache.ts';
|
|
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';
|
|
25
|
+
import { oneLine } from './inspector.ts';
|
|
22
26
|
|
|
23
27
|
export interface LiveRun {
|
|
24
28
|
session: AgentSession; info: RunInfo; updated: number; streaming?: AgentMessage;
|
|
@@ -72,9 +76,10 @@ const packageName = (path: string): string | undefined => {
|
|
|
72
76
|
/** A task's cwd may start with `~` and may be relative to the spawning agent's directory. */
|
|
73
77
|
export const taskDirectory = (cwd: string, path = '.', windows = process.platform === 'win32') =>
|
|
74
78
|
resolve(cwd, path.replace(windows ? /^~(?=$|[\\/])/ : /^~(?=$|\/)/, homedir()));
|
|
79
|
+
const TITLE_DOC = 'Short label shown to the user, e.g. "Fix steering bug"';
|
|
75
80
|
const CWD_DOC = 'Project directory the subagent works in (~ allowed); its AGENTS.md files load from there. Defaults to your current directory.';
|
|
76
81
|
/** The spawn tools' tasks. What a task leaves out of model and thinking is the profile's subagent choice. */
|
|
77
|
-
export const TASKS = Type.Array(Type.Object({ task: Type.String(), cwd: Type.Optional(Type.String({ description: CWD_DOC })),
|
|
82
|
+
export const TASKS = Type.Array(Type.Object({ task: Type.String(), title: Type.Optional(Type.String({ description: TITLE_DOC })), cwd: Type.Optional(Type.String({ description: CWD_DOC })),
|
|
78
83
|
model: Type.Optional(Type.String({ description: 'Leave unset for the profile\'s subagent model (named in your instructions). Set only when the user asks for a model, as provider/id from models().' })),
|
|
79
84
|
thinking: Type.Optional(StringEnum(THINKING, { description: 'Leave unset for the profile\'s subagent level. Set only when the user asks for one.' })) }), { minItems: 1 });
|
|
80
85
|
export type Task = Static<typeof TASKS>[number];
|
|
@@ -279,7 +284,7 @@ export class Children {
|
|
|
279
284
|
launch.then(done, done);
|
|
280
285
|
return launch;
|
|
281
286
|
}
|
|
282
|
-
private async launchBatch({ tasks, cwd, depth, parentId, connected, signal, cancelled }: { tasks:
|
|
287
|
+
private async launchBatch({ tasks, cwd, depth, parentId, connected, signal, cancelled }: { tasks: (Omit<Task, 'model' | 'thinking'> & { model: Model<Api>; thinking: ModelChoice['thinking'] })[]; cwd: string; depth: number;
|
|
283
288
|
parentId?: string; connected: boolean; signal?: AbortSignal; cancelled: () => boolean }) {
|
|
284
289
|
const launched: LiveRun[] = [];
|
|
285
290
|
let reserved = tasks.length;
|
|
@@ -291,7 +296,9 @@ export class Children {
|
|
|
291
296
|
const id = randomUUID().slice(0, 8), directory = taskDirectory(cwd, task.cwd);
|
|
292
297
|
const session = await this.open({ id, directory, depth, parentId, connected, model: task.model, thinking: task.thinking,
|
|
293
298
|
sessionManager: SessionManager.create(directory, join(this.profileDirectory, 'runs')) });
|
|
294
|
-
|
|
299
|
+
// One short line without brackets, so the `[id · title]` head of its reports stays parseable and small.
|
|
300
|
+
const title = oneLine(task.title ?? '').replace(/[[\]]/g, '').trim().slice(0, 128).trimEnd();
|
|
301
|
+
const info: RunInfo = { id, task: task.task, ...(title ? { title } : {}), cwd: directory, model: `${task.model.provider}/${task.model.id}`, thinking: session.thinkingLevel,
|
|
295
302
|
parentSession: this.options.parentSession ?? '', parentId, depth, sessionFile: session.sessionFile, started: Date.now(), state: 'running', guidance: [], ...(connected ? { connected: true } : {}) };
|
|
296
303
|
const live: LiveRun = { session, info, updated: Date.now(), tools: new Map(), pendingReports: [], pendingGuidance: [], steering: Promise.resolve() };
|
|
297
304
|
launched.push(live); this.save(info); this.running.set(id, live);
|
|
@@ -372,7 +379,7 @@ export class Children {
|
|
|
372
379
|
parameters: Type.Object({ message: Type.String() }), execute: async (_id: string, args: { message: string }) => {
|
|
373
380
|
const message = args.message.trim();
|
|
374
381
|
if (!message) throw new Error('Message is empty.');
|
|
375
|
-
const text =
|
|
382
|
+
const text = `${reportHead(this.history.records.get(id) ?? { id })} ${connected ? 'Connected agent message' : 'Message from subagent (still running)'}: ${message}`;
|
|
376
383
|
if (!await this.toParent(parentId, text)) throw new Error('Your parent is no longer running.');
|
|
377
384
|
return result(parentId ? `Message sent to parent ${parentId}.` : 'Message sent to the main agent.');
|
|
378
385
|
},
|
|
@@ -420,7 +427,7 @@ export class Children {
|
|
|
420
427
|
if (transition(info, 'paused')) {
|
|
421
428
|
this.save(info);
|
|
422
429
|
// In a grouped spawn the note takes the run's place, so its siblings' reports are not held until the user comes back; its later report arrives on its own.
|
|
423
|
-
const note =
|
|
430
|
+
const note = `${reportHead(info)} Interrupted by the user; it waits for their next message, so no report until then.`, batch = live.batch;
|
|
424
431
|
live.batch = undefined;
|
|
425
432
|
if (!info.connected) await (batch ? this.deliver(info, batch, note) : this.toParent(info.parentId, note))
|
|
426
433
|
.catch(error => this.warn(`Could not tell the parent about the interrupt: ${String(error)}`));
|
|
@@ -507,7 +514,7 @@ export class Children {
|
|
|
507
514
|
// A metadata failure must not suppress delivery of the actual result.
|
|
508
515
|
try { this.save(info); } catch (error) { this.warn(`Could not save run metadata: ${String(error)}`); }
|
|
509
516
|
// A deleted run's transcript is gone.
|
|
510
|
-
await this.deliver(info, live.batch,
|
|
517
|
+
await this.deliver(info, live.batch, `${reportHead(info)} ${info.report}${this.deleting.has(info.id) ? '' : `\n\n${fullChat(info.id)}`}`);
|
|
511
518
|
}
|
|
512
519
|
/** Sends a run's report to its parent; in a grouped spawn, only once every child of the spawn has one. */
|
|
513
520
|
private async deliver(info: RunInfo, batch: LiveRun['batch'], text: string) {
|
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,13 +86,13 @@ 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; };
|
|
89
93
|
function who(e) {
|
|
90
94
|
// Logs from before the work kind held reports as user messages starting "[id] "; another extension's shown custom message is tagged the same way.
|
|
91
|
-
if (e.kind === 'work' || e.kind === 'user' && /^\[[\w.:-]
|
|
95
|
+
if (e.kind === 'work' || e.kind === 'user' && /^\[[\w.:-]+(?: · [^\]\n]*)?\] /.test(e.text)) return ['Subagent', 'work', 'subagent report'];
|
|
92
96
|
if (e.kind === 'user') return ['You', 'you', 'from you'];
|
|
93
97
|
if (e.kind === 'talk') return ['OptChat', 'talk', 'reply'];
|
|
94
98
|
if (e.kind === 'tool') return ['Tool call', 'tool', 'tool step'];
|
|
@@ -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
|
+
} });
|
package/src/connected-window.ts
CHANGED
|
@@ -87,8 +87,8 @@ class LiveTurn implements Component {
|
|
|
87
87
|
export async function openConnectedWindow(pi: ExtensionAPI, ctx: ExtensionContext, profile: string, setTitle: (title: string) => void = title => ctx.ui.setTitle(title), agentId?: string, activeOnly = false) {
|
|
88
88
|
// Each window draws with its own transcript, so nothing from an earlier window carries over.
|
|
89
89
|
transcript = undefined;
|
|
90
|
-
let started = false, ended = false, liveTurn: LiveTurn | undefined, live: LiveState | undefined, turn: AgentMessage[] = [];
|
|
91
|
-
const title = (state: WindowState) => setTitle(windowTitle(profile, state));
|
|
90
|
+
let label: string | undefined, started = false, ended = false, liveTurn: LiveTurn | undefined, live: LiveState | undefined, turn: AgentMessage[] = [];
|
|
91
|
+
const title = (state: WindowState) => setTitle(windowTitle(profile, state, label));
|
|
92
92
|
const display = (text: string, details: Details = {}) => pi.sendMessage<Details>({ customType: 'optchat-connected', content: text, display: true, details }, { triggerTurn: false });
|
|
93
93
|
const refresh = () => { if (liveTurn) { liveTurn.turn = turn; liveTurn.live = live; liveTurn.update(); } };
|
|
94
94
|
/** A reply moves into the chat once all its tool calls have results, as Pi's own chat does when a tool finishes. */
|
|
@@ -103,8 +103,8 @@ export async function openConnectedWindow(pi: ExtensionAPI, ctx: ExtensionContex
|
|
|
103
103
|
const hideLive = () => { live = undefined; liveTurn = undefined; ctx.ui.setWidget('optchat-connected', undefined); };
|
|
104
104
|
const connection = await connectWindow(profilePath(profile), event => {
|
|
105
105
|
if (event.name === 'started') {
|
|
106
|
-
started = true; showLive(); title('working');
|
|
107
|
-
ctx.ui.setStatus('optchat', `OptChat: ${profile} · ${agentId ? 'attached' : 'connected'} agent ${event.text} · /complete`);
|
|
106
|
+
label = event.title; started = true; showLive(); title('working');
|
|
107
|
+
ctx.ui.setStatus('optchat', `OptChat: ${profile} · ${agentId ? 'attached' : 'connected'} agent ${event.text}${label ? ` · ${label}` : ''} · /complete`);
|
|
108
108
|
} else if (event.name === 'status') {
|
|
109
109
|
live = event.live; refresh();
|
|
110
110
|
// The tab says working exactly when the spinner spins.
|