pi-optchat 0.6.4 → 0.6.5

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
@@ -137,7 +137,7 @@ See OpenAI's guides on [exporting ChatGPT data](https://help.openai.com/en/artic
137
137
 
138
138
  ## Connected windows
139
139
 
140
- Each profile is locked to one Pi process. If you open the same profile in a second terminal, Pi offers to connect it to the original window as a subagent.
140
+ Each profile is locked to one Pi process. If you open the same profile in a second terminal, Pi offers to connect it to the original window as a subagent, or to go back to the profile picker.
141
141
 
142
142
  - Your first message starts a subagent in the second window's working directory. Later messages continue the same conversation.
143
143
  - The window looks like a normal Pi chat: replies, tool calls with their output and running time, the working spinner, and reports from the subagent's own agents in the dark box. Ctrl+O expands tool output.
@@ -181,7 +181,7 @@ To delete a profile, delete its folder. Your original Pi sessions are kept in Pi
181
181
  - **Images** are available during the current run but stored in memory as text placeholders.
182
182
  - **Pi's auto-compaction is off.** A single very long run can still hit the model's context limit; stop it and continue in a new turn.
183
183
  - **Restarts**: unsent inputs are recovered into memory, and pending subagent reports are delivered. Interrupted subagents are not restarted.
184
- - **Skill and template inputs** can occasionally be recovered as an unanswered input after a crash, because Pi expands them after the input journal records them. Plain text chat is unaffected.
184
+ - **Prompt-template inputs** can be saved twice: expanded, and later in their original form as an unanswered input, because Pi expands them after the input journal records them. Skill commands (`/skill:name`) are matched back to their journaled input and don't have this problem. Plain text chat is unaffected.
185
185
 
186
186
  ## How it differs from the recipe
187
187
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-optchat",
3
- "version": "0.6.4",
3
+ "version": "0.6.5",
4
4
  "type": "module",
5
5
  "pi": {
6
6
  "extensions": [
package/src/agents.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { existsSync, readFileSync } from 'node:fs';
3
- import { join, dirname } from 'node:path';
2
+ import { existsSync, readFileSync, statSync } from 'node:fs';
3
+ import { join, dirname, resolve } from 'node:path';
4
+ import { homedir } from 'node:os';
4
5
  import { createAgentSession, DefaultResourceLoader, SessionManager, SettingsManager, getAgentDir, type AgentSession, type AgentSessionEvent, type ModelRegistry } from '@earendil-works/pi-coding-agent';
5
6
  import type { AgentMessage } from '@earendil-works/pi-agent-core';
6
7
  import { SUBAGENT, VIEW_DOC } from './prompts.ts';
@@ -28,10 +29,16 @@ const packageName = (path: string): string | undefined => {
28
29
  for (let dir = dirname(path); dir !== dirname(dir); dir = dirname(dir)) {
29
30
  const manifest = join(dir, 'package.json');
30
31
  if (!existsSync(manifest)) continue;
31
- const data: unknown = JSON.parse(readFileSync(manifest, 'utf8'));
32
- return data && typeof data === 'object' && 'name' in data && typeof data.name === 'string' ? data.name : undefined;
32
+ try {
33
+ const data: unknown = JSON.parse(readFileSync(manifest, 'utf8'));
34
+ return data && typeof data === 'object' && 'name' in data && typeof data.name === 'string' ? data.name : undefined;
35
+ } catch { return undefined; } // A broken manifest is not OptChat's and must not block every spawn.
33
36
  }
34
37
  };
38
+ /** A task's cwd may start with `~` and may be relative to the spawning agent's directory. */
39
+ export const taskDirectory = (cwd: string, path = '.', windows = process.platform === 'win32') =>
40
+ resolve(cwd, path.replace(windows ? /^~(?=$|[\\/])/ : /^~(?=$|\/)/, homedir()));
41
+ export const CWD_DOC = 'Project directory the subagent works in (~ allowed); its AGENTS.md files load from there. Defaults to your current directory.';
35
42
  const isOptchat = (path: string) => packageName(path) === 'pi-optchat';
36
43
  export class Children {
37
44
  private readonly running = new Map<string, LiveRun>();
@@ -92,6 +99,7 @@ export class Children {
92
99
  } catch (error) { this.warn(`Could not record subagent activity: ${String(error)}`); }
93
100
  }
94
101
  async spawn(tasks: { task: string; cwd?: string }[], cwd: string, signal?: AbortSignal, parentId?: string, connected = false) {
102
+ for (const directory of tasks.map(t => taskDirectory(cwd, t.cwd))) if (!existsSync(directory) || !statSync(directory).isDirectory()) throw new Error(`No such directory: ${directory}`);
95
103
  if (this.closing) throw new Error('Profile is closing.');
96
104
  this.settling++;
97
105
  try { await this.memory.settle(signal); } finally { this.settling--; }
@@ -113,7 +121,7 @@ export class Children {
113
121
  for (const task of tasks) {
114
122
  signal?.throwIfAborted();
115
123
  if (cancelled()) throw new Error('Parent or profile is stopping.');
116
- const id = randomUUID().slice(0, 8), directory = task.cwd ?? cwd;
124
+ const id = randomUUID().slice(0, 8), directory = taskDirectory(cwd, task.cwd);
117
125
  const delegation = depth < 3 ? '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 8 active agents total.' : 'You are at the maximum delegation depth. Complete your task with your own tools.';
118
126
  const instructions = [this.instructions(), delegation, connected ? 'You are speaking directly with the user in a connected window. Continue this conversation across requests. Use tell_parent for questions or findings the main agent needs now. A handoff will be generated when the user completes or disconnects the window.'
119
127
  : 'Use tell_parent only when your parent needs something now (a blocking question, an important early finding, or when asked to). Your final answer is delivered automatically; do not repeat it with tell_parent.'].filter(Boolean).join('\n\n');
@@ -151,7 +159,7 @@ export class Children {
151
159
  }
152
160
  } catch (error) {
153
161
  for (const child of launched) {
154
- child.session.dispose(); this.running.delete(child.info.id);
162
+ this.dispose(child.session); this.running.delete(child.info.id);
155
163
  child.info.state = 'failed'; child.info.ended = Date.now(); child.info.report = `Launch failed: ${String(error)}`;
156
164
  if (child.info.connected) child.info.handoff = { reason: signal?.aborted ? 'disconnected' : 'failed' };
157
165
  this.save(child.info);
@@ -171,7 +179,7 @@ export class Children {
171
179
  }
172
180
  private delegationTools(parentId: string, cwd: string) {
173
181
  return [{ name: 'spawn', label: 'Delegate task', description: 'Delegate parts of your task. Results arrive automatically after this run; never poll or sleep waiting. Maximum depth 3 and 8 active agents per profile.',
174
- parameters: Type.Object({ tasks: Type.Array(Type.Object({ task: Type.String(), cwd: Type.Optional(Type.String()) }), { minItems: 1, maxItems: 8 }) }),
182
+ parameters: Type.Object({ tasks: Type.Array(Type.Object({ task: Type.String(), cwd: Type.Optional(Type.String({ description: CWD_DOC })) }), { minItems: 1, maxItems: 8 }) }),
175
183
  execute: async (_id: string, args: { tasks: { task: string; cwd?: string }[] }, signal?: AbortSignal) => result(`Started: ${(await this.spawn(args.tasks, cwd, signal, parentId)).join(', ')}. Results will arrive automatically.`),
176
184
  }, { name: 'tell', label: 'Guide child', description: 'Send guidance to one of your direct children.',
177
185
  parameters: Type.Object({ id: Type.String(), message: Type.String() }),
@@ -199,6 +207,9 @@ export class Children {
199
207
  },
200
208
  };
201
209
  }
210
+ private dispose(session: AgentSession) {
211
+ try { session.dispose(); } catch (error) { this.warn(`Subagent cleanup failed: ${String(error)}`); }
212
+ }
202
213
  private directChildren(id: string) { return [...this.running.values()].filter(c => c.info.parentId === id); }
203
214
  private async execute(live: LiveRun, view: string) {
204
215
  const { session, info } = live;
@@ -240,7 +251,8 @@ export class Children {
240
251
  for (const g of info.guidance) if (g.state === 'queued') g.state = 'undelivered';
241
252
  try { await session.extensionRunner.emit({ type: 'session_shutdown', reason: 'quit' }); }
242
253
  catch (error) { this.warn(`Subagent cleanup failed: ${String(error)}`); }
243
- finally { session.dispose(); this.running.delete(info.id); }
254
+ // A failed dispose must neither keep the slot taken nor drop the report below.
255
+ this.dispose(session); this.running.delete(info.id);
244
256
  }
245
257
  if (info.connected) {
246
258
  info.handoff ??= { reason: 'failed' };
package/src/inbox.ts CHANGED
@@ -23,8 +23,14 @@ export class Inbox {
23
23
  const item = { id: randomUUID(), text, date: new Date().toISOString() };
24
24
  this.items.push(item); this.save(); return item.id;
25
25
  }
26
- claim(text: string) {
27
- const item = this.items.find(i => !this.claimed.has(i.id) && i.text === text);
26
+ claim(text: string) { return this.claimWhere(i => i.text === text); }
27
+ /** Pi expands `/skill:name args` after the input is journaled; match the expansion back to that input. */
28
+ claimSkill(name: string, args = '') {
29
+ const command = `/skill:${name}`;
30
+ return this.claimWhere(i => i.text.startsWith(command) && /^(\s|$)/.test(i.text.slice(command.length)) && i.text.slice(command.length).trim() === args);
31
+ }
32
+ private claimWhere(match: (item: Arrival) => boolean) {
33
+ const item = this.items.find(i => !this.claimed.has(i.id) && match(i));
28
34
  if (!item) return undefined;
29
35
  this.claimed.add(item.id); return item.id;
30
36
  }
package/src/index.ts CHANGED
@@ -4,17 +4,17 @@ import { join } from 'node:path';
4
4
  import { hostname } from 'node:os';
5
5
  import { createHash } from 'node:crypto';
6
6
  import type { AgentMessage, ThinkingLevel } from '@earendil-works/pi-agent-core';
7
- import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
7
+ import { parseSkillBlock, type ExtensionAPI, type ExtensionContext, type ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
8
8
  import { Type } from 'typebox';
9
9
  import { Memory } from './memory.ts';
10
10
  import { createCompressor } from './compactor.ts';
11
11
  import { atomicWrite, createProfile, instructions, lastProfile, listProfiles, loadConfig, lockProfile, profilePath, rememberProfile, saveConfig, ProfileBusyError, type ProfileConfig } from './profiles.ts';
12
12
  import { MASTER, VIEW_DOC } from './prompts.ts';
13
13
  import { cachePayload, record } from './cache.ts';
14
- import { asUser, boundedMessage, buildContext, logMessage, previousExchange, REPORT_TYPE, RUN_BOUNDARY, textContent } from './transcript.ts';
14
+ import { asUser, boundedMessage, buildContext, logMessage, previousExchange, REPORT_TYPE, RUN_BOUNDARY, textContent, typedText } from './transcript.ts';
15
15
  import { registerReportRenderer } from './report-message.ts';
16
16
  import { memoryTools, result } from './tools.ts';
17
- import { Children } from './agents.ts';
17
+ import { Children, CWD_DOC } from './agents.ts';
18
18
  import { exportBrowser } from './browser.ts';
19
19
  import { Inbox } from './inbox.ts';
20
20
  import { checkpoint } from './checkpoint.ts';
@@ -114,6 +114,7 @@ export default function optchat(pi: ExtensionAPI) {
114
114
  untitle?.(); untitle = undefined; title.clear(); working = false;
115
115
  }
116
116
  };
117
+ const CONNECT = 'Start a connected subagent conversation here', BACK = 'Back';
117
118
  const chooseProfile = async (ctx: ExtensionContext): Promise<string | undefined> => {
118
119
  if (!ctx.hasUI) return undefined;
119
120
  const names = listProfiles(), last = lastProfile();
@@ -175,20 +176,28 @@ export default function optchat(pi: ExtensionAPI) {
175
176
 
176
177
  pi.on('session_start', async (_event, ctx) => {
177
178
  stopping = false;
178
- const saved = ctx.sessionManager.getEntries().find(e => e.type === 'custom' && e.customType === binding);
179
+ const entries = ctx.sessionManager.getEntries();
180
+ const saved = entries.findLast(e => e.type === 'custom' && e.customType === binding);
179
181
  const boundName = saved?.type === 'custom' && record(saved.data) && typeof saved.data.name === 'string' ? saved.data.name : undefined;
182
+ // A session with conversation in it belongs to its profile. One that is only bound (a fresh `/optchat profile` session) may still pick another.
183
+ const settled = boundName !== undefined && entries.some(e => e.type === 'message' || e.type === 'custom_message');
180
184
  const flag = pi.getFlag('optchat-profile');
181
185
  try {
182
186
  if (boundName && typeof flag === 'string' && flag !== boundName) throw new Error(`Session belongs to ${boundName}; cannot resume it as ${flag}.`);
183
- const name = boundName ?? (typeof flag === 'string' ? flag : await chooseProfile(ctx));
184
- if (!name) { status(ctx); return; }
185
- try { await openProfile(name, ctx); }
186
- catch (error) {
187
- if (!(error instanceof ProfileBusyError) || ctx.mode !== 'tui') throw error;
188
- if (!await ctx.ui.confirm('Profile open in another window', `${error.owner}\nStart a connected subagent conversation here?`)) throw error;
189
- remote = await openConnectedWindow(pi, ctx, name, text => title.show(t => ctx.ui.setTitle(t), text)); fault = undefined;
187
+ let name = boundName ?? (typeof flag === 'string' ? flag : await chooseProfile(ctx));
188
+ for (;;) {
189
+ if (!name) { status(ctx); return; }
190
+ try { await openProfile(name, ctx); break; }
191
+ catch (error) {
192
+ if (!(error instanceof ProfileBusyError) || ctx.mode !== 'tui') throw error;
193
+ // A resumed conversation already belongs to this profile, so another profile needs a new session (/optchat profile).
194
+ const choice = await ctx.ui.select(`${name} is open in another window\n${error.owner}`, settled ? [CONNECT] : [CONNECT, BACK]);
195
+ if (choice === CONNECT) { remote = await openConnectedWindow(pi, ctx, name, text => title.show(t => ctx.ui.setTitle(t), text)); fault = undefined; break; }
196
+ if (choice !== BACK) throw error;
197
+ name = await chooseProfile(ctx);
198
+ }
190
199
  }
191
- if (!boundName) pi.appendEntry(binding, { name });
200
+ if (name !== boundName) pi.appendEntry(binding, { name });
192
201
  } catch (error) {
193
202
  if (active) await stop().catch(() => {});
194
203
  fault = errorText(error); ctx.ui.notify(fault, 'error');
@@ -243,7 +252,10 @@ export default function optchat(pi: ExtensionAPI) {
243
252
  const text = textContent(message.content);
244
253
  if (reports.includes(text)) receipts.set(message, reportReceipt(text));
245
254
  else {
246
- let receipt = active.inbox.claim(text);
255
+ // The inbox journaled the typed input: match without image placeholders or Pi's image notes.
256
+ const typed = typedText(message.content), skill = parseSkillBlock(typed.bare);
257
+ let receipt = active.inbox.claim(typed.text) ?? active.inbox.claim(typed.bare)
258
+ ?? (skill ? active.inbox.claimSkill(skill.name, skill.userMessage) : undefined);
247
259
  if (!receipt) { active.inbox.record(text); receipt = active.inbox.claim(text); }
248
260
  if (receipt) receipts.set(message, receipt);
249
261
  }
@@ -302,8 +314,8 @@ export default function optchat(pi: ExtensionAPI) {
302
314
  registerReportRenderer(pi);
303
315
  for (const tool of memoryTools(() => required().memory)) pi.registerTool(tool);
304
316
  pi.registerTool({ name: 'spawn', label: 'Spawn background agents',
305
- description: 'Start background subagents, returning IDs immediately. Use only when the user asks. Each receives the current memory view and read-only zoom/date. Children may delegate two more levels; the whole profile allows 8 active agents. Completion reports arrive automatically; never poll or sleep waiting for them.',
306
- parameters: Type.Object({ tasks: Type.Array(Type.Object({ task: Type.String(), cwd: Type.Optional(Type.String()) }), { minItems: 1, maxItems: 8 }) }),
317
+ 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. Each receives the current memory view and read-only zoom/date. Children may delegate two more levels; the whole profile allows 8 active agents. Completion reports arrive automatically; never poll or sleep waiting for them.',
318
+ parameters: Type.Object({ tasks: Type.Array(Type.Object({ task: Type.String(), cwd: Type.Optional(Type.String({ description: CWD_DOC })) }), { minItems: 1, maxItems: 8 }) }),
307
319
  async execute(_id, args, signal, _update, ctx) {
308
320
  const ids = await required().children.spawn(args.tasks, ctx.cwd, signal); status(ctx);
309
321
  return result(`Started: ${ids.join(', ')}. Reports will arrive automatically.`);
package/src/memory.ts CHANGED
@@ -12,10 +12,12 @@ export interface Part { l: number; i: number }
12
12
  export interface Summary extends Part { text: string; size: number }
13
13
  export interface Compression { context: string; source: string; merge: boolean; historical?: boolean }
14
14
  export type Compressor = (input: Compression, signal: AbortSignal) => Promise<string>;
15
- const key = ({ l, i }: Part) => `${l}:${i}`;
15
+ const key = ({ l, i }: Part) => l * 2 ** 40 + i;
16
+ const UNBUILT = '(not summarized yet: zoom it)';
16
17
  export const start = ({ l, i }: Part) => i * 2 ** l;
17
18
  export const end = (part: Part) => start(part) + 2 ** part.l;
18
19
  export const bytes = (s: string) => Buffer.byteLength(s, 'utf8');
20
+ const UNBUILT_BYTES = bytes(UNBUILT);
19
21
  export const flat = (s: string) => s.replace(/[\r\n]+/g, ' ');
20
22
  export function cap(text: string, limit = CAP) {
21
23
  if (text.length <= limit) return text;
@@ -68,13 +70,17 @@ function isSummary(value: unknown): value is Summary {
68
70
  /** The log is authoritative. The tree and monotonically coarsening view follow recipe §§2–6. */
69
71
  export class Memory {
70
72
  readonly root: Entry[] = [];
71
- readonly tree = new Map<string, Summary>();
73
+ readonly tree = new Map<number, Summary>();
72
74
  readonly view: Part[] = [];
73
75
  private readonly events = new EventEmitter();
74
76
  private readonly controller = new AbortController();
75
- private readonly busy = new Map<string, Promise<void>>();
76
- private readonly retryAt = new Map<string, number>();
77
- private readonly reported = new Set<string>();
77
+ private readonly busy = new Map<number, Promise<void>>();
78
+ private readonly retryAt = new Map<number, number>();
79
+ private readonly reported = new Set<number>();
80
+ private viewBytes = 0;
81
+ private leaves = 0;
82
+ /** Per level, every node below this index is built. */
83
+ private readonly low: number[] = [];
78
84
  private retryTimer?: ReturnType<typeof setTimeout>;
79
85
  private scheduled = false;
80
86
  private stopped = false;
@@ -91,40 +97,53 @@ export class Memory {
91
97
  for (const value of records(join(directory, 'tree'), warn)) {
92
98
  if (!isSummary(value) || value.l < 0 || value.i < 0 || end(value) > this.root.length)
93
99
  throw new Error('Invalid OptChat summary record.');
100
+ if (value.l === 0 && !this.tree.has(key(value))) this.leaves++;
94
101
  this.tree.set(key(value), { ...value, size: bytes(value.text) });
95
102
  }
96
103
  // Fold history in order; do not retile the entire log on each turn.
97
- for (let i = 0; i < this.root.length; i++) { this.view.push({ l: 0, i }); this.fit(i + 1); }
104
+ for (let i = 0; i < this.root.length; i++) { this.push(i); this.fit(i + 1); }
98
105
  this.schedule();
99
106
  }
100
107
  append(kind: Kind, text: string, date = new Date().toISOString(), receipt?: string) {
101
108
  if (this.stopped) throw new Error('Memory is closed.');
102
109
  const entry: Entry = { i: this.root.length, kind, text, date, size: bytes(`${kind}: ${text}`), ...(receipt ? { receipt } : {}) };
103
110
  appendJson(join(this.directory, 'main', `${localDay()}.jsonl`), entry);
104
- this.root.push(entry); this.view.push({ l: 0, i: entry.i }); this.fit(); this.schedule();
111
+ this.root.push(entry); this.push(entry.i); this.fit(); this.schedule();
105
112
  return entry;
106
113
  }
107
114
  node(part: Part) { return this.tree.get(key(part)); }
108
- private text(part: Part) { return this.node(part)?.text ?? '(not summarized yet: zoom it)'; }
115
+ private text(part: Part) { return this.node(part)?.text ?? UNBUILT; }
116
+ private partBytes(part: Part) { return this.node(part)?.size ?? UNBUILT_BYTES; }
117
+ private push(i: number) { const part = { l: 0, i }; this.view.push(part); this.viewBytes += this.partBytes(part); }
109
118
  render() { return `<chat>\n${this.view.map(p => `${start(p)}+${2 ** p.l}|${flat(this.text(p))}`).join('\n')}\n</chat>`; }
110
119
  get ready() { return this.view.every(p => this.node(p)); }
111
- get pending() { return this.root.filter(e => !this.tree.has(key({ l: 0, i: e.i }))).length; }
120
+ get pending() { return this.root.length - this.leaves; }
112
121
  get active() { return this.busy.size; }
113
- get size() { return this.view.reduce((n, p) => n + bytes(this.text(p)), 0); }
122
+ get size() { return this.viewBytes; }
123
+ /** The view tiles the log in order, so a binary search finds the part covering a position. */
124
+ private visible(part: Part) {
125
+ const at = start(part);
126
+ for (let lo = 0, hi = this.view.length - 1; lo <= hi;) {
127
+ const mid = (lo + hi) >> 1, p = this.view[mid];
128
+ if (end(p) <= at) lo = mid + 1;
129
+ else if (start(p) > at) hi = mid - 1;
130
+ else return p.l === part.l && p.i === part.i;
131
+ }
132
+ return false;
133
+ }
114
134
  private fit(total = this.root.length) {
115
- let size = this.size;
116
- while (size > this.budget) {
135
+ while (this.viewBytes > this.budget) {
117
136
  let best = -1; let due = -Infinity;
118
137
  for (let j = 0; j + 1 < this.view.length; j++) {
119
138
  const a = this.view[j], b = this.view[j + 1];
120
- if (a.l !== b.l || a.i % 2 || b.i !== a.i + 1 || !this.node({ l: a.l + 1, i: a.i / 2 })) continue;
139
+ if (a.l !== b.l || a.i % 2 || b.i !== a.i + 1) continue;
121
140
  const age = (total - start(a)) / 2 ** (a.l + 2);
122
- if (age > due) { best = j; due = age; }
141
+ if (age > due && this.node({ l: a.l + 1, i: a.i / 2 })) { best = j; due = age; }
123
142
  }
124
143
  if (best < 0) break;
125
144
  const a = this.view[best], b = this.view[best + 1];
126
145
  const parent = { l: a.l + 1, i: a.i / 2 };
127
- size += bytes(this.text(parent)) - bytes(this.text(a)) - bytes(this.text(b));
146
+ this.viewBytes += this.partBytes(parent) - this.partBytes(a) - this.partBytes(b);
128
147
  this.view.splice(best, 2, parent);
129
148
  }
130
149
  this.events.emit('change');
@@ -140,11 +159,14 @@ export class Memory {
140
159
  const first = this.view.find(p => !this.node(p));
141
160
  const boundary = first ? start(first) : total;
142
161
  for (let l = 0; 2 ** l <= total; l++) {
143
- for (let i = 0; (i + 1) * 2 ** l <= total; i++) {
162
+ let low = this.low[l] ?? 0;
163
+ while (this.node({ l, i: low })) low++;
164
+ this.low[l] = low;
165
+ for (let i = low; (i + 1) * 2 ** l <= total; i++) {
144
166
  if (this.busy.size >= this.jobs) return;
145
167
  const part = { l, i }, id = key(part);
168
+ if ((l === 0 ? i : end(part)) > boundary) break;
146
169
  if (this.node(part) || this.busy.has(id) || (this.retryAt.get(id) ?? 0) > Date.now()) continue;
147
- if ((l === 0 ? i : end(part)) > boundary) continue;
148
170
  if (l && (!this.node({ l: l - 1, i: 2 * i }) || !this.node({ l: l - 1, i: 2 * i + 1 }))) continue;
149
171
  const promise = this.build(part).catch(error => {
150
172
  if (this.stopped) return;
@@ -173,6 +195,8 @@ export class Memory {
173
195
  const node = { ...part, text, size: bytes(text) };
174
196
  appendJson(join(this.directory, 'tree', `${localDay()}.jsonl`), node);
175
197
  this.tree.set(key(part), node); this.retryAt.delete(key(part));
198
+ // A leaf is usually still in the view when built, but after a damaged tree file a saved parent can already hide it.
199
+ if (part.l === 0) { this.leaves++; if (this.visible(part)) this.viewBytes += node.size - UNBUILT_BYTES; }
176
200
  if (!this.retryAt.size) this.lastError = undefined;
177
201
  this.fit();
178
202
  }
package/src/transcript.ts CHANGED
@@ -8,16 +8,22 @@ export const RUN_BOUNDARY = 'optchat.run';
8
8
  /** Subagent traffic to the main agent: a custom message on screen, a plain user message to the model and memory. */
9
9
  export const REPORT_TYPE = 'optchat-report';
10
10
 
11
- export function textContent(content: unknown): string {
11
+ export function textContent(content: unknown, images = true): string {
12
12
  if (typeof content === 'string') return content;
13
13
  if (!Array.isArray(content)) return '';
14
14
  return content.map((part: unknown) => {
15
15
  if (typeof part !== 'object' || part === null) return '';
16
16
  if ('type' in part && part.type === 'text' && 'text' in part && typeof part.text === 'string') return part.text;
17
- if ('type' in part && part.type === 'image') return '[image attachment: available in Pi session; text memory does not preserve image bytes]';
17
+ if (images && 'type' in part && part.type === 'image') return '[image attachment: available in Pi session; text memory does not preserve image bytes]';
18
18
  return '';
19
19
  }).filter(Boolean).join('\n');
20
20
  }
21
+ /** What the user typed, as Pi's input event (and so the inbox) saw it: no image placeholders, and without the
22
+ * `[Image …]` notes Pi appends after the text when it resizes, converts or omits an attached image. */
23
+ export function typedText(content: unknown) {
24
+ const text = textContent(content, false);
25
+ return { text, bare: text.replace(/\n\n\[Image[ :][^\n]*\](?:\n\[Image[ :][^\n]*\])*$/, '') };
26
+ }
21
27
  /** Reports reach the model, memory and the previous-exchange replay exactly as the user messages they used to be. */
22
28
  export function asUser(message: AgentMessage): AgentMessage {
23
29
  if (message.role !== 'custom' || message.customType !== REPORT_TYPE) return message;