pi-optchat 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/settings.ts CHANGED
@@ -3,13 +3,13 @@ import { NODE } from './memory.ts';
3
3
  /** Per-profile choices where OptChat departs from Victor's recipe. Each default is the recipe's behaviour unless noted. */
4
4
  export const SETTINGS = {
5
5
  subagentLevels: { kind: 'number', label: 'Subagent levels', default: 1, min: 1, unit: '',
6
- description: 'How many levels of subagents. 1: only the main agent starts subagents, as in Victor\'s recipe. 2: those subagents may start their own, and so on.',
6
+ description: 'How many levels of subagents. 1: only the main agent starts subagents. 2: those subagents may start their own, and so on.',
7
7
  applies: 'Applies to subagents started or resumed after this.' },
8
8
  maxAgents: { kind: 'number', label: 'Max active agents', default: 8, min: 1, unit: '',
9
9
  description: 'Subagents running at once in this profile, all levels together. It also caps how deep a chain can go.',
10
10
  applies: 'Applies to the next spawn.' },
11
- groupReports: { kind: 'toggle', label: 'Group subagent reports', default: true,
12
- description: 'On: the subagents started by one spawn report together, in one message once the last of them finishes, as in Victor\'s recipe. Off: each subagent reports as soon as it finishes.',
11
+ groupReports: { kind: 'toggle', label: 'Group subagent reports', default: false,
12
+ description: 'Off: each subagent reports as soon as it finishes, as in Victor\'s recipe. On: the subagents started by one spawn report together, in one message once the last of them finishes.',
13
13
  applies: 'Applies to the next spawn.' },
14
14
  previousExchange: { kind: 'toggle', label: 'Previous exchange', default: true,
15
15
  description: 'Send your last request and its answer in full with the next turn, so a follow-up reads the exact wording. Off: memory view only, as in Victor\'s recipe. On by default: a study found it costs little and saves zooms.',
package/src/tools.ts CHANGED
@@ -1,10 +1,14 @@
1
1
  import { Type } from 'typebox';
2
- import { flat, start, type Memory } from './memory.ts';
2
+ import { flat, PAGE, start, type Memory } from './memory.ts';
3
+ import { loadImages } from './images.ts';
3
4
  export const result = (text: string) => ({ content: [{ type: 'text' as const, text }], details: {} });
4
5
  export const SEARCH_PAGE = 20;
5
6
  const SNIPPET = 200;
6
7
  /** Added after the view doc when Memory search is on. */
7
8
  export const SEARCH_DOC = '\n\nsearch(text) finds the original messages that contain text, newest first. Use it for an exact name, number, PR, path or error the view doesn\'t show, then zoom(id, 1) to read a hit. A hit is one message: zoom around it too, especially the messages after it, where the outcome usually is.';
9
+ const ZOOM_ONLY = 'zoom is your only\nallowed mechanism ', ZOOM_AND_SEARCH = 'zoom and search are your only\nallowed mechanisms ';
10
+ /** With Memory search on, the view doc allows search next to zoom; also turns a built prompt either way. */
11
+ export const allowSearch = (prompt: string, on: boolean) => on ? prompt.replace(ZOOM_ONLY, ZOOM_AND_SEARCH) : prompt.replace(ZOOM_AND_SEARCH, ZOOM_ONLY);
8
12
 
9
13
  /** One page of hits, each with its id, the view line holding it, its date and a snippet around the first match; at most ~5 KB. */
10
14
  export function searchPage(memory: Memory, text: string, before?: number) {
@@ -25,9 +29,14 @@ export function searchPage(memory: Memory, text: string, before?: number) {
25
29
 
26
30
  export function memoryTools(memory: () => Memory) {
27
31
  return [
28
- { name: 'zoom', label: 'Zoom memory', description: 'Open the line id+n of the view into the two lines of n/2 under it; n = 1 gives the message whole.',
29
- parameters: Type.Object({ id: Type.Integer({ minimum: 0 }), n: Type.Integer({ minimum: 1 }) }),
30
- async execute(_id: string, args: { id: number; n: number }) { return result(memory().zoom(args.id, args.n)); } },
32
+ { name: 'zoom', label: 'Zoom memory', description: `Open the line id+n of the view into the two lines of n/2 under it; n = 1 gives the message whole. A message over ${PAGE.toLocaleString('en-US')} characters comes in pages; offset and limit (characters) read any part of it, and are not needed for a shorter one. A message's images come back with it.`,
33
+ parameters: Type.Object({ id: Type.Integer({ minimum: 0 }), n: Type.Integer({ minimum: 1 }),
34
+ offset: Type.Optional(Type.Integer({ minimum: 0 })), limit: Type.Optional(Type.Integer({ minimum: 1, maximum: PAGE })) }),
35
+ async execute(_id: string, args: { id: number; n: number; offset?: number; limit?: number }) {
36
+ const m = memory(), text = m.zoom(args.id, args.n, args.offset, args.limit), page = result(text);
37
+ // A message's images come back with its text, as read returns a PNG; summaries stay text.
38
+ return args.n === 1 ? { ...page, content: [...page.content, ...loadImages(m.directory, text)] } : page;
39
+ } },
31
40
  { name: 'date', label: 'Memory date', description: 'The date and time of message id.',
32
41
  parameters: Type.Object({ id: Type.Integer({ minimum: 0 }) }),
33
42
  async execute(_id: string, args: { id: number }) { return result(memory().date(args.id)); } },
package/src/transcript.ts CHANGED
@@ -1,13 +1,18 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import type { AgentMessage } from '@earendil-works/pi-agent-core';
2
3
  import type { SessionEntry } from '@earendil-works/pi-coding-agent';
3
4
  import { getCurrentSystemMessage, type SystemMessage, type UserMessage } from '@earendil-works/pi-ai';
4
5
  import { CAP, cap, type Memory } from './memory.ts';
5
6
  import { record } from './cache.ts';
6
7
  import { DEFAULT_SETTINGS } from './settings.ts';
8
+ import { imageRef, isImage } from './images.ts';
7
9
 
8
10
  export const RUN_BOUNDARY = 'optchat.run';
9
- /** Subagent traffic to the main agent: a custom message on screen, a plain user message to the model and memory. */
11
+ /** Subagent traffic to the main agent: a custom message on screen, a plain user message to the model, `work` in memory. */
10
12
  export const REPORT_TYPE = 'optchat-report';
13
+ export const REPORT_RECEIPT = 'report:';
14
+ /** A report's receipt in memory: it marks the entry as `work` and keeps a restart from delivering the report twice. */
15
+ export const reportReceipt = (text: string) => REPORT_RECEIPT + createHash('sha256').update(text).digest('hex');
11
16
 
12
17
  export function textContent(content: unknown, images = true): string {
13
18
  if (typeof content === 'string') return content;
@@ -15,24 +20,34 @@ export function textContent(content: unknown, images = true): string {
15
20
  return content.map((part: unknown) => {
16
21
  if (typeof part !== 'object' || part === null) return '';
17
22
  if ('type' in part && part.type === 'text' && 'text' in part && typeof part.text === 'string') return part.text;
18
- if (images && 'type' in part && part.type === 'image') return '[image attachment: available in Pi session; text memory does not preserve image bytes]';
23
+ if (images && isImage(part)) return imageRef(part);
19
24
  return '';
20
25
  }).filter(Boolean).join('\n');
21
26
  }
22
- /** What the user typed, as Pi's input event (and so the inbox) saw it: no image placeholders, and without the
27
+ /** What the user typed, as Pi's input event (and so the inbox) saw it: no image references, and without the
23
28
  * `[Image …]` notes Pi appends after the text when it resizes, converts or omits an attached image. */
24
29
  export function typedText(content: unknown) {
25
30
  const text = textContent(content, false);
26
31
  return { text, bare: text.replace(/\n\n\[Image[ :][^\n]*\](?:\n\[Image[ :][^\n]*\])*$/, '') };
27
32
  }
28
- /** Reports reach the model, memory and the previous-exchange replay exactly as the user messages they used to be. */
33
+ /** Pi's convertToLlm sends every custom message to the model as a user message. A shown one is part of the chat, so it
34
+ * becomes a user message here too; another extension's starts with "[customType] ", as the recipe marks background work, so
35
+ * the compactor never takes it for the user's words. A hidden one (display false), such as context an extension injects
36
+ * each turn, stays custom: the model still sees it, and memory and the replay leave it out. Reports reach the model and the
37
+ * previous-exchange replay as user messages; memory logs them as `work`. */
29
38
  export function asUser(message: AgentMessage): AgentMessage {
30
- if (message.role !== 'custom' || message.customType !== REPORT_TYPE) return message;
31
- return { role: 'user', content: textContent(message.content), timestamp: message.timestamp };
39
+ if (message.role !== 'custom' || !message.display) return message;
40
+ const { content, customType, timestamp } = message;
41
+ if (customType === REPORT_TYPE) return { role: 'user', content, timestamp };
42
+ const tag = `[${customType}] `;
43
+ if (typeof content === 'string') return { role: 'user', content: tag + content, timestamp };
44
+ const [first, ...rest] = content;
45
+ if (first?.type === 'text') return { role: 'user', content: [{ ...first, text: tag + first.text }, ...rest], timestamp };
46
+ return { role: 'user', content: [{ type: 'text', text: tag.trimEnd() }, ...content], timestamp };
32
47
  }
33
48
  export function logMessage(memory: Memory, message: AgentMessage, receipt?: string) {
34
49
  const date = new Date(message.timestamp).toISOString();
35
- if (message.role === 'user') memory.append('user', textContent(message.content), date, receipt);
50
+ if (message.role === 'user') memory.append(receipt?.startsWith(REPORT_RECEIPT) ? 'work' : 'user', textContent(message.content), date, receipt);
36
51
  else if (message.role === 'assistant') {
37
52
  for (const block of message.content) {
38
53
  if (block.type === 'text' && block.text.trim()) memory.append('talk', block.text, date);
@@ -40,7 +55,9 @@ export function logMessage(memory: Memory, message: AgentMessage, receipt?: stri
40
55
  }
41
56
  if (message.stopReason === 'error' || message.stopReason === 'aborted')
42
57
  memory.append('echo', `Agent ${message.stopReason}: ${message.errorMessage ?? 'No further details'}`, date);
43
- } else if (message.role === 'toolResult') memory.append('echo', cap(`${message.toolName}: ${textContent(message.content)}`), date);
58
+ } else if (message.role === 'toolResult')
59
+ // A zoom's page already names the images it returns.
60
+ memory.append('echo', cap(`${message.toolName}: ${textContent(message.content, message.toolName !== 'zoom')}`), date);
44
61
  }
45
62
  export function boundedMessage(message: AgentMessage): AgentMessage {
46
63
  if (message.role !== 'toolResult') return message;
@@ -78,7 +95,7 @@ export function previousExchange(branch: readonly SessionEntry[], limit = PREVIO
78
95
  function latestExchange(branch: readonly SessionEntry[]) {
79
96
  let end = -1;
80
97
  let legacyEnd = branch.length;
81
- const messages = (entries: readonly SessionEntry[]) => entries.flatMap(entry => entry.type === 'message' ? [asUser(entry.message)] : entry.type === 'custom_message' && entry.customType === REPORT_TYPE ? [asUser({ role: 'custom', customType: entry.customType, content: entry.content, display: entry.display, timestamp: Date.parse(entry.timestamp) })] : []);
98
+ const messages = (entries: readonly SessionEntry[]) => entries.flatMap(entry => entry.type === 'message' ? [asUser(entry.message)] : entry.type === 'custom_message' ? [asUser({ role: 'custom', customType: entry.customType, content: entry.content, display: entry.display, timestamp: Date.parse(entry.timestamp) })] : []);
82
99
  for (let i = branch.length - 1; i >= 0; i--) {
83
100
  const entry = branch[i];
84
101
  if (entry.type !== 'custom' || entry.customType !== RUN_BOUNDARY || !record(entry.data)) continue;
@@ -3,13 +3,13 @@ import { createConnection, createServer, type Socket } from 'node:net';
3
3
  import type { Children } from './agents.ts';
4
4
  import type { AgentMessage } from '@earendil-works/pi-agent-core';
5
5
  import { record } from './cache.ts';
6
- import { checkSocketPath, profileSocket, removeStaleSocket } from './profiles.ts';
6
+ import { checkSocketPath, isWindows, profileSocket, removeStaleSocket } from './profiles.ts';
7
7
  import { textContent } from './transcript.ts';
8
8
  import { isActiveRun } from './runs.ts';
9
9
 
10
- type Action = 'start' | 'say' | 'tell-main' | 'complete';
10
+ type Action = 'start' | 'say' | 'tell-main' | 'answer' | 'complete';
11
11
  interface Request { kind: 'request'; id: number; action: Action; text?: string; cwd?: string }
12
- interface Reply { kind: 'reply'; id: number; error?: string }
12
+ interface Reply { kind: 'reply'; id: number; error?: string; text?: string }
13
13
  /** A tool the subagent is running right now, with its streamed output so far. */
14
14
  export interface LiveTool { id: string; name: string; args: unknown; output?: unknown; started: number }
15
15
  /** What the subagent is doing between finished messages: its streaming reply and running tools. */
@@ -36,13 +36,16 @@ function parse(value: unknown): Frame {
36
36
  return { kind: 'event', name, text: value.text, from: from === 'user' || from === 'agent' ? from : undefined,
37
37
  message: isMessage(value.message) ? value.message : undefined, live: isLive(value.live) ? value.live : undefined };
38
38
  if (typeof value.id !== 'number' || !Number.isSafeInteger(value.id)) throw new Error('Invalid request ID');
39
- if (value.kind === 'reply' && (value.error === undefined || typeof value.error === 'string')) return { kind: 'reply', id: value.id, error: value.error };
40
- if (value.kind === 'request' && (action === 'start' || action === 'say' || action === 'tell-main' || action === 'complete')
39
+ if (value.kind === 'reply' && (value.error === undefined || typeof value.error === 'string') && (value.text === undefined || typeof value.text === 'string'))
40
+ return { kind: 'reply', id: value.id, error: value.error, text: value.text };
41
+ if (value.kind === 'request' && (action === 'start' || action === 'say' || action === 'tell-main' || action === 'answer' || action === 'complete')
41
42
  && (value.text === undefined || typeof value.text === 'string') && (value.cwd === undefined || typeof value.cwd === 'string'))
42
43
  return { kind: 'request', id: value.id, action, text: value.text, cwd: value.cwd };
43
44
  throw new Error('Invalid window message');
44
45
  }
45
46
  const SHORTENED = '\n[Display shortened; full text is saved in the transcript.]';
47
+ /** Stays under the 4 MB frame bound even when JSON escapes some characters. */
48
+ const ANSWER_LIMIT = 1_000_000;
46
49
  const shorten = (text: string, limit: number) => text.length > limit ? `${text.slice(0, limit)}${SHORTENED}` : text;
47
50
  /** Long text parts are cut and images dropped: the window only draws them, the owner keeps the full transcript. */
48
51
  function clipContent(content: unknown): unknown {
@@ -59,7 +62,7 @@ const clipOutput = (output: unknown) => record(output) ? { ...output, content: c
59
62
  */
60
63
  const fits = (value: unknown) => { try { return JSON.stringify(value).length < 1_000_000; } catch { return false; } };
61
64
 
62
- /** Local JSONL protocol, bounded before parsing; the socket is accessible only by its OS user. */
65
+ /** Local JSONL protocol, bounded before parsing. On POSIX the socket file is mode 0600, so only its OS user can connect; a Windows named pipe has no file permissions, so its hashed profile name is the only separation. */
63
66
  function wire(socket: Socket, receive: (frame: Frame) => void) {
64
67
  let buffer = '';
65
68
  socket.setEncoding('utf8');
@@ -110,6 +113,12 @@ export async function serveWindows(directory: string, children: Children, availa
110
113
  send({ kind: 'event', name: 'started', text: child });
111
114
  } else {
112
115
  if (!child) throw new Error('Send your first request to start a conversation');
116
+ if (frame.action === 'answer') {
117
+ // The agent's last reply in full, not the display copy that events carry (headless `pi -p` prints it).
118
+ const text = children.live(child)?.session.getLastAssistantText() ?? '';
119
+ if (text.length > ANSWER_LIMIT) throw new Error(`The reply is over ${ANSWER_LIMIT.toLocaleString('en')} characters; the original Pi window has it in full`);
120
+ send({ kind: 'reply', id: frame.id, text }); return;
121
+ }
113
122
  if (frame.action === 'complete') {
114
123
  const live = children.live(child);
115
124
  if (live) drainMessages(live.session.messages, live.info.task);
@@ -142,14 +151,20 @@ export async function serveWindows(directory: string, children: Children, availa
142
151
  const timer = setInterval(() => {
143
152
  if (!child || socket.destroyed || ending) return;
144
153
  const live = children.live(child), info = children.history.records.get(child);
145
- if (!info) return;
154
+ if (!info) {
155
+ // Deleted from the Agents page: end the window (and a headless join) instead of leaving it waiting forever.
156
+ ending = true;
157
+ send({ kind: 'event', name: 'finished', text: 'This conversation was deleted from the Agents page.' });
158
+ return;
159
+ }
146
160
  if (live) drainMessages(live.session.messages, info.task);
147
161
  const preview = live?.streaming && 'content' in live.streaming ? textContent(live.streaming.content).slice(-2000) : '';
148
162
  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}`;
149
163
  let state: LiveState = { state: info.state, model: info.model,
150
164
  streaming: live?.streaming?.role === 'assistant' ? clip(live.streaming) : undefined,
151
165
  tools: live ? [...live.tools].map(([id, t]) => ({ id, name: t.name, args: t.args, output: clipOutput(t.output), started: t.started })) : [],
152
- agents: [...children.history.records.values()].filter(run => run.parentId === child && isActiveRun(run)).length };
166
+ // A finished child counts until its report has reached this agent, so the agent never looks idle in between.
167
+ agents: [...children.history.records.values()].filter(run => run.parentId === child && (isActiveRun(run) || children.live(run.id))).length };
153
168
  if (!fits(state)) state = { ...state, streaming: undefined, tools: state.tools.map(t => ({ ...t, args: {}, output: undefined })) };
154
169
  const status = JSON.stringify(state);
155
170
  if (status !== lastStatus) { lastStatus = status; send({ kind: 'event', name: 'status', text, live: state }); }
@@ -160,33 +175,38 @@ export async function serveWindows(directory: string, children: Children, availa
160
175
  send({ kind: 'event', name: 'finished', text: info.handoff.text ?? 'Conversation ended.' });
161
176
  }
162
177
  }, 150);
163
- socket.on('close', () => {
178
+ const { promise: ended, resolve } = Promise.withResolvers<void>();
179
+ const connection = ended.then(async () => {
164
180
  clearInterval(timer); controller.abort(); connections.delete(socket);
165
- const cleanup = queue.then(async () => { if (child) await children.finish(child, closing ? 'owner-stopped' : 'disconnected'); });
166
- track(cleanup);
181
+ await queue;
182
+ if (child) await children.finish(child, closing ? 'owner-stopped' : 'disconnected');
167
183
  });
184
+ track(connection);
185
+ socket.once('close', resolve);
168
186
  });
169
187
  await new Promise<void>((resolve, reject) => { server.once('error', reject); server.listen(path, () => { server.off('error', reject); resolve(); }); });
170
- chmodSync(path, 0o600);
188
+ if (!isWindows) chmodSync(path, 0o600);
171
189
  return async () => {
172
190
  closing = true;
173
- const closed = new Promise<void>(resolve => server.close(() => resolve()));
191
+ const { promise: closed, resolve } = Promise.withResolvers<void>();
192
+ server.close(() => resolve());
174
193
  for (const socket of connections) socket.destroy();
175
194
  await closed;
176
- await Promise.allSettled([...work]);
195
+ // A socket's close handler adds its cleanup after this point, so drain the tracked work instead of snapshotting it.
196
+ while (work.size) await Promise.allSettled([...work]);
177
197
  };
178
198
  }
179
199
 
180
200
  export async function connectWindow(directory: string, event: (event: WindowEvent) => void, disconnected: () => void) {
181
201
  const socket = createConnection(profileSocket(directory, 'windows'));
182
202
  let next = 0;
183
- const pending = new Map<number, { resolve: () => void; reject: (error: Error) => void; timer: NodeJS.Timeout }>();
203
+ const pending = new Map<number, { resolve: (text?: string) => void; reject: (error: Error) => void; timer: NodeJS.Timeout }>();
184
204
  const send = wire(socket, frame => {
185
205
  if (frame.kind === 'event') { event(frame); return; }
186
206
  if (frame.kind !== 'reply') { socket.destroy(); return; }
187
207
  const request = pending.get(frame.id); if (!request) return;
188
208
  clearTimeout(request.timer); pending.delete(frame.id);
189
- if (frame.error) request.reject(new Error(frame.error)); else request.resolve();
209
+ if (frame.error) request.reject(new Error(frame.error)); else request.resolve(frame.text);
190
210
  });
191
211
  socket.on('close', () => {
192
212
  for (const request of pending.values()) { clearTimeout(request.timer); request.reject(new Error('Connection to the original Pi window was lost')); }
@@ -202,7 +222,7 @@ export async function connectWindow(directory: string, event: (event: WindowEven
202
222
  if (socket.destroyed) throw new Error('The original Pi window is disconnected');
203
223
  if (text && text.length > 256_000) throw new Error('Connected-window messages are limited to 256,000 characters');
204
224
  const id = next++;
205
- await new Promise<void>((resolve, reject) => {
225
+ return await new Promise<string | undefined>((resolve, reject) => {
206
226
  const timer = setTimeout(() => { pending.delete(id); reject(new Error('Owner response timed out; closing this connection')); socket.destroy(); }, 120_000);
207
227
  pending.set(id, { resolve, reject, timer }); send({ kind: 'request', id, action, text, cwd });
208
228
  });
@@ -210,3 +230,42 @@ export async function connectWindow(directory: string, event: (event: WindowEven
210
230
  close() { socket.destroy(); },
211
231
  };
212
232
  }
233
+
234
+ /**
235
+ * A headless run (`pi -p`) on a profile another Pi owns: one request to a connected subagent, its final reply,
236
+ * then `complete`, so the main agent gets the handoff. Connects up front, so a missing session fails before the prompt.
237
+ */
238
+ export async function joinHeadless(directory: string) {
239
+ let last: AgentMessage | undefined;
240
+ let settle: (outcome: { ended?: string; lost?: true }) => void = () => {};
241
+ const outcome = new Promise<{ ended?: string; lost?: true }>(resolve => { settle = resolve; });
242
+ const connection = await connectWindow(directory, event => {
243
+ if (event.name === 'message' && event.from === 'agent') last = event.message;
244
+ // Waiting with no agents of its own means the agent is waiting for its user: the reply is done. A paused agent
245
+ // waits for the owner's user instead, and a finished one ends with `finished` once its last messages are sent.
246
+ else if (event.name === 'status' && event.live?.state === 'waiting' && !event.live.agents) settle({});
247
+ else if (event.name === 'finished') settle({ ended: event.text });
248
+ }, () => settle({ lost: true })).catch((error: unknown) => {
249
+ const code = record(error) ? error.code : undefined;
250
+ throw code === 'ENOENT' || code === 'ECONNREFUSED' ? new Error('No running OptChat session on this profile to join') : error;
251
+ });
252
+ let asked = false;
253
+ return {
254
+ async ask(text: string, cwd: string) {
255
+ if (asked) throw new Error('A headless run joins for one request');
256
+ asked = true;
257
+ await connection.request('start', text, cwd);
258
+ const { ended, lost } = await outcome;
259
+ const reply = last;
260
+ if (reply?.role === 'assistant' && (reply.stopReason === 'error' || reply.stopReason === 'aborted')) throw new Error(reply.errorMessage ?? `Request ${reply.stopReason}`);
261
+ if (lost) throw new Error('Connection to the original Pi window was lost');
262
+ if (ended !== undefined) throw new Error(`The conversation ended before a reply: ${ended}`);
263
+ // Events carry display copies cut for length, so the reply itself is fetched whole.
264
+ const answer = await connection.request('answer') ?? '';
265
+ await connection.request('complete');
266
+ connection.close();
267
+ return answer;
268
+ },
269
+ close() { connection.close(); },
270
+ };
271
+ }