pi-optchat 0.8.6 → 0.8.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -245,7 +245,7 @@ OptChat keeps the recipe's numbers: 512-byte lines, binary merges, the 128,000
245
245
  | Prompts | The original and 2026-10-08 prompts | The same, lightly adapted, all in `src/prompts.ts`: reports start `[id]`, a note on `[OptChat status]`, "don't poll slow things", the size limit as a ceiling rather than a target | Fit OptChat's tools; the size wording gave less filler with no recall loss (#129) |
246
246
  | Compactor instructions | Gets your instructions | Doesn't | An A/B found they added nothing (#112) |
247
247
  | Summary size | Strict 512 bytes | Asked for 512, keeps up to 640 without a retry | Saves retries for a little view space |
248
- | Memory view | Sawtooth, 128,000 → 64,000 bytes | Same, saved to `view.json` and reloaded as it was | |
248
+ | Memory view | Sawtooth, 128,000 → 64,000 bytes | Same, saved to `view.json` with the compactions' view and reloaded as they were | A restart keeps both prompt prefixes cached |
249
249
  | Compaction view | Ends at the first unsummarized line | Shows that line as a placeholder and keeps the lines after it | Cutting there hid short lines like an echo's own tool call; summaries scored lower (#101) |
250
250
  | Tool-result summaries | Result only | Also sees the raw tool call | Recall 3.88 → 4.22 (#153) |
251
251
  | Cache marks | View in 4-line blocks, one mark on the last whole block | Two more marks, 20 and 40 blocks before it | With one mark, a turn that added over 80 lines made the next turn rewrite the whole view; now up to 240 lines still read from cache |
package/docs/inspector.md CHANGED
@@ -40,5 +40,6 @@ A memory gauge: how many messages the profile holds and how much of the 128 KB v
40
40
 
41
41
  - If summarizing keeps failing, the last error and the retry countdown show under it. A turn waiting for summaries shows the same error next to its spinner, usually a summarizer model you aren't logged in to (`/optchat model`).
42
42
  - Once every summary a turn waits for has failed, the turn goes on, with "(not summarized yet: zoom it)" in place of the missing lines, and the failed summaries keep retrying in the background.
43
+ - A summary the model itself won't write (a refusal, an empty reply, or 5 replies cut off at the token limit) isn't retried: that line keeps the start of what it summarizes, after "(not summarized: zoom it)", and a warning names it once. Errors such as a dropped connection, a rate limit or a missing login keep retrying.
43
44
  - It also counts running agents, and interrupted ones waiting for you; their list is on Agents.
44
45
  - While summaries or agents are at work, the bar's Activity item gets a **●**.
@@ -23,7 +23,7 @@ The README has the summary table. This page has the detail: what changed in each
23
23
 
24
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
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.
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
27
 
28
28
  ## Compaction view and scheduling
29
29
 
package/docs/storage.md CHANGED
@@ -12,7 +12,7 @@ An import builds a new memory generation (`memories/<id>/`) and switches `active
12
12
 
13
13
  ## The `Store` seam
14
14
 
15
- Memory reaches disk through one seam, `Store` in `src/store.ts`: the log, summary nodes, saved view and images, which is what would travel between machines.
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
16
 
17
17
  - Runs, recovery journals, imports and their pointer, usage, settings and `AGENTS.md` stay local files.
18
18
  - `FileStore` (the layout in the README) is the only store today. Another, such as Postgres, would implement the same few operations.
package/docs/subagents.md CHANGED
@@ -19,7 +19,7 @@ Your MCP servers (`~/.pi/agent/mcp.json`, the project's `.pi/mcp.json`) work in
19
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
20
 
21
21
  - What a task leaves out comes from the profile: a model alone runs at the profile's thinking level, or the nearest level that model takes.
22
- - The `models` tool (`provider` optional) lists what a task may name, with the profile's default marked. The agent is told to call it when you name a family without a version ("Opus"), since the newest ids may postdate its training.
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
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
24
  - A resumed child keeps the model and level it ran with.
25
25
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-optchat",
3
- "version": "0.8.6",
3
+ "version": "0.8.8",
4
4
  "type": "module",
5
5
  "pi": {
6
6
  "extensions": [
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 { RunInfo } from './runs.ts';
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
@@ -68,8 +68,8 @@ export const taskDirectory = (cwd: string, path = '.', windows = process.platfor
68
68
  const CWD_DOC = 'Project directory the subagent works in (~ allowed); its AGENTS.md files load from there. Defaults to your current directory.';
69
69
  /** The spawn tools' tasks. What a task leaves out of model and thinking is the profile's subagent choice. */
70
70
  export const TASKS = Type.Array(Type.Object({ task: Type.String(), cwd: Type.Optional(Type.String({ description: CWD_DOC })),
71
- model: Type.Optional(Type.String({ description: 'Model as provider/id, such as anthropic/claude-sonnet-5-5. Set it only when the user asks for one; otherwise the profile\'s subagent model runs.' })),
72
- thinking: Type.Optional(StringEnum(THINKING, { description: 'Thinking level. Set it only when the user asks for one; otherwise the profile\'s subagent level applies.' })) }), { minItems: 1 });
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
73
  export type Task = Static<typeof TASKS>[number];
74
74
  /** The tool beside spawn that lists what a task's `model` may be. */
75
75
  export const modelsTool = (children: () => Children) => ({ name: 'models', label: 'List models',
@@ -147,13 +147,21 @@ export class Children {
147
147
  const file = this.history.records.get(id)?.sessionFile;
148
148
  return file ? sessionMessages(file) : [];
149
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
+ }
150
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. */
151
159
  transcript(id: string, reader?: string) {
152
160
  const known = reader ? this.history.descendants(reader).some(run => run.id === id) : this.running.has(id) || this.history.records.has(id);
153
161
  if (!known) return undefined;
154
162
  const live = this.running.get(id);
155
163
  if (!live) return this.messages(id);
156
- const retained = live.session.sessionManager.getEntries().flatMap(e => e.type === 'message' ? [e.message] : []);
164
+ const retained = this.retained(id);
157
165
  // Pi puts the finalized message in session.messages before message_end listeners run, then persists that same object.
158
166
  const tail = live.streaming ?? live.session.messages.at(-1);
159
167
  return tail && !retained.includes(tail) ? [...retained, tail] : retained;
@@ -272,7 +280,7 @@ export class Children {
272
280
  thinking?: ModelChoice['thinking']; sessionManager: SessionManager }) {
273
281
  const { id, directory, depth, parentId, connected } = o;
274
282
  const { subagentLevels, maxAgents, memorySearch } = this.settings, delegates = depth < subagentLevels;
275
- 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');
276
284
  // The user's settings list their installed packages; a copy in memory keeps the child from writing them back.
277
285
  const settingsManager = SettingsManager.inMemory({ ...SettingsManager.create(directory, getAgentDir()).getSettings(),
278
286
  compaction: subagentCompaction(o.model.contextWindow, refreshFraction(this.settings.refreshAt)), cacheWarming: 'off' });
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, NODE, type Compressor } from './memory.ts';
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
 
@@ -42,10 +42,21 @@ function primeFirst() {
42
42
  };
43
43
  };
44
44
  }
45
+ /** Pi's agent loop retries a dropped connection itself; streamSimple doesn't, so a transient error is asked again before it surfaces. */
46
+ export async function retryTransient(ask: () => Promise<AssistantMessage>, onReply: (reply: AssistantMessage) => void,
47
+ delays: readonly number[] = [1000, 2000], signal?: AbortSignal) {
48
+ for (let retry = 0; ; retry++) {
49
+ const reply = await ask();
50
+ onReply(reply);
51
+ if (retry >= delays.length || !isRetryableAssistantError(reply)) return reply;
52
+ await sleep(delays[retry], undefined, { signal });
53
+ }
54
+ }
55
+
45
56
  /** The model is asked for 512 bytes; `accepted` is the longest line kept without a retry (the profile's summary size tolerance). */
46
57
  export function createCompressor(registry: ModelRegistry, choice: () => ModelChoice,
47
58
  onUsage: (message: AssistantMessage) => void = () => {}, accepted = () => DEFAULT_SETTINGS.summaryAcceptBytes,
48
- retryDelays: readonly number[] = [1000, 2000]): Compressor {
59
+ retryDelays?: readonly number[]): Compressor {
49
60
  const gate = primeFirst();
50
61
  return async (input, signal) => {
51
62
  const selected = choice();
@@ -72,31 +83,26 @@ export function createCompressor(registry: ModelRegistry, choice: () => ModelCho
72
83
  return await stream.result();
73
84
  } finally { warmed(false); }
74
85
  };
75
- // Pi's agent loop retries a dropped connection itself; streamSimple doesn't, so a transient error is asked again before it surfaces.
76
- const answer = async () => {
77
- for (let retry = 0; ; retry++) {
78
- const reply = await ask();
79
- onUsage(reply);
80
- if (retry >= retryDelays.length || !isRetryableAssistantError(reply)) return reply;
81
- await sleep(retryDelays[retry], undefined, { signal });
82
- }
83
- };
86
+ const answer = () => retryTransient(ask, onUsage, retryDelays, signal);
84
87
  for (let attempt = 0; attempt < 5; attempt++) {
85
88
  const reply = await answer();
89
+ // A stop reason means the model answered: a refusal or a content filter, which asking again repeats.
90
+ // A provider's own 'error' stop (Mistral) is a failure on its side, not an answer: a plain error, tried again later.
91
+ if (reply.stopReason === 'error' && reply.rawStopReason !== undefined && reply.rawStopReason !== 'error' && !isRetryableAssistantError(reply))
92
+ throw new Refused(reply.errorMessage ?? `Compactor stopped: ${reply.rawStopReason}`);
86
93
  if (reply.stopReason === 'error' || reply.stopReason === 'aborted') throw new Error(reply.errorMessage ?? `Compactor ${reply.stopReason}`);
87
94
  // A reply cut off at the token limit, or one that calls a tool, is no summary: ask again.
88
95
  if (reply.stopReason === 'length' || reply.content.some(c => c.type === 'toolCall')) continue;
89
96
  // The compactions' view shows each line under its id+n| head, which a line can copy.
90
97
  const line = reply.content.filter(c => c.type === 'text').map(c => c.text).join('').trim().replace(/^\d+\+\d+\|\s*/, '');
91
- if (!line) throw new Error('Compactor returned no text.');
98
+ if (!line) throw new Refused('Compactor returned no text.');
92
99
  tries.push(line);
93
100
  // A merge of two short lines can come back nearly as big as both, so a line must also shrink what it replaces.
94
101
  if (bytes(line) <= accepted() && bytes(line) < bytes(input.source)) break;
95
102
  messages.push(reply);
96
- const cut = Buffer.from(line).subarray(0, NODE).toString('utf8').replace(/\uFFFD$/, '');
97
- messages.push({ role: 'user', content: tooLong(bytes(line), cut), timestamp: Date.now() });
103
+ messages.push({ role: 'user', content: tooLong(bytes(line), cut(line)), timestamp: Date.now() });
98
104
  }
99
- if (!tries.length) throw new Error('Compactor returned no whole line in 5 tries.');
105
+ if (!tries.length) throw new Refused('Compactor returned no whole line in 5 tries.');
100
106
  return tries.reduce((a, b) => bytes(a) <= bytes(b) ? a : b);
101
107
  };
102
108
  }
package/src/handoff.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { ModelRegistry } from '@earendil-works/pi-coding-agent';
2
2
  import type { AgentMessage } from '@earendil-works/pi-agent-core';
3
3
  import type { AssistantMessage } from '@earendil-works/pi-ai';
4
- import { reasoningFor, type ModelChoice } from './compactor.ts';
4
+ import { reasoningFor, retryTransient, type ModelChoice } from './compactor.ts';
5
5
  import { HANDOFF } from './prompts.ts';
6
6
  import type { RunInfo } from './runs.ts';
7
7
  import { textContent } from './transcript.ts';
@@ -30,7 +30,7 @@ function formatEvidence({ run, messages, transcriptError }: HandoffEvidence) {
30
30
 
31
31
  /** Summarize in one call when possible; otherwise fold chunks with the prior handoff included in the budget. */
32
32
  export function createHandoffSummarizer(registry: ModelRegistry, choice: () => ModelChoice,
33
- usage: (message: AssistantMessage) => void) {
33
+ usage: (message: AssistantMessage) => void, retryDelays?: readonly number[]) {
34
34
  return async (run: RunInfo, messages: AgentMessage[], descendants: HandoffEvidence[] = []) => {
35
35
  const selected = choice(), model = registry.find(selected.provider, selected.model);
36
36
  if (!model) throw new Error('Profile compactor model unavailable');
@@ -50,11 +50,10 @@ export function createHandoffSummarizer(registry: ModelRegistry, choice: () => M
50
50
  let end = Math.min(transcript.length, offset + available);
51
51
  // Keep multibyte characters intact at chunk boundaries.
52
52
  while (end < transcript.length && (transcript[end] & 0xc0) === 0x80) end--;
53
- const reply = await registry.streamSimple(model, {
53
+ const reply = await retryTransient(() => registry.streamSimple(model, {
54
54
  systemPrompt: HANDOFF,
55
55
  messages: [{ role: 'user', timestamp: Date.now(), content: prefix + transcript.subarray(offset, end).toString('utf8') }],
56
- }, { reasoning: reasoningFor(model, selected.thinking), maxTokens, signal: AbortSignal.timeout(300_000) }).result();
57
- usage(reply);
56
+ }, { reasoning: reasoningFor(model, selected.thinking), maxTokens, signal: AbortSignal.timeout(300_000) }).result(), usage, retryDelays);
58
57
  if (reply.stopReason === 'error' || reply.stopReason === 'aborted') throw new Error(reply.errorMessage ?? reply.stopReason);
59
58
  if (reply.stopReason === 'length') throw new Error('Handoff hit the model output limit before finishing');
60
59
  summary = textContent(reply.content).trim();
package/src/index.ts CHANGED
@@ -10,7 +10,7 @@ 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';
@@ -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,7 +507,7 @@ 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. A task names a model only when the user asks for one; models() lists the valid ids. Each receives the current memory view and read-only zoom/date. Whether children may delegate further, and how many agents may run at once, is set per profile. Completion reports arrive automatically; never poll or sleep waiting for them.',
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
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);
package/src/inspector.ts CHANGED
@@ -2,7 +2,7 @@ import { matchesKey, sliceByColumn, truncateToWidth, visibleWidth, wrapTextWithA
2
2
  import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
3
3
  import type { Children } from './agents.ts';
4
4
  import type { Memory } from './memory.ts';
5
- import { isActiveRun, isRunning } from './runs.ts';
5
+ import { isActiveRun, isHandingOff, isRunning } from './runs.ts';
6
6
  import type { Usage } from '@earendil-works/pi-ai';
7
7
  import { ranges, summarizeUsage, type UsageLedger, type UsageRange, type UsageRole } from './usage.ts';
8
8
 
@@ -162,12 +162,12 @@ export class Inspector implements Component, Focusable {
162
162
  private activityLines(width: number) {
163
163
  const { memory, children, color } = this.options;
164
164
  const { done, total, retryIn } = memory.progress();
165
- const list = children.history.list(), agents = list.filter(isRunning).length, paused = list.filter(r => r.state === 'paused').length;
165
+ const list = children.history.list(), agents = list.filter(isRunning).length, paused = list.filter(r => r.state === 'paused').length, handingOff = list.filter(isHandingOff).length;
166
166
  const filled = total ? Math.floor(10 * done / total) : 0;
167
167
  const lines = [`${color('muted', 'Memory')} · ${count(memory.root.length)} messages · view ${Math.round(memory.size / 1000)} KB / ${Math.round(memory.budget / 1000)} KB`,
168
168
  total ? `Catching up · ${count(done)} of ${count(total)} summaries ${color('accent', '█'.repeat(filled))}${color('dim', '░'.repeat(10 - filled))}` : color('dim', 'Settled')];
169
169
  if (memory.lastError) lines.push(color('dim', `${oneLine(memory.lastError)}${retryIn === undefined ? '' : ` · retry in ${elapsed(retryIn)}`}`));
170
- lines.push('', `${color('muted', 'Agents')} · ${agents} running${paused ? ` · ${paused} interrupted` : ''}`);
170
+ lines.push('', `${color('muted', 'Agents')} · ${agents} running${paused ? ` · ${paused} interrupted` : ''}${handingOff ? ` · ${handingOff} handing off` : ''}`);
171
171
  return lines.flatMap(l => l ? wrapTextWithAnsi(l, width) : ['']);
172
172
  }
173
173
  render(width: number): string[] {
@@ -190,7 +190,7 @@ export class Inspector implements Component, Focusable {
190
190
  if (cursor >= this.top + this.height) this.top = cursor - this.height + 1;
191
191
  const rows = list.slice(this.top, this.top + this.height).map(run => {
192
192
  const live = children.live(run.id), tools = live ? [...live.tools.values()].map(t => t.name).join(', ') : '';
193
- const status = live ? `${run.state === 'stopping' ? 'stopping' : run.state === 'waiting' ? 'waiting for children' : run.state === 'paused' ? 'interrupted · waiting for you' : tools || (live.streaming ? 'responding' : 'working')} · ${elapsed(Date.now() - live.updated)} ago` : run.state;
193
+ const status = live ? `${run.state === 'stopping' ? 'stopping' : run.state === 'waiting' ? 'waiting for children' : run.state === 'paused' ? 'interrupted · waiting for you' : tools || (live.streaming ? 'responding' : 'working')} · ${elapsed(Date.now() - live.updated)} ago` : isHandingOff(run) ? 'handing off' : run.state;
194
194
  return { run, task: `${' '.repeat(run.depth - 1)}${run.parentId ? '↳ ' : ''}${oneLine(run.task)}`, status, time: elapsed((run.ended ?? Date.now()) - run.started) };
195
195
  });
196
196
  // Columns: task (flexible) · status · duration (right-aligned); status yields first on narrow screens.
package/src/memory.ts CHANGED
@@ -4,6 +4,8 @@ import { FileStore, storeFor, type Saved, type Store } from './store.ts';
4
4
  export const NODE = 512;
5
5
  export const VIEW = 128_000;
6
6
  export const CAP = 30_000;
7
+ /** The most characters of one message a leaf sends the compactor, so a huge paste can't overflow it; zoom still pages through all of it. */
8
+ export const SOURCE = 100_000;
7
9
  /** The most characters one zoom into a message returns, so a page stays under the tool output CAP instead of losing its middle. */
8
10
  export const PAGE = 25_000;
9
11
  export const KINDS = ['user', 'talk', 'tool', 'echo', 'note', 'work'] as const;
@@ -16,13 +18,19 @@ export interface Summary extends Part { text: string; size: number }
16
18
  * answers, raw, since its own line is often not built yet (#153). */
17
19
  export interface Compression { context: string; source: string; part: Part; historical?: boolean; call?: { i: number; text: string } }
18
20
  export type Compressor = (input: Compression, signal: AbortSignal) => Promise<string>;
21
+ /** A compressor throws this when the model itself gives no line for an input (a refusal, an empty reply, 5 cut-off replies):
22
+ * asked again it fails alike, so the node keeps the start of its source instead of being retried forever (#193). */
23
+ export class Refused extends Error {}
19
24
  const key = ({ l, i }: Part) => l * 2 ** 40 + i;
20
25
  const UNBUILT = '(not summarized yet: zoom it)';
26
+ const CLIPPED = '(not summarized: zoom it)';
21
27
  export const start = ({ l, i }: Part) => i * 2 ** l;
22
28
  export const end = (part: Part) => start(part) + 2 ** part.l;
23
29
  export const bytes = (s: string) => Buffer.byteLength(s, 'utf8');
24
30
  const UNBUILT_BYTES = bytes(UNBUILT);
25
31
  export const flat = (s: string) => s.replace(/[\r\n]+/g, ' ');
32
+ /** The first NODE bytes, without a split character at the end. */
33
+ export const cut = (s: string) => Buffer.from(s).subarray(0, NODE).toString('utf8').replace(/\uFFFD+$/, '');
26
34
  const lineBytes = (s: string) => bytes(flat(s));
27
35
  const notice = (omitted: number) => `\n[${omitted} characters omitted; head and tail retained]\n`;
28
36
  const VIEW_OPEN = '<chat>\n';
@@ -150,10 +158,8 @@ export class Memory {
150
158
  }
151
159
  for (const node of this.tree.values()) this.queueParent(node);
152
160
  for (let i = 0; i < this.root.length; i++) if (!this.node({ l: 0, i })) this.unbuilt.add(i);
153
- // Refolding the log gives a different view than the live one, and every cache entry would miss, so the view is saved.
161
+ // Refolding the log gives different views than the live ones, and every cache entry would miss, so the views are saved.
154
162
  const loaded = this.load(saved.view);
155
- for (const part of this.view) this.compaction.push(part);
156
- this.compaction.fit(loaded, true);
157
163
  for (let i = loaded; i < this.root.length; i++) { this.push(i); this.fit(i + 1); }
158
164
  this.fit(); this.save(); this.schedule();
159
165
  }
@@ -182,33 +188,43 @@ export class Memory {
182
188
  this.writing = tail;
183
189
  return settled;
184
190
  }
185
- /** Takes the saved view if it still tiles the log from the start, and returns how many messages it covers. */
191
+ /** Takes the saved views if they still tile the log from the start, and returns how many messages they cover. */
186
192
  private load(view: string | undefined) {
187
193
  if (view === undefined) return 0;
188
- const rebuild = () => { this.warn('Rebuilt the memory view: the saved one does not match the log.'); return 0; };
189
194
  let saved: unknown;
190
- try { saved = JSON.parse(view); } catch { return rebuild(); }
195
+ try { saved = JSON.parse(view); } catch { saved = undefined; }
196
+ const chat = this.tiling(saved);
197
+ if (!chat) { this.warn('Rebuilt the memory view: the saved one does not match the log.'); return 0; }
198
+ for (const part of chat.parts) this.chat.push(part);
199
+ this.chat.merging = chat.merging;
200
+ // An older view.json has only the chat's view: the compactions' is refolded from it, a cold prefix once.
201
+ const compaction = object(saved) ? this.tiling(saved.compaction) : undefined;
202
+ if (compaction?.covered === chat.covered) { for (const part of compaction.parts) this.compaction.push(part); this.compaction.merging = compaction.merging; }
203
+ else { for (const part of chat.parts) this.compaction.push(part); this.compaction.fit(chat.covered, true); }
204
+ return chat.covered;
205
+ }
206
+ /** A saved view, `{ parts, merging }` or an older bare list of parts, if it tiles the log from the start. */
207
+ private tiling(saved: unknown) {
191
208
  const merging = object(saved) && saved.merging === true;
192
209
  if (object(saved) && typeof saved.merging === 'boolean') saved = saved.parts;
193
- if (!Array.isArray(saved)) return rebuild();
210
+ if (!Array.isArray(saved)) return;
194
211
  const parts: Part[] = [];
195
212
  let covered = 0;
196
213
  for (const value of saved) {
197
214
  const [l, i] = Array.isArray(value) ? value : [];
198
215
  const part = { l, i };
199
216
  if (!Number.isSafeInteger(l) || !Number.isSafeInteger(i) || l < 0 || start(part) !== covered || end(part) > this.root.length || (l > 0 && !this.node(part)))
200
- return rebuild();
217
+ return;
201
218
  parts.push(part); covered = end(part);
202
219
  }
203
- for (const part of parts) this.chat.push(part);
204
- this.chat.merging = merging;
205
- return covered;
220
+ return { parts, merging, covered };
206
221
  }
207
- /** Saved when it merges or changes batch state: a view that only grew is the saved one plus later messages, as `load` replays it.
208
- * The log stays authoritative, so a failed save only warns. */
222
+ /** Saved when either view merges or changes batch state: a view that only grew is the saved one plus later messages, as `load`
223
+ * replays it. The log stays authoritative, so a failed save only warns. */
209
224
  private save() {
210
225
  const failed = (error: unknown) => { this.warn(`Could not save the memory view: ${error instanceof Error ? error.message : String(error)}`); };
211
- try { void this.enqueue(() => this.store.saveView(JSON.stringify({ parts: this.view.map(p => [p.l, p.i]), merging: this.chat.merging })), failed); }
226
+ const saved = ({ parts, merging }: Sawtooth) => ({ parts: parts.map(p => [p.l, p.i]), merging });
227
+ try { void this.enqueue(() => this.store.saveView(JSON.stringify({ ...saved(this.chat), compaction: saved(this.compaction) })), failed); }
212
228
  catch (error) { failed(error); }
213
229
  }
214
230
  private push(i: number) {
@@ -231,15 +247,15 @@ export class Memory {
231
247
  onChange(listener: () => void) { this.events.on('change', listener); return () => { this.events.off('change', listener); }; }
232
248
  /** The view line covering message `at`. */
233
249
  covering(at: number) { return this.chat.covering(at); }
234
- /** Returns whether the chat's view or batch state changed. */
250
+ /** Returns whether either view, or its batch state, changed. */
235
251
  private fit(total = this.root.length) {
236
- const merging = this.chat.merging;
252
+ const merging = [this.chat.merging, this.compaction.merging];
237
253
  const merged = this.chat.fit(total);
238
- this.compaction.fit(total, merged);
254
+ const compacted = this.compaction.fit(total, merged);
239
255
  const owed = this.owed();
240
256
  this.peak = owed > 0 ? Math.max(this.peak, owed) : 0;
241
257
  this.events.emit('change');
242
- return merged || merging !== this.chat.merging;
258
+ return merged || compacted || merging[0] !== this.chat.merging || merging[1] !== this.compaction.merging;
243
259
  }
244
260
  private schedule() {
245
261
  if (this.scheduled || this.stopped) return;
@@ -286,11 +302,18 @@ export class Memory {
286
302
  : [0, 1].map(offset => this.text({ l: part.l - 1, i: 2 * part.i + offset })).join('\n');
287
303
  const mergeSource = part.l > 0 ? [0, 1].map(offset => flat(this.text({ l: part.l - 1, i: 2 * part.i + offset }))).join('\n') : source;
288
304
  const call = part.l === 0 ? this.callOf(part.i) : undefined;
289
- const text = bytes(source) <= NODE ? source : (await this.compress({ context: this.compactionView(part), source: mergeSource, part,
290
- historical: this.root.slice(start(part), end(part)).some(entry => !!entry.origin),
291
- ...(call ? { call: { i: call.i, text: cap(`tool: ${call.text}`, 4000) } } : {}) }, this.controller.signal)).trim();
305
+ let text = source;
306
+ if (bytes(source) > NODE) try {
307
+ text = (await this.compress({ context: this.compactionView(part), source: cap(mergeSource, SOURCE), part,
308
+ historical: this.root.slice(start(part), end(part)).some(entry => !!entry.origin),
309
+ ...(call ? { call: { i: call.i, text: cap(`tool: ${call.text}`, 4000) } } : {}) }, this.controller.signal)).trim();
310
+ if (!text) throw new Refused('Compactor returned an empty summary.');
311
+ } catch (error) {
312
+ if (!(error instanceof Refused) || this.stopped) throw error;
313
+ text = cut(`${CLIPPED} ${flat(mergeSource)}`);
314
+ this.warn(`Compactor ${start(part)}+${2 ** part.l}: ${error.message} (kept its start instead)`);
315
+ }
292
316
  if (this.stopped) return;
293
- if (!text) throw new Error('Compactor returned an empty summary.');
294
317
  const node = { ...part, text, size: lineBytes(text) };
295
318
  // A failed node write fails the build, which is retried.
296
319
  const written = this.enqueue(() => this.store.appendNode(node), error => { throw error; });
@@ -349,7 +372,7 @@ export class Memory {
349
372
  * Paging a message that fits in one page is allowed, but the page says so. */
350
373
  zoom(id: number, n: number, offset?: number, limit?: number) {
351
374
  if (!Number.isSafeInteger(id) || id < 0 || !Number.isSafeInteger(n) || n < 1 || !Number.isInteger(Math.log2(n)) || id % n || id + n > this.root.length)
352
- throw new Error(`No line ${id}+${n}.`);
375
+ throw new Error(`No line ${id}+${n}: n is a power of 2, id a multiple of n. The tree's roots: ${this.roots() || 'none, the chat is empty'}.`);
353
376
  if (n === 1) {
354
377
  const { kind, text } = this.root[id], page = Math.min(limit ?? PAGE, PAGE);
355
378
  if (!Number.isSafeInteger(page) || page < 1) throw new Error('limit must be a positive integer.');
@@ -366,12 +389,19 @@ export class Memory {
366
389
  }
367
390
  if (offset !== undefined) throw new Error('offset and limit page one message: use them with n = 1.');
368
391
  const l = Math.log2(n) - 1, i = 2 * id / n;
392
+ // A half not built yet shows as in the view, so the other half still comes back.
369
393
  return [0, 1].map(offset => {
370
- const part = { l, i: i + offset }, node = this.node(part);
371
- if (!node) throw new Error('This range is not summarized yet.');
372
- return `${start(part)}+${2 ** l}|${flat(node.text)}`;
394
+ const part = { l, i: i + offset };
395
+ return `${start(part)}+${2 ** l}|${flat(this.text(part))}`;
373
396
  }).join('\n');
374
397
  }
398
+ /** The largest whole lines covering the chat, oldest first: one per power of two in the message count. */
399
+ private roots() {
400
+ const lines: string[] = [];
401
+ for (let id = 0, n = 2 ** Math.floor(Math.log2(this.root.length || 1)); n >= 1; n /= 2)
402
+ if (id + n <= this.root.length) { lines.push(`${id}+${n}`); id += n; }
403
+ return lines.join(', ');
404
+ }
375
405
  /** Original messages containing `text`, ignoring case, newest first, older than message `before`. Never summaries,
376
406
  * nor the logged zoom/search calls and results, which only copy memory. */
377
407
  search(text: string, before = this.root.length) {
package/src/navigation.ts CHANGED
@@ -3,7 +3,7 @@ import { matchesKey, truncateToWidth, visibleWidth, type KeyId } from '@earendil
3
3
  import type { Children } from './agents.ts';
4
4
  import type { Memory } from './memory.ts';
5
5
  import { inspectorShowing, nextPage, type InspectorPage } from './inspector.ts';
6
- import { isRunning } from './runs.ts';
6
+ import { isHandingOff, isRunning } from './runs.ts';
7
7
 
8
8
  /** Configured before extension registration; this also works in legacy terminals with F6. */
9
9
  export function inspectorShortcut(value = process.env.OPTCHAT_INSPECT_KEY ?? 'f6'): KeyId {
@@ -52,9 +52,9 @@ export function mountNavigation(ctx: ExtensionContext, children: Children, memor
52
52
  invalidate() {},
53
53
  render(width: number) {
54
54
  if (inspectorShowing()) return []; // The open panel replaces the editor and this bar.
55
- const list = children.history.list(), running = list.filter(isRunning).length, paused = list.filter(r => r.state === 'paused').length;
55
+ const list = children.history.list(), running = list.filter(isRunning).length, paused = list.filter(r => r.state === 'paused').length, handingOff = list.filter(isHandingOff).length;
56
56
  const label = (page: InspectorPage, text: string) => navigation.selected === page ? theme.fg('accent', `› ${text}`) : theme.fg('muted', text);
57
- const left = `${label('agents', `Agents: ${running} running${paused ? ` · ${paused} interrupted` : ''} · ${list.length - running - paused} saved`)} ${label('usage', 'Usage')} ${memory.progress().total || running ? theme.fg('accent', '● ') : ''}${label('activity', 'Activity')}`;
57
+ const left = `${label('agents', `Agents: ${running} running${paused ? ` · ${paused} interrupted` : ''}${handingOff ? ` · ${handingOff} handing off` : ''} · ${list.length - running - paused - handingOff} saved`)} ${label('usage', 'Usage')} ${memory.progress().total || running || handingOff ? theme.fg('accent', '● ') : ''}${label('activity', 'Activity')}`;
58
58
  const hint = theme.fg('dim', navigation.selected ? '←→ select · Enter open · Esc input' : `${previous ? '' : '↓ select · '}${shortcut} inspect`);
59
59
  const gap = width - visibleWidth(left) - visibleWidth(hint);
60
60
  return [truncateToWidth(gap >= 3 ? `${left}${' '.repeat(gap)}${hint}` : `${left} ${hint}`, width)];
package/src/prompts.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { ModelChoice } from './compactor.ts';
1
2
  import { NODE, start, type Part } from './memory.ts';
2
3
 
3
4
  // Prompts from Victor Taelin's OptChat recipe, lightly adapted; docs/recipe-differences.md lists the changes.
@@ -70,8 +71,10 @@ further along than it was, and never narrate the process of summarizing
70
71
  ("output unseen", "read-only", "unconfirmed") unless that uncertainty is itself
71
72
  a fact OptChat must know. If told the line is too long, shorten it. Non-ASCII
72
73
  characters cost 2-4 bytes.`;
73
- /** Spawn tasks may name a model; the ids the model knows from training drift, so the models tool is the source. */
74
- export const MODELS_HINT = 'When the user names a model family without a version ("Opus", "GPT"), assume the newest one and call models() for its exact id: your training may not know it.';
74
+ /** Names the profile's subagent choice, so the agent has no gap to fill from memory when it spawns: unset means this
75
+ * model, and the ids it knows from training drift, so the models tool is the only source for a named one. */
76
+ export const modelsHint = ({ provider, model, thinking }: ModelChoice) =>
77
+ `Subagents run ${provider}/${model} at thinking ${thinking}: leave a task's model and thinking unset for that. Set either one only when the user asks for it. A named model takes its id from models(), never from memory: your training may not know the newest ones, and a family without a version ("Opus", "GPT") means the newest one.`;
75
78
  /** Labels the line naming running agents, last in every main-agent request while any run (src/transcript.ts); never stored. */
76
79
  export const AT_WORK = '[OptChat status] Agents at work now: ';
77
80
  export const MASTER = `You are OptChat, an AI agent that works for one user in a single chat that
@@ -81,7 +84,6 @@ message.
81
84
  Do the user's tasks yourself (or delegate to subagents according to the tasks and user preferences), with your tools, following the user's instructions
82
85
  at the end of this prompt: who they are, how their files are organized and how
83
86
  they want work done. Use subagents when it makes sense and when the user asks for them.
84
- ${MODELS_HINT}
85
87
 
86
88
  Messages the user sends while you work reach you between tool calls. Subagents
87
89
  and computer tasks run in the background; each one's report reaches you as a
@@ -153,7 +155,7 @@ export const IMPORT_GUIDANCE = `Entries marked Historical are imported records,
153
155
 
154
156
  // Subagent instructions, joined after the user's own (AGENTS.md etc.): one of the two delegation paragraphs,
155
157
  // then STEERABLE, then the connected or plain tell_parent paragraph.
156
- export const delegation = (maxAgents: number) => `You may delegate parts of your assigned task with spawn when useful. Child reports arrive automatically after your current run ends; the harness keeps you alive to receive them. Never poll, sleep, or wait in a tool for children. Finish your current work and return; you will be prompted with their results. The profile allows ${maxAgents} active agents total. ${MODELS_HINT}`;
158
+ export const delegation = (maxAgents: number, choice: ModelChoice) => `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. ${modelsHint(choice)}`;
157
159
  export const NO_DELEGATION = 'You are at the maximum delegation depth. Complete your task with your own tools.';
158
160
  /** Pi hands steering to a run only between tool calls, so one long command keeps the parent and the user from reaching it. */
159
161
  export const STEERABLE = 'Never block in a single command for more than about 60 seconds. To wait for something, poll in short separate tool calls (for example one `sleep 30` per call), so messages from your parent or the user can reach you between calls.';
package/src/runs.ts CHANGED
@@ -38,6 +38,8 @@ export function transition(run: RunInfo, to: RunState) {
38
38
  export const isActiveRun = (run: RunInfo) => run.state === 'running' || run.state === 'waiting' || run.state === 'stopping' || run.state === 'paused';
39
39
  /** Live and working: a paused run is neither running nor finished. */
40
40
  export const isRunning = (run: RunInfo) => isActiveRun(run) && run.state !== 'paused';
41
+ /** A connected run that has ended but whose handoff summary has not reached the main agent yet. */
42
+ export const isHandingOff = (run: RunInfo) => !isActiveRun(run) && !!run.handoff && !run.handoff.delivered;
41
43
  function isRun(value: unknown): value is RunInfo {
42
44
  return record(value) && ['id', 'task', 'cwd', 'model', 'thinking', 'parentSession'].every(k => typeof value[k] === 'string')
43
45
  && typeof value.id === 'string' && /^[a-zA-Z0-9-]+$/.test(value.id)
@@ -123,7 +123,7 @@ export async function serveWindows(directory: string, children: Children, availa
123
123
  if (frame.activeOnly && (!children.live(id) || !['running', 'waiting', 'paused'].includes(info.state)))
124
124
  throw new Error(`Subagent ${id} is no longer available in the menu. Refresh and choose a live agent.`);
125
125
  const live = children.live(id);
126
- const messages = live?.session.messages ?? children.messages(id);
126
+ const messages = children.retained(id);
127
127
  said.add(info.task.trim());
128
128
  // This socket borrows the existing session, never its lifetime, slot or report destination.
129
129
  // Other views (the main window's Agents page, other terminals) stay open alongside it.
@@ -148,7 +148,7 @@ export async function serveWindows(directory: string, children: Children, availa
148
148
  }
149
149
  if (frame.action === 'complete') {
150
150
  const live = children.live(child);
151
- if (live) drainMessages(live.session.messages, live.info.task);
151
+ if (live) drainMessages(children.retained(child), live.info.task);
152
152
  ending = true;
153
153
  if (!attached) track(children.finish(child, 'complete').then(() => {}));
154
154
  send({ kind: 'event', name: 'finished', text: attached ? 'Detached. The background agent remains with its original parent.'
@@ -167,7 +167,8 @@ export async function serveWindows(directory: string, children: Children, availa
167
167
  const drainMessages = (messages: AgentMessage[], task: string) => {
168
168
  const displayable = messages.filter(message => message.role === 'user' || message.role === 'assistant' || message.role === 'toolResult');
169
169
  // An attachment may replay hours of tool output. Pace the history instead of overflowing the bounded wire
170
- // or starving the owner's event loop. The next status tick continues from the same cursor.
170
+ // or starving the owner's event loop. The next status tick continues from the same cursor, which stays valid because
171
+ // retained messages are only ever appended (unlike Pi's projection, which compaction and retries replace).
171
172
  let batch = 0;
172
173
  while (cursor < displayable.length) {
173
174
  if (batch++ >= 32 || socket.writableLength > 1_000_000) return false;
@@ -190,7 +191,7 @@ export async function serveWindows(directory: string, children: Children, availa
190
191
  send({ kind: 'event', name: 'finished', text: 'This conversation was deleted from the Agents page.' });
191
192
  return;
192
193
  }
193
- if (live) drainMessages(live.session.messages, info.task);
194
+ if (live) drainMessages(children.retained(child), info.task);
194
195
  const preview = live?.streaming && 'content' in live.streaming ? textContent(live.streaming.content).slice(-2000) : '';
195
196
  const text = `${info.state === 'waiting' ? 'Awaiting user or child reports' : info.state} · ${info.model}\n${live ? [...live.tools.values()].map(t => t.name).join(', ') : ''}\n${preview}`;
196
197
  let state: LiveState = { state: info.state, model: info.model,
@@ -202,7 +203,7 @@ export async function serveWindows(directory: string, children: Children, availa
202
203
  const status = JSON.stringify(state);
203
204
  if (status !== lastStatus) { lastStatus = status; send({ kind: 'event', name: 'status', text, live: state }); }
204
205
  if (!live && (attached && !isActiveRun(info) || info.handoff?.delivered)) {
205
- try { if (!drainMessages(finalMessages ??= children.messages(child), info.task)) return; }
206
+ try { if (!drainMessages(finalMessages ??= children.retained(child), info.task)) return; }
206
207
  catch (error) { send({ kind: 'event', name: 'message', text: `Could not read the final transcript: ${errorText(error)}` }); }
207
208
  ending = true;
208
209
  send({ kind: 'event', name: 'finished', text: attached ? `Agent ${child} is ${info.state}. Its report goes to its original parent; /complete closes only this view.` : info.handoff?.text ?? 'Conversation ended.' });