pi-optchat 0.8.5 → 0.8.7
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 +91 -102
- package/docs/connected-windows.md +63 -0
- package/docs/import.md +74 -0
- package/docs/inspector.md +45 -0
- package/docs/recipe-differences.md +52 -0
- package/docs/storage.md +25 -0
- package/docs/subagents.md +100 -0
- package/docs/victor-recipe.md +2 -2
- package/package.json +1 -1
- package/src/agent-view.ts +2 -2
- package/src/agents.ts +65 -27
- package/src/cache.ts +16 -4
- package/src/compactor.ts +7 -5
- package/src/import/job.ts +2 -2
- package/src/index.ts +8 -7
- package/src/inspector.ts +4 -4
- package/src/memory.ts +57 -27
- package/src/navigation.ts +3 -3
- package/src/prompts.ts +7 -2
- package/src/runs.ts +2 -0
- package/src/settings.ts +5 -3
- package/src/window-bridge.ts +6 -5
|
@@ -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. As in UniiChat, a node the model refuses keeps the first 512 bytes of its source instead (#193), and a summarizer is sent at most 100,000 characters of one message; zoom still pages through all of it.
|
|
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.
|
package/docs/storage.md
ADDED
|
@@ -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. The saved view (`view.json`) holds the chat's view and the compactions' view, each with its batch state; an older one without the compactions' view refolds it from the chat's, once.
|
|
16
|
+
|
|
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 agent's instructions name the profile's subagent model and level, so it leaves a task's `model` unset unless you ask for another one. The `models` tool (`provider` optional) lists what a task may name, with the default marked; the agent is told to take ids only from it, never from memory, since the newest ids may postdate its training ("Opus" without a version means the newest).
|
|
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.
|
package/docs/victor-recipe.md
CHANGED
|
@@ -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
|
|
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
|
|
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
package/src/agent-view.ts
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
import { Editor, Spacer, compositeTuiLine, matchesKey, truncateToWidth, visibleWidth, type Component, type Focusable, type TUI, type TuiMouseEvent, type TuiMouseEventResult } from '@earendil-works/pi-tui';
|
|
7
7
|
import type { AgentMessage } from '@earendil-works/pi-agent-core';
|
|
8
8
|
import type { Children } from './agents.ts';
|
|
9
|
-
import type
|
|
9
|
+
import { isHandingOff, type RunInfo } from './runs.ts';
|
|
10
10
|
import { textContent } from './transcript.ts';
|
|
11
11
|
import { record } from './cache.ts';
|
|
12
12
|
import { elapsed, fit, oneLine } from './inspector.ts';
|
|
@@ -164,7 +164,7 @@ export interface AgentViewOptions {
|
|
|
164
164
|
|
|
165
165
|
const statusOf = (run: RunInfo, children: Children) => {
|
|
166
166
|
const live = children.live(run.id);
|
|
167
|
-
if (!live) return run.state;
|
|
167
|
+
if (!live) return isHandingOff(run) ? 'handing off' : run.state;
|
|
168
168
|
if (run.state === 'stopping') return 'stopping';
|
|
169
169
|
if (run.state === 'waiting') return 'waiting for its agents';
|
|
170
170
|
if (run.state === 'paused') return 'interrupted · waiting for you';
|
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
|
|
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
|
-
|
|
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: 'Leave unset for the profile\'s subagent model (named in your instructions). Set only when the user asks for a model, as provider/id from models().' })),
|
|
72
|
+
thinking: Type.Optional(StringEnum(THINKING, { description: 'Leave unset for the profile\'s subagent level. Set only when the user asks for one.' })) }), { 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); }
|
|
@@ -126,10 +147,24 @@ export class Children {
|
|
|
126
147
|
const file = this.history.records.get(id)?.sessionFile;
|
|
127
148
|
return file ? sessionMessages(file) : [];
|
|
128
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* Every message a run has finalized, in order and never rewritten: Pi's session entries, for a live run too.
|
|
152
|
+
* `session.messages` is only the model's projection, which compaction and retries replace with a shorter list.
|
|
153
|
+
*/
|
|
154
|
+
retained(id: string): AgentMessage[] {
|
|
155
|
+
const live = this.running.get(id);
|
|
156
|
+
return live ? live.session.sessionManager.getEntries().flatMap(e => e.type === 'message' ? [e.message] : []) : this.messages(id);
|
|
157
|
+
}
|
|
129
158
|
/** 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
159
|
transcript(id: string, reader?: string) {
|
|
131
160
|
const known = reader ? this.history.descendants(reader).some(run => run.id === id) : this.running.has(id) || this.history.records.has(id);
|
|
132
|
-
|
|
161
|
+
if (!known) return undefined;
|
|
162
|
+
const live = this.running.get(id);
|
|
163
|
+
if (!live) return this.messages(id);
|
|
164
|
+
const retained = this.retained(id);
|
|
165
|
+
// Pi puts the finalized message in session.messages before message_end listeners run, then persists that same object.
|
|
166
|
+
const tail = live.streaming ?? live.session.messages.at(-1);
|
|
167
|
+
return tail && !retained.includes(tail) ? [...retained, tail] : retained;
|
|
133
168
|
}
|
|
134
169
|
/** Runs at work now: running, waiting for children, or stopping; not paused. */
|
|
135
170
|
get working() { return [...this.running.values()].filter(live => isRunning(live.info)).map(live => live.info); }
|
|
@@ -157,16 +192,23 @@ export class Children {
|
|
|
157
192
|
this.changed();
|
|
158
193
|
} catch (error) { this.warn(`Could not record subagent activity: ${String(error)}`); }
|
|
159
194
|
}
|
|
160
|
-
async spawn(tasks:
|
|
195
|
+
async spawn(tasks: Task[], cwd: string, signal?: AbortSignal, parentId?: string, connected = false) {
|
|
161
196
|
return (await this.launch(tasks, cwd, signal, parentId, connected)).ids;
|
|
162
197
|
}
|
|
163
198
|
/** The spawn tools' answer, saying how this spawn's reports will come back. */
|
|
164
|
-
async start(tasks:
|
|
199
|
+
async start(tasks: Task[], cwd: string, signal?: AbortSignal, parentId?: string) {
|
|
165
200
|
const { ids, grouped } = await this.launch(tasks, cwd, signal, parentId);
|
|
166
201
|
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
202
|
}
|
|
168
|
-
private async launch(tasks:
|
|
203
|
+
private async launch(tasks: Task[], cwd: string, signal: AbortSignal | undefined, parentId: string | undefined, connected = false) {
|
|
169
204
|
for (const directory of tasks.map(t => taskDirectory(cwd, t.cwd))) if (!existsSync(directory) || !statSync(directory).isDirectory()) throw new Error(`No such directory: ${directory}`);
|
|
205
|
+
const profile = this.choice();
|
|
206
|
+
const runs = tasks.map(task => {
|
|
207
|
+
const name = task.model ?? `${profile.provider}/${profile.model}`, model = this.find(name);
|
|
208
|
+
// 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.
|
|
209
|
+
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'}.`);
|
|
210
|
+
return { ...task, model, thinking: task.thinking ?? profile.thinking };
|
|
211
|
+
});
|
|
170
212
|
if (this.closing) throw new Error('Profile is closing.');
|
|
171
213
|
this.settling++;
|
|
172
214
|
try { await this.memory.settle(signal); } finally { this.settling--; }
|
|
@@ -179,10 +221,7 @@ export class Children {
|
|
|
179
221
|
if (this.closing) throw new Error('Profile is closing.');
|
|
180
222
|
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
223
|
const view = this.memory.render();
|
|
182
|
-
const
|
|
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 }));
|
|
224
|
+
const launched = await this.track(this.launchBatch({ tasks: runs, cwd, depth, parentId, connected, signal, cancelled }));
|
|
186
225
|
// Grouped, the last child of this spawn to finish delivers every report; otherwise each reports on its own.
|
|
187
226
|
const batch = groupReports && !connected ? { ids: launched.map(c => c.info.id), reports: new Map<string, string>() } : undefined;
|
|
188
227
|
for (const live of launched) {
|
|
@@ -202,8 +241,8 @@ export class Children {
|
|
|
202
241
|
launch.then(done, done);
|
|
203
242
|
return launch;
|
|
204
243
|
}
|
|
205
|
-
private async launchBatch({ tasks, cwd, depth, parentId, connected,
|
|
206
|
-
|
|
244
|
+
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;
|
|
245
|
+
parentId?: string; connected: boolean; signal?: AbortSignal; cancelled: () => boolean }) {
|
|
207
246
|
const launched: LiveRun[] = [];
|
|
208
247
|
let reserved = tasks.length;
|
|
209
248
|
this.launching += reserved;
|
|
@@ -212,9 +251,9 @@ export class Children {
|
|
|
212
251
|
signal?.throwIfAborted();
|
|
213
252
|
if (cancelled()) throw new Error('Parent or profile is stopping.');
|
|
214
253
|
const id = randomUUID().slice(0, 8), directory = taskDirectory(cwd, task.cwd);
|
|
215
|
-
const session = await this.open({ id, directory, depth, parentId, connected,
|
|
254
|
+
const session = await this.open({ id, directory, depth, parentId, connected, model: task.model, thinking: task.thinking,
|
|
216
255
|
sessionManager: SessionManager.create(directory, join(this.profileDirectory, 'runs')) });
|
|
217
|
-
const info: RunInfo = { id, task: task.task, cwd: directory, model: `${
|
|
256
|
+
const info: RunInfo = { id, task: task.task, cwd: directory, model: `${task.model.provider}/${task.model.id}`, thinking: session.thinkingLevel,
|
|
218
257
|
parentSession: this.options.parentSession ?? '', parentId, depth, sessionFile: session.sessionFile, started: Date.now(), state: 'running', guidance: [], ...(connected ? { connected: true } : {}) };
|
|
219
258
|
const live: LiveRun = { session, info, updated: Date.now(), tools: new Map(), pendingReports: [], pendingGuidance: [] };
|
|
220
259
|
launched.push(live); this.save(info); this.running.set(id, live);
|
|
@@ -237,11 +276,11 @@ export class Children {
|
|
|
237
276
|
return launched;
|
|
238
277
|
}
|
|
239
278
|
/** 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;
|
|
279
|
+
private async open(o: { id: string; directory: string; depth: number; parentId?: string; connected: boolean; model: Model<Api>;
|
|
241
280
|
thinking?: ModelChoice['thinking']; sessionManager: SessionManager }) {
|
|
242
281
|
const { id, directory, depth, parentId, connected } = o;
|
|
243
282
|
const { subagentLevels, maxAgents, memorySearch } = this.settings, delegates = depth < subagentLevels;
|
|
244
|
-
const instructions = [this.instructions(), delegates ? delegation(maxAgents) : NO_DELEGATION, STEERABLE, connected ? CONNECTED : NOT_CONNECTED].filter(Boolean).join('\n\n');
|
|
283
|
+
const instructions = [this.instructions(), delegates ? delegation(maxAgents, this.choice()) : NO_DELEGATION, STEERABLE, connected ? CONNECTED : NOT_CONNECTED].filter(Boolean).join('\n\n');
|
|
245
284
|
// The user's settings list their installed packages; a copy in memory keeps the child from writing them back.
|
|
246
285
|
const settingsManager = SettingsManager.inMemory({ ...SettingsManager.create(directory, getAgentDir()).getSettings(),
|
|
247
286
|
compaction: subagentCompaction(o.model.contextWindow, refreshFraction(this.settings.refreshAt)), cacheWarming: 'off' });
|
|
@@ -249,8 +288,8 @@ export class Children {
|
|
|
249
288
|
noPromptTemplates: true,
|
|
250
289
|
extensionsOverride: base => ({ ...base, extensions: base.extensions.filter(e => !isOptchat(e.resolvedPath)) }),
|
|
251
290
|
extensionFactories: [...builtinExtensions(this.options.builtins?.() ?? []), pi => {
|
|
252
|
-
const provider = this.registry.getRegisteredProviderConfig(o.provider);
|
|
253
|
-
if (provider) pi.registerProvider(o.provider, provider);
|
|
291
|
+
const provider = this.registry.getRegisteredProviderConfig(o.model.provider);
|
|
292
|
+
if (provider) pi.registerProvider(o.model.provider, provider);
|
|
254
293
|
// Same prompt as the main agent (AGENTS.md files, skills, cwd); only the OptChat preamble differs.
|
|
255
294
|
pi.on('before_agent_start', event => {
|
|
256
295
|
event.systemPromptOptions.customPrompt = allowSearch(`${SUBAGENT}\n\n${VIEW_DOC}${memorySearch ? SEARCH_DOC : ''}`, memorySearch);
|
|
@@ -262,8 +301,8 @@ export class Children {
|
|
|
262
301
|
await loader.reload();
|
|
263
302
|
const { session } = await (this.options.createSession ?? createAgentSession)({ cwd: directory, resourceLoader: loader, settingsManager,
|
|
264
303
|
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'],
|
|
304
|
+
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)],
|
|
305
|
+
excludeTools: delegates ? [] : ['spawn', 'tell', 'models'],
|
|
267
306
|
});
|
|
268
307
|
// Callers track the session only after this returns: clean up here if its extensions fail to start.
|
|
269
308
|
try { await session.bindExtensions({}); }
|
|
@@ -271,9 +310,9 @@ export class Children {
|
|
|
271
310
|
return session;
|
|
272
311
|
}
|
|
273
312
|
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:
|
|
276
|
-
execute: async (_id: string, args: { tasks:
|
|
313
|
+
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.`,
|
|
314
|
+
parameters: Type.Object({ tasks: TASKS }),
|
|
315
|
+
execute: async (_id: string, args: { tasks: Task[] }, signal?: AbortSignal) => result(await this.start(args.tasks, cwd, signal, parentId)),
|
|
277
316
|
}, { 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
317
|
parameters: Type.Object({ id: Type.String(), message: Type.String() }),
|
|
279
318
|
execute: async (_id: string, args: { id: string; message: string }) => {
|
|
@@ -517,14 +556,13 @@ export class Children {
|
|
|
517
556
|
try { if (run.sessionFile && existsSync(run.sessionFile)) manager = SessionManager.open(run.sessionFile); } catch { manager = undefined; }
|
|
518
557
|
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
558
|
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
|
|
521
|
-
const model = slash > 0 ? this.registry.find(provider, run.model.slice(slash + 1)) : undefined;
|
|
559
|
+
const model = this.find(run.model);
|
|
522
560
|
if (!model) throw new Error(`${id}'s model ${run.model} is not available, so it cannot be resumed. Spawn a fresh subagent instead.`);
|
|
523
561
|
this.launching++; this.resuming.add(id);
|
|
524
562
|
let reserved = true;
|
|
525
563
|
try {
|
|
526
564
|
// 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,
|
|
565
|
+
const session = await this.open({ id, directory: run.cwd, depth: run.depth, parentId: run.parentId, connected: false, model, sessionManager: manager });
|
|
528
566
|
// stop() cannot see this child until it is registered, so a parent stopped meanwhile must cancel it here.
|
|
529
567
|
const parent = run.parentId ? this.running.get(run.parentId) : undefined;
|
|
530
568
|
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
|
|
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
|
-
|
|
64
|
-
|
|
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/compactor.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { clampThinkingLevel, isRetryableAssistantError, type Api, type Assistant
|
|
|
3
3
|
import type { ThinkingLevel } from '@earendil-works/pi-agent-core';
|
|
4
4
|
import type { ModelRegistry } from '@earendil-works/pi-coding-agent';
|
|
5
5
|
import { COMPACT, compaction, IMPORT_GUIDANCE, tooLong } from './prompts.ts';
|
|
6
|
-
import { bytes,
|
|
6
|
+
import { bytes, cut, Refused, type Compressor } from './memory.ts';
|
|
7
7
|
import { cachePayload, splitView } from './cache.ts';
|
|
8
8
|
import { DEFAULT_SETTINGS } from './settings.ts';
|
|
9
9
|
|
|
@@ -83,20 +83,22 @@ export function createCompressor(registry: ModelRegistry, choice: () => ModelCho
|
|
|
83
83
|
};
|
|
84
84
|
for (let attempt = 0; attempt < 5; attempt++) {
|
|
85
85
|
const reply = await answer();
|
|
86
|
+
// A stop reason means the model answered: a refusal or a content filter, which asking again repeats.
|
|
87
|
+
if (reply.stopReason === 'error' && reply.rawStopReason !== undefined && !isRetryableAssistantError(reply))
|
|
88
|
+
throw new Refused(reply.errorMessage ?? `Compactor stopped: ${reply.rawStopReason}`);
|
|
86
89
|
if (reply.stopReason === 'error' || reply.stopReason === 'aborted') throw new Error(reply.errorMessage ?? `Compactor ${reply.stopReason}`);
|
|
87
90
|
// A reply cut off at the token limit, or one that calls a tool, is no summary: ask again.
|
|
88
91
|
if (reply.stopReason === 'length' || reply.content.some(c => c.type === 'toolCall')) continue;
|
|
89
92
|
// The compactions' view shows each line under its id+n| head, which a line can copy.
|
|
90
93
|
const line = reply.content.filter(c => c.type === 'text').map(c => c.text).join('').trim().replace(/^\d+\+\d+\|\s*/, '');
|
|
91
|
-
if (!line) throw new
|
|
94
|
+
if (!line) throw new Refused('Compactor returned no text.');
|
|
92
95
|
tries.push(line);
|
|
93
96
|
// A merge of two short lines can come back nearly as big as both, so a line must also shrink what it replaces.
|
|
94
97
|
if (bytes(line) <= accepted() && bytes(line) < bytes(input.source)) break;
|
|
95
98
|
messages.push(reply);
|
|
96
|
-
|
|
97
|
-
messages.push({ role: 'user', content: tooLong(bytes(line), cut), timestamp: Date.now() });
|
|
99
|
+
messages.push({ role: 'user', content: tooLong(bytes(line), cut(line)), timestamp: Date.now() });
|
|
98
100
|
}
|
|
99
|
-
if (!tries.length) throw new
|
|
101
|
+
if (!tries.length) throw new Refused('Compactor returned no whole line in 5 tries.');
|
|
100
102
|
return tries.reduce((a, b) => bytes(a) <= bytes(b) ? a : b);
|
|
101
103
|
};
|
|
102
104
|
}
|
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.
|
|
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 "
|
|
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
|
@@ -10,13 +10,13 @@ import { Memory } from './memory.ts';
|
|
|
10
10
|
import { atomicWrite, storeFor } from './store.ts';
|
|
11
11
|
import { createCompressor } from './compactor.ts';
|
|
12
12
|
import { createProfile, holdProfile, instructions, isWindows, lastProfile, listProfiles, loadConfig, profilePath, rememberProfile, saveConfig, ProfileBusyError, type ProfileConfig } from './profiles.ts';
|
|
13
|
-
import { allowSearch, CONTINUITY, IMPORT_GUIDANCE, MASTER, SEARCH_DOC, TURN_REFRESHED, VIEW_DOC } from './prompts.ts';
|
|
13
|
+
import { allowSearch, CONTINUITY, IMPORT_GUIDANCE, MASTER, modelsHint, SEARCH_DOC, TURN_REFRESHED, VIEW_DOC } from './prompts.ts';
|
|
14
14
|
import { cachePayload, record } from './cache.ts';
|
|
15
15
|
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,
|
|
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);
|
|
@@ -385,7 +385,7 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
385
385
|
const a = required();
|
|
386
386
|
// Pi's own prompt sections (AGENTS.md files, skills, cwd) stay; the profile's instructions go last.
|
|
387
387
|
syncSearch(a.config);
|
|
388
|
-
event.systemPromptOptions.customPrompt = allowSearch(`${MASTER}\n\n${VIEW_DOC}${a.config.previousExchange ? CONTINUITY : ''}${a.config.memorySearch ? SEARCH_DOC : ''}`, a.config.memorySearch);
|
|
388
|
+
event.systemPromptOptions.customPrompt = allowSearch(`${MASTER}\n\n${modelsHint(a.config.subagent)}\n\n${VIEW_DOC}${a.config.previousExchange ? CONTINUITY : ''}${a.config.memorySearch ? SEARCH_DOC : ''}`, a.config.memorySearch);
|
|
389
389
|
event.systemPromptOptions.sections.instructions = `${instructions(a.dir)}\n\n${IMPORT_GUIDANCE}`;
|
|
390
390
|
prompt = event.systemPrompt;
|
|
391
391
|
});
|
|
@@ -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:
|
|
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. Leave a task\'s model unset for the profile\'s default; name one only when the user asks, with an id from models(). 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.
|
|
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
|
})();
|