pi-optchat 0.8.5 → 0.8.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +91 -102
- package/docs/connected-windows.md +63 -0
- package/docs/import.md +74 -0
- package/docs/inspector.md +45 -0
- package/docs/recipe-differences.md +52 -0
- package/docs/storage.md +25 -0
- package/docs/subagents.md +100 -0
- package/docs/victor-recipe.md +2 -2
- package/package.json +1 -1
- package/src/agent-view.ts +2 -2
- package/src/agents.ts +65 -27
- package/src/cache.ts +16 -4
- package/src/compactor.ts +7 -5
- package/src/import/job.ts +2 -2
- package/src/index.ts +8 -7
- package/src/inspector.ts +4 -4
- package/src/memory.ts +57 -27
- package/src/navigation.ts +3 -3
- package/src/prompts.ts +7 -2
- package/src/runs.ts +2 -0
- package/src/settings.ts +5 -3
- package/src/window-bridge.ts +6 -5
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
|
|
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
|
|
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
|
|
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 {
|
|
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
|
|
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
|
|
217
|
+
return;
|
|
201
218
|
parts.push(part); covered = end(part);
|
|
202
219
|
}
|
|
203
|
-
|
|
204
|
-
this.chat.merging = merging;
|
|
205
|
-
return covered;
|
|
220
|
+
return { parts, merging, covered };
|
|
206
221
|
}
|
|
207
|
-
/** Saved when
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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 }
|
|
371
|
-
|
|
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;
|
|
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
|
-
|
|
27
|
-
description: 'Summaries
|
|
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);
|
package/src/window-bridge.ts
CHANGED
|
@@ -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 =
|
|
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(
|
|
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(
|
|
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.
|
|
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.' });
|