pi-optchat 0.8.5 → 0.8.6

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 CHANGED
@@ -2,13 +2,13 @@
2
2
 
3
3
  # pi-optchat
4
4
 
5
- A Pi extension that implements [Victor Taelin's OptChat recipe](https://gist.github.com/VictorTaelin/91837951a5ce5b38f341ec1ba1df6449): one endless chat per profile, remembered through a summary tree instead of compaction.
5
+ A Pi extension that gives you one endless chat per profile, remembered through a summary tree instead of compaction. It implements [Victor Taelin's OptChat recipe](https://gist.github.com/VictorTaelin/91837951a5ce5b38f341ec1ba1df6449).
6
6
 
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.
7
+ - **Memory**: every message is kept, and each turn starts from a fresh context holding a bounded view of everything so far.
8
8
  - **Profiles**: separate memories and instructions, such as `work` and `personal`.
9
- - **Subagents**: delegate tasks to background agents, inspect them live, and send them guidance.
9
+ - **Subagents**: delegate tasks to background agents, watch them live, and send them guidance.
10
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
- - **Connected windows**: a second Pi window on the same profile becomes a subagent you talk to directly, or opens an existing background subagent with `--optchat-agent`.
11
+ - **Connected windows**: a second Pi window on the same profile becomes a subagent you talk to directly, or opens an existing background subagent.
12
12
 
13
13
  It runs inside ordinary Pi, with no fork or separate launcher.
14
14
 
@@ -35,11 +35,23 @@ 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 (`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.
38
+ ### Headless and RPC
39
39
 
40
- In RPC and SDK hosts that create sessions themselves (Pi Web), send `/optchat profile work` as the first message of a new session. A profile is open in one session at a time, so a second session on it gets "Profile already running".
40
+ - `pi -p`, `--mode rpc` and other extensions' runners need `--optchat-profile work`. Without it (and without a saved profile in a resumed session) the run is plain Pi with no OptChat memory.
41
+ - In hosts that create sessions themselves (Pi Web), send `/optchat profile work` as the first message of a new session.
42
+ - If the profile is open in another Pi, `pi -p` joins it as a connected window by default; `--optchat-connect auto|join|off` controls this.
41
43
 
42
- 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. A profile held by another `pi -p` (or JSON-mode) run can't be joined, since that run exits after its own prompt. Once it exits, `auto` opens the profile itself; `join` still fails, as no Pi has it open.
44
+ Details, errors and join rules: [docs/connected-windows.md](docs/connected-windows.md#headless-and-rpc-runs).
45
+
46
+ ## How it works
47
+
48
+ - Every message is logged and becomes one line: a short one (up to 512 bytes) word for word, a longer one summarized by the compactor model to about 512 bytes (up to 640 with the default Summary size tolerance).
49
+ - Pairs of lines merge into a binary tree of summaries: recent lines cover one message each, older lines cover more.
50
+ - Each turn starts from a fresh context: the memory view (the tree's lines, up to 128,000 bytes), your previous exchange, and your new message.
51
+ - When the view passes 128,000 bytes, lines merge in one batch down to 64,000, so between merges it only grows at the end and each turn reads the last one's view from the cache.
52
+ - The agent reads originals with `zoom` (open a line into the two it was made from, or a message in full) and `date`.
53
+
54
+ [Victor's recipe](https://gist.github.com/VictorTaelin/91837951a5ce5b38f341ec1ba1df6449) explains the design; [How it differs from the recipe](#how-it-differs-from-the-recipe) lists what OptChat changes.
43
55
 
44
56
  ## Commands
45
57
 
@@ -47,7 +59,7 @@ If that profile is open in another Pi, `pi -p` joins it like a connected window:
47
59
  | --- | --- |
48
60
  | `/optchat` | Status and actions menu. |
49
61
  | `/optchat profile` | Select or create a profile. Switching starts a fresh Pi session. |
50
- | `/optchat settings` | This profile's settings: models, subagent levels and limits, previous exchange, summary size tolerance, import summaries at once. |
62
+ | `/optchat settings` | This profile's [settings](#settings). |
51
63
  | `/optchat model` | Compactor model and effort for this profile. Type to filter the models you are logged in to; the current one is marked. |
52
64
  | `/optchat agents` | Live agent tree and saved run history. |
53
65
  | `/optchat agents model` | Subagent model and effort for this profile, picked the same way. |
@@ -73,7 +85,7 @@ Compression and subagents make extra model requests with your provider credentia
73
85
 
74
86
  ## Settings
75
87
 
76
- `/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.
88
+ `/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 Turn refresh threshold.
77
89
 
78
90
  | Setting | Default | What it does |
79
91
  | --- | --- | --- |
@@ -82,14 +94,14 @@ Compression and subagents make extra model requests with your provider credentia
82
94
  | 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. |
83
95
  | Max active agents | 8 | Subagents running at once in the profile, all levels together, so it also caps how deep a chain can go. |
84
96
  | 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. |
85
- | Previous exchange | on | Replays your last request and answer in full with the next turn (see below). Off is the recipe. |
97
+ | Previous exchange | on | Replays your last request and answer in full with the next turn. Off is the recipe. |
86
98
  | Previous exchange limit | 16 KB | A larger last exchange is left out. |
87
- | 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. |
99
+ | Memory search | off | Gives the agent, and subagents started or resumed after the change, a `search` tool over your original messages. Off is the recipe: zoom and date only. Applies from the next turn. |
88
100
  | 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. |
89
- | Turn refresh threshold | 30% | Once the current turn's verbatim messages pass this share of the model's context window, they move into memory and the turn goes on with a fresh window (see Good to know). Type 30, 30% or 0.3. 0% is off, as in the recipe. Applies from the next call. |
90
- | 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. |
101
+ | Turn refresh threshold | 30% | Once the current turn's verbatim messages pass this share of the model's context window, they move into memory and the turn goes on with a fresh window (see [Good to know](#good-to-know)). Type 30, 30% or 0.3. 0% is off, as in the recipe. Applies from the next call. |
102
+ | Summaries at once | 8 | Summaries built at once, by chat compaction and by an import, 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), so if your provider caps parallel requests, set it below that cap so the main turn has room. Applies to the next import, and to chat once the profile opens again. A `config.json` that still says `importJobs` keeps its value. |
91
103
 
92
- Numbers must be whole numbers of at least 1 (512 for the summary size tolerance, at most 64 for Import: summaries at once); Turn refresh threshold is a percentage from 0% to 90%. Missing keys in an older `config.json` take their defaults.
104
+ Numbers must be whole numbers of at least 1 (512 for the summary size tolerance, at most 64 for Summaries at once); Turn refresh threshold is a percentage from 0% to 90%. Missing keys in an older `config.json` take their defaults.
93
105
 
94
106
  **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.
95
107
 
@@ -99,18 +111,15 @@ Numbers must be whole numbers of at least 1 (512 for the summary size tolerance,
99
111
 
100
112
  Ask in plain words, for example: "Spawn an agent to investigate this repository and report back."
101
113
 
102
- - Children get the profile's memory view (frozen at launch), its instructions, read-only `zoom`/`date`, and normal coding tools plus your installed extensions.
103
- - 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.
104
- - Each child reports to its parent as soon as it finishes, as one message starting `[id]`. A failed child reports its error text, and one whose request aborts with no stop (a provider abort) still reports `Task aborted: ...`. A stopped child's report says who stopped it (you, the agent that started it, or Pi closing while it was paused) and that `tell` resumes it, followed by its last message when it has one. 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.
105
- - 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.)
106
- - Every report ends with `Full chat: zoom("<id>")`, so the agent can check it against what the child actually did: `zoom` with a run ID returns that child's chat so far as `kind|text` lines (its task without the memory view, replies, tool calls, tool results cut to their first and last ~500 characters, and what it was told), in 25,000-character pages that say which `offset` to go on from. It works on running children too; a subagent can read only its own descendants' chats, as with `tell`. The report box leaves the trailer out.
107
- - While any subagent runs, each new input to the main agent (your message, a report, a `tell_parent` message, the restart note, a connected window's message) ends with one status line, `[OptChat status] Agents at work now: 8f28e694 "Review PR #123 carefully", …` (id and the first words of each task), so it knows what is still out without asking. It is rebuilt each time and never stored. It isn't repeated on the calls after a tool result within one turn, and nothing is sent while no agent runs. The main prompt says what the label means, so the model doesn't take it for the user. It comes after the cached view and turn, with the turn's cache mark on the block before it, so it costs no cache.
108
- - 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.
109
- - `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.
110
- - 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).
111
- - **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.
112
- - Stopping an agent stops its whole subtree. A failed parent stops its descendants.
113
- - 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.
114
+ - **What they get**: the profile's memory view (frozen at launch) and instructions, read-only `zoom`/`date`, normal coding tools, your installed extensions, and your MCP servers.
115
+ - **Reports**: each child reports when it finishes, as one message starting `[id]`, shown in a dark grey `↳ subagent <id>` box. Turn on **Group subagent reports** to get one message per spawn instead.
116
+ - **Model**: children run the profile's subagent model. Ask for another in your request ("review it with anthropic/claude-haiku-5-5 at low") and the agent sets that task's `model` and `thinking`.
117
+ - **Talking**: the parent sends guidance with `tell`; the child messages its parent mid-run with `tell_parent`. `tell` to a finished child resumes it with its saved transcript.
118
+ - **Limits**: by default only the main agent starts subagents. **Subagent levels** lets them delegate further, and **Max active agents** (8) caps how many run at once; going over returns an error, there is no queue.
119
+ - **Stopping**: stopping an agent stops its whole subtree. Agents run inside the Pi process, so closing Pi stops them.
120
+ - **Restarts**: agents cut off by a close, crash or `/reload` resume when the profile next opens in interactive Pi, and the main agent is told which. Ones you stopped or paused stay stopped.
121
+
122
+ More (MCP inheritance, per-task models, report formats, the "agents at work" status line, resume rules): [docs/subagents.md](docs/subagents.md).
114
123
 
115
124
  ## Agents, usage and activity inspector
116
125
 
@@ -126,90 +135,61 @@ An **Agents | Usage | Activity** bar sits below the input.
126
135
 
127
136
  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.
128
137
 
129
- **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. Deleting a finished agent sends no report, so memory logs `[id] deleted by the user` right away, without a model turn. 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.
130
-
131
- **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.
138
+ **Agents** lists runs as a tree with state, elapsed time, current tool, and last activity. **M** picks the subagent model; **D** twice deletes the selected agent and the agents it started. **Enter** opens that agent's conversation, drawn like the main chat; type to send it guidance.
132
139
 
133
- | Key | Action |
140
+ | Key (in an agent's conversation) | Action |
134
141
  | --- | --- |
135
142
  | **Escape** | Clear a draft, else back to the main chat |
136
- | **Ctrl+C** | Clear a draft, else interrupt the agent's current step. Never ends the agent: queued messages go to it at once and it carries on; with none queued it waits for you (**interrupted · waiting for you**) until your next message, or a `tell` from the main agent, resumes it |
143
+ | **Ctrl+C** | Clear a draft, else interrupt the agent's current step. Never ends the agent; with nothing queued it waits for you |
137
144
  | **Up** (empty input) | Take your newest queued message back to edit; send it again, or clear it to drop it |
138
145
  | **Ctrl+X** twice | Stop this agent and the agents it started (the only key that ends it) |
139
146
  | **Page Up/Down**, mouse wheel | Scroll; back at the bottom it follows again. The wheel needs Pi's default fullscreen mode |
140
147
  | **Ctrl+O** | Expand tool output (Pi's own toggle) |
141
148
 
142
- Guidance shows as queued until delivered, or undelivered if the child stops first. When you interrupt an agent with nothing queued, the agent that started it gets a one-line note instead of a report, so it isn't left waiting. Guidance you send is also saved in main memory. Reasoning is not shown. Transcripts stay browsable after restart, and browsing them makes no model calls.
143
-
144
- **Usage** shows this session, last hour, today, last 7 days, or all time (**Left/Right**): one row per role and model (main agent, subagents, compactor, imports) with estimated cost, share of the total, output tokens, and how much input came from the cache. Costs are API prices, not your subscription bill.
149
+ **Usage** shows token use and estimated cost per role and model, for this session, last hour, today, last 7 days, or all time (**Left/Right**). Costs are API prices, not your subscription bill.
145
150
 
146
151
  ![Usage page](docs/screenshots/usage.png)
147
152
 
148
- **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 **●**.
153
+ **Activity** is a memory gauge: how full the 128 KB view is, whether summaries are **Settled** or **Catching up**, the last error if summarizing keeps failing, and running agents. The bar's Activity item gets a **●** while summaries or agents are at work.
149
154
 
150
155
  ![Activity page](docs/screenshots/activity.png)
151
156
 
152
- - Costs are API-rate estimates, not your subscription bill. Unknown rates show zero.
153
- - Record counts are not request counts; retries and tool overhead can add records.
154
- - Main-agent tracking starts with v0.3.0 (resumed sessions are backfilled). Older records without a parent session are left out of **This session**.
155
- - All views are limited to the active profile.
157
+ Details (deleting agents, interrupts, usage caveats, failed summaries): [docs/inspector.md](docs/inspector.md).
156
158
 
157
159
  ## Import history
158
160
 
159
161
  Pick the destination profile, then run `/optchat import`.
160
162
 
161
- 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.
162
- 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.
163
- 3. **Mode** (only if the profile already has history):
164
- - **Append**: keep existing summaries and add the import. Faster and cheaper.
165
- - **Rebuild by conversation start date**: regenerate the whole tree, ordered by conversation start.
166
- 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.
167
-
168
- **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`). A message typed to Claude Code while it was working counts as a user message; Claude Code's `[Request interrupted by user]` markers and the replies they cut off are left out. 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.
169
-
170
- **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.
171
-
172
- **Duplicates**: re-importing skips messages already present, even if titles or paths changed. A resumed Claude Code session copies earlier messages into its own file; those copies are matched by message id and text, so they come in once, also against messages an earlier import stored. Changed source messages can appear as a separate historical version.
163
+ 1. **Source**: Claude Code, Claude Code memories, Codex, Pi / OMP, or a ChatGPT export (ZIP, folder, or `conversations.json`). Scanning is local and makes no model calls.
164
+ 2. **Select**: for the local sources, pick projects (busiest first), optionally filter by start date, then take all conversations or pick some.
165
+ 3. **Mode** (only if the profile already has history): **Append** keeps existing summaries and adds the import (faster, cheaper); **Rebuild by conversation start date** regenerates the whole tree.
166
+ 4. **Preview**: destination, new and duplicate counts, text size, rough token estimate, and compactor. A big import costs about 3x that estimate in compactor input.
173
167
 
174
- **Pausing**: **Pause import** (or Escape) saves progress, and so does restarting Pi. `/optchat import` then offers **Resume** or **Discard staged import**. While an import is pending, chat in that profile is blocked; other profiles still work. Imports need the main agent and its subagents to be idle.
168
+ - Only user messages and final assistant replies come in, with original dates and source labels, marked as historical so old requests are not treated as new instructions. Tool calls, reasoning and file bytes are left out.
169
+ - Re-importing skips messages already present.
170
+ - **Pause import** (or Escape, or restarting Pi) saves progress; `/optchat import` then offers **Resume** or **Discard staged import**. Chat in that profile is blocked while an import is pending.
171
+ - Imports switch to the new memory only when the whole tree is ready; the previous one stays on disk. Source files are never modified.
175
172
 
176
- **Safety**: imports build a new memory generation and switch to it only when the whole tree is ready. The previous generation stays on disk. Source files are never modified.
173
+ Source paths, selection keys, exactly what is left out, and duplicate rules: [docs/import.md](docs/import.md).
177
174
 
178
- Damaged or unsupported records are listed before you start, so you can cancel or continue without them. Conversations that disappear during the scan are skipped with a warning.
175
+ ## Connected windows
179
176
 
180
- See OpenAI's guides on [exporting ChatGPT data](https://help.openai.com/en/articles/7260999-exporting-your-chatgpt-history-and-data) and the [conversation file format](https://help.openai.com/en/articles/9106926-transfer-exported-conversations-between-chatgpt-accounts).
177
+ Each profile is locked to one Pi process. Open the same profile in a second terminal and Pi offers to connect it to the original window as a subagent.
181
178
 
182
- ## Open an existing background agent in another terminal
179
+ - Your first message starts a subagent in the second window's working directory; later messages continue it. The window looks like a normal Pi chat.
180
+ - The subagent runs inside the original process, which stays the only writer of memory.
181
+ - `/tell-main <message>` messages the main agent. `/complete` ends the conversation and the compactor writes a handoff for the main agent; closing the window also produces one, marked **interrupted**.
182
+ - Text only. The connection is a local socket restricted to your OS user (on Windows a named pipe, without that restriction; see [Windows](#windows)); no daemon or server.
183
183
 
184
- When the profile is already open, `pi --optchat-profile work` in a second terminal offers **Start a connected subagent conversation here**, followed by **Open subagent <id> · <state> · <task>** for each running, waiting or paused background agent. You can also skip the menu with the ID shown in the main window's **Agents** page or a subagent report:
184
+ To open an existing background agent instead, pick it from the menu, or pass its ID:
185
185
 
186
186
  ```sh
187
187
  pi --optchat-profile work --optchat-agent 8ab62e89
188
188
  ```
189
189
 
190
- This opens that **same agent's session** as a normal Pi chat: its task, previous replies and tool results, then live output. Messages you type are guidance to that agent; a paused agent continues when you send one. `/tell-main` still contacts the main agent.
191
-
192
- - The original Pi window stays the only owner: it runs the agent, and reports still go to the agent's parent. Opening the window starts no new agent or model call.
193
- - **Closing the terminal or `/complete` only detaches.** The agent keeps running. Stop or interrupt it from the main window's **Agents** page, which can open the same agent at the same time, as can other terminals.
194
- - A finished agent opens for reading, without being restarted.
195
- - This needs interactive Pi and a running owner window. An unknown ID, or a conversation owned by another connected window, is refused rather than starting a new one. The attached terminal saves no session to resume; run the command again instead. The agent ID is not a Pi session ID.
196
-
197
- ## Connected windows
198
-
199
- Each profile is locked to one Pi process. If you open the same profile in a second terminal, Pi offers to connect it to the original window as a subagent, or to go back to the profile picker. Only an interactive or RPC Pi accepts connections; a headless `pi -p` run holding the profile does not.
190
+ This opens that same agent's session; what you type is guidance to it. Closing the terminal or `/complete` only detaches, and the agent keeps running.
200
191
 
201
- - Your first message starts a subagent in the second window's working directory. Later messages continue the same conversation.
202
- - The window looks like a normal Pi chat: replies, tool calls with their output and running time, the working spinner, and reports from the subagent's own agents in the dark box. Ctrl+O expands tool output.
203
- - The subagent runs inside the original process, which stays the only writer of memory. It appears in the original window's inspector and uses one of the 8 agent slots. While it is open, the original window can't switch profile or import.
204
- - The main agent is told when the conversation starts. Use `/tell-main <message>` to message it yourself; the subagent can use `tell_parent`, and the main agent replies with `tell`. Routine turns don't wake the main agent.
205
- - Run `/complete` when done. The window closes, remaining work stops, and the compactor writes a handoff for the main agent: decisions, changes, evidence, failures, unfinished work, and links to the transcripts.
206
- - Closing or force-quitting the window also produces a handoff, marked **interrupted**.
207
- - If the original window is closed cleanly, handoffs are delivered on next start. If it is killed, reopening the profile recovers unfinished handoffs (work is not restarted).
208
- - Closing the original window, or running `/reload` in it, ends every connected conversation; Pi gives no chance to warn before a reload. The connected window then shows **disconnected**. `/new` there starts over: after the profile picker if Pi was started without `--optchat-profile`, a profile still open elsewhere offers **Start a connected subagent conversation here** again, and a free one opens in this window.
209
- - Text only. For images, give the agent a file path.
210
- - The connection is a local socket restricted to your OS user. No daemon or server.
211
-
212
- Handoff limits: the whole transcript is summarized in one call if it fits in about 128,000 input tokens (estimated at 4 bytes per token; less on smaller models), otherwise in chunks. Output is up to 16,000 tokens, with a 5-minute timeout per call. If summarizing fails, a labelled fallback still reports the task, last result, and transcript locations.
192
+ Details (reload behaviour, handoff limits, attach rules, headless joins): [docs/connected-windows.md](docs/connected-windows.md).
213
193
 
214
194
  ## Storage
215
195
 
@@ -228,45 +208,54 @@ Profile data lives in `~/.optchat/profiles/<name>/` (override the root with `OPT
228
208
  | `usage.jsonl` | Usage ledger |
229
209
  | `memory.html` | Snapshot from `/optchat browse` |
230
210
 
231
- Each profile folder is a local Git repository, committed after each turn and on clean shutdown (memory and config; not runs, HTML, or usage). It has no remote, so it is not a backup. To back up, copy the folder while Pi is closed.
232
-
233
- To delete a profile, delete its folder. Your original Pi sessions are kept in Pi's normal session directory.
211
+ - Each profile folder is a local Git repository, committed after each turn. It has no remote, so it is not a backup: copy the folder while Pi is closed.
212
+ - To delete a profile, delete its folder. Your original Pi sessions are kept in Pi's normal session directory.
234
213
 
235
- Memory reaches disk through one seam, `Store` in `src/store.ts`: the log, summary nodes, saved view and images, which is what would travel between machines. Runs, recovery journals, imports and their pointer, usage, settings and `AGENTS.md` stay local files. `FileStore` (the layout above) is the only store today; another, such as Postgres, would implement the same few operations, and its lease (take, heartbeat, release) is optional: without one, OptChat keeps its machine-local profile lock. 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.
214
+ Git checkpoints and the pluggable `Store` seam (for memory on another machine): [docs/storage.md](docs/storage.md).
236
215
 
237
216
  ## Windows
238
217
 
239
218
  The same install and commands work on Windows, and the offline tests run there too. What differs:
240
219
 
241
- - **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.
242
- - **ZIP exports** are read with Windows' own `tar.exe` (in System32 since Windows 10) instead of `unzip`; nothing is extracted to disk.
243
- - **Home paths.** `~` in a subagent task directory is your home directory; `~\project` is read as a home path only on Windows.
244
- - **File modes and fsync** (profile folder 0700, socket 0600, directory fsync after a rename) are POSIX-only and are skipped on Windows.
220
+ - The profile lock and connected-window bridge use named pipes instead of socket files, so a Windows profile is not permission-protected the way a POSIX socket file is.
221
+ - ZIP exports are read with Windows' own `tar.exe` instead of `unzip`.
222
+ - `~` in a subagent task directory is your home directory; `~\project` is read as a home path only on Windows.
223
+ - File modes and directory fsync are POSIX-only and are skipped.
245
224
  - Files are opened with `explorer.exe` instead of `open` or `xdg-open`.
246
225
 
226
+ Pipe names and permissions: [docs/storage.md](docs/storage.md#windows-differences).
227
+
247
228
  ## Good to know
248
229
 
249
230
  - **Profiles separate memory and instructions only.** Agents keep full filesystem access and share provider credentials.
250
- - **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).
251
231
  - **Use worktrees** when parallel agents edit the same repository; they share the filesystem.
252
232
  - **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.
253
- - **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.
254
- - **Long turns** (#146): Pi's auto-compaction is off for the main agent, since memory replaces it. Instead, once the current turn's verbatim messages pass the Turn refresh threshold (30% of the model's window by default), they are flushed to memory, the view is rebuilt, and only the task message plus the last tool call and its result stay verbatim; the earlier steps are the view's last lines, a zoom away. In an A/B on a 200K model, turns that otherwise gave up around 100K tokens kept going and covered the whole task. `OPTCHAT_REFRESH_AT` (a fraction, e.g. `0.3`; `0` is off) overrides the setting. Subagent transcripts aren't in the summary tree, so subagents use Pi's own compaction instead, starting at about the same point.
255
- - **Restarts**: unsent inputs are recovered into memory, and pending subagent reports are delivered. Interrupted subagents are not restarted.
233
+ - **Tab title**: `π 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).
234
+ - **Images** you paste, or a tool returns, are kept under the profile's `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. Summaries stay text. Imported conversations keep a text placeholder.
235
+ - **Long turns** (#146): Pi's auto-compaction is off for the main agent, since memory replaces it. Once the current turn's verbatim messages pass the Turn refresh threshold (30% of the model's window by default), they are flushed to memory, the view is rebuilt, and only the task message plus the last tool call and its result stay verbatim; the earlier steps are the view's last lines, a zoom away. In an A/B on a 200K model, turns that otherwise gave up around 100K tokens kept going and covered the whole task. `OPTCHAT_REFRESH_AT` (a fraction, e.g. `0.3`; `0` is off) overrides the setting. Subagents use Pi's own compaction instead, starting at about the same point.
236
+ - **Restarts**: unsent inputs are recovered into memory, and pending subagent reports are delivered.
256
237
  - **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.
257
238
 
258
239
  ## How it differs from the recipe
259
240
 
260
- `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 two added sentences: don't poll anything slow (CI, a deploy) yourself, hand the watching to a subagent, and a message starting "[OptChat status]" is OptChat's own note, not the user; 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.
261
-
262
- 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)):
263
-
264
- 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.
265
- 2. **Subagents** are built in with Pi's SDK rather than a separate package. With Subagent levels above 1 they can delegate further.
266
- 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.
267
- 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.
268
- 5. **Profiles**, the **inspector**, the **usage ledger**, **import**, and **connected windows** are additions. Import adds historical-record guidance to the prompts.
269
- 6. **Not done**: computer use and hosting on an always-on machine.
241
+ OptChat keeps the recipe's numbers: 512-byte lines, binary merges, the 128,000 → 64,000-byte view, 8 compression workers, 5 shortening attempts, a 30,000-character tool output cap. The ones you can turn off or tune are [settings](#settings). Prompt-by-prompt detail: [docs/recipe-differences.md](docs/recipe-differences.md).
242
+
243
+ | Area | Recipe | OptChat | Why |
244
+ | --- | --- | --- | --- |
245
+ | Prompts | The original and 2026-10-08 prompts | The same, lightly adapted, all in `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
+ | Compactor instructions | Gets your instructions | Doesn't | An A/B found they added nothing (#112) |
247
+ | Summary size | Strict 512 bytes | Asked for 512, keeps up to 640 without a retry | Saves retries for a little view space |
248
+ | Memory view | Sawtooth, 128,000 → 64,000 bytes | Same, saved to `view.json` and reloaded as it was | |
249
+ | Compaction view | Ends at the first unsummarized line | Shows that line as a placeholder and keeps the lines after it | Cutting there hid short lines like an echo's own tool call; summaries scored lower (#101) |
250
+ | Tool-result summaries | Result only | Also sees the raw tool call | Recall 3.88 → 4.22 (#153) |
251
+ | Cache marks | View in 4-line blocks, one mark on the last whole block | Two more marks, 20 and 40 blocks before it | With one mark, a turn that added over 80 lines made the next turn rewrite the whole view; now up to 240 lines still read from cache |
252
+ | Previous exchange | Memory view only | Last request and answer in full (up to 16 KB) | "Why is that?" refers to what you actually read |
253
+ | Subagents | Reports start `[Name]` | Built in with Pi's SDK, not a separate package; reports start `[id]`; Subagent levels setting (default 1) | |
254
+ | Memory search | Not there | Opt-in `search` over original messages | Exact names, numbers and errors the view doesn't show |
255
+ | Large messages | Returned whole, middle cut by the 30,000-character cap | `zoom(id, 1)` pages at 25,000 characters | Nothing cut out |
256
+ | Long turns | No limit; a long turn can run out of window | Turn refresh at 30% of the window | Turns kept going past ~100K tokens (#146) |
257
+ | Profiles, inspector, usage, import, connected windows | Not there | Added | |
258
+ | Computer use, always-on hosting | In the recipe | Not done | |
270
259
 
271
260
  ## Development
272
261
 
@@ -298,6 +287,6 @@ The workflow checks that the tag matches `package.json`, runs the type check and
298
287
 
299
288
  ## Credits and license
300
289
 
301
- 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.
290
+ 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. See [docs/victor-recipe.md](docs/victor-recipe.md) for how the code maps to the recipe.
302
291
 
303
292
  MIT licensed. See [LICENSE](LICENSE) and [third-party notices](THIRD_PARTY_NOTICES.md).
@@ -0,0 +1,63 @@
1
+ # Connected windows, background agents and headless runs
2
+
3
+ Each profile is locked to one Pi process. This page covers the ways a second Pi can reach a profile that is already open.
4
+
5
+ ## Connected windows
6
+
7
+ If you open the same profile in a second terminal, Pi offers to connect it to the original window as a subagent, or to go back to the profile picker. Only an interactive or RPC Pi accepts connections; a headless `pi -p` run holding the profile does not.
8
+
9
+ - Your first message starts a subagent in the second window's working directory. Later messages continue the same conversation.
10
+ - The window looks like a normal Pi chat: replies, tool calls with their output and running time, the working spinner, and reports from the subagent's own agents in the dark box. Ctrl+O expands tool output.
11
+ - The subagent runs inside the original process, which stays the only writer of memory. It appears in the original window's inspector and takes one of the profile's active agent slots (Max active agents, 8 by default). While it is open, the original window can't switch profile or import.
12
+ - The main agent is told when the conversation starts. Use `/tell-main <message>` to message it yourself; the subagent can use `tell_parent`, and the main agent replies with `tell`. Routine turns don't wake the main agent.
13
+ - Text only. For images, give the agent a file path.
14
+ - The connection is a local socket restricted to your OS user. No daemon or server. On Windows it is a machine-wide named pipe without file permissions, so it has no such restriction (see [storage.md](storage.md#windows-differences)).
15
+
16
+ ### Ending a conversation
17
+
18
+ - Run `/complete` when done. The window closes, remaining work stops, and the compactor writes a handoff for the main agent: decisions, changes, evidence, failures, unfinished work, and links to the transcripts.
19
+ - Closing or force-quitting the window also produces a handoff, marked **interrupted**.
20
+ - If the original window is closed cleanly, handoffs are delivered on next start. If it is killed, reopening the profile recovers unfinished handoffs (work is not restarted).
21
+ - Closing the original window, or running `/reload` in it, ends every connected conversation; Pi gives no chance to warn before a reload. The connected window then shows **disconnected**. `/new` there starts over: after the profile picker if Pi was started without `--optchat-profile`, a profile still open elsewhere offers **Start a connected subagent conversation here** again, and a free one opens in this window.
22
+
23
+ ### Handoff limits
24
+
25
+ The whole transcript is summarized in one call if it fits in about 128,000 input tokens (estimated at 4 bytes per token; less on smaller models), otherwise in chunks. Output is up to 16,000 tokens, with a 5-minute timeout per call. If summarizing fails, a labelled fallback still reports the task, last result, and transcript locations.
26
+
27
+ ## Open an existing background agent in another terminal
28
+
29
+ When the profile is already open, `pi --optchat-profile work` in a second terminal offers **Start a connected subagent conversation here**, followed by **Open subagent <id> · <state> · <task>** for each running, waiting or paused background agent. You can also skip the menu with the ID shown in the main window's **Agents** page or a subagent report:
30
+
31
+ ```sh
32
+ pi --optchat-profile work --optchat-agent 8ab62e89
33
+ ```
34
+
35
+ This opens that **same agent's session** as a normal Pi chat: its task, previous replies and tool results, then live output. Messages you type are guidance to that agent; a paused agent continues when you send one. `/tell-main` still contacts the main agent.
36
+
37
+ - The original Pi window stays the only owner: it runs the agent, and reports still go to the agent's parent. Opening the window starts no new agent or model call.
38
+ - **Closing the terminal or `/complete` only detaches.** The agent keeps running. Stop or interrupt it from the main window's **Agents** page, which can open the same agent at the same time, as can other terminals.
39
+ - A finished agent opens for reading, without being restarted.
40
+ - This needs interactive Pi and a running owner window. An unknown ID, or a conversation owned by another connected window, is refused rather than starting a new one.
41
+ - The attached terminal saves no session to resume; run the command again instead. The agent ID is not a Pi session ID.
42
+
43
+ ## Headless and RPC runs
44
+
45
+ For headless use (`pi -p`, `--mode rpc`, other extensions' runners), pass `--optchat-profile work`.
46
+
47
+ - Without it, and without a saved profile in a resumed session, a headless run is plain Pi with no OptChat memory.
48
+ - If the requested profile can't open (misspelled, deleted, or busy and not joinable, see below), the run reports the error and doesn't answer.
49
+
50
+ In RPC and SDK hosts that create sessions themselves (Pi Web), send `/optchat profile work` as the first message of a new session. A profile is open in one session at a time, so a second session on it gets "Profile already running".
51
+
52
+ ### Joining a busy profile with `pi -p`
53
+
54
+ If the 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` picks when:
55
+
56
+ | Value | Behaviour |
57
+ | --- | --- |
58
+ | `auto` (default) | Joins only when the profile is busy. |
59
+ | `join` | Always joins, and fails if no Pi has the profile open. |
60
+ | `off` | Reports the busy profile as an error. |
61
+
62
+ - A failed join exits non-zero.
63
+ - A profile held by another `pi -p` (or JSON-mode) run can't be joined, since that run exits after its own prompt. Once it exits, `auto` opens the profile itself; `join` still fails, as no Pi has it open.
package/docs/import.md ADDED
@@ -0,0 +1,74 @@
1
+ # Import in detail
2
+
3
+ The README has the steps. This page lists what each source brings in and how duplicates, pauses and failures are handled.
4
+
5
+ ## Sources
6
+
7
+ | Source | Where it reads |
8
+ | --- | --- |
9
+ | Claude Code | `~/.claude/projects` |
10
+ | Claude Code memories | `~/.claude/projects/*/memory/` |
11
+ | Codex | `~/.codex/sessions`, `~/.codex/archived_sessions` |
12
+ | Pi / OMP | `~/.pi/agent/sessions`, `~/.omp/agent/sessions`, and each OMP profile's `~/.omp/profiles/<name>/agent/sessions` (both write the same session format) |
13
+ | ChatGPT export | A ZIP, folder, or `conversations.json`. A ZIP is read with `unzip` on macOS/Linux and the built-in `tar.exe` on Windows; nothing is extracted to disk. |
14
+
15
+ Scanning is local and makes no model calls.
16
+
17
+ ## Selecting
18
+
19
+ 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.
20
+
21
+ | Key | Action |
22
+ | --- | --- |
23
+ | **Tab** (and **Space** when the filter is empty) | Toggle |
24
+ | **Enter** | Continue |
25
+ | Typing | Filter |
26
+ | **Ctrl+A** / **Ctrl+D** | Select / clear matches |
27
+ | **Esc** | Cancel |
28
+
29
+ Nothing is classified as work or personal for you.
30
+
31
+ ## Cost
32
+
33
+ The preview shows a rough token estimate. 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.
34
+
35
+ ## What gets imported
36
+
37
+ User messages and final assistant replies, with original dates and source labels, as in Victor's recipe. Imported records are marked as historical so old requests are not treated as new instructions.
38
+
39
+ Left out:
40
+
41
+ - Tool calls and results, intermediate commentary, reasoning, subagent transcripts, replayed context, and image/audio/file bytes. Imported conversations keep a text placeholder for images.
42
+ - The output Claude Code logs for slash and shell commands. The command itself stays as typed (`/name args` or `!command`).
43
+ - Claude Code's `[Request interrupted by user]` markers and the replies they cut off. A message typed to Claude Code while it was working counts as a user message.
44
+ - The context messages Codex injects, such as the AGENTS.md instructions and the environment context.
45
+ - The messages OMP injects (reminders, background job notices).
46
+ - For Pi / OMP, the skill's text and command output: `/skill:name args`, `!command` and `$code` stay as typed. `!!` and `$$` commands, kept from the model, stay out.
47
+ - Pi sessions that ran under OptChat: their messages are already in a profile's memory.
48
+
49
+ Also:
50
+
51
+ - Dropped text never becomes a conversation's title.
52
+ - ChatGPT alternate branches are labelled as alternatives, and so are Pi / OMP branches left by a rewind (`[alternate branch]`).
53
+ - A forked session adds only its new messages.
54
+
55
+ ## Claude Code memories
56
+
57
+ 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.
58
+
59
+ ## Duplicates
60
+
61
+ Re-importing skips messages already present, even if titles or paths changed. A resumed Claude Code session copies earlier messages into its own file; those copies are matched by message id and text, so they come in once, also against messages an earlier import stored. Changed source messages can appear as a separate historical version.
62
+
63
+ ## Pausing
64
+
65
+ **Pause import** (or Escape) saves progress, and so does restarting Pi. `/optchat import` then offers **Resume** or **Discard staged import**. While an import is pending, chat in that profile is blocked; other profiles still work. Imports need the main agent and its subagents to be idle.
66
+
67
+ ## Safety
68
+
69
+ - Imports build a new memory generation and switch to it only when the whole tree is ready. The previous generation stays on disk.
70
+ - Source files are never modified.
71
+ - Damaged or unsupported records are listed before you start, so you can cancel or continue without them.
72
+ - Conversations that disappear during the scan are skipped with a warning.
73
+
74
+ See OpenAI's guides on [exporting ChatGPT data](https://help.openai.com/en/articles/7260999-exporting-your-chatgpt-history-and-data) and the [conversation file format](https://help.openai.com/en/articles/9106926-transfer-exported-conversations-between-chatgpt-accounts).
@@ -0,0 +1,44 @@
1
+ # Inspector in detail
2
+
3
+ The README has the keys. This page covers what each page shows and the edge cases.
4
+
5
+ ## Agents
6
+
7
+ Runs are listed 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.
8
+
9
+ ### Deleting an agent
10
+
11
+ Press **D** twice to delete the selected agent and the agents it started, with their transcripts.
12
+
13
+ - 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.
14
+ - Deleting a finished agent sends no report, so memory logs `[id] deleted by the user` right away, without a model turn.
15
+ - The main agent can no longer resume a deleted agent.
16
+ - 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.
17
+
18
+ ### An agent's conversation
19
+
20
+ **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.
21
+
22
+ - **Ctrl+C** never ends the agent: queued messages go to it at once and it carries on; with none queued it waits for you (**interrupted · waiting for you**) until your next message, or a `tell` from the main agent, resumes it.
23
+ - When you interrupt an agent with nothing queued, the agent that started it gets a one-line note instead of a report, so it isn't left waiting.
24
+ - Guidance shows as queued until delivered, or undelivered if the child stops first. Guidance you send is also saved in main memory.
25
+ - Reasoning is not shown.
26
+ - Transcripts stay browsable after restart, and browsing them makes no model calls.
27
+
28
+ ## Usage
29
+
30
+ Shows this session, last hour, today, last 7 days, or all time (**Left/Right**): one row per role and model (main agent, subagents, compactor, imports) with estimated cost, share of the total, output tokens, and how much input came from the cache.
31
+
32
+ - Costs are API-rate estimates, not your subscription bill. Unknown rates show zero.
33
+ - Record counts are not request counts; retries and tool overhead can add records.
34
+ - Main-agent tracking starts with v0.3.0 (resumed sessions are backfilled). Older records without a parent session are left out of **This session**.
35
+ - All views are limited to the active profile.
36
+
37
+ ## Activity
38
+
39
+ 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.
40
+
41
+ - 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`).
42
+ - Once every summary a turn 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.
43
+ - It also counts running agents, and interrupted ones waiting for you; their list is on Agents.
44
+ - While summaries or agents are at work, the bar's Activity item gets a **●**.
@@ -0,0 +1,52 @@
1
+ # How OptChat differs from the recipe
2
+
3
+ The README has the summary table. This page has the detail: what changed in each prompt, the numbers OptChat keeps, and how each addition behaves. `docs/victor-recipe.md` maps the recipe's sections to the source files.
4
+
5
+ ## Prompts
6
+
7
+ `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:
8
+
9
+ - **Main agent.** The turn rules are the revised recipe's (2026-10-08) word for word, with three changes:
10
+ - its paragraph on computers is left out;
11
+ - reports start `[id] ` instead of `[Name]`;
12
+ - two sentences are added: don't poll anything slow (CI, a deploy) yourself, hand the watching to a subagent; and a message starting `[OptChat status]` is OptChat's own note, not the user.
13
+ - **View doc.** Keeps the original's description of the view. It drops the "No message appears in full" line (untrue with Previous exchange on) and the 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.
14
+ - **Compactor.** The revised recipe's intro, view and Compactions sections, without its turn-only sections, and with:
15
+ - OptChat's kinds: `talk` for replies, subagent reports as `work`;
16
+ - 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 (#129).
17
+ - **Compaction task and "Too long" retry.** The recipe's, verbatim, with a 512-dash ruler for the size.
18
+ - **Compactor instructions.** Unlike the recipe, the compactor doesn't get your instructions: an A/B found they added nothing (#112).
19
+ - **Tool results.** A tool result's leaf summary also gets the raw tool call it answers, as a `<call>` block of context (#153 A/B: recall 3.88 → 4.22).
20
+ - **Subagent prompt.** 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.
21
+
22
+ ## Numbers kept from the recipe
23
+
24
+ - 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 (setting: Summary size tolerance).
25
+ - A memory view that grows a line per message and, past 128,000 bytes, merges in one batch down to 64,000. Between batches it only grows at its end, so each turn reads the last one's view from the cache. The view is saved and reloaded as it was.
26
+ - Binary merges, 8 compression workers by default (setting: Summaries at once), fixed retry delays, 5 shortening attempts, and a 30,000-character tool output cap.
27
+
28
+ ## Compaction view and scheduling
29
+
30
+ Compactions get their own view, as in the 2026-10-08 revision: the memory view merged further, to between 16,000 and 32,000 bytes with the same sawtooth, ending at the line being built.
31
+
32
+ A message's summary starts once fewer than N lines before it are unsummarized, so up to N compactions run at once on one cached view (N is the **Summaries at once** setting, 8 by default). An import logs its messages the same way, waiting only while that many messages and due merges are unbuilt, so the merges keep up.
33
+
34
+ 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 (#101).
35
+
36
+ ## Cache marks
37
+
38
+ 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. 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. With the two extra marks, a turn that adds up to 240 lines still reads the last view from the cache. OpenAI requests get no marks (see `docs/victor-recipe.md`).
39
+
40
+ 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.
41
+
42
+ ## Additions
43
+
44
+ Each run's context is the memory view, the previous exchange, and your new message. The additions that change the recipe's behaviour are settings (see [Settings](../README.md#settings)).
45
+
46
+ 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; setting: 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.
47
+ 2. **Subagents** are built in with Pi's SDK rather than a separate package. With Subagent levels above 1 they can delegate further. A spawn can set each task's model and thinking level when you ask for them.
48
+ 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.
49
+ 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.
50
+ 5. **Turn refresh** (#146). Once the current turn's verbatim messages pass the Turn refresh threshold (30% by default), they move into memory and the turn goes on with a fresh window. 0% is off, as in the recipe. See "Long turns" in the README.
51
+ 6. **Profiles**, the **inspector**, the **usage ledger**, **import**, and **connected windows** are additions. Import adds historical-record guidance to the prompts.
52
+ 7. **Not done**: computer use and hosting on an always-on machine.
@@ -0,0 +1,25 @@
1
+ # Storage
2
+
3
+ Profile data lives in `~/.optchat/profiles/<name>/` (override the root with `OPTCHAT_HOME`). The README has the table of what each path holds.
4
+
5
+ ## Git checkpoints
6
+
7
+ Each profile folder is a local Git repository, committed after each turn and on clean shutdown (memory and config; not runs, HTML, or usage). It has no remote, so it is not a backup. To back up, copy the folder while Pi is closed.
8
+
9
+ ## Import generations
10
+
11
+ An import builds a new memory generation (`memories/<id>/`) and switches `active-memory.json` to it only when the whole tree is ready. Older generations are kept.
12
+
13
+ ## The `Store` seam
14
+
15
+ Memory reaches disk through one seam, `Store` in `src/store.ts`: the log, summary nodes, saved view and images, which is what would travel between machines.
16
+
17
+ - Runs, recovery journals, imports and their pointer, usage, settings and `AGENTS.md` stay local files.
18
+ - `FileStore` (the layout in the README) is the only store today. Another, such as Postgres, would implement the same few operations.
19
+ - Its lease (take, heartbeat, release) is optional: without one, OptChat keeps its machine-local profile lock.
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
+
22
+ ## Windows differences
23
+
24
+ - **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.
25
+ - **File modes and fsync** (profile folder 0700, socket 0600, directory fsync after a rename) are POSIX-only and are skipped on Windows.
@@ -0,0 +1,100 @@
1
+ # Subagents in detail
2
+
3
+ The README covers the basics. This page has the rest.
4
+
5
+ ## What a child gets
6
+
7
+ - The profile's memory view (frozen at launch), its instructions, read-only `zoom`/`date`, and normal coding tools plus your installed extensions (except pi-optchat itself).
8
+ - The Pi built-in extensions the main session loaded: MCP, codemode and tool search.
9
+
10
+ ### MCP servers
11
+
12
+ Your MCP servers (`~/.pi/agent/mcp.json`, the project's `.pi/mcp.json`) work in subagents, with the sign-ins you made in Pi.
13
+
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
+ - Each subagent opens its own server connections (stdio servers start once per subagent) and closes them when it ends.
16
+
17
+ ## Model and thinking level
18
+
19
+ 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.
20
+
21
+ - What a task leaves out comes from the profile: a model alone runs at the profile's thinking level, or the nearest level that model takes.
22
+ - The `models` tool (`provider` optional) lists what a task may name, with the profile's default marked. The agent is told to call it when you name a family without a version ("Opus"), since the newest ids may postdate its training.
23
+ - A model Pi doesn't know refuses the whole spawn before any child starts, and the error lists the models you can use.
24
+ - A resumed child keeps the model and level it ran with.
25
+
26
+ ## Reports
27
+
28
+ Each child reports to its parent as soon as it finishes, as one message starting `[id]`. The parent stays alive to receive reports; it never polls.
29
+
30
+ - A failed child reports its error text.
31
+ - A child whose request aborts with no stop (a provider abort) still reports `Task aborted: ...`.
32
+ - A stopped child's report says who stopped it (you, the agent that started it, or Pi closing while it was paused) and that `tell` resumes it, followed by its last message when it has one.
33
+
34
+ ### Grouped reports
35
+
36
+ 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.
37
+
38
+ - 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.
39
+ - Messages sent with `tell_parent`, connected windows and their handoffs are never held back.
40
+ - A child resumed with `tell` reports on its own.
41
+
42
+ ### How reports look
43
+
44
+ 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.
45
+
46
+ - The model still receives them as ordinary user messages.
47
+ - Memory logs them as kind `work` (older logs hold them as `user` messages starting `[id]`).
48
+ - Exception: reports recovered at startup, before your first message in the session, still show as plain user messages.
49
+
50
+ ### Reading a child's chat
51
+
52
+ Every report ends with `Full chat: zoom("<id>")`, so the agent can check it against what the child actually did. The report box leaves this trailer out.
53
+
54
+ `zoom` with a run ID returns that child's chat so far as `kind|text` lines: its task without the memory view, replies, tool calls, tool results cut to their first and last ~500 characters, and what it was told. It comes in 25,000-character pages that say which `offset` to go on from. It works on running children too. A subagent can read only its own descendants' chats, as with `tell`.
55
+
56
+ ## The "agents at work" status line
57
+
58
+ While any subagent runs, each new input to the main agent (your message, a report, a `tell_parent` message, the restart note, a connected window's message) ends with one status line:
59
+
60
+ ```
61
+ [OptChat status] Agents at work now: 8f28e694 "Review PR #123 carefully", …
62
+ ```
63
+
64
+ It lists the id and the first words of each task, so the main agent knows what is still out without asking.
65
+
66
+ - It is rebuilt each time and never stored.
67
+ - It isn't repeated on the calls after a tool result within one turn, and nothing is sent while no agent runs.
68
+ - The main prompt says what the label means, so the model doesn't take it for the user.
69
+ - It comes after the cached view and turn, with the turn's cache mark on the block before it, so it costs no cache.
70
+
71
+ ## Talking to a running child
72
+
73
+ - The parent sends a running child guidance with `tell`.
74
+ - The child messages 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.
75
+
76
+ ## Resuming a finished child
77
+
78
+ `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.
79
+
80
+ - This also works for children from earlier Pi sessions.
81
+ - Only the agent that started the child can resume it, and the resumed child takes one active slot.
82
+ - Connected windows can't be resumed, and a child whose transcript is missing must be spawned fresh.
83
+
84
+ ## Levels and limits
85
+
86
+ - 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).
87
+ - **Max active agents** (8 by default) caps how many agents can be active per profile, including parents waiting on descendants.
88
+ - Going over a limit returns an error; there is no queue.
89
+
90
+ ## Stopping and restarts
91
+
92
+ - Stopping an agent stops its whole subtree. A failed parent stops its descendants.
93
+ - Agents run inside the Pi process. Closing Pi stops them; there is no detached mode.
94
+ - 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.
95
+ - Headless `pi -p` doesn't resume them; they wait for the next interactive start.
96
+ - Ones you stopped or paused aren't resumed.
97
+
98
+ ## Long subagent turns
99
+
100
+ Subagent transcripts aren't in the summary tree, so subagents use Pi's own compaction instead of the Turn refresh threshold, starting at about the same point.
@@ -6,7 +6,7 @@ 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 the README lists, 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/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
 
@@ -15,7 +15,7 @@ The source remains upstream rather than duplicating the full article here. Its p
15
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 (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).
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.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-optchat",
3
- "version": "0.8.5",
3
+ "version": "0.8.6",
4
4
  "type": "module",
5
5
  "pi": {
6
6
  "extensions": [
package/src/agents.ts CHANGED
@@ -5,7 +5,7 @@ import { homedir } from 'node:os';
5
5
  import { createAgentSession, DefaultResourceLoader, SessionManager, SettingsManager, getAgentDir, type AgentSession, type AgentSessionEvent, type ExtensionAPI, type InlineExtension, type ModelRegistry } from '@earendil-works/pi-coding-agent';
6
6
  import * as sdk from '@earendil-works/pi-coding-agent';
7
7
  import type { AgentMessage } from '@earendil-works/pi-agent-core';
8
- import type { Api, Model } from '@earendil-works/pi-ai';
8
+ import { StringEnum, type Api, type Model } from '@earendil-works/pi-ai';
9
9
  import { allowSearch, CONNECTED, cutOffChildren, delegation, NO_DELEGATION, NOT_CONNECTED, RESTARTED, SEARCH_DOC, STEERABLE, SUBAGENT, VIEW_DOC } from './prompts.ts';
10
10
  import { memoryTools, searchTool } from './tools.ts';
11
11
  import { VIEW, type Memory } from './memory.ts';
@@ -14,10 +14,11 @@ import { cachePayload } from './cache.ts';
14
14
  import { CUT_OFF, RunHistory, isRunning, transition, sessionMessages, type RunInfo, type RunState, type FinishReason } from './runs.ts';
15
15
  import { UsageLedger } from './usage.ts';
16
16
  import { fullChat, refreshFraction, textContent } from './transcript.ts';
17
- import { Type } from 'typebox';
17
+ import { Type, type Static } from 'typebox';
18
18
  import { result } from './tools.ts';
19
19
  import type { HandoffEvidence } from './handoff.ts';
20
20
  import { DEFAULT_SETTINGS, type Settings } from './settings.ts';
21
+ import { THINKING } from './profiles.ts';
21
22
 
22
23
  export interface LiveRun {
23
24
  session: AgentSession; info: RunInfo; updated: number; streaming?: AgentMessage;
@@ -64,7 +65,17 @@ const packageName = (path: string): string | undefined => {
64
65
  /** A task's cwd may start with `~` and may be relative to the spawning agent's directory. */
65
66
  export const taskDirectory = (cwd: string, path = '.', windows = process.platform === 'win32') =>
66
67
  resolve(cwd, path.replace(windows ? /^~(?=$|[\\/])/ : /^~(?=$|\/)/, homedir()));
67
- export const CWD_DOC = 'Project directory the subagent works in (~ allowed); its AGENTS.md files load from there. Defaults to your current directory.';
68
+ const CWD_DOC = 'Project directory the subagent works in (~ allowed); its AGENTS.md files load from there. Defaults to your current directory.';
69
+ /** The spawn tools' tasks. What a task leaves out of model and thinking is the profile's subagent choice. */
70
+ export const TASKS = Type.Array(Type.Object({ task: Type.String(), cwd: Type.Optional(Type.String({ description: CWD_DOC })),
71
+ model: Type.Optional(Type.String({ description: 'Model as provider/id, such as anthropic/claude-sonnet-5-5. Set it only when the user asks for one; otherwise the profile\'s subagent model runs.' })),
72
+ thinking: Type.Optional(StringEnum(THINKING, { description: 'Thinking level. Set it only when the user asks for one; otherwise the profile\'s subagent level applies.' })) }), { minItems: 1 });
73
+ export type Task = Static<typeof TASKS>[number];
74
+ /** The tool beside spawn that lists what a task's `model` may be. */
75
+ export const modelsTool = (children: () => Children) => ({ name: 'models', label: 'List models',
76
+ description: 'The models a spawn task may name, as provider/id, with the profile\'s subagent default marked. provider narrows the list to one provider.',
77
+ parameters: Type.Object({ provider: Type.Optional(Type.String()) }),
78
+ execute: async (_id: string, args: { provider?: string }) => result(children().models(args.provider)) });
68
79
  const isOptchat = (path: string) => packageName(path) === 'pi-optchat';
69
80
 
70
81
  // Pi's CLI adds its built-in extensions (MCP, codemode, tool search) to its own session; SDK sessions such as
@@ -114,6 +125,16 @@ export class Children {
114
125
  }
115
126
  private get settings(): Settings { return { ...DEFAULT_SETTINGS, ...this.options.settings?.() }; }
116
127
  private full(extra: number) { return this.running.size + this.launching + extra > this.settings.maxAgents; }
128
+ /** A model named `provider/id`, as runs record it. */
129
+ /** The models a task may name, one `provider/id` per line: every model Pi is logged in to, the profile's subagent choice marked. */
130
+ models(provider?: string) {
131
+ const profile = this.choice(), fallback = `${profile.provider}/${profile.model}`;
132
+ const available = this.registry.getAvailable();
133
+ const names = available.filter(m => !provider || m.provider === provider).map(m => `${m.provider}/${m.id}`).sort();
134
+ if (!names.length) throw new Error(provider ? `No models for provider ${provider}. Providers: ${[...new Set(available.map(m => m.provider))].sort().join(', ') || 'none'}.` : 'No models available.');
135
+ return names.map(name => name === fallback ? `${name} (subagent default, thinking ${profile.thinking})` : name).join('\n');
136
+ }
137
+ private find(name: string) { const slash = name.indexOf('/'); return slash > 0 ? this.registry.find(name.slice(0, slash), name.slice(slash + 1)) : undefined; }
117
138
  get ids() { return [...this.running.keys()]; }
118
139
  get active() { return this.completions.size > 0 || this.launching > 0 || this.settling > 0; }
119
140
  live(id: string) { return this.running.get(id); }
@@ -129,7 +150,13 @@ export class Children {
129
150
  /** A run's messages for zoom, live or finished; undefined for no such run. A subagent `reader` sees only its own descendants, as with tell. */
130
151
  transcript(id: string, reader?: string) {
131
152
  const known = reader ? this.history.descendants(reader).some(run => run.id === id) : this.running.has(id) || this.history.records.has(id);
132
- return known ? this.messages(id) : undefined;
153
+ if (!known) return undefined;
154
+ const live = this.running.get(id);
155
+ if (!live) return this.messages(id);
156
+ const retained = live.session.sessionManager.getEntries().flatMap(e => e.type === 'message' ? [e.message] : []);
157
+ // Pi puts the finalized message in session.messages before message_end listeners run, then persists that same object.
158
+ const tail = live.streaming ?? live.session.messages.at(-1);
159
+ return tail && !retained.includes(tail) ? [...retained, tail] : retained;
133
160
  }
134
161
  /** Runs at work now: running, waiting for children, or stopping; not paused. */
135
162
  get working() { return [...this.running.values()].filter(live => isRunning(live.info)).map(live => live.info); }
@@ -157,16 +184,23 @@ export class Children {
157
184
  this.changed();
158
185
  } catch (error) { this.warn(`Could not record subagent activity: ${String(error)}`); }
159
186
  }
160
- async spawn(tasks: { task: string; cwd?: string }[], cwd: string, signal?: AbortSignal, parentId?: string, connected = false) {
187
+ async spawn(tasks: Task[], cwd: string, signal?: AbortSignal, parentId?: string, connected = false) {
161
188
  return (await this.launch(tasks, cwd, signal, parentId, connected)).ids;
162
189
  }
163
190
  /** The spawn tools' answer, saying how this spawn's reports will come back. */
164
- async start(tasks: { task: string; cwd?: string }[], cwd: string, signal?: AbortSignal, parentId?: string) {
191
+ async start(tasks: Task[], cwd: string, signal?: AbortSignal, parentId?: string) {
165
192
  const { ids, grouped } = await this.launch(tasks, cwd, signal, parentId);
166
193
  return `Started: ${ids.join(', ')}. ${grouped && ids.length > 1 ? 'Their reports will arrive together, as one message, once all of them have finished.' : 'Reports will arrive automatically.'}`;
167
194
  }
168
- private async launch(tasks: { task: string; cwd?: string }[], cwd: string, signal: AbortSignal | undefined, parentId: string | undefined, connected = false) {
195
+ private async launch(tasks: Task[], cwd: string, signal: AbortSignal | undefined, parentId: string | undefined, connected = false) {
169
196
  for (const directory of tasks.map(t => taskDirectory(cwd, t.cwd))) if (!existsSync(directory) || !statSync(directory).isDirectory()) throw new Error(`No such directory: ${directory}`);
197
+ const profile = this.choice();
198
+ const runs = tasks.map(task => {
199
+ const name = task.model ?? `${profile.provider}/${profile.model}`, model = this.find(name);
200
+ // Like Pi's own setModel: a named model needs auth, so a guess at a provider the user isn't logged in to fails here, not in the child.
201
+ if (!model || task.model && !this.registry.hasConfiguredAuth(model)) throw new Error(`Subagent model unavailable: ${name}. Models you can use: ${this.registry.getAvailable().map(m => `${m.provider}/${m.id}`).join(', ') || 'none'}.`);
202
+ return { ...task, model, thinking: task.thinking ?? profile.thinking };
203
+ });
170
204
  if (this.closing) throw new Error('Profile is closing.');
171
205
  this.settling++;
172
206
  try { await this.memory.settle(signal); } finally { this.settling--; }
@@ -179,10 +213,7 @@ export class Children {
179
213
  if (this.closing) throw new Error('Profile is closing.');
180
214
  if (this.full(tasks.length)) throw new Error(`Profile limit: at most ${maxAgents} active agents, including parents and descendants. Reduce the batch or continue without delegating.`);
181
215
  const view = this.memory.render();
182
- const selected = this.choice();
183
- const model = this.registry.find(selected.provider, selected.model);
184
- if (!model) throw new Error(`Subagent model unavailable: ${selected.provider}/${selected.model}`);
185
- const launched = await this.track(this.launchBatch({ tasks, cwd, depth, parentId, connected, selected, model, signal, cancelled }));
216
+ const launched = await this.track(this.launchBatch({ tasks: runs, cwd, depth, parentId, connected, signal, cancelled }));
186
217
  // Grouped, the last child of this spawn to finish delivers every report; otherwise each reports on its own.
187
218
  const batch = groupReports && !connected ? { ids: launched.map(c => c.info.id), reports: new Map<string, string>() } : undefined;
188
219
  for (const live of launched) {
@@ -202,8 +233,8 @@ export class Children {
202
233
  launch.then(done, done);
203
234
  return launch;
204
235
  }
205
- private async launchBatch({ tasks, cwd, depth, parentId, connected, selected, model, signal, cancelled }: { tasks: { task: string; cwd?: string }[]; cwd: string; depth: number; parentId?: string; connected: boolean;
206
- selected: ModelChoice; model: Model<Api>; signal?: AbortSignal; cancelled: () => boolean }) {
236
+ private async launchBatch({ tasks, cwd, depth, parentId, connected, signal, cancelled }: { tasks: { task: string; cwd?: string; model: Model<Api>; thinking: ModelChoice['thinking'] }[]; cwd: string; depth: number;
237
+ parentId?: string; connected: boolean; signal?: AbortSignal; cancelled: () => boolean }) {
207
238
  const launched: LiveRun[] = [];
208
239
  let reserved = tasks.length;
209
240
  this.launching += reserved;
@@ -212,9 +243,9 @@ export class Children {
212
243
  signal?.throwIfAborted();
213
244
  if (cancelled()) throw new Error('Parent or profile is stopping.');
214
245
  const id = randomUUID().slice(0, 8), directory = taskDirectory(cwd, task.cwd);
215
- const session = await this.open({ id, directory, depth, parentId, connected, provider: selected.provider, model, thinking: selected.thinking,
246
+ const session = await this.open({ id, directory, depth, parentId, connected, model: task.model, thinking: task.thinking,
216
247
  sessionManager: SessionManager.create(directory, join(this.profileDirectory, 'runs')) });
217
- const info: RunInfo = { id, task: task.task, cwd: directory, model: `${selected.provider}/${selected.model}`, thinking: session.thinkingLevel,
248
+ const info: RunInfo = { id, task: task.task, cwd: directory, model: `${task.model.provider}/${task.model.id}`, thinking: session.thinkingLevel,
218
249
  parentSession: this.options.parentSession ?? '', parentId, depth, sessionFile: session.sessionFile, started: Date.now(), state: 'running', guidance: [], ...(connected ? { connected: true } : {}) };
219
250
  const live: LiveRun = { session, info, updated: Date.now(), tools: new Map(), pendingReports: [], pendingGuidance: [] };
220
251
  launched.push(live); this.save(info); this.running.set(id, live);
@@ -237,7 +268,7 @@ export class Children {
237
268
  return launched;
238
269
  }
239
270
  /** Builds a child session with the same prompt, tools and extensions whether it is new or resumed. */
240
- private async open(o: { id: string; directory: string; depth: number; parentId?: string; connected: boolean; provider: string; model: Model<Api>;
271
+ private async open(o: { id: string; directory: string; depth: number; parentId?: string; connected: boolean; model: Model<Api>;
241
272
  thinking?: ModelChoice['thinking']; sessionManager: SessionManager }) {
242
273
  const { id, directory, depth, parentId, connected } = o;
243
274
  const { subagentLevels, maxAgents, memorySearch } = this.settings, delegates = depth < subagentLevels;
@@ -249,8 +280,8 @@ export class Children {
249
280
  noPromptTemplates: true,
250
281
  extensionsOverride: base => ({ ...base, extensions: base.extensions.filter(e => !isOptchat(e.resolvedPath)) }),
251
282
  extensionFactories: [...builtinExtensions(this.options.builtins?.() ?? []), pi => {
252
- const provider = this.registry.getRegisteredProviderConfig(o.provider);
253
- if (provider) pi.registerProvider(o.provider, provider);
283
+ const provider = this.registry.getRegisteredProviderConfig(o.model.provider);
284
+ if (provider) pi.registerProvider(o.model.provider, provider);
254
285
  // Same prompt as the main agent (AGENTS.md files, skills, cwd); only the OptChat preamble differs.
255
286
  pi.on('before_agent_start', event => {
256
287
  event.systemPromptOptions.customPrompt = allowSearch(`${SUBAGENT}\n\n${VIEW_DOC}${memorySearch ? SEARCH_DOC : ''}`, memorySearch);
@@ -262,8 +293,8 @@ export class Children {
262
293
  await loader.reload();
263
294
  const { session } = await (this.options.createSession ?? createAgentSession)({ cwd: directory, resourceLoader: loader, settingsManager,
264
295
  model: o.model, thinkingLevel: o.thinking, sessionManager: o.sessionManager,
265
- customTools: [...memoryTools(() => this.memory, run => this.transcript(run, id)), ...(memorySearch ? [searchTool(() => this.memory)] : []), ...(delegates ? this.delegationTools(id, directory, subagentLevels, maxAgents) : []), this.parentTool(id, parentId, connected)],
266
- excludeTools: delegates ? [] : ['spawn', 'tell'],
296
+ customTools: [...memoryTools(() => this.memory, run => this.transcript(run, id)), ...(memorySearch ? [searchTool(() => this.memory)] : []), ...(delegates ? [...this.delegationTools(id, directory, subagentLevels, maxAgents), modelsTool(() => this)] : []), this.parentTool(id, parentId, connected)],
297
+ excludeTools: delegates ? [] : ['spawn', 'tell', 'models'],
267
298
  });
268
299
  // Callers track the session only after this returns: clean up here if its extensions fail to start.
269
300
  try { await session.bindExtensions({}); }
@@ -271,9 +302,9 @@ export class Children {
271
302
  return session;
272
303
  }
273
304
  private delegationTools(parentId: string, cwd: string, levels: number, maxAgents: number) {
274
- return [{ name: 'spawn', label: 'Delegate task', description: `Delegate parts of your task. Results arrive automatically after this run; never poll or sleep waiting. Maximum depth ${levels} and ${maxAgents} active agents per profile.`,
275
- parameters: Type.Object({ tasks: Type.Array(Type.Object({ task: Type.String(), cwd: Type.Optional(Type.String({ description: CWD_DOC })) }), { minItems: 1 }) }),
276
- execute: async (_id: string, args: { tasks: { task: string; cwd?: string }[] }, signal?: AbortSignal) => result(await this.start(args.tasks, cwd, signal, parentId)),
305
+ return [{ name: 'spawn', label: 'Delegate task', description: `Delegate parts of your task. Results arrive automatically after this run; never poll or sleep waiting. models() lists the ids a task's model may name. Maximum depth ${levels} and ${maxAgents} active agents per profile.`,
306
+ parameters: Type.Object({ tasks: TASKS }),
307
+ execute: async (_id: string, args: { tasks: Task[] }, signal?: AbortSignal) => result(await this.start(args.tasks, cwd, signal, parentId)),
277
308
  }, { name: 'tell', label: 'Guide child', description: 'Send guidance to one of your direct children. A finished child is resumed with its earlier conversation, and its new report arrives automatically.',
278
309
  parameters: Type.Object({ id: Type.String(), message: Type.String() }),
279
310
  execute: async (_id: string, args: { id: string; message: string }) => {
@@ -517,14 +548,13 @@ export class Children {
517
548
  try { if (run.sessionFile && existsSync(run.sessionFile)) manager = SessionManager.open(run.sessionFile); } catch { manager = undefined; }
518
549
  if (!manager?.getEntries().some(e => e.type === 'message')) throw new Error(`The saved transcript of ${id} is missing or unreadable, so it cannot be resumed. Spawn a fresh subagent and give it the context it needs.`);
519
550
  if (!existsSync(run.cwd) || !statSync(run.cwd).isDirectory()) throw new Error(`${id}'s directory ${run.cwd} no longer exists, so it cannot be resumed. Spawn a fresh subagent instead.`);
520
- const slash = run.model.indexOf('/'), provider = run.model.slice(0, slash);
521
- const model = slash > 0 ? this.registry.find(provider, run.model.slice(slash + 1)) : undefined;
551
+ const model = this.find(run.model);
522
552
  if (!model) throw new Error(`${id}'s model ${run.model} is not available, so it cannot be resumed. Spawn a fresh subagent instead.`);
523
553
  this.launching++; this.resuming.add(id);
524
554
  let reserved = true;
525
555
  try {
526
556
  // No thinking level: the session restores the one it ran with.
527
- const session = await this.open({ id, directory: run.cwd, depth: run.depth, parentId: run.parentId, connected: false, provider, model, sessionManager: manager });
557
+ const session = await this.open({ id, directory: run.cwd, depth: run.depth, parentId: run.parentId, connected: false, model, sessionManager: manager });
528
558
  // stop() cannot see this child until it is registered, so a parent stopped meanwhile must cancel it here.
529
559
  const parent = run.parentId ? this.running.get(run.parentId) : undefined;
530
560
  if (this.closing || run.parentId && (!parent || !['running', 'waiting', 'paused'].includes(parent.info.state))) {
package/src/cache.ts CHANGED
@@ -30,8 +30,10 @@ function lastBlock(message: Record<string, unknown>) {
30
30
  return record(block) ? block : undefined;
31
31
  }
32
32
 
33
- /** Anthropic: marks on the view (see MARKS) plus automatic end-of-request caching, so the next call finds the last one's entry
34
- * and pays only for the lines after it. */
33
+ /** Anthropic: marks on the view (see MARKS) plus a mark at the end of the request, so the next call finds the last one's entry
34
+ * and pays only for the lines after it. The end mark sits on the last block itself rather than as top-level automatic caching:
35
+ * a proxy or setting that gives block marks a 1h TTL makes Anthropic refuse a top-level mark, whose TTL defaults to 5m, on the
36
+ * same block ("Top-level cache_control has ttl='5m' but the target block already has cache_control with ttl='1h'"). */
35
37
  export function cachePayload(payload: unknown): unknown {
36
38
  if (!record(payload) || !Array.isArray(payload.messages)) return payload;
37
39
  const messages = payload.messages;
@@ -60,7 +62,17 @@ export function cachePayload(payload: unknown): unknown {
60
62
  // Pi may end the request with an empty system message carrying settings (effort); it holds nothing to cache.
61
63
  const [before, last] = messages.filter(message => !record(message) || message.role !== 'system').slice(-2);
62
64
  const turn = isAtWork(last) && record(before) && before.role === 'user' ? lastBlock(before) : undefined;
63
- if (turn) turn.cache_control = { type: 'ephemeral' };
64
- else payload.cache_control = { type: 'ephemeral' };
65
+ const end = turn ?? endBlock(messages);
66
+ if (end) end.cache_control = { type: 'ephemeral' };
65
67
  return payload;
66
68
  }
69
+
70
+ /** The last block of the last non-system message that can carry a mark (thinking blocks can't). */
71
+ function endBlock(messages: unknown[]) {
72
+ const message = messages.filter(m => !record(m) || m.role !== 'system').at(-1);
73
+ if (!record(message)) return undefined;
74
+ if (typeof message.content === 'string') return lastBlock(message);
75
+ if (!Array.isArray(message.content)) return undefined;
76
+ const block: unknown = message.content.findLast((b: unknown) => record(b) && b.type !== 'thinking' && b.type !== 'redacted_thinking');
77
+ return record(block) ? block : undefined;
78
+ }
package/src/import/job.ts CHANGED
@@ -95,7 +95,7 @@ export function discardImport(dir: string) {
95
95
  rmSync(pendingFile(dir)); rmSync(generationPath(dir, job.target), { recursive: true, force: true });
96
96
  }
97
97
  export interface ImportProgress { messages: number; total: number; summaries: number; error?: string }
98
- export async function runImport(dir: string, compress: Compressor, signal: AbortSignal, jobs = DEFAULT_SETTINGS.importJobs,
98
+ export async function runImport(dir: string, compress: Compressor, signal: AbortSignal, jobs = DEFAULT_SETTINGS.jobs,
99
99
  progress: (state: ImportProgress) => void = () => {}): Promise<ImportJob> {
100
100
  const job = pendingImport(dir); if (!job) throw new Error('No pending import.');
101
101
  const path = generationPath(dir, job.target);
@@ -113,7 +113,7 @@ export async function runImport(dir: string, compress: Compressor, signal: Abort
113
113
  if (memory.root.length > plan.length || memory.root.some((e, i) => e.text !== plan[i].text)) throw new Error('Import staging data does not match its log; original memory remains intact.');
114
114
  report();
115
115
  // Recipe §10: imported messages are compressed like any other. Each is logged once its node can start, as a burst of live
116
- // messages is, so up to `jobs` summaries run at once (the "Import: summaries at once" setting). Unlike a turn, an import waits through failures (its progress shows the error).
116
+ // messages is, so up to `jobs` summaries run at once (the "Summaries at once" setting). Unlike a turn, an import waits through failures (its progress shows the error).
117
117
  for (const e of plan.slice(memory.root.length)) { await memory.settle(signal, 'ahead'); memory.append(e.kind, e.text, e.date, e.receipt, e.origin); }
118
118
  await memory.settle(signal, 'tree'); signal.throwIfAborted();
119
119
  // Close all writers before the single atomic pointer swap. The previous generation stays intact.
package/src/index.ts CHANGED
@@ -16,7 +16,7 @@ import { saveImages } from './images.ts';
16
16
  import { asUser, boundedMessage, atWork, buildContext, logMessage, previousExchange, refreshedRun, refreshBudget, refreshFraction, refreshPoint, reportReceipt, REPORT_RECEIPT, REPORT_TYPE, RUN_BOUNDARY, textContent, typedText } from './transcript.ts';
17
17
  import { registerReportRenderer, type ReportDetails } from './report-message.ts';
18
18
  import { memoryTools, result, searchTool } from './tools.ts';
19
- import { Children, CWD_DOC, loadedBuiltins } from './agents.ts';
19
+ import { Children, loadedBuiltins, modelsTool, TASKS } from './agents.ts';
20
20
  import { isActiveRun, type RunInfo } from './runs.ts';
21
21
  import { exportBrowser } from './browser.ts';
22
22
  import { Inbox } from './inbox.ts';
@@ -204,7 +204,7 @@ export default function optchat(pi: ExtensionAPI) {
204
204
  const memory = await Memory.open(memoryDirectory(dir), createCompressor(ctx.modelRegistry, () => config.compactor, message => {
205
205
  usage.compression(message, 'compactor', sessionId);
206
206
  status(ctx);
207
- }, () => config.summaryAcceptBytes), warning => ctx.ui.notify(warning, 'error'));
207
+ }, () => config.summaryAcceptBytes), warning => ctx.ui.notify(warning, 'error'), undefined, config.jobs);
208
208
  openingMemory = memory;
209
209
  const inbox = new Inbox(dir);
210
210
  const recovered = pendingImport(dir) ? 0 : inbox.recover(memory);
@@ -507,13 +507,14 @@ export default function optchat(pi: ExtensionAPI) {
507
507
  if (tools.includes('search') !== memorySearch) pi.setActiveTools(memorySearch ? [...tools, 'search'] : tools.filter(name => name !== 'search'));
508
508
  };
509
509
  pi.registerTool({ name: 'spawn', label: 'Spawn background agents',
510
- description: 'Start background subagents, returning IDs immediately. Use only when the user asks. Give each task the cwd of the project it works on, so the subagent starts there with that project\'s AGENTS.md. Each receives the current memory view and read-only zoom/date. Whether children may delegate further, and how many agents may run at once, is set per profile. Completion reports arrive automatically; never poll or sleep waiting for them.',
511
- parameters: Type.Object({ tasks: Type.Array(Type.Object({ task: Type.String(), cwd: Type.Optional(Type.String({ description: CWD_DOC })) }), { minItems: 1 }) }),
510
+ description: 'Start background subagents, returning IDs immediately. Use only when the user asks. Give each task the cwd of the project it works on, so the subagent starts there with that project\'s AGENTS.md. A task names a model only when the user asks for one; models() lists the valid ids. Each receives the current memory view and read-only zoom/date. Whether children may delegate further, and how many agents may run at once, is set per profile. Completion reports arrive automatically; never poll or sleep waiting for them.',
511
+ parameters: Type.Object({ tasks: TASKS }),
512
512
  async execute(_id, args, signal, _update, ctx) {
513
513
  const text = await required().children.start(args.tasks, ctx.cwd, signal); status(ctx);
514
514
  return result(text);
515
515
  },
516
516
  });
517
+ pi.registerTool(modelsTool(() => required().children));
517
518
  pi.registerTool({ name: 'tell', label: 'Tell background agent', description: 'Send a message to a subagent. A running one gets it at its next tool boundary. A finished one you started is resumed with its earlier conversation, and its new report arrives automatically.',
518
519
  parameters: Type.Object({ id: Type.String(), message: Type.String() }),
519
520
  async execute(_id, args) { return result(await required().children.tell(args.id, args.message)); },
@@ -586,7 +587,7 @@ export default function optchat(pi: ExtensionAPI) {
586
587
  const compress = createCompressor(ctx.modelRegistry, () => a.config.compactor, message => {
587
588
  a.usage.compression(message, 'import', ctx.sessionManager.getSessionId());
588
589
  }, () => a.config.summaryAcceptBytes);
589
- const completed = await showProgress(ctx, job, (signal, progress) => runImport(a.dir, compress, signal, a.config.importJobs, progress), signal);
590
+ const completed = await showProgress(ctx, job, (signal, progress) => runImport(a.dir, compress, signal, a.config.jobs, progress), signal);
590
591
  ctx.ui.notify(completed ? `Imported ${job.added} messages into ${a.name}. Previous memory retained at ${job.previous === '.' ? a.dir : join(a.dir, job.previous)}.`
591
592
  : 'Import paused. Use /optchat import to resume. Other profiles remain available.', 'info');
592
593
  })();
package/src/memory.ts CHANGED
@@ -130,7 +130,7 @@ export class Memory {
130
130
  return new Memory({ directory, store, saved: await store.load() }, compress, warn, budget, jobs);
131
131
  }
132
132
  /** A directory alone opens a FileStore there. `jobs`: summaries built at once, and how many unbuilt lines a new message's node
133
- * waits behind. Chat keeps 8; imports pass the "Import: summaries at once" setting. */
133
+ * waits behind. Chat and imports pass the "Summaries at once" setting. */
134
134
  constructor(source: string | { directory: string; store: Store; saved: Saved }, private readonly compress: Compressor,
135
135
  private readonly warn: (s: string) => void = console.error,
136
136
  readonly budget = VIEW, private readonly jobs = 8, private readonly retryMs = 10_000) {
package/src/prompts.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { NODE, start, type Part } from './memory.ts';
2
2
 
3
- // Prompts from Victor Taelin's OptChat recipe, lightly adapted; README "How it differs from the recipe" lists the changes.
3
+ // Prompts from Victor Taelin's OptChat recipe, lightly adapted; docs/recipe-differences.md lists the changes.
4
4
  export const COMPACT = `You are OptChat, an AI agent that works for one user in a single chat that never ends.
5
5
  Each call to you is a turn or a compaction:
6
6
  the view below is followed by the user's new message, or by a task starting "Compaction:".
@@ -70,6 +70,8 @@ further along than it was, and never narrate the process of summarizing
70
70
  ("output unseen", "read-only", "unconfirmed") unless that uncertainty is itself
71
71
  a fact OptChat must know. If told the line is too long, shorten it. Non-ASCII
72
72
  characters cost 2-4 bytes.`;
73
+ /** Spawn tasks may name a model; the ids the model knows from training drift, so the models tool is the source. */
74
+ export const MODELS_HINT = 'When the user names a model family without a version ("Opus", "GPT"), assume the newest one and call models() for its exact id: your training may not know it.';
73
75
  /** Labels the line naming running agents, last in every main-agent request while any run (src/transcript.ts); never stored. */
74
76
  export const AT_WORK = '[OptChat status] Agents at work now: ';
75
77
  export const MASTER = `You are OptChat, an AI agent that works for one user in a single chat that
@@ -79,6 +81,7 @@ message.
79
81
  Do the user's tasks yourself (or delegate to subagents according to the tasks and user preferences), with your tools, following the user's instructions
80
82
  at the end of this prompt: who they are, how their files are organized and how
81
83
  they want work done. Use subagents when it makes sense and when the user asks for them.
84
+ ${MODELS_HINT}
82
85
 
83
86
  Messages the user sends while you work reach you between tool calls. Subagents
84
87
  and computer tasks run in the background; each one's report reaches you as a
@@ -150,7 +153,7 @@ export const IMPORT_GUIDANCE = `Entries marked Historical are imported records,
150
153
 
151
154
  // Subagent instructions, joined after the user's own (AGENTS.md etc.): one of the two delegation paragraphs,
152
155
  // then STEERABLE, then the connected or plain tell_parent paragraph.
153
- export const delegation = (maxAgents: number) => `You may delegate parts of your assigned task with spawn when useful. Child reports arrive automatically after your current run ends; the harness keeps you alive to receive them. Never poll, sleep, or wait in a tool for children. Finish your current work and return; you will be prompted with their results. The profile allows ${maxAgents} active agents total.`;
156
+ export const delegation = (maxAgents: number) => `You may delegate parts of your assigned task with spawn when useful. Child reports arrive automatically after your current run ends; the harness keeps you alive to receive them. Never poll, sleep, or wait in a tool for children. Finish your current work and return; you will be prompted with their results. The profile allows ${maxAgents} active agents total. ${MODELS_HINT}`;
154
157
  export const NO_DELEGATION = 'You are at the maximum delegation depth. Complete your task with your own tools.';
155
158
  /** Pi hands steering to a run only between tool calls, so one long command keeps the parent and the user from reaching it. */
156
159
  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.';
package/src/settings.ts CHANGED
@@ -23,9 +23,9 @@ export const SETTINGS = {
23
23
  summaryAcceptBytes: { kind: 'number', label: 'Summary size tolerance', default: NODE * 1.25, min: NODE, unit: 'bytes',
24
24
  description: `The compactor is always asked for ${NODE}-byte lines; a longer line up to this size is kept instead of retried. ${NODE}: strict, as in Victor's recipe. The default saves retries for a little more view space.`,
25
25
  applies: 'Applies to the next summary.' },
26
- importJobs: { kind: 'number', label: 'Import: summaries at once', default: 8, min: 1, max: 64, unit: '',
27
- description: 'Summaries an import builds at once. 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 itself always runs 8.',
28
- applies: 'Applies to the next import.' },
26
+ jobs: { kind: 'number', label: 'Summaries at once', default: 8, min: 1, max: 64, unit: '',
27
+ description: 'Summaries built at once, by chat compaction and by an import. 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. If your provider caps parallel requests, set it below that cap so the main turn has room.',
28
+ applies: 'Applies to the next import, and to chat once the profile opens again (e.g. after /reload).' },
29
29
  refreshAt: { kind: 'percent', label: 'Turn refresh threshold', default: 0.3, min: 0, max: 0.9,
30
30
  description: 'Once the current turn\'s messages pass this share of the model\'s context window, they move into memory and the turn goes on with a fresh window: the rebuilt view, the task word for word, and its last tool call and result. 0%: off, as in Victor\'s recipe, and a long turn can run out of window. OPTCHAT_REFRESH_AT (a fraction, e.g. 0.3; 0 is off) overrides it. Subagents use Pi\'s own compaction at about the same point.',
31
31
  applies: 'Applies from the next call, and to subagents started or resumed after this.' },
@@ -54,6 +54,8 @@ export function invalid(key: SettingKey, value: unknown) {
54
54
 
55
55
  /** Missing keys take their defaults, so config files written before a setting existed keep working. */
56
56
  export function readSettings(value: Record<string, unknown>): Settings {
57
+ // "Summaries at once" was `importJobs` while it only covered imports; an older config.json keeps its value.
58
+ if (!Object.hasOwn(value, 'jobs') && Object.hasOwn(value, 'importJobs')) value = { ...value, jobs: value.importJobs };
57
59
  return Object.fromEntries(SETTING_KEYS.map(key => {
58
60
  const raw = Object.hasOwn(value, key) ? value[key] : SETTINGS[key].default, problem = invalid(key, raw);
59
61
  if (problem) throw new Error(problem);