pi-optchat 0.8.5 → 0.8.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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';
@@ -130,7 +138,7 @@ export class Memory {
130
138
  return new Memory({ directory, store, saved: await store.load() }, compress, warn, budget, jobs);
131
139
  }
132
140
  /** A directory alone opens a FileStore there. `jobs`: summaries built at once, and how many unbuilt lines a new message's node
133
- * waits behind. Chat keeps 8; imports pass the "Import: summaries at once" setting. */
141
+ * waits behind. Chat and imports pass the "Summaries at once" setting. */
134
142
  constructor(source: string | { directory: string; store: Store; saved: Saved }, private readonly compress: Compressor,
135
143
  private readonly warn: (s: string) => void = console.error,
136
144
  readonly budget = VIEW, private readonly jobs = 8, private readonly retryMs = 10_000) {
@@ -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,6 +1,7 @@
1
+ import type { ModelChoice } from './compactor.ts';
1
2
  import { NODE, start, type Part } from './memory.ts';
2
3
 
3
- // Prompts from Victor Taelin's OptChat recipe, lightly adapted; README "How it differs from the recipe" lists the changes.
4
+ // Prompts from Victor Taelin's OptChat recipe, lightly adapted; docs/recipe-differences.md lists the changes.
4
5
  export const COMPACT = `You are OptChat, an AI agent that works for one user in a single chat that never ends.
5
6
  Each call to you is a turn or a compaction:
6
7
  the view below is followed by the user's new message, or by a task starting "Compaction:".
@@ -70,6 +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.`;
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.`;
73
78
  /** Labels the line naming running agents, last in every main-agent request while any run (src/transcript.ts); never stored. */
74
79
  export const AT_WORK = '[OptChat status] Agents at work now: ';
75
80
  export const MASTER = `You are OptChat, an AI agent that works for one user in a single chat that
@@ -150,7 +155,7 @@ export const IMPORT_GUIDANCE = `Entries marked Historical are imported records,
150
155
 
151
156
  // Subagent instructions, joined after the user's own (AGENTS.md etc.): one of the two delegation paragraphs,
152
157
  // then STEERABLE, then the connected or plain tell_parent paragraph.
153
- export const delegation = (maxAgents: number) => `You may delegate parts of your assigned task with spawn when useful. Child reports arrive automatically after your current run ends; the harness keeps you alive to receive them. Never poll, sleep, or wait in a tool for children. Finish your current work and return; you will be prompted with their results. The profile allows ${maxAgents} active agents total.`;
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)}`;
154
159
  export const NO_DELEGATION = 'You are at the maximum delegation depth. Complete your task with your own tools.';
155
160
  /** Pi hands steering to a run only between tool calls, so one long command keeps the parent and the user from reaching it. */
156
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)
package/src/settings.ts CHANGED
@@ -23,9 +23,9 @@ export const SETTINGS = {
23
23
  summaryAcceptBytes: { kind: 'number', label: 'Summary size tolerance', default: NODE * 1.25, min: NODE, unit: 'bytes',
24
24
  description: `The compactor is always asked for ${NODE}-byte lines; a longer line up to this size is kept instead of retried. ${NODE}: strict, as in Victor's recipe. The default saves retries for a little more view space.`,
25
25
  applies: 'Applies to the next summary.' },
26
- importJobs: { kind: 'number', label: 'Import: summaries at once', default: 8, min: 1, max: 64, unit: '',
27
- description: 'Summaries an import builds at once. With N at once, a summary can see up to N−1 lines not summarized yet before it, so higher is faster but a little less accurate (at 8 that costs about 0.8 points per summary, #108). Higher also hits your provider\'s rate limits harder; the compactor retries what is refused. Chat itself always runs 8.',
28
- applies: 'Applies to the next import.' },
26
+ jobs: { kind: 'number', label: 'Summaries at once', default: 8, min: 1, max: 64, unit: '',
27
+ description: 'Summaries built at once, by chat compaction and by an import. With N at once, a summary can see up to N−1 lines not summarized yet before it, so higher is faster but a little less accurate (at 8 that costs about 0.8 points per summary, #108). Higher also hits your provider\'s rate limits harder; the compactor retries what is refused. If your provider caps parallel requests, set it below that cap so the main turn has room.',
28
+ applies: 'Applies to the next import, and to chat once the profile opens again (e.g. after /reload).' },
29
29
  refreshAt: { kind: 'percent', label: 'Turn refresh threshold', default: 0.3, min: 0, max: 0.9,
30
30
  description: 'Once the current turn\'s messages pass this share of the model\'s context window, they move into memory and the turn goes on with a fresh window: the rebuilt view, the task word for word, and its last tool call and result. 0%: off, as in Victor\'s recipe, and a long turn can run out of window. OPTCHAT_REFRESH_AT (a fraction, e.g. 0.3; 0 is off) overrides it. Subagents use Pi\'s own compaction at about the same point.',
31
31
  applies: 'Applies from the next call, and to subagents started or resumed after this.' },
@@ -54,6 +54,8 @@ export function invalid(key: SettingKey, value: unknown) {
54
54
 
55
55
  /** Missing keys take their defaults, so config files written before a setting existed keep working. */
56
56
  export function readSettings(value: Record<string, unknown>): Settings {
57
+ // "Summaries at once" was `importJobs` while it only covered imports; an older config.json keeps its value.
58
+ if (!Object.hasOwn(value, 'jobs') && Object.hasOwn(value, 'importJobs')) value = { ...value, jobs: value.importJobs };
57
59
  return Object.fromEntries(SETTING_KEYS.map(key => {
58
60
  const raw = Object.hasOwn(value, key) ? value[key] : SETTINGS[key].default, problem = invalid(key, raw);
59
61
  if (problem) throw new Error(problem);
@@ -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.' });