pi-optchat 0.7.2 → 0.8.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 +36 -22
- package/THIRD_PARTY_NOTICES.md +2 -2
- package/docs/victor-recipe.md +5 -5
- package/package.json +2 -1
- package/src/agents.ts +78 -14
- package/src/browser.ts +5 -3
- package/src/cache.ts +14 -10
- package/src/checkpoint.ts +1 -1
- package/src/compactor.ts +9 -11
- package/src/handoff.ts +3 -3
- package/src/images.ts +44 -0
- package/src/import/job.ts +15 -9
- package/src/import/sources.ts +108 -14
- package/src/import/ui.ts +6 -4
- package/src/inbox.ts +15 -4
- package/src/index.ts +122 -41
- package/src/inspector.ts +19 -2
- package/src/memory.ts +196 -79
- package/src/profiles.ts +51 -32
- package/src/prompts.ts +191 -5
- package/src/runs.ts +16 -3
- package/src/settings-page.ts +2 -2
- package/src/settings.ts +12 -5
- package/src/tools.ts +10 -6
- package/src/transcript.ts +26 -9
- package/src/window-bridge.ts +76 -17
- package/src/import/guidance.ts +0 -1
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ A Pi extension that implements [Victor Taelin's OptChat recipe](https://gist.git
|
|
|
7
7
|
- **Memory**: every message is logged and summarized into a binary tree. Each turn starts from a fresh context holding a bounded memory view; the agent uses `zoom` and `date` to read originals.
|
|
8
8
|
- **Profiles**: separate memories and instructions, such as `work` and `personal`.
|
|
9
9
|
- **Subagents**: delegate tasks to background agents, inspect them live, and send them guidance.
|
|
10
|
-
- **Import**: bring in history from Claude Code (conversations and memories), Codex, or ChatGPT.
|
|
10
|
+
- **Import**: bring in history from Claude Code (conversations and memories), Codex, Pi and [OMP](https://github.com/can1357/oh-my-pi), or ChatGPT.
|
|
11
11
|
- **Connected windows**: a second Pi window on the same profile becomes a subagent you talk to directly.
|
|
12
12
|
|
|
13
13
|
It runs inside ordinary Pi, with no fork or separate launcher.
|
|
@@ -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.0.2 or compatible, Node.js 22.19+, and Git. Tested on macOS; the offline tests also run on Linux.
|
|
24
|
+
Requirements: Pi 1.0.2 or compatible, 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
|
|
|
@@ -35,7 +35,9 @@ To uninstall, run `pi remove git:github.com/jonaslsaa/pi-optchat`. Profile data
|
|
|
35
35
|
|
|
36
36
|
The footer shows the active profile. Memory follows the profile across directories and Pi sessions. New sessions show the profile picker with the last-used profile first; resumed sessions restore their profile.
|
|
37
37
|
|
|
38
|
-
For headless use, pass `--optchat-profile work`.
|
|
38
|
+
For headless use (`pi -p`, `--mode rpc`, other extensions' runners), pass `--optchat-profile work`. Without it, and without a saved profile in a resumed session, a headless run is plain Pi with no OptChat memory. If the requested profile can't open (misspelled, deleted, or open in another Pi), the run reports the error and doesn't answer.
|
|
39
|
+
|
|
40
|
+
If that profile is open in another Pi, `pi -p` joins it like a connected window: a subagent in that Pi answers, only its final reply goes to stdout, and the main agent gets the handoff. `--optchat-connect auto` (default) joins only when the profile is busy, `join` always joins (and fails if no Pi has the profile open), `off` reports the busy profile as an error. A failed join exits non-zero.
|
|
39
41
|
|
|
40
42
|
## Commands
|
|
41
43
|
|
|
@@ -43,7 +45,7 @@ For headless use, pass `--optchat-profile work`.
|
|
|
43
45
|
| --- | --- |
|
|
44
46
|
| `/optchat` | Status and actions menu. |
|
|
45
47
|
| `/optchat profile` | Select or create a profile. Switching starts a fresh Pi session. |
|
|
46
|
-
| `/optchat settings` | This profile's settings: models, subagent levels and limits, previous exchange, summary size tolerance. |
|
|
48
|
+
| `/optchat settings` | This profile's settings: models, subagent levels and limits, previous exchange, summary size tolerance, import summaries at once. |
|
|
47
49
|
| `/optchat model` | Compactor model and effort for this profile. Type to filter the models you are logged in to; the current one is marked. |
|
|
48
50
|
| `/optchat agents` | Live agent tree and saved run history. |
|
|
49
51
|
| `/optchat agents model` | Subagent model and effort for this profile, picked the same way. |
|
|
@@ -69,21 +71,22 @@ Compression and subagents make extra model requests with your provider credentia
|
|
|
69
71
|
|
|
70
72
|
## Settings
|
|
71
73
|
|
|
72
|
-
`/optchat settings` opens this profile's settings. Each row shows its value and default, the selected row says what it does and when a change applies, and a change is saved right away to the profile's `config.json`. Defaults follow Victor's recipe, except Previous exchange
|
|
74
|
+
`/optchat settings` opens this profile's settings. Each row shows its value and default, the selected row says what it does and when a change applies, and a change is saved right away to the profile's `config.json`. Defaults follow Victor's recipe, except Previous exchange, Summary size tolerance and Group subagent reports, which keep OptChat's earlier behaviour.
|
|
73
75
|
|
|
74
76
|
| Setting | Default | What it does |
|
|
75
77
|
| --- | --- | --- |
|
|
76
78
|
| Compactor model | Sonnet 5.5, medium | Same as `/optchat model`. Applies to the next summary. |
|
|
77
79
|
| Subagent model | Opus 5.5, high | Same as `/optchat agents model`. Applies to new subagents. |
|
|
78
|
-
| Subagent levels | 1 | 1: only the main agent starts subagents
|
|
80
|
+
| Subagent levels | 1 | 1: only the main agent starts subagents. 2 or more: subagents may start their own, that many levels deep. Applies to subagents started or resumed after the change. |
|
|
79
81
|
| Max active agents | 8 | Subagents running at once in the profile, all levels together, so it also caps how deep a chain can go. |
|
|
80
|
-
| Group subagent reports |
|
|
82
|
+
| Group subagent reports | off | Off: each subagent reports as soon as it finishes (the recipe). On: the subagents started by one spawn report together, in one message once the last of them finishes. Applies to the next spawn. |
|
|
81
83
|
| Previous exchange | on | Replays your last request and answer in full with the next turn (see below). Off is the recipe. |
|
|
82
84
|
| Previous exchange limit | 16 KB | A larger last exchange is left out. |
|
|
83
85
|
| Memory search | off | Gives the agent, and subagents started or resumed after the change, a `search` tool over your original messages (see below). Off is the recipe: zoom and date only. Applies from the next turn. |
|
|
84
86
|
| Summary size tolerance | 640 bytes | The compactor is always asked for 512-byte lines; a longer line up to this size is kept instead of retried. 512 is the recipe's strict rule. |
|
|
87
|
+
| Import: summaries at once | 8 | Summaries an import builds at once, 1 to 64. With N at once, a summary can see up to N−1 lines not summarized yet before it, so higher is faster but a little less accurate (at 8 that costs about 0.8 points per summary, #108). Higher also hits your provider's rate limits harder (the compactor retries what is refused). Chat always runs 8. Applies to the next import. |
|
|
85
88
|
|
|
86
|
-
Numbers must be whole numbers of at least 1 (512 for the summary size tolerance). Missing keys in an older `config.json` take their defaults.
|
|
89
|
+
Numbers must be whole numbers of at least 1 (512 for the summary size tolerance, at most 64 for Import: summaries at once). Missing keys in an older `config.json` take their defaults.
|
|
87
90
|
|
|
88
91
|
**Upgrading from 0.6.x:** subagent levels used to be fixed at 3 and now default to 1, so subagents no longer start their own subagents until you set Subagent levels to 2 or 3.
|
|
89
92
|
|
|
@@ -95,14 +98,14 @@ Ask in plain words, for example: "Spawn an agent to investigate this repository
|
|
|
95
98
|
|
|
96
99
|
- Children get the profile's memory view (frozen at launch), its instructions, read-only `zoom`/`date`, and normal coding tools plus your installed extensions.
|
|
97
100
|
- Children also get the Pi built-in extensions the main session loaded: MCP, codemode and tool search. Your MCP servers (`~/.pi/agent/mcp.json`, the project's `.pi/mcp.json`) work in subagents, with the sign-ins you made in Pi. 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. Each subagent opens its own server connections (stdio servers start once per subagent) and closes them when it ends.
|
|
98
|
-
-
|
|
99
|
-
- In the main chat, subagent messages and reports appear in a dark grey box labelled `↳ subagent <id> · still running` or `· report`, so they don't look like something you typed. The model still receives them as ordinary user messages. (One exception: reports recovered at startup, before your first message in the session, still show as plain user messages.)
|
|
101
|
+
- Each child reports to its parent as soon as it finishes, as one message starting `[id]`. A stopped or failed child reports its stop or error text. Turn **Group subagent reports** on to have the children of one spawn report together: when the last of them finishes, their reports reach the parent as one message, `[id] report` each, in spawn order. Each finished report is journaled right away, so if Pi dies before the rest finish, the reports it already has are delivered at the next start. Messages sent with `tell_parent`, connected windows and their handoffs are never held back, and a child resumed with `tell` reports on its own. The parent stays alive to receive reports; it never polls.
|
|
102
|
+
- In the main chat, subagent messages and reports appear in a dark grey box labelled `↳ subagent <id> · still running` or `· report`, so they don't look like something you typed. The model still receives them as ordinary user messages; memory logs them as kind `work` (older logs hold them as `user` messages starting `[id]`). (One exception: reports recovered at startup, before your first message in the session, still show as plain user messages.)
|
|
100
103
|
- The parent can send a running child guidance with `tell`, and the child can message its parent mid-run with `tell_parent` (a question, an early finding). It reaches the parent like a report, marked "still running": between tool calls if the parent is busy, or waking it if it's waiting.
|
|
101
104
|
- `tell` to a finished child resumes it: the same agent (ID, parent, model, directory) reopens its saved transcript, gets the message as a new prompt, and sends a new report. This also works for children from earlier Pi sessions. Only the agent that started the child can resume it, and the resumed child takes one active slot. Connected windows can't be resumed, and a child whose transcript is missing must be spawned fresh.
|
|
102
105
|
- By default only the main agent starts subagents. Set **Subagent levels** in `/optchat settings` to let them delegate further (3 means child, grandchild, great-grandchild).
|
|
103
106
|
- **Max active agents** (8 by default) caps how many agents can be active per profile, including parents waiting on descendants. Going over a limit returns an error; there is no queue.
|
|
104
107
|
- Stopping an agent stops its whole subtree. A failed parent stops its descendants.
|
|
105
|
-
- Agents run inside the Pi process. Closing Pi stops them; there is no detached mode.
|
|
108
|
+
- Agents run inside the Pi process. Closing Pi stops them; there is no detached mode. Subagents that were working when Pi closed, crashed or `/reload`ed are resumed from their saved transcripts when the profile next opens in interactive Pi, and the main agent is told which. Headless `pi -p` doesn't resume them; they wait for the next interactive start. Ones you stopped or paused aren't resumed.
|
|
106
109
|
|
|
107
110
|
## Agents, usage and activity inspector
|
|
108
111
|
|
|
@@ -118,7 +121,7 @@ An **Agents | Usage | Activity** bar sits below the input.
|
|
|
118
121
|
|
|
119
122
|
Set a different shortcut with `OPTCHAT_INSPECT_KEY=ctrl+shift+a pi`. If another extension supplies a custom editor, OptChat leaves its Down key alone; use the shortcut or commands instead.
|
|
120
123
|
|
|
121
|
-
**Agents** lists runs as a tree with state, elapsed time, current tool, and last activity. Navigate with **Up/Down**, **Page Up/Down**, **Home/End**, and press **M** to pick the subagent model.
|
|
124
|
+
**Agents** lists runs as a tree with state, elapsed time, current tool, and last activity. Navigate with **Up/Down**, **Page Up/Down**, **Home/End**, and press **M** to pick the subagent model. Press **D** twice to delete the selected agent and the agents it started, with their transcripts. An agent that is still working is stopped first, and its stop report reaches its parent before it is deleted. The report tells the parent that you dropped the task, so it does not delegate it again. The main agent can no longer resume a deleted agent. Deleting a connected window that is still open stops it like closing the window; its handoff still reaches the main agent, but the transcript the handoff points to is deleted.
|
|
122
125
|
|
|
123
126
|
**Enter** swaps the screen to that agent's conversation, drawn with Pi's own chat components, so it reads like the main chat: its task, replies, and collapsed tool calls, following live output. Typing and **Enter** send it guidance. Guidance from the main agent and reports from the agent's own agents show in labelled boxes, so only your own messages look typed. **Escape** goes back to the main chat.
|
|
124
127
|
|
|
@@ -137,7 +140,7 @@ Guidance shows as queued until delivered, or undelivered if the child stops firs
|
|
|
137
140
|
|
|
138
141
|

|
|
139
142
|
|
|
140
|
-
**Activity** is a memory gauge: how many messages the profile holds and how much of the 128 KB view they fill, then either **Settled** or **Catching up · 12 of 40 summaries** with a progress bar counted from when the backlog last grew from empty. If summarizing keeps failing, the last error and the retry countdown show under it. It also counts running agents, and interrupted ones waiting for you; their list is on Agents. While summaries or agents are at work, the bar's Activity item gets a **●**.
|
|
143
|
+
**Activity** is a memory gauge: how many messages the profile holds and how much of the 128 KB view they fill, then either **Settled** or **Catching up · 12 of 40 summaries** with a progress bar counted from when the backlog last grew from empty. If summarizing keeps failing, the last error and the retry countdown show under it. A turn waiting for summaries shows the same error next to its spinner, usually a summarizer model you aren't logged in to (`/optchat model`). Once every summary it waits for has failed, the turn goes on, with "(not summarized yet: zoom it)" in place of the missing lines, and the failed summaries keep retrying in the background. It also counts running agents, and interrupted ones waiting for you; their list is on Agents. While summaries or agents are at work, the bar's Activity item gets a **●**.
|
|
141
144
|
|
|
142
145
|

|
|
143
146
|
|
|
@@ -150,14 +153,14 @@ Guidance shows as queued until delivered, or undelivered if the child stops firs
|
|
|
150
153
|
|
|
151
154
|
Pick the destination profile, then run `/optchat import`.
|
|
152
155
|
|
|
153
|
-
1. **Source**: Claude Code (`~/.claude/projects`), Claude Code memories, Codex (`~/.codex/sessions`, `~/.codex/archived_sessions`), or a ChatGPT export (ZIP, folder, or `conversations.json`; ZIP
|
|
154
|
-
2. **Select**: for Claude Code, its memories, and
|
|
156
|
+
1. **Source**: Claude Code (`~/.claude/projects`), Claude Code memories, Codex (`~/.codex/sessions`, `~/.codex/archived_sessions`), Pi / OMP (`~/.pi/agent/sessions`, `~/.omp/agent/sessions`, and each OMP profile's `~/.omp/profiles/<name>/agent/sessions`; both write the same session format), or a ChatGPT export (ZIP, folder, or `conversations.json`; a ZIP is read with `unzip` on macOS/Linux and the built-in `tar.exe` on Windows). Scanning is local and makes no model calls.
|
|
157
|
+
2. **Select**: for Claude Code, its memories, Codex, and Pi / OMP, pick projects (busiest first), optionally filter by start date, then take all conversations or pick some. **Tab** toggles (and **Space** when the filter is empty), **Enter** continues, type to filter, **Ctrl+A**/**Ctrl+D** select/clear matches, **Esc** cancels. Nothing is classified as work or personal for you.
|
|
155
158
|
3. **Mode** (only if the profile already has history):
|
|
156
159
|
- **Append**: keep existing summaries and add the import. Faster and cheaper.
|
|
157
160
|
- **Rebuild by conversation start date**: regenerate the whole tree, ordered by conversation start.
|
|
158
161
|
4. **Preview**: destination, new and duplicate counts, text size, rough token estimate, and compactor. This is not a price quote: a big import costs about 3x that estimate in compactor input, because every message is summarized and then re-read in merges, each with the memory view as (mostly cached) context.
|
|
159
162
|
|
|
160
|
-
**What gets imported**: user messages and final assistant replies, with original dates and source labels, as in Victor's recipe. Tool calls and results, intermediate commentary, reasoning, subagent transcripts, replayed context, and image/audio/file bytes are left out. So is the output Claude Code logs for slash and shell commands; the command itself stays as typed (`/name args` or `!command`). So are the context messages Codex injects, such as the AGENTS.md instructions and the environment context. Dropped text never becomes a conversation's title. ChatGPT alternate branches are labelled as alternatives. Imported records are marked as historical so old requests are not treated as new instructions.
|
|
163
|
+
**What gets imported**: user messages and final assistant replies, with original dates and source labels, as in Victor's recipe. Tool calls and results, intermediate commentary, reasoning, subagent transcripts, replayed context, and image/audio/file bytes are left out. So is the output Claude Code logs for slash and shell commands; the command itself stays as typed (`/name args` or `!command`). So are the context messages Codex injects, such as the AGENTS.md instructions and the environment context, and the messages OMP injects (reminders, background job notices). Pi / OMP keep `/skill:name args`, `!command` and `$code` as typed, without the skill's text or the output; `!!` and `$$` commands, kept from the model, stay out. Dropped text never becomes a conversation's title. ChatGPT alternate branches are labelled as alternatives, and so are Pi / OMP branches left by a rewind (`[alternate branch]`). Pi sessions that ran under OptChat are skipped: their messages are already in a profile's memory. A forked session adds only its new messages. Imported records are marked as historical so old requests are not treated as new instructions.
|
|
161
164
|
|
|
162
165
|
**Claude Code memories**: the auto-memory topic files in `~/.claude/projects/*/memory/` (not `MEMORY.md`, which only indexes them), picked by project. Each file becomes one dated note in the memory tree, not part of the prompt. An edited file comes in again as a newer note.
|
|
163
166
|
|
|
@@ -208,28 +211,39 @@ Each profile folder is a local Git repository, committed after each turn and on
|
|
|
208
211
|
|
|
209
212
|
To delete a profile, delete its folder. Your original Pi sessions are kept in Pi's normal session directory.
|
|
210
213
|
|
|
214
|
+
## Windows
|
|
215
|
+
|
|
216
|
+
The same install and commands work on Windows, and the offline tests run there too. What differs:
|
|
217
|
+
|
|
218
|
+
- **Named pipes, not socket files.** The profile lock and the connected-window bridge listen on `\\.\pipe\optchat-<hash>-lock` and `optchat-<hash>-windows`, where the hash comes from the profile path: a Windows pipe has no filesystem entry inside the profile, so the hash is the only thing separating profiles. Pipes are machine-wide and carry no file permissions, so a Windows profile is not permission-protected the way a POSIX socket file (mode 0600) is.
|
|
219
|
+
- **ZIP exports** are read with Windows' own `tar.exe` (in System32 since Windows 10) instead of `unzip`; nothing is extracted to disk.
|
|
220
|
+
- **Home paths.** `~` in a subagent task directory is your home directory; `~\project` is read as a home path only on Windows.
|
|
221
|
+
- **File modes and fsync** (profile folder 0700, socket 0600, directory fsync after a rename) are POSIX-only and are skipped on Windows.
|
|
222
|
+
- Files are opened with `explorer.exe` instead of `open` or `xdg-open`.
|
|
223
|
+
|
|
211
224
|
## Good to know
|
|
212
225
|
|
|
213
226
|
- **Profiles separate memory and instructions only.** Agents keep full filesystem access and share provider credentials.
|
|
214
227
|
- **Tab title**: the terminal tab shows the profile and what it is doing: `π personal` while waiting for you, `● π personal` while the agent works, plus `· 2 agents` while subagents run. A connected window shows `↳ personal`, `● ↳ personal`, then `↳ personal · done` or `↳ personal · disconnected`. OptChat replaces Pi's default title and puts its own back when Pi resets it (new session, reload, rename).
|
|
215
228
|
- **Use worktrees** when parallel agents edit the same repository; they share the filesystem.
|
|
216
229
|
- **Instructions**: the main agent and subagents get Pi's usual global and repository `AGENTS.md` files and your skills, followed by the profile's `AGENTS.md`, which comes last and wins. OptChat replaces only Pi's opening prompt. Prompt templates work in the main session only.
|
|
217
|
-
- **Images**
|
|
230
|
+
- **Images** you paste, or a tool returns, are kept in the profile's memory folder under `images/` (once each, shrunk to 2,048 px on the long side and about 1.5 MB with Pi's own resizer; smaller ones stay as they are). The log names each as `[image <hash>]`, and `zoom(id, 1)` returns a message's images with its text, so the agent can look again in a later turn. Summaries stay text. Imported conversations keep a text placeholder.
|
|
218
231
|
- **Pi's auto-compaction is off.** A single very long run can still hit the model's context limit; stop it and continue in a new turn.
|
|
219
232
|
- **Restarts**: unsent inputs are recovered into memory, and pending subagent reports are delivered. Interrupted subagents are not restarted.
|
|
220
233
|
- **Prompt-template inputs** can be saved twice: expanded, and later in their original form as an unanswered input, because Pi expands them after the input journal records them. Skill commands (`/skill:name`) are matched back to their journaled input and don't have this problem. Plain text chat is unaffected.
|
|
221
234
|
|
|
222
235
|
## How it differs from the recipe
|
|
223
236
|
|
|
224
|
-
|
|
237
|
+
`src/prompts.ts` holds every prompt sent to a model (the recipe's four, the subagent instruction paragraphs, the compaction task and its retry, the handoff prompt, the import note), each with a comment on where it goes; tool descriptions stay with their tools. The recipe's prompts are lightly adapted. The main agent's turn rules are the revised recipe's (2026-10-08) word for word, without its paragraph on computers, with reports starting "[id] " instead of "[Name]", and with one added sentence: don't poll anything slow (CI, a deploy) yourself, hand the watching to a subagent; the view doc keeps the original's description of the view, drops its "No message appears in full" line (untrue with Previous exchange on) and its list of when to zoom, adds one sentence on zooming out from a message to the summaries above it, and ends with the revised recipe's rule that the view is the truth and zoom the only way through it; the compactor prompt is the revised recipe's intro, view and Compactions sections, with OptChat's kinds (`talk` for replies, subagent reports as `work`) and without its turn-only sections, and with its value list reworded so the size limit is a ceiling rather than a target (bare approvals are recorded as the decision they made, lines don't narrate what wasn't seen yet, and a tool result is described by what its output shows rather than by a cause guessed from an exit code or an empty match; an A/B found less filler and no recall loss, see #129); unlike the recipe, the compactor doesn't get your instructions, since an A/B found they added nothing (#112); and the subagent prompt is the original's plus a closing note on time: report progress on long tasks with tell_parent, and say so before waiting on something slow, so the main agent can go on. OptChat keeps the recipe's numbers: 512-byte summary nodes (by default summaries up to 640 bytes are accepted without a retry, as long as they are smaller than what they replace; see Summary size tolerance), a memory view that grows a line per message and, past 128,000 bytes, merges in one batch down to 64,000 (so between batches it only grows at its end and each turn reads the last one's view from the cache; the view is saved and reloaded as it was), binary merges, 8 compression workers, fixed retry delays, 5 shortening attempts, and a 30,000-character tool output cap. Compactions get their own view, as in the 2026-10-08 revision of the recipe: the memory view merged further, to between 16,000 and 32,000 bytes with the same sawtooth, ending at the line being built. A message's summary starts once fewer than 8 lines before it are unsummarized, so up to 8 compactions run at once on one cached view. An import logs its messages the same way, waiting only while 8 messages and due merges are unbuilt, so the merges keep up. Where the recipe ends a compaction's view at the first line not summarized yet, OptChat shows that line as a placeholder and keeps the lines after it: cutting there also hid the short lines that follow, such as an echo's own tool call, and an A/B on real turns scored those summaries lower (see #101). The compactor's task and its "Too long" retry are the recipe's, verbatim, with a 512-dash ruler for the size. Anthropic requests split the view into blocks of 4 lines with cache marks on the last whole one and on the blocks 20 and 40 before it, so a turn that adds up to 240 lines of tool calls and results still reads the last view from the cache, and when that view is not cached yet, one compactor call goes first and the others wait until it starts answering, so they read the cache instead of each writing it. See `docs/victor-recipe.md` for notes.
|
|
225
238
|
|
|
226
239
|
Each run's context is the memory view, the previous exchange, and your new message. Deliberate additions (the ones that change the recipe's behaviour are settings, see [Settings](#settings)):
|
|
227
240
|
|
|
228
241
|
1. **Previous exchange kept verbatim.** Your last request (with any steering) and the final answer are included in full, so "why is that?" refers to what you actually read. Tool calls and reasoning are not carried over. It comes on top of the 128,000-byte view. If it is over 16,000 bytes (about 4,000 tokens, usually a big paste; Previous exchange limit) it is left out entirely, and the model relies on the view and zoom as in Victor's recipe. A new Pi session starts with the memory view only.
|
|
229
242
|
2. **Subagents** are built in with Pi's SDK rather than a separate package. With Subagent levels above 1 they can delegate further.
|
|
230
|
-
3. **Memory search** (off by default). With the setting on, the agent also gets `search(text, before?)`: plain, case-insensitive text matching over the original messages, never the summaries (a summary can be wrong, and one fact repeats at every level of the tree), skipping logged zoom and search results. It returns 20 hits at a time, newest first, each with its id, the view line that holds it when that is a summary (`1234 (in 1024+256)`), its date and a snippet; `before: id` pages back, and `zoom(id, 1)` reads a hit. One line about it is added to the system prompt. Turning it on or off changes the cached prompt once.
|
|
231
|
-
4. **
|
|
232
|
-
5. **
|
|
243
|
+
3. **Memory search** (off by default). With the setting on, the agent also gets `search(text, before?)`: plain, case-insensitive text matching over the original messages, never the summaries (a summary can be wrong, and one fact repeats at every level of the tree), skipping logged zoom and search results. It returns 20 hits at a time, newest first, each with its id, the view line that holds it when that is a summary (`1234 (in 1024+256)`), its date and a snippet; `before: id` pages back, and `zoom(id, 1)` reads a hit. One line about it is added to the system prompt, and the rule that zoom is the only way through memory names search too. Turning it on or off changes the cached prompt once.
|
|
244
|
+
4. **Large messages.** `zoom(id, 1)` returns a message over 25,000 characters in pages, `[showing characters 0-25000 of 92000; next page: offset 25000]`, and `offset`/`limit` read any part of one (used on a shorter message, the page adds a note that it fits in one zoom); the recipe returns it whole, where the 30,000-character tool output cap cut out its middle.
|
|
245
|
+
5. **Profiles**, the **inspector**, the **usage ledger**, **import**, and **connected windows** are additions. Import adds historical-record guidance to the prompts.
|
|
246
|
+
6. **Not done**: computer use and hosting on an always-on machine.
|
|
233
247
|
|
|
234
248
|
## Development
|
|
235
249
|
|
|
@@ -261,6 +275,6 @@ The workflow checks that the tag matches `package.json`, runs the type check and
|
|
|
261
275
|
|
|
262
276
|
## Credits and license
|
|
263
277
|
|
|
264
|
-
Based on [Victor Taelin's OptChat recipe](https://gist.github.com/VictorTaelin/91837951a5ce5b38f341ec1ba1df6449) and [OptMem](https://github.com/VictorTaelin/OptMem). This is an independent Pi implementation, not Victor's official OptChat.
|
|
278
|
+
Based on [Victor Taelin's OptChat recipe](https://gist.github.com/VictorTaelin/91837951a5ce5b38f341ec1ba1df6449) and [OptMem](https://github.com/VictorTaelin/OptMem). His revised gist (2026-10-08) calls his own version UniiChat. This is an independent Pi implementation, not Victor's official OptChat.
|
|
265
279
|
|
|
266
280
|
MIT licensed. See [LICENSE](LICENSE) and [third-party notices](THIRD_PARTY_NOTICES.md).
|
package/THIRD_PARTY_NOTICES.md
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
## Victor Taelin's OptChat recipe
|
|
4
4
|
|
|
5
|
-
The OptChat memory design and the
|
|
6
|
-
come from Victor Taelin's publicly shared implementation recipe:
|
|
5
|
+
The OptChat memory design and the prompt strings in `src/prompts.ts`
|
|
6
|
+
(adapted) come from Victor Taelin's publicly shared implementation recipe:
|
|
7
7
|
|
|
8
8
|
https://gist.github.com/VictorTaelin/91837951a5ce5b38f341ec1ba1df6449
|
|
9
9
|
|
package/docs/victor-recipe.md
CHANGED
|
@@ -6,16 +6,16 @@ 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
|
|
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 the README lists, 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,
|
|
14
|
-
- `src/compactor.ts`: contextual compression and size retries.
|
|
15
|
-
- `src/cache.ts`:
|
|
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, 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
|
-
- `src/settings.ts`: per-profile settings for the departures from the recipe. Defaults are the recipe's (
|
|
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 grouped subagent reports (on, where the revised recipe reports each subagent on its own).
|
|
19
19
|
- `src/import/`: profile-scoped historical imports retain user messages and final assistant replies, following the lighter history described in recipe section 10. An import hands `Memory` one message at a time, each once the one before it is summarized, so the compactor sees the view a live chat would have shown it. Source adapters, final-reply detection, replay filtering, and ChatGPT branch labels are integration choices. Live-chat tool logging remains unchanged.
|
|
20
20
|
|
|
21
21
|
Profiles, native Pi UI, conversation import, and local Git checkpoints are integration choices described in the README.
|
package/package.json
CHANGED
package/src/agents.ts
CHANGED
|
@@ -6,12 +6,12 @@ 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 type { Api, Model } from '@earendil-works/pi-ai';
|
|
9
|
-
import { SUBAGENT, VIEW_DOC } from './prompts.ts';
|
|
10
|
-
import { memoryTools,
|
|
9
|
+
import { allowSearch, CONNECTED, cutOffChildren, delegation, NO_DELEGATION, NOT_CONNECTED, RESTARTED, SEARCH_DOC, STEERABLE, SUBAGENT, VIEW_DOC } from './prompts.ts';
|
|
10
|
+
import { memoryTools, searchTool } from './tools.ts';
|
|
11
11
|
import { type Memory } from './memory.ts';
|
|
12
12
|
import type { ModelChoice } from './compactor.ts';
|
|
13
13
|
import { cachePayload } from './cache.ts';
|
|
14
|
-
import { RunHistory, transition, sessionMessages, type RunInfo, type RunState, type FinishReason } from './runs.ts';
|
|
14
|
+
import { CUT_OFF, RunHistory, transition, sessionMessages, type RunInfo, type RunState, type FinishReason } from './runs.ts';
|
|
15
15
|
import { UsageLedger } from './usage.ts';
|
|
16
16
|
import { textContent } from './transcript.ts';
|
|
17
17
|
import { Type } from 'typebox';
|
|
@@ -55,8 +55,6 @@ export const taskDirectory = (cwd: string, path = '.', windows = process.platfor
|
|
|
55
55
|
resolve(cwd, path.replace(windows ? /^~(?=$|[\\/])/ : /^~(?=$|\/)/, homedir()));
|
|
56
56
|
export const CWD_DOC = 'Project directory the subagent works in (~ allowed); its AGENTS.md files load from there. Defaults to your current directory.';
|
|
57
57
|
const isOptchat = (path: string) => packageName(path) === 'pi-optchat';
|
|
58
|
-
/** Pi hands steering to a run only between tool calls, so one long command keeps the parent and the user from reaching it. */
|
|
59
|
-
export const STEERABLE = 'Never block in a single command for more than about 60 seconds. To wait for something, poll in short separate tool calls (for example one `sleep 30` per call), so messages from your parent or the user can reach you between calls.';
|
|
60
58
|
|
|
61
59
|
// Pi's CLI adds its built-in extensions (MCP, codemode, tool search) to its own session; SDK sessions such as
|
|
62
60
|
// subagents must add them. Pi versions that do not export a factory simply do not get that extension.
|
|
@@ -77,6 +75,7 @@ export class Children {
|
|
|
77
75
|
private closing = false;
|
|
78
76
|
private launching = 0;
|
|
79
77
|
private readonly resuming = new Set<string>();
|
|
78
|
+
private readonly deleting = new Set<string>();
|
|
80
79
|
private settling = 0;
|
|
81
80
|
private readonly launches = new Set<Promise<unknown>>();
|
|
82
81
|
private readonly completions = new Set<Promise<void>>();
|
|
@@ -216,9 +215,7 @@ export class Children {
|
|
|
216
215
|
thinking?: ModelChoice['thinking']; sessionManager: SessionManager }) {
|
|
217
216
|
const { id, directory, depth, parentId, connected } = o;
|
|
218
217
|
const { subagentLevels, maxAgents, memorySearch } = this.settings, delegates = depth < subagentLevels;
|
|
219
|
-
const
|
|
220
|
-
const instructions = [this.instructions(), delegation, STEERABLE, connected ? 'You are speaking directly with the user in a connected window. Continue this conversation across requests. Use tell_parent for questions or findings the main agent needs now. A handoff will be generated when the user completes or disconnects the window.'
|
|
221
|
-
: 'Use tell_parent only when your parent needs something now (a blocking question, an important early finding, or when asked to). Your final answer is delivered automatically; do not repeat it with tell_parent.'].filter(Boolean).join('\n\n');
|
|
218
|
+
const instructions = [this.instructions(), delegates ? delegation(maxAgents) : NO_DELEGATION, STEERABLE, connected ? CONNECTED : NOT_CONNECTED].filter(Boolean).join('\n\n');
|
|
222
219
|
// The user's settings list their installed packages; a copy in memory keeps the child from writing them back.
|
|
223
220
|
const settingsManager = SettingsManager.inMemory({ ...SettingsManager.create(directory, getAgentDir()).getSettings(), compaction: { enabled: false }, cacheWarming: 'off' });
|
|
224
221
|
const loader = new DefaultResourceLoader({ cwd: directory, agentDir: getAgentDir(), settingsManager,
|
|
@@ -229,7 +226,7 @@ export class Children {
|
|
|
229
226
|
if (provider) pi.registerProvider(o.provider, provider);
|
|
230
227
|
// Same prompt as the main agent (AGENTS.md files, skills, cwd); only the OptChat preamble differs.
|
|
231
228
|
pi.on('before_agent_start', event => {
|
|
232
|
-
event.systemPromptOptions.customPrompt = `${SUBAGENT}\n\n${VIEW_DOC}${memorySearch ? SEARCH_DOC : ''}
|
|
229
|
+
event.systemPromptOptions.customPrompt = allowSearch(`${SUBAGENT}\n\n${VIEW_DOC}${memorySearch ? SEARCH_DOC : ''}`, memorySearch);
|
|
233
230
|
event.systemPromptOptions.sections.instructions = instructions;
|
|
234
231
|
});
|
|
235
232
|
pi.on('before_provider_request', (event, ctx) => ctx.model?.api === 'anthropic-messages' ? cachePayload(event.payload) : event.payload);
|
|
@@ -361,6 +358,13 @@ export class Children {
|
|
|
361
358
|
// A failed dispose must neither keep the slot taken nor drop the report below.
|
|
362
359
|
this.dispose(session); this.running.delete(info.id);
|
|
363
360
|
}
|
|
361
|
+
if (info.cutOff) {
|
|
362
|
+
// Aborted by Pi closing, not by anyone's choice: no report now, the next start resumes it.
|
|
363
|
+
if (info.state === 'stopped' && transition(info, 'interrupted')) { info.report = CUT_OFF; this.save(info); return; }
|
|
364
|
+
delete info.cutOff; // It finished on its own while Pi was closing.
|
|
365
|
+
}
|
|
366
|
+
// Its cut-off children were named to it when it was resumed; having ended on its own, it no longer will resume them.
|
|
367
|
+
if (!this.closing) for (const run of this.history.descendants(info.id)) if (run.cutOff) { delete run.cutOff; this.save(run); }
|
|
364
368
|
if (info.connected) {
|
|
365
369
|
info.handoff ??= { reason: 'failed' };
|
|
366
370
|
transition(info, info.handoff.reason === 'complete' ? 'completed' : 'interrupted');
|
|
@@ -368,6 +372,8 @@ export class Children {
|
|
|
368
372
|
await this.deliverHandoff(info);
|
|
369
373
|
return;
|
|
370
374
|
}
|
|
375
|
+
// Said plainly, so the parent does not take the user's choice for a failure and delegate the task again.
|
|
376
|
+
if (this.deleting.has(info.id)) info.report = 'Deleted by the user while it was working. The user dropped this task on purpose: do not redo it or delegate it again unless they ask.';
|
|
371
377
|
// A metadata failure must not suppress delivery of the actual result.
|
|
372
378
|
try { this.save(info); } catch (error) { this.warn(`Could not save run metadata: ${String(error)}`); }
|
|
373
379
|
let text = `[${info.id}] ${info.report}`;
|
|
@@ -384,16 +390,17 @@ export class Children {
|
|
|
384
390
|
if (parent && parent.info.state !== 'stopping') { parent.pendingReports.push(text); this.changed(); }
|
|
385
391
|
return;
|
|
386
392
|
}
|
|
387
|
-
if (this.closing) this.memory.append('
|
|
393
|
+
if (this.closing) this.memory.append('work', text);
|
|
388
394
|
else try { await this.report(text, { count }); }
|
|
389
395
|
catch (error) {
|
|
390
|
-
this.memory.append('
|
|
396
|
+
this.memory.append('work', text);
|
|
391
397
|
this.warn(`Subagent report saved but could not wake the parent: ${String(error)}`);
|
|
392
398
|
}
|
|
393
399
|
if (batch && batch.ids.length > 1) this.options.hold?.(batch.ids[0], []);
|
|
394
400
|
}
|
|
395
401
|
/** `caller` is the agent sending a manager message: undefined for the main agent, else the parent subagent's ID. */
|
|
396
402
|
async tell(id: string, message: string, source: 'manager' | 'user' = 'manager', caller?: string) {
|
|
403
|
+
if (this.deleting.has(id)) throw new Error(`${id} is being deleted by the user.`);
|
|
397
404
|
const live = this.running.get(id);
|
|
398
405
|
if (!live && source === 'manager' && this.history.records.has(id)) return this.track(this.resume(id, message, caller));
|
|
399
406
|
if (live && !['running', 'waiting', 'paused'].includes(live.info.state)) throw new Error(`${id} is finishing. Its report will arrive on its own${live.info.connected || source === 'user' ? '' : '; tell it again after that to resume it'}.`);
|
|
@@ -488,15 +495,17 @@ export class Children {
|
|
|
488
495
|
await this.shutdown(session); this.dispose(session); throw new Error('Parent or profile is stopping.');
|
|
489
496
|
}
|
|
490
497
|
// The finished record stays untouched (and resumable) unless the new one is saved.
|
|
491
|
-
const { ended: _ended, ...rest } = run;
|
|
498
|
+
const { ended: _ended, cutOff: _cutOff, ...rest } = run;
|
|
499
|
+
const cut = [...this.history.records.values()].filter(r => r.parentId === id && r.cutOff).map(r => r.id);
|
|
500
|
+
const prompt = cutOffChildren(text, cut);
|
|
492
501
|
const info: RunInfo = { ...rest, state: 'running', started: Date.now(), parentSession: this.options.parentSession ?? run.parentSession,
|
|
493
|
-
guidance: [...run.guidance, { text, date: Date.now(), state: 'queued', from: 'manager' }] };
|
|
502
|
+
guidance: [...run.guidance, { text: prompt, date: Date.now(), state: 'queued', from: 'manager' }] };
|
|
494
503
|
try { this.save(info); } catch (error) { this.history.records.set(id, run); await this.shutdown(session); this.dispose(session); throw error; }
|
|
495
504
|
const live: LiveRun = { session, info, updated: Date.now(), tools: new Map(), pendingReports: [], pendingGuidance: [] };
|
|
496
505
|
this.running.set(id, live);
|
|
497
506
|
this.launching--; reserved = false;
|
|
498
507
|
session.subscribe(event => this.observe(live, event));
|
|
499
|
-
const work = this.execute(live,
|
|
508
|
+
const work = this.execute(live, prompt).catch(error => this.warn(`Subagent completion failed: ${String(error)}`))
|
|
500
509
|
.finally(() => { this.completions.delete(work); this.changed(); });
|
|
501
510
|
this.completions.add(work);
|
|
502
511
|
live.completion = work;
|
|
@@ -504,6 +513,35 @@ export class Children {
|
|
|
504
513
|
this.changed();
|
|
505
514
|
return `${id} had finished, so I resumed it with its earlier conversation. Its new report will come back on its own.`;
|
|
506
515
|
}
|
|
516
|
+
/**
|
|
517
|
+
* Resumes the main agent's subagents that the last Pi close or crash cut off, then tells the main agent once.
|
|
518
|
+
* Cut-off children of a cut-off parent wait for it: resuming it names them, and its `tell` resumes them.
|
|
519
|
+
*/
|
|
520
|
+
async resumeCutOff() {
|
|
521
|
+
const cut = [...this.history.records.values()].filter(run => run.cutOff).sort((a, b) => a.depth - b.depth || a.started - b.started).map(run => run.id);
|
|
522
|
+
const resumed: string[] = [], lines: string[] = [], orphans: string[] = [];
|
|
523
|
+
// Always the current record: a `tell` during startup may have resumed the run and replaced it meanwhile.
|
|
524
|
+
const current = (id: string) => { const run = this.history.records.get(id); return run?.cutOff ? run : undefined; };
|
|
525
|
+
const drop = (run: RunInfo) => { delete run.cutOff; this.save(run); };
|
|
526
|
+
for (const id of cut) {
|
|
527
|
+
const run = current(id);
|
|
528
|
+
if (!run) continue;
|
|
529
|
+
if (run.parentId) {
|
|
530
|
+
// Its parent was not cut off (it was paused, say) or could not be resumed: nobody else will mention it.
|
|
531
|
+
if (!this.history.records.get(run.parentId)?.cutOff && !this.running.has(run.parentId)) { orphans.push(`${run.id} (under ${run.parentId})`); drop(run); }
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
try { await this.track(this.resume(id, RESTARTED, undefined)); resumed.push(id); }
|
|
535
|
+
catch (error) {
|
|
536
|
+
const still = current(id);
|
|
537
|
+
if (!still) continue; // Resumed by someone else meanwhile.
|
|
538
|
+
lines.push(`Could not resume ${id}: ${error instanceof Error ? error.message : String(error)}`); drop(still);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
if (orphans.length) lines.push(`Also cut off, but not resumed because their parent agent is not running: ${orphans.join(', ')}.`);
|
|
542
|
+
if (!resumed.length && !lines.length) return;
|
|
543
|
+
await this.report([`Pi restarted while subagents were working.${resumed.length ? ` Resumed ${resumed.join(', ')} from where ${resumed.length > 1 ? 'they' : 'it'} left off; reports arrive as usual.` : ''}`, ...lines].join('\n'));
|
|
544
|
+
}
|
|
507
545
|
async finish(id: string, reason: FinishReason) {
|
|
508
546
|
const live = this.running.get(id);
|
|
509
547
|
if (live?.info.connected) {
|
|
@@ -565,8 +603,34 @@ export class Children {
|
|
|
565
603
|
}
|
|
566
604
|
await Promise.allSettled(stopping.map(run => run.session.abort()));
|
|
567
605
|
}
|
|
606
|
+
/** The user's delete: stops the run and its descendants if they still work, lets their reports reach their parents, then deletes their files. */
|
|
607
|
+
async remove(id: string) {
|
|
608
|
+
if (!this.history.records.has(id)) throw new Error(`No subagent ${id}.`);
|
|
609
|
+
if (this.deleting.has(id)) throw new Error(`${id} is already being deleted.`);
|
|
610
|
+
const runs = () => [id, ...this.history.descendants(id).map(run => run.id)], marked = new Set<string>();
|
|
611
|
+
// An overlapping delete of a descendant keeps its own marks, so neither delete clears the other's.
|
|
612
|
+
const mark = () => { for (const run of runs()) if (!this.deleting.has(run)) { marked.add(run); this.deleting.add(run); } };
|
|
613
|
+
mark(); this.changed();
|
|
614
|
+
try {
|
|
615
|
+
// Launches and resumes register their runs before they settle, and a stopped parent can still have children launching.
|
|
616
|
+
for (;;) {
|
|
617
|
+
await Promise.allSettled(this.launches);
|
|
618
|
+
const live = runs().flatMap(run => this.running.get(run) ?? []);
|
|
619
|
+
if (!live.length) break;
|
|
620
|
+
mark();
|
|
621
|
+
await Promise.allSettled(live.map(run => this.stop(run.info.id)));
|
|
622
|
+
await Promise.allSettled(live.map(run => run.completion));
|
|
623
|
+
}
|
|
624
|
+
mark();
|
|
625
|
+
const records = runs().flatMap(run => this.history.records.get(run) ?? []);
|
|
626
|
+
if (records.some(run => run.connected && !run.handoff?.delivered)) throw new Error(`${id} has a connected conversation whose handoff has not reached the main agent yet, so it was kept.`);
|
|
627
|
+
for (const run of records.reverse()) this.history.remove(run.id); // Children first, so a failure never leaves one without its parent.
|
|
628
|
+
} finally { for (const run of marked) this.deleting.delete(run); this.changed(); }
|
|
629
|
+
}
|
|
568
630
|
async close() {
|
|
569
631
|
this.closing = true;
|
|
632
|
+
// Only work in progress is picked up at the next start: not paused runs waiting for the user, runs being stopped, or connected windows (they hand off).
|
|
633
|
+
for (const { info } of this.running.values()) if (!info.connected && (info.state === 'running' || info.state === 'waiting')) info.cutOff = true;
|
|
570
634
|
await Promise.allSettled([...this.running.keys()].map(id => this.stop(id)));
|
|
571
635
|
await Promise.allSettled(this.launches);
|
|
572
636
|
await Promise.allSettled(this.completions);
|
package/src/browser.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
|
-
import { atomicWrite } from './
|
|
2
|
+
import { atomicWrite } from './memory.ts';
|
|
3
3
|
import { type Memory } from './memory.ts';
|
|
4
4
|
|
|
5
5
|
// One self-contained, read-only HTML snapshot of a profile's memory.
|
|
@@ -82,12 +82,14 @@ function when(a, b) {
|
|
|
82
82
|
if (lo === hi) return day(x) + ', ' + time(x);
|
|
83
83
|
return x.toDateString() === y.toDateString() ? day(x) + ', ' + time(x) + '–' + time(y) : day(x) + ' ' + time(x) + ' – ' + day(y) + ' ' + time(y);
|
|
84
84
|
}
|
|
85
|
-
const SOURCE = { claude: 'Claude Code', 'claude-memory': 'Claude Code memory', codex: 'Codex', chatgpt: 'ChatGPT' };
|
|
85
|
+
const SOURCE = { claude: 'Claude Code', 'claude-memory': 'Claude Code memory', codex: 'Codex', pi: 'Pi / OMP', chatgpt: 'ChatGPT' };
|
|
86
86
|
// Imported text starts with a "[Historical …]" line for the model; the meta line already says it.
|
|
87
87
|
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
88
|
const short = (s, n) => { s = s.replace(/\s+/g, ' ').trim(); return s.length > n ? s.slice(0, n - 1) + '…' : s; };
|
|
89
89
|
function who(e) {
|
|
90
|
-
|
|
90
|
+
// 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.:-]+\] /.test(e.text)) return ['Subagent', 'work', 'subagent report'];
|
|
92
|
+
if (e.kind === 'user') return ['You', 'you', 'from you'];
|
|
91
93
|
if (e.kind === 'talk') return ['OptChat', 'talk', 'reply'];
|
|
92
94
|
if (e.kind === 'tool') return ['Tool call', 'tool', 'tool step'];
|
|
93
95
|
if (e.kind === 'echo') return ['Tool result', 'tool', 'tool step'];
|
package/src/cache.ts
CHANGED
|
@@ -4,20 +4,24 @@ export function record(value: unknown): value is Record<string, unknown> {
|
|
|
4
4
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
-
/**
|
|
7
|
+
/** The view in blocks of 4 lines counted from its start (recipe §3.3), byte for byte. While the view only grows at its end,
|
|
8
|
+
* every block but the last is the same in the next call. */
|
|
8
9
|
export function splitView(text: string) {
|
|
9
10
|
const pieces: string[] = [];
|
|
10
11
|
let offset = 0;
|
|
11
|
-
for (
|
|
12
|
-
if (
|
|
13
|
-
const cut = text.lastIndexOf('\n', mark) + 1;
|
|
14
|
-
if (cut > offset) { pieces.push(text.slice(offset, cut)); offset = cut; }
|
|
15
|
-
}
|
|
12
|
+
for (let count = 0, at = text.indexOf('\n'); at >= 0; at = text.indexOf('\n', at + 1))
|
|
13
|
+
if (++count % 4 === 0) { pieces.push(text.slice(offset, at + 1)); offset = at + 1; }
|
|
16
14
|
pieces.push(text.slice(offset));
|
|
17
15
|
return pieces;
|
|
18
16
|
}
|
|
19
17
|
|
|
20
|
-
/**
|
|
18
|
+
/** How many blocks before the view's last whole one each mark sits. Anthropic looks back only 20 blocks from a mark for an
|
|
19
|
+
* earlier entry, so the marks 20 and 40 blocks back still find the last call's entry after a turn of tool calls adds up to
|
|
20
|
+
* 60 blocks (240 lines). With the end of the request, that is Anthropic's limit of 4 marks. */
|
|
21
|
+
const MARKS = [0, 20, 40];
|
|
22
|
+
|
|
23
|
+
/** Anthropic: marks on the view (see MARKS) plus automatic end-of-request caching, so the next call finds the last one's entry
|
|
24
|
+
* and pays only for the lines after it. */
|
|
21
25
|
export function cachePayload(payload: unknown): unknown {
|
|
22
26
|
if (!record(payload) || !Array.isArray(payload.messages)) return payload;
|
|
23
27
|
const messages = payload.messages;
|
|
@@ -26,14 +30,14 @@ export function cachePayload(payload: unknown): unknown {
|
|
|
26
30
|
if (!record(message) || message.role !== 'user' || !Array.isArray(message.content)) continue;
|
|
27
31
|
const at = message.content.findIndex((block: unknown) => record(block) && block.type === 'text' && typeof block.text === 'string' && isView(block.text));
|
|
28
32
|
if (at < 0) continue;
|
|
29
|
-
const pieces = splitView(message.content[at].text);
|
|
30
|
-
const blocks = pieces.map((text, j) => ({ type: 'text', text, ...(j
|
|
33
|
+
const pieces = splitView(message.content[at].text), marked = new Set(MARKS.map(back => pieces.length - 2 - back));
|
|
34
|
+
const blocks = pieces.map((text, j) => ({ type: 'text', text, ...(marked.has(j) ? { cache_control: { type: 'ephemeral' } } : {}) }));
|
|
31
35
|
message.content.splice(at, 1, ...blocks);
|
|
32
36
|
for (const block of blocks) view.add(block);
|
|
33
37
|
break;
|
|
34
38
|
}
|
|
35
39
|
if (!view.size) return payload;
|
|
36
|
-
// Pi's
|
|
40
|
+
// These marks only: Pi's own system, tool and recent-message marks are dropped.
|
|
37
41
|
for (const section of [payload.system, payload.tools]) {
|
|
38
42
|
if (Array.isArray(section)) for (const item of section) if (record(item)) delete item.cache_control;
|
|
39
43
|
}
|
package/src/checkpoint.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { execFile } from 'node:child_process';
|
|
|
2
2
|
import { promisify } from 'node:util';
|
|
3
3
|
import { existsSync } from 'node:fs';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
-
import { atomicWrite } from './
|
|
5
|
+
import { atomicWrite } from './memory.ts';
|
|
6
6
|
const exec = promisify(execFile);
|
|
7
7
|
|
|
8
8
|
export async function checkpoint(directory: string) {
|
package/src/compactor.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { clampThinkingLevel, type Api, type AssistantMessage, type Message, type Model } from '@earendil-works/pi-ai';
|
|
2
2
|
import type { ThinkingLevel } from '@earendil-works/pi-agent-core';
|
|
3
3
|
import type { ModelRegistry } from '@earendil-works/pi-coding-agent';
|
|
4
|
-
import { COMPACT } from './prompts.ts';
|
|
4
|
+
import { COMPACT, compaction, IMPORT_GUIDANCE, tooLong } from './prompts.ts';
|
|
5
5
|
import { bytes, NODE, type Compressor } from './memory.ts';
|
|
6
6
|
import { cachePayload, splitView } from './cache.ts';
|
|
7
|
-
import { IMPORT_GUIDANCE } from './import/guidance.ts';
|
|
8
7
|
import { DEFAULT_SETTINGS } from './settings.ts';
|
|
9
8
|
|
|
10
9
|
export interface ModelChoice { provider: string; model: string; thinking: ThinkingLevel }
|
|
@@ -13,16 +12,15 @@ export const reasoningFor = (model: Model<Api>, level: ThinkingLevel) => {
|
|
|
13
12
|
const thinking = clampThinkingLevel(model, level);
|
|
14
13
|
return thinking === 'off' ? undefined : thinking;
|
|
15
14
|
};
|
|
16
|
-
/** The size example: a line of exactly NODE bytes about OptChat itself. Every claim in it is true and timeless (no ids, PRs or decisions),
|
|
17
|
-
* so a summary that copies it states nothing false; the request also fences it off from the input. */
|
|
18
|
-
export const SCALE = 'note: How OptChat memory works. Each message becomes a leaf line: a short message is its own line, a longer one is compressed to about 512 bytes. Adjacent lines merge in pairs into a binary tree: two lines into one line covering both, two of those into one covering four, and so on. The view shows recent messages one per line and older ones more per line, within a fixed byte budget. zoom(id, n) opens line id+n into the two lines it was made from, down to the original message; date(id) tells when it was sent.';
|
|
19
15
|
const WARM_MS = 4 * 60_000; // Anthropic's short cache lives 5 minutes from its last use.
|
|
20
16
|
|
|
21
|
-
/** 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.
|
|
17
|
+
/** 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.
|
|
18
|
+
* Parallel compactions end their views at different messages, so a call also waits for a primer of a shorter prefix of its own view. */
|
|
22
19
|
function primeFirst() {
|
|
23
20
|
const warm = new Map<string, number | Promise<void>>();
|
|
24
21
|
return async (prefix: string, signal: AbortSignal) => {
|
|
25
|
-
for (
|
|
22
|
+
const priming = () => { for (const [k, state] of warm) if (typeof state !== 'number' && prefix.startsWith(k)) return state; };
|
|
23
|
+
for (let state = warm.get(prefix) ?? priming(); state !== undefined; state = warm.get(prefix) ?? priming()) {
|
|
26
24
|
if (typeof state === 'number') { if (Date.now() - state < WARM_MS) break; warm.delete(prefix); continue; }
|
|
27
25
|
// A cancelled waiter leaves at once instead of waiting for someone else's primer.
|
|
28
26
|
signal.throwIfAborted();
|
|
@@ -52,8 +50,7 @@ export function createCompressor(registry: ModelRegistry, choice: () => ModelCho
|
|
|
52
50
|
const model = registry.find(selected.provider, selected.model);
|
|
53
51
|
if (!model) throw new Error(`Compactor model unavailable: ${selected.provider}/${selected.model}. Use /optchat model.`);
|
|
54
52
|
const thinking = reasoningFor(model, selected.thinking);
|
|
55
|
-
|
|
56
|
-
const step = `${input.historical ? IMPORT_GUIDANCE + '\n\n' : ''}For scale only, here is an example line, not from this chat; it is exactly 512 bytes and is never part of your input or your line:\n<example>${SCALE}</example>\n\n${input.merge ? 'Merge these two lines into one' : 'Compress this message into one line'}, in at most 512 bytes:\n<input>\n${input.source}\n</input>`;
|
|
53
|
+
const step = `${input.historical ? IMPORT_GUIDANCE + '\n\n' : ''}${compaction(input)}`;
|
|
57
54
|
const messages: Message[] = [{ role: 'user', content: [{ type: 'text', text: input.context }, { type: 'text', text: step }], timestamp: Date.now() }];
|
|
58
55
|
const view = splitView(input.context);
|
|
59
56
|
const prefix = model.api === 'anthropic-messages' && view.length > 1 ? `${model.provider}/${model.id}/${thinking ?? 'off'}\n${view.slice(0, -1).join('')}` : undefined;
|
|
@@ -74,14 +71,15 @@ export function createCompressor(registry: ModelRegistry, choice: () => ModelCho
|
|
|
74
71
|
} finally { warmed(false); }
|
|
75
72
|
onUsage(reply);
|
|
76
73
|
if (reply.stopReason === 'error' || reply.stopReason === 'aborted') throw new Error(reply.errorMessage ?? `Compactor ${reply.stopReason}`);
|
|
77
|
-
|
|
74
|
+
// The compactions' view shows each line under its id+n| head, which a line can copy.
|
|
75
|
+
const line = reply.content.filter(c => c.type === 'text').map(c => c.text).join('').trim().replace(/^\d+\+\d+\|\s*/, '');
|
|
78
76
|
if (!line) throw new Error('Compactor returned no text.');
|
|
79
77
|
tries.push(line);
|
|
80
78
|
// A merge of two short lines can come back nearly as big as both, so a line must also shrink what it replaces.
|
|
81
79
|
if (bytes(line) <= accepted() && bytes(line) < bytes(input.source)) break;
|
|
82
80
|
messages.push(reply);
|
|
83
81
|
const cut = Buffer.from(line).subarray(0, NODE).toString('utf8').replace(/\uFFFD$/, '');
|
|
84
|
-
messages.push({ role: 'user', content:
|
|
82
|
+
messages.push({ role: 'user', content: tooLong(bytes(line), cut), timestamp: Date.now() });
|
|
85
83
|
}
|
|
86
84
|
return tries.reduce((a, b) => bytes(a) <= bytes(b) ? a : b);
|
|
87
85
|
};
|