pi-optchat 0.6.7 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -20
- package/docs/victor-recipe.md +5 -4
- package/package.json +4 -3
- package/src/agents.ts +88 -41
- package/src/cache.ts +12 -22
- package/src/checkpoint.ts +2 -4
- package/src/compactor.ts +21 -10
- package/src/handoff.ts +2 -2
- package/src/import/job.ts +26 -11
- package/src/import/multi-select.ts +3 -3
- package/src/import/sources.ts +75 -21
- package/src/import/ui.ts +1 -1
- package/src/index.ts +76 -36
- package/src/memory.ts +61 -34
- package/src/profiles.ts +33 -14
- package/src/prompts.ts +1 -1
- package/src/report-message.ts +8 -4
- package/src/runs.ts +14 -2
- package/src/settings-page.ts +164 -0
- package/src/settings.ts +48 -0
- package/src/tools.ts +28 -1
- package/src/transcript.ts +15 -4
- package/src/usage.ts +6 -6
- package/src/window-bridge.ts +4 -4
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+

|
|
2
|
+
|
|
1
3
|
# pi-optchat
|
|
2
4
|
|
|
3
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.
|
|
@@ -41,6 +43,7 @@ For headless use, pass `--optchat-profile work`.
|
|
|
41
43
|
| --- | --- |
|
|
42
44
|
| `/optchat` | Status and actions menu. |
|
|
43
45
|
| `/optchat profile` | Select or create a profile. Switching starts a fresh Pi session. |
|
|
46
|
+
| `/optchat settings` | This profile's settings: models, subagent levels and limits, previous exchange, summary size tolerance. |
|
|
44
47
|
| `/optchat model` | Compactor model and effort for this profile. |
|
|
45
48
|
| `/optchat agents` | Live agent tree and saved run history. |
|
|
46
49
|
| `/optchat agents model` | Subagent model and effort for this profile. |
|
|
@@ -63,18 +66,40 @@ Subagent and compactor settings are saved per profile and do not follow the main
|
|
|
63
66
|
|
|
64
67
|
Compression and subagents make extra model requests with your provider credentials.
|
|
65
68
|
|
|
69
|
+
## Settings
|
|
70
|
+
|
|
71
|
+
`/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 and Summary size tolerance, which keep OptChat's earlier behaviour.
|
|
72
|
+
|
|
73
|
+
| Setting | Default | What it does |
|
|
74
|
+
| --- | --- | --- |
|
|
75
|
+
| Compactor model | Sonnet 5.5, medium | Same as `/optchat model`. Applies to the next summary. |
|
|
76
|
+
| Subagent model | Opus 5.5, high | Same as `/optchat agents model`. Applies to new subagents. |
|
|
77
|
+
| Subagent levels | 1 | 1: only the main agent starts subagents (the recipe). 2 or more: subagents may start their own, that many levels deep. Applies to subagents started or resumed after the change. |
|
|
78
|
+
| Max active agents | 8 | Subagents running at once in the profile, all levels together, so it also caps how deep a chain can go. |
|
|
79
|
+
| Group subagent reports | on | The subagents started by one spawn report together, in one message once the last of them finishes (the recipe). Off: each reports as soon as it finishes. Applies to the next spawn. |
|
|
80
|
+
| Previous exchange | on | Replays your last request and answer in full with the next turn (see below). Off is the recipe. |
|
|
81
|
+
| Previous exchange limit | 16 KB | A larger last exchange is left out. |
|
|
82
|
+
| 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. |
|
|
83
|
+
| 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. |
|
|
84
|
+
|
|
85
|
+
Numbers must be whole numbers of at least 1 (512 for the summary size tolerance). Missing keys in an older `config.json` take their defaults.
|
|
86
|
+
|
|
87
|
+
**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.
|
|
88
|
+
|
|
89
|
+

|
|
90
|
+
|
|
66
91
|
## Subagents
|
|
67
92
|
|
|
68
93
|
Ask in plain words, for example: "Spawn an agent to investigate this repository and report back."
|
|
69
94
|
|
|
70
95
|
- Children get the profile's memory view (frozen at launch), its instructions, read-only `zoom`/`date`, and normal coding tools plus your installed extensions.
|
|
71
96
|
- 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.
|
|
72
|
-
-
|
|
97
|
+
- 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. A stopped or failed child counts as finished, with its stop or error text as its report. 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. Turn **Group subagent reports** off to get each report as soon as its child finishes. 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.
|
|
73
98
|
- In the main chat, subagent messages and reports appear in a dark grey box labelled `↳ subagent <id> · still running` or `· report`, so they don't look like something you typed. The model still receives them as ordinary user messages. (One exception: reports recovered at startup, before your first message in the session, still show as plain user messages.)
|
|
74
99
|
- 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.
|
|
75
|
-
- `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
|
|
76
|
-
-
|
|
77
|
-
-
|
|
100
|
+
- `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.
|
|
101
|
+
- 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).
|
|
102
|
+
- **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.
|
|
78
103
|
- Stopping an agent stops its whole subtree. A failed parent stops its descendants.
|
|
79
104
|
- Agents run inside the Pi process. Closing Pi stops them; there is no detached mode.
|
|
80
105
|
|
|
@@ -119,17 +144,17 @@ Guidance shows as queued until delivered, or undelivered if the child stops firs
|
|
|
119
144
|
Pick the destination profile, then run `/optchat import`.
|
|
120
145
|
|
|
121
146
|
1. **Source**: Claude Code (`~/.claude/projects`), Claude Code memories, Codex (`~/.codex/sessions`, `~/.codex/archived_sessions`), or a ChatGPT export (ZIP, folder, or `conversations.json`; ZIP needs `unzip`). Scanning is local and makes no model calls.
|
|
122
|
-
2. **Select**: for Claude Code, its memories, and Codex, pick projects (busiest first), optionally filter by start date, then take all conversations or pick some. **Space**
|
|
147
|
+
2. **Select**: for Claude Code, its memories, and Codex, 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.
|
|
123
148
|
3. **Mode** (only if the profile already has history):
|
|
124
149
|
- **Append**: keep existing summaries and add the import. Faster and cheaper.
|
|
125
150
|
- **Rebuild by conversation start date**: regenerate the whole tree, ordered by conversation start.
|
|
126
|
-
4. **Preview**: destination, new and duplicate counts, text size, rough token estimate, and compactor. This is not a price quote.
|
|
151
|
+
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.
|
|
127
152
|
|
|
128
|
-
**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. ChatGPT alternate branches are labelled as alternatives. Imported records are marked as historical so old requests are not treated as new instructions.
|
|
153
|
+
**What gets imported**: user messages and final assistant replies, with original dates and source labels, as in Victor's recipe. Tool calls and results, intermediate commentary, reasoning, subagent transcripts, replayed context, and image/audio/file bytes are left out. So is the output Claude Code logs for slash and shell commands; the command itself stays as typed (`/name args` or `!command`). So are the context messages Codex injects, such as the AGENTS.md instructions and the environment context. Dropped text never becomes a conversation's title. ChatGPT alternate branches are labelled as alternatives. Imported records are marked as historical so old requests are not treated as new instructions.
|
|
129
154
|
|
|
130
155
|
**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.
|
|
131
156
|
|
|
132
|
-
**Duplicates**: re-importing skips messages already present, even if titles or paths changed. Changed source messages can appear as a separate historical version.
|
|
157
|
+
**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.
|
|
133
158
|
|
|
134
159
|
**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.
|
|
135
160
|
|
|
@@ -189,14 +214,15 @@ To delete a profile, delete its folder. Your original Pi sessions are kept in Pi
|
|
|
189
214
|
|
|
190
215
|
## How it differs from the recipe
|
|
191
216
|
|
|
192
|
-
The recipe's four prompts are kept verbatim in `src/prompts.ts
|
|
217
|
+
The recipe's four prompts are kept verbatim in `src/prompts.ts` (the view doc adds one sentence: you can zoom out too, from a message to the summaries above it), along with its 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 128,000-byte memory view, binary merges, 8 compression workers, fixed retry delays, 5 shortening attempts, and a 30,000-character tool output cap. Each compactor request shows a 512-byte example line for scale. It is a true line about OptChat itself, labelled as not from the chat and fenced off in `<example>` tags, with the text to summarize in `<input>` tags: shown bare, a made-up example was sometimes summarized as if it were chat and spread up the tree. Anthropic requests get stable cache breakpoints on the view, 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.
|
|
193
218
|
|
|
194
|
-
Each run's context is the memory view, the previous exchange, and your new message. Deliberate additions:
|
|
219
|
+
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)):
|
|
195
220
|
|
|
196
|
-
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
|
|
197
|
-
2. **Subagents** are built in with Pi's SDK rather than a separate package.
|
|
198
|
-
3. **
|
|
199
|
-
4. **
|
|
221
|
+
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.
|
|
222
|
+
2. **Subagents** are built in with Pi's SDK rather than a separate package. With Subagent levels above 1 they can delegate further.
|
|
223
|
+
3. **Memory search** (off by default). With the setting on, the agent also gets `search(text, before?)`: plain, case-insensitive text matching over the original messages, never the summaries (a summary can be wrong, and one fact repeats at every level of the tree), skipping logged zoom and search results. It returns 20 hits at a time, newest first, each with its id, the view line that holds it when that is a summary (`1234 (in 1024+256)`), its date and a snippet; `before: id` pages back, and `zoom(id, 1)` reads a hit. One line about it is added to the system prompt. Turning it on or off changes the cached prompt once.
|
|
224
|
+
4. **Profiles**, the **inspector**, the **usage ledger**, **import**, and **connected windows** are additions. Import adds historical-record guidance to the prompts.
|
|
225
|
+
5. **Not done**: computer use and hosting on an always-on machine.
|
|
200
226
|
|
|
201
227
|
## Development
|
|
202
228
|
|
|
@@ -219,12 +245,12 @@ npm run test:live # paid Anthropic calls on synthetic data in a disposable prof
|
|
|
219
245
|
|
|
220
246
|
Pi's [package directory](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/packages.md) lists npm packages with the `pi-package` keyword, which the manifest already has. GitHub alone is not enough.
|
|
221
247
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
248
|
+
Publishing a GitHub release publishes to npm. The [Publish workflow](.github/workflows/publish.yml) uses npm trusted publishing, so no token or `npm login` is involved:
|
|
249
|
+
|
|
250
|
+
1. Merge a PR that bumps `version` in `package.json`.
|
|
251
|
+
2. `gh release create vX.Y.Z --target main --generate-notes`
|
|
252
|
+
|
|
253
|
+
The workflow checks that the tag matches `package.json`, runs the type check and tests, and publishes with provenance. It skips versions already on npm. To retry a tag, run it by hand: `gh workflow run publish.yml -f tag=vX.Y.Z`.
|
|
228
254
|
|
|
229
255
|
## Credits and license
|
|
230
256
|
|
package/docs/victor-recipe.md
CHANGED
|
@@ -10,11 +10,12 @@ The source remains upstream rather than duplicating the full article here. Its f
|
|
|
10
10
|
|
|
11
11
|
## Implementation mapping
|
|
12
12
|
|
|
13
|
-
- `src/memory.ts`: append-only log, binary summary tree, compression scheduling, bounded view, zoom/date.
|
|
13
|
+
- `src/memory.ts`: append-only log, binary summary tree, compression scheduling, bounded view, zoom/date, and the opt-in text search over original messages (`src/tools.ts` has the tool; not in the recipe, off by default).
|
|
14
14
|
- `src/compactor.ts`: contextual compression and size retries.
|
|
15
|
-
- `src/cache.ts`: stable Anthropic cache boundaries.
|
|
16
|
-
- `src/transcript.ts`: fresh context per parent run, current-run tool loop retained. The previous completed exchange is also retained in full text, an intentional addition to the summary-only recipe for conversational continuity.
|
|
15
|
+
- `src/cache.ts`: stable Anthropic cache boundaries. OpenAI requests get no marks: GPT-5.6 rejects 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
|
+
- `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/
|
|
18
|
+
- `src/settings.ts`: per-profile settings for the departures from the recipe. Defaults are the recipe's (one subagent level, no memory search), except the previous exchange (on) and the summary size tolerance (640 bytes, against the recipe's strict 512).
|
|
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.
|
|
19
20
|
|
|
20
21
|
Profiles, native Pi UI, conversation import, and local Git checkpoints are integration choices described in the README.
|
package/package.json
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-optchat",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"pi": {
|
|
6
6
|
"extensions": [
|
|
7
7
|
"./src/index.ts"
|
|
8
|
-
]
|
|
8
|
+
],
|
|
9
|
+
"image": "https://raw.githubusercontent.com/jonaslsaa/pi-optchat/main/docs/banner.jpg"
|
|
9
10
|
},
|
|
10
11
|
"scripts": {
|
|
11
12
|
"check": "tsc --noEmit",
|
|
12
|
-
"test": "tsx --test test/*.test.ts",
|
|
13
|
+
"test": "tsx --import ./test/support.ts --test test/*.test.ts",
|
|
13
14
|
"test:live": "tsx test/live.ts"
|
|
14
15
|
},
|
|
15
16
|
"devDependencies": {
|
package/src/agents.ts
CHANGED
|
@@ -7,25 +7,32 @@ import * as sdk from '@earendil-works/pi-coding-agent';
|
|
|
7
7
|
import type { AgentMessage } from '@earendil-works/pi-agent-core';
|
|
8
8
|
import type { Api, Model } from '@earendil-works/pi-ai';
|
|
9
9
|
import { SUBAGENT, VIEW_DOC } from './prompts.ts';
|
|
10
|
-
import { memoryTools } from './tools.ts';
|
|
10
|
+
import { memoryTools, SEARCH_DOC, searchTool } from './tools.ts';
|
|
11
11
|
import { type Memory } from './memory.ts';
|
|
12
12
|
import type { ModelChoice } from './compactor.ts';
|
|
13
13
|
import { cachePayload } from './cache.ts';
|
|
14
|
-
import { RunHistory, sessionMessages, type RunInfo, type FinishReason } from './runs.ts';
|
|
14
|
+
import { RunHistory, transition, sessionMessages, type RunInfo, type RunState, type FinishReason } from './runs.ts';
|
|
15
15
|
import { UsageLedger } from './usage.ts';
|
|
16
16
|
import { textContent } from './transcript.ts';
|
|
17
17
|
import { Type } from 'typebox';
|
|
18
18
|
import { result } from './tools.ts';
|
|
19
19
|
import type { HandoffEvidence } from './handoff.ts';
|
|
20
|
+
import { DEFAULT_SETTINGS, type Settings } from './settings.ts';
|
|
20
21
|
|
|
21
22
|
export interface LiveRun {
|
|
22
23
|
session: AgentSession; info: RunInfo; updated: number; streaming?: AgentMessage;
|
|
23
24
|
tools: Map<string, { name: string; args: unknown; output?: unknown; started: number }>;
|
|
24
25
|
pendingReports: string[]; pendingGuidance: string[]; wake?: () => void; completion?: Promise<void>;
|
|
26
|
+
/** Shared by the children of one spawn whose reports are delivered together, in spawn order. */
|
|
27
|
+
batch?: { ids: string[]; reports: Map<string, string> };
|
|
25
28
|
}
|
|
26
29
|
interface Options { parentSession?: string; usage?: UsageLedger; createSession?: typeof createAgentSession;
|
|
27
30
|
/** Names of the built-in extensions the main session loaded (see `loadedBuiltins`). */
|
|
28
31
|
builtins?: () => Iterable<string>;
|
|
32
|
+
/** Journals the reports a main-agent spawn has so far while its other children still run (none: the batch was delivered), so a crash cannot lose them. */
|
|
33
|
+
hold?: (batch: string, texts: string[]) => void;
|
|
34
|
+
/** Read on every spawn, so a changed profile setting applies to the next one. Missing settings take their defaults. */
|
|
35
|
+
settings?: () => Partial<Settings>;
|
|
29
36
|
summarizeHandoff?: (run: RunInfo, messages: AgentMessage[], descendants?: HandoffEvidence[]) => Promise<string> }
|
|
30
37
|
|
|
31
38
|
// Subagents load the user's installed extensions, except any copy of OptChat itself: they get memory tools directly and must not open a profile.
|
|
@@ -65,10 +72,11 @@ export class Children {
|
|
|
65
72
|
private launching = 0;
|
|
66
73
|
private readonly resuming = new Set<string>();
|
|
67
74
|
private settling = 0;
|
|
75
|
+
private readonly launches = new Set<Promise<unknown>>();
|
|
68
76
|
private readonly completions = new Set<Promise<void>>();
|
|
69
77
|
constructor(private readonly memory: Memory, private readonly registry: ModelRegistry,
|
|
70
78
|
private readonly choice: () => ModelChoice, private readonly instructions: () => string,
|
|
71
|
-
private readonly report: (text: string, once?: boolean) => Promise<void>, private readonly warn: (text: string) => void,
|
|
79
|
+
private readonly report: (text: string, options?: { once?: boolean; count?: number }) => Promise<void>, private readonly warn: (text: string) => void,
|
|
72
80
|
private readonly profileDirectory = memory.directory, private readonly options: Options = {}) {
|
|
73
81
|
this.history = new RunHistory(profileDirectory);
|
|
74
82
|
for (const warning of this.history.warnings) warn(warning);
|
|
@@ -80,6 +88,8 @@ export class Children {
|
|
|
80
88
|
} catch (error) { warn(`Could not backfill child usage for ${run.id}: ${String(error)}`); }
|
|
81
89
|
}
|
|
82
90
|
}
|
|
91
|
+
private get settings(): Settings { return { ...DEFAULT_SETTINGS, ...this.options.settings?.() }; }
|
|
92
|
+
private full(extra: number) { return this.running.size + this.launching + extra > this.settings.maxAgents; }
|
|
83
93
|
get ids() { return [...this.running.keys()]; }
|
|
84
94
|
get active() { return this.completions.size > 0 || this.launching > 0 || this.settling > 0; }
|
|
85
95
|
live(id: string) { return this.running.get(id); }
|
|
@@ -117,6 +127,14 @@ export class Children {
|
|
|
117
127
|
} catch (error) { this.warn(`Could not record subagent activity: ${String(error)}`); }
|
|
118
128
|
}
|
|
119
129
|
async spawn(tasks: { task: string; cwd?: string }[], cwd: string, signal?: AbortSignal, parentId?: string, connected = false) {
|
|
130
|
+
return (await this.launch(tasks, cwd, signal, parentId, connected)).ids;
|
|
131
|
+
}
|
|
132
|
+
/** The spawn tools' answer, saying how this spawn's reports will come back. */
|
|
133
|
+
async start(tasks: { task: string; cwd?: string }[], cwd: string, signal?: AbortSignal, parentId?: string) {
|
|
134
|
+
const { ids, grouped } = await this.launch(tasks, cwd, signal, parentId);
|
|
135
|
+
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.'}`;
|
|
136
|
+
}
|
|
137
|
+
private async launch(tasks: { task: string; cwd?: string }[], cwd: string, signal: AbortSignal | undefined, parentId: string | undefined, connected = false) {
|
|
120
138
|
for (const directory of tasks.map(t => taskDirectory(cwd, t.cwd))) if (!existsSync(directory) || !statSync(directory).isDirectory()) throw new Error(`No such directory: ${directory}`);
|
|
121
139
|
if (this.closing) throw new Error('Profile is closing.');
|
|
122
140
|
this.settling++;
|
|
@@ -125,13 +143,36 @@ export class Children {
|
|
|
125
143
|
const cancelled = () => this.closing || (parentId !== undefined && this.running.get(parentId)?.info.state !== 'running');
|
|
126
144
|
if (parentId && (!parent || parent.info.state !== 'running')) throw new Error('The parent is no longer running.');
|
|
127
145
|
const depth = parent ? parent.info.depth + 1 : 1;
|
|
128
|
-
|
|
146
|
+
const { subagentLevels, maxAgents, groupReports } = this.settings;
|
|
147
|
+
if (depth > subagentLevels) throw new Error(`Delegation depth limit reached: this profile allows ${subagentLevels} level${subagentLevels === 1 ? '' : 's'} of subagents.`);
|
|
129
148
|
if (this.closing) throw new Error('Profile is closing.');
|
|
130
|
-
if (this.
|
|
149
|
+
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.`);
|
|
131
150
|
const view = this.memory.render();
|
|
132
151
|
const selected = this.choice();
|
|
133
152
|
const model = this.registry.find(selected.provider, selected.model);
|
|
134
153
|
if (!model) throw new Error(`Subagent model unavailable: ${selected.provider}/${selected.model}`);
|
|
154
|
+
const launched = await this.track(this.launchBatch({ tasks, cwd, depth, parentId, connected, selected, model, signal, cancelled }));
|
|
155
|
+
// Grouped, the last child of this spawn to finish delivers every report; otherwise each reports on its own.
|
|
156
|
+
const batch = groupReports && !connected ? { ids: launched.map(c => c.info.id), reports: new Map<string, string>() } : undefined;
|
|
157
|
+
for (const live of launched) {
|
|
158
|
+
live.batch = batch;
|
|
159
|
+
const work = this.execute(live, `${view}\n\nYour task:\n${live.info.task}`).catch(error => this.warn(`Subagent completion failed: ${String(error)}`))
|
|
160
|
+
.finally(() => { this.completions.delete(work); this.changed(); });
|
|
161
|
+
this.completions.add(work);
|
|
162
|
+
live.completion = work;
|
|
163
|
+
}
|
|
164
|
+
this.changed();
|
|
165
|
+
return { ids: launched.map(c => c.info.id), grouped: batch !== undefined };
|
|
166
|
+
}
|
|
167
|
+
/** close() waits for launches, so shutdown never unlocks the profile under a child that is still opening. */
|
|
168
|
+
private track<T>(launch: Promise<T>) {
|
|
169
|
+
this.launches.add(launch);
|
|
170
|
+
const done = () => { this.launches.delete(launch); };
|
|
171
|
+
launch.then(done, done);
|
|
172
|
+
return launch;
|
|
173
|
+
}
|
|
174
|
+
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;
|
|
175
|
+
selected: ModelChoice; model: Model<Api>; signal?: AbortSignal; cancelled: () => boolean }) {
|
|
135
176
|
const launched: LiveRun[] = [];
|
|
136
177
|
let reserved = tasks.length;
|
|
137
178
|
this.launching += reserved;
|
|
@@ -155,28 +196,21 @@ export class Children {
|
|
|
155
196
|
for (const child of launched) {
|
|
156
197
|
await this.shutdown(child.session); // Extensions such as MCP close their connections and stop their server processes.
|
|
157
198
|
this.dispose(child.session); this.running.delete(child.info.id);
|
|
158
|
-
child.info
|
|
199
|
+
transition(child.info, 'failed'); child.info.ended = Date.now(); child.info.report = `Launch failed: ${String(error)}`;
|
|
159
200
|
if (child.info.connected) child.info.handoff = { reason: signal?.aborted ? 'disconnected' : 'failed' };
|
|
160
201
|
this.save(child.info);
|
|
161
202
|
if (child.info.connected) await this.deliverHandoff(child.info).catch(error => this.warn(`Handoff saved for recovery: ${String(error)}`));
|
|
162
203
|
}
|
|
163
204
|
throw error;
|
|
164
205
|
} finally { this.launching -= reserved; }
|
|
165
|
-
|
|
166
|
-
for (const live of launched) {
|
|
167
|
-
const work = this.execute(live, `${view}\n\nYour task:\n${live.info.task}`).catch(error => this.warn(`Subagent completion failed: ${String(error)}`))
|
|
168
|
-
.finally(() => { this.completions.delete(work); this.changed(); });
|
|
169
|
-
this.completions.add(work);
|
|
170
|
-
live.completion = work;
|
|
171
|
-
}
|
|
172
|
-
this.changed();
|
|
173
|
-
return launched.map(c => c.info.id);
|
|
206
|
+
return launched;
|
|
174
207
|
}
|
|
175
208
|
/** Builds a child session with the same prompt, tools and extensions whether it is new or resumed. */
|
|
176
209
|
private async open(o: { id: string; directory: string; depth: number; parentId?: string; connected: boolean; provider: string; model: Model<Api>;
|
|
177
210
|
thinking?: ModelChoice['thinking']; sessionManager: SessionManager }) {
|
|
178
211
|
const { id, directory, depth, parentId, connected } = o;
|
|
179
|
-
const
|
|
212
|
+
const { subagentLevels, maxAgents, memorySearch } = this.settings, delegates = depth < subagentLevels;
|
|
213
|
+
const delegation = delegates ? `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.` : 'You are at the maximum delegation depth. Complete your task with your own tools.';
|
|
180
214
|
const instructions = [this.instructions(), delegation, connected ? 'You are speaking directly with the user in a connected window. Continue this conversation across requests. Use tell_parent for questions or findings the main agent needs now. A handoff will be generated when the user completes or disconnects the window.'
|
|
181
215
|
: 'Use tell_parent only when your parent needs something now (a blocking question, an important early finding, or when asked to). Your final answer is delivered automatically; do not repeat it with tell_parent.'].filter(Boolean).join('\n\n');
|
|
182
216
|
// The user's settings list their installed packages; a copy in memory keeps the child from writing them back.
|
|
@@ -189,7 +223,7 @@ export class Children {
|
|
|
189
223
|
if (provider) pi.registerProvider(o.provider, provider);
|
|
190
224
|
// Same prompt as the main agent (AGENTS.md files, skills, cwd); only the OptChat preamble differs.
|
|
191
225
|
pi.on('before_agent_start', event => {
|
|
192
|
-
event.systemPromptOptions.customPrompt = `${SUBAGENT}\n\n${VIEW_DOC}`;
|
|
226
|
+
event.systemPromptOptions.customPrompt = `${SUBAGENT}\n\n${VIEW_DOC}${memorySearch ? SEARCH_DOC : ''}`;
|
|
193
227
|
event.systemPromptOptions.sections.instructions = instructions;
|
|
194
228
|
});
|
|
195
229
|
pi.on('before_provider_request', (event, ctx) => ctx.model?.api === 'anthropic-messages' ? cachePayload(event.payload) : event.payload);
|
|
@@ -198,18 +232,18 @@ export class Children {
|
|
|
198
232
|
await loader.reload();
|
|
199
233
|
const { session } = await (this.options.createSession ?? createAgentSession)({ cwd: directory, resourceLoader: loader, settingsManager,
|
|
200
234
|
model: o.model, thinkingLevel: o.thinking, sessionManager: o.sessionManager,
|
|
201
|
-
customTools: [...memoryTools(() => this.memory), ...(
|
|
202
|
-
excludeTools:
|
|
235
|
+
customTools: [...memoryTools(() => this.memory), ...(memorySearch ? [searchTool(() => this.memory)] : []), ...(delegates ? this.delegationTools(id, directory, subagentLevels, maxAgents) : []), this.parentTool(id, parentId, connected)],
|
|
236
|
+
excludeTools: delegates ? [] : ['spawn', 'tell'],
|
|
203
237
|
});
|
|
204
238
|
// Callers track the session only after this returns: clean up here if its extensions fail to start.
|
|
205
239
|
try { await session.bindExtensions({}); }
|
|
206
240
|
catch (error) { await this.shutdown(session); this.dispose(session); throw error; }
|
|
207
241
|
return session;
|
|
208
242
|
}
|
|
209
|
-
private delegationTools(parentId: string, cwd: string) {
|
|
210
|
-
return [{ name: 'spawn', label: 'Delegate task', description:
|
|
211
|
-
parameters: Type.Object({ tasks: Type.Array(Type.Object({ task: Type.String(), cwd: Type.Optional(Type.String({ description: CWD_DOC })) }), { minItems: 1
|
|
212
|
-
execute: async (_id: string, args: { tasks: { task: string; cwd?: string }[] }, signal?: AbortSignal) => result(
|
|
243
|
+
private delegationTools(parentId: string, cwd: string, levels: number, maxAgents: number) {
|
|
244
|
+
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.`,
|
|
245
|
+
parameters: Type.Object({ tasks: Type.Array(Type.Object({ task: Type.String(), cwd: Type.Optional(Type.String({ description: CWD_DOC })) }), { minItems: 1 }) }),
|
|
246
|
+
execute: async (_id: string, args: { tasks: { task: string; cwd?: string }[] }, signal?: AbortSignal) => result(await this.start(args.tasks, cwd, signal, parentId)),
|
|
213
247
|
}, { 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.',
|
|
214
248
|
parameters: Type.Object({ id: Type.String(), message: Type.String() }),
|
|
215
249
|
execute: async (_id: string, args: { id: string; message: string }) => {
|
|
@@ -248,34 +282,36 @@ export class Children {
|
|
|
248
282
|
const { session, info } = live;
|
|
249
283
|
try {
|
|
250
284
|
if (info.connected) await this.report(`[${info.id}] User started a connected conversation in ${info.cwd}. That agent is handling this request with the user directly; don't do it yourself. Initial message: ${info.task}\n\nUse tell with this agent ID only if you know something it needs. It stays open between replies and sends a final handoff on completion or disconnect.`);
|
|
251
|
-
|
|
285
|
+
// A stop that lands before the first prompt has no turn to abort, so it is honoured here.
|
|
286
|
+
const state: RunState = info.state; // a local, so the loop below still sees later state changes
|
|
287
|
+
if (this.closing || info.handoff || state === 'stopping') throw new Error('Stopped before its first request.');
|
|
252
288
|
await session.prompt(prompt);
|
|
253
289
|
while (info.state !== 'stopping') {
|
|
254
290
|
const last = session.messages.findLast(m => m.role === 'assistant');
|
|
255
291
|
if (last?.role === 'assistant' && (last.stopReason === 'error' || last.stopReason === 'aborted')) break;
|
|
256
292
|
if (live.pendingGuidance.length) {
|
|
257
|
-
info
|
|
293
|
+
transition(info, 'running'); this.save(info);
|
|
258
294
|
await session.prompt(live.pendingGuidance.shift()!);
|
|
259
295
|
continue;
|
|
260
296
|
}
|
|
261
297
|
if (live.pendingReports.length) {
|
|
262
|
-
info
|
|
298
|
+
transition(info, 'running'); this.save(info);
|
|
263
299
|
await session.prompt(live.pendingReports.splice(0).join('\n\n'));
|
|
264
300
|
continue;
|
|
265
301
|
}
|
|
266
302
|
const children = this.directChildren(info.id).flatMap(child => child.completion ? [child.completion] : []);
|
|
267
303
|
if (!children.length && !info.connected) break;
|
|
268
304
|
const wake = new Promise<void>(resolve => { live.wake = resolve; });
|
|
269
|
-
info
|
|
305
|
+
transition(info, 'waiting'); this.save(info);
|
|
270
306
|
await Promise.race([...children, wake]); live.wake = undefined;
|
|
271
307
|
}
|
|
272
308
|
const last = session.messages.findLast(m => m.role === 'assistant');
|
|
273
|
-
info
|
|
274
|
-
: last?.role === 'assistant' && last.stopReason === 'error' ? 'failed' : 'completed';
|
|
309
|
+
transition(info, info.state === 'stopping' || last?.role === 'assistant' && last.stopReason === 'aborted' ? 'stopped'
|
|
310
|
+
: last?.role === 'assistant' && last.stopReason === 'error' ? 'failed' : 'completed');
|
|
275
311
|
info.report = last?.role === 'assistant' && (last.stopReason === 'error' || last.stopReason === 'aborted')
|
|
276
312
|
? `Task ${last.stopReason}: ${last.errorMessage ?? 'No details'}` : session.getLastAssistantText() || 'Finished without a text report.';
|
|
277
313
|
} catch (error) {
|
|
278
|
-
info
|
|
314
|
+
transition(info, info.state === 'stopping' ? 'stopped' : 'failed'); info.report = `${info.state}: ${String(error)}`;
|
|
279
315
|
} finally {
|
|
280
316
|
const children = this.directChildren(info.id);
|
|
281
317
|
await Promise.allSettled(children.map(child => this.stop(child.info.id)));
|
|
@@ -288,30 +324,39 @@ export class Children {
|
|
|
288
324
|
}
|
|
289
325
|
if (info.connected) {
|
|
290
326
|
info.handoff ??= { reason: 'failed' };
|
|
291
|
-
info
|
|
327
|
+
transition(info, info.handoff.reason === 'complete' ? 'completed' : 'interrupted');
|
|
292
328
|
this.save(info);
|
|
293
329
|
await this.deliverHandoff(info);
|
|
294
330
|
return;
|
|
295
331
|
}
|
|
296
332
|
// A metadata failure must not suppress delivery of the actual result.
|
|
297
333
|
try { this.save(info); } catch (error) { this.warn(`Could not save run metadata: ${String(error)}`); }
|
|
298
|
-
|
|
334
|
+
let text = `[${info.id}] ${info.report}`;
|
|
335
|
+
const batch = live.batch;
|
|
336
|
+
let count: number | undefined;
|
|
337
|
+
if (batch) {
|
|
338
|
+
batch.reports.set(info.id, text);
|
|
339
|
+
const texts = batch.ids.flatMap(id => batch.reports.get(id) ?? []);
|
|
340
|
+
if (texts.length < batch.ids.length) { if (!info.parentId) this.options.hold?.(batch.ids[0], texts); return; }
|
|
341
|
+
text = texts.join('\n\n'); count = texts.length;
|
|
342
|
+
}
|
|
299
343
|
if (info.parentId) {
|
|
300
344
|
const parent = this.running.get(info.parentId);
|
|
301
345
|
if (parent && parent.info.state !== 'stopping') { parent.pendingReports.push(text); this.changed(); }
|
|
302
346
|
return;
|
|
303
347
|
}
|
|
304
|
-
if (this.closing)
|
|
305
|
-
try { await this.report(text); }
|
|
348
|
+
if (this.closing) this.memory.append('user', text);
|
|
349
|
+
else try { await this.report(text, { count }); }
|
|
306
350
|
catch (error) {
|
|
307
351
|
this.memory.append('user', text);
|
|
308
352
|
this.warn(`Subagent report saved but could not wake the parent: ${String(error)}`);
|
|
309
353
|
}
|
|
354
|
+
if (batch && batch.ids.length > 1) this.options.hold?.(batch.ids[0], []);
|
|
310
355
|
}
|
|
311
356
|
/** `caller` is the agent sending a manager message: undefined for the main agent, else the parent subagent's ID. */
|
|
312
357
|
async tell(id: string, message: string, source: 'manager' | 'user' = 'manager', caller?: string) {
|
|
313
358
|
const live = this.running.get(id);
|
|
314
|
-
if (!live && source === 'manager' && this.history.records.has(id)) return this.resume(id, message, caller);
|
|
359
|
+
if (!live && source === 'manager' && this.history.records.has(id)) return this.track(this.resume(id, message, caller));
|
|
315
360
|
if (live && !['running', 'waiting'].includes(live.info.state)) throw new Error(`${id} is finishing. Its report will arrive on its own${live.info.connected || source === 'user' ? '' : '; tell it again after that to resume it'}.`);
|
|
316
361
|
if (!live) throw new Error(`No running subagent ${id}.`);
|
|
317
362
|
const text = live.info.connected && source === 'manager' ? `[Main agent guidance]\n${message.trim()}` : message.trim(); if (!message.trim()) throw new Error('Message is empty.');
|
|
@@ -323,6 +368,7 @@ export class Children {
|
|
|
323
368
|
else await live.session.steer(text);
|
|
324
369
|
}
|
|
325
370
|
catch (error) { guidance.state = 'undelivered'; this.save(live.info); throw error; }
|
|
371
|
+
if (guidance.state === 'undelivered') throw new Error(`${id} finished before it read the message. Tell it again to resume it.`);
|
|
326
372
|
return 'Message queued for the next tool boundary.';
|
|
327
373
|
}
|
|
328
374
|
/** Reopens a finished child from its saved transcript, same ID, parent and model, and gives it a new message. */
|
|
@@ -338,7 +384,7 @@ export class Children {
|
|
|
338
384
|
: `Only ${id}'s parent ${run.parentId} can resume it, and that parent is no longer running. Resume ${run.parentId} instead, or spawn a new subagent.`);
|
|
339
385
|
}
|
|
340
386
|
if (this.closing) throw new Error('Profile is closing.');
|
|
341
|
-
if (this.
|
|
387
|
+
if (this.full(1)) throw new Error(`Profile limit: at most ${this.settings.maxAgents} active agents, including parents and descendants. ${id} can be resumed when one finishes.`);
|
|
342
388
|
let manager: SessionManager | undefined;
|
|
343
389
|
try { if (run.sessionFile && existsSync(run.sessionFile)) manager = SessionManager.open(run.sessionFile); } catch { manager = undefined; }
|
|
344
390
|
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.`);
|
|
@@ -387,7 +433,7 @@ export class Children {
|
|
|
387
433
|
const recovery = (async () => {
|
|
388
434
|
for (const run of eligible) {
|
|
389
435
|
run.handoff ??= { reason: 'owner-stopped' };
|
|
390
|
-
run
|
|
436
|
+
transition(run, run.handoff.reason === 'complete' ? 'completed' : 'interrupted'); this.save(run);
|
|
391
437
|
await this.deliverHandoff(run);
|
|
392
438
|
}
|
|
393
439
|
})();
|
|
@@ -417,7 +463,7 @@ export class Children {
|
|
|
417
463
|
handoff.text = `[${run.id}] Connected conversation ${handoff.reason === 'complete' ? 'completed by user' : `interrupted (${handoff.reason})`}. This describes the conversation ending, not proof that every task succeeded.\n${summary}\n${source}`;
|
|
418
464
|
run.report = handoff.text; this.save(run);
|
|
419
465
|
}
|
|
420
|
-
await this.report(handoff.text, true);
|
|
466
|
+
await this.report(handoff.text, { once: true });
|
|
421
467
|
handoff.delivered = true; this.save(run);
|
|
422
468
|
}
|
|
423
469
|
async stop(id: string) {
|
|
@@ -426,17 +472,18 @@ export class Children {
|
|
|
426
472
|
const descendants: LiveRun[] = [];
|
|
427
473
|
const collect = (run: LiveRun) => { descendants.push(run); for (const child of this.directChildren(run.info.id)) collect(child); };
|
|
428
474
|
collect(live);
|
|
429
|
-
|
|
475
|
+
const stopping = descendants.filter(run => transition(run.info, 'stopping'));
|
|
476
|
+
for (const run of stopping) {
|
|
430
477
|
if (run.info.connected) run.info.handoff ??= { reason: 'owner-stopped' };
|
|
431
|
-
run.info.state = 'stopping';
|
|
432
478
|
run.wake?.();
|
|
433
479
|
try { this.save(run.info); } catch (error) { this.warn(`Could not save stop status: ${String(error)}`); }
|
|
434
480
|
}
|
|
435
|
-
await Promise.allSettled(
|
|
481
|
+
await Promise.allSettled(stopping.map(run => run.session.abort()));
|
|
436
482
|
}
|
|
437
483
|
async close() {
|
|
438
484
|
this.closing = true;
|
|
439
485
|
await Promise.allSettled([...this.running.keys()].map(id => this.stop(id)));
|
|
486
|
+
await Promise.allSettled(this.launches);
|
|
440
487
|
await Promise.allSettled(this.completions);
|
|
441
488
|
}
|
|
442
489
|
}
|
package/src/cache.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isView } from './memory.ts';
|
|
2
|
+
|
|
1
3
|
export function record(value: unknown): value is Record<string, unknown> {
|
|
2
4
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
3
5
|
}
|
|
@@ -19,38 +21,26 @@ export function splitView(text: string) {
|
|
|
19
21
|
export function cachePayload(payload: unknown): unknown {
|
|
20
22
|
if (!record(payload) || !Array.isArray(payload.messages)) return payload;
|
|
21
23
|
const messages = payload.messages;
|
|
22
|
-
|
|
24
|
+
const view = new Set<unknown>();
|
|
23
25
|
for (const message of messages) {
|
|
24
26
|
if (!record(message) || message.role !== 'user' || !Array.isArray(message.content)) continue;
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
break;
|
|
33
|
-
}
|
|
34
|
-
if (found) break;
|
|
27
|
+
const at = message.content.findIndex((block: unknown) => record(block) && block.type === 'text' && typeof block.text === 'string' && isView(block.text));
|
|
28
|
+
if (at < 0) continue;
|
|
29
|
+
const pieces = splitView(message.content[at].text);
|
|
30
|
+
const blocks = pieces.map((text, j) => ({ type: 'text', text, ...(j < pieces.length - 1 ? { cache_control: { type: 'ephemeral' } } : {}) }));
|
|
31
|
+
message.content.splice(at, 1, ...blocks);
|
|
32
|
+
for (const block of blocks) view.add(block);
|
|
33
|
+
break;
|
|
35
34
|
}
|
|
36
|
-
if (!
|
|
35
|
+
if (!view.size) return payload;
|
|
37
36
|
// Pi's default system/recent-message marks would exceed Anthropic's four-mark limit.
|
|
38
|
-
// Remove all adapter marks first, keeping only the newly split view's three marks.
|
|
39
37
|
for (const section of [payload.system, payload.tools]) {
|
|
40
38
|
if (Array.isArray(section)) for (const item of section) if (record(item)) delete item.cache_control;
|
|
41
39
|
}
|
|
42
|
-
let seenView = false;
|
|
43
40
|
for (const message of messages) {
|
|
44
41
|
if (!record(message)) continue;
|
|
45
42
|
delete message.cache_control;
|
|
46
|
-
if (
|
|
47
|
-
let inView = false;
|
|
48
|
-
for (const item of message.content) {
|
|
49
|
-
if (!record(item)) continue;
|
|
50
|
-
if (!seenView && item.type === 'text' && typeof item.text === 'string' && item.text.startsWith('<chat>\n')) { inView = true; seenView = true; }
|
|
51
|
-
if (!inView) delete item.cache_control;
|
|
52
|
-
if (inView && typeof item.text === 'string' && item.text.includes('</chat>')) { delete item.cache_control; inView = false; }
|
|
53
|
-
}
|
|
43
|
+
if (Array.isArray(message.content)) for (const item of message.content) if (record(item) && !view.has(item)) delete item.cache_control;
|
|
54
44
|
}
|
|
55
45
|
payload.cache_control = { type: 'ephemeral' };
|
|
56
46
|
return payload;
|
package/src/checkpoint.ts
CHANGED
|
@@ -7,10 +7,8 @@ const exec = promisify(execFile);
|
|
|
7
7
|
|
|
8
8
|
export async function checkpoint(directory: string) {
|
|
9
9
|
const git = (args: string[]) => exec('git', ['-C', directory, ...args], { maxBuffer: 1_000_000 });
|
|
10
|
-
if (!existsSync(join(directory, '.git')))
|
|
11
|
-
|
|
12
|
-
atomicWrite(join(directory, '.gitignore'), '/runs/\n/memory.html\n/usage.jsonl\n*.tmp\n');
|
|
13
|
-
}
|
|
10
|
+
if (!existsSync(join(directory, '.git'))) await git(['init', '-q', '-b', 'main']);
|
|
11
|
+
if (!existsSync(join(directory, '.gitignore'))) atomicWrite(join(directory, '.gitignore'), '/runs/\n/memory.html\n/usage.jsonl\n*.tmp\n');
|
|
14
12
|
await git(['add', '-A', '--', '.']);
|
|
15
13
|
const status = await git(['diff', '--cached', '--name-only']);
|
|
16
14
|
if (!status.stdout.trim()) return;
|