pi-optchat 0.8.1 → 0.8.2

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
@@ -100,6 +100,8 @@ Ask in plain words, for example: "Spawn an agent to investigate this repository
100
100
  - Children also get the Pi built-in extensions the main session loaded: MCP, codemode and tool search. Your MCP servers (`~/.pi/agent/mcp.json`, the project's `.pi/mcp.json`) work in subagents, with the sign-ins you made in Pi. If MCP is off in the main session (`--no-mcp`, `-builtin:mcp` in settings, or an extension that replaces `/mcp`), it is off in subagents too. Each subagent opens its own server connections (stdio servers start once per subagent) and closes them when it ends.
101
101
  - Each child reports to its parent as soon as it finishes, as one message starting `[id]`. A stopped or failed child reports its stop or error text. Turn **Group subagent reports** on to have the children of one spawn report together: when the last of them finishes, their reports reach the parent as one message, `[id] report` each, in spawn order. Each finished report is journaled right away, so if Pi dies before the rest finish, the reports it already has are delivered at the next start. Messages sent with `tell_parent`, connected windows and their handoffs are never held back, and a child resumed with `tell` reports on its own. The parent stays alive to receive reports; it never polls.
102
102
  - In the main chat, subagent messages and reports appear in a dark grey box labelled `↳ subagent <id> · still running` or `· report`, so they don't look like something you typed. The model still receives them as ordinary user messages; memory logs them as kind `work` (older logs hold them as `user` messages starting `[id]`). (One exception: reports recovered at startup, before your first message in the session, still show as plain user messages.)
103
+ - Every report ends with `Full chat: zoom("<id>")`, so the agent can check it against what the child actually did: `zoom` with a run ID returns that child's chat so far as `kind|text` lines (its task without the memory view, replies, tool calls, tool results cut to their first and last ~500 characters, and what it was told), in 25,000-character pages that say which `offset` to go on from. It works on running children too; a subagent can read only its own descendants' chats, as with `tell`. The report box leaves the trailer out.
104
+ - While any subagent runs, each new input to the main agent (your message, a report, a `tell_parent` message, the restart note, a connected window's message) ends with one status line, `[OptChat status] Agents at work now: 8f28e694 "Review PR #123 carefully", …` (id and the first words of each task), so it knows what is still out without asking. It is rebuilt each time and never stored. It isn't repeated on the calls after a tool result within one turn, and nothing is sent while no agent runs. The main prompt says what the label means, so the model doesn't take it for the user. It comes after the cached view and turn, with the turn's cache mark on the block before it, so it costs no cache.
103
105
  - The parent can send a running child guidance with `tell`, and the child can message its parent mid-run with `tell_parent` (a question, an early finding). It reaches the parent like a report, marked "still running": between tool calls if the parent is busy, or waking it if it's waiting.
104
106
  - `tell` to a finished child resumes it: the same agent (ID, parent, model, directory) reopens its saved transcript, gets the message as a new prompt, and sends a new report. This also works for children from earlier Pi sessions. Only the agent that started the child can resume it, and the resumed child takes one active slot. Connected windows can't be resumed, and a child whose transcript is missing must be spawned fresh.
105
107
  - By default only the main agent starts subagents. Set **Subagent levels** in `/optchat settings` to let them delegate further (3 means child, grandchild, great-grandchild).
@@ -121,7 +123,7 @@ An **Agents | Usage | Activity** bar sits below the input.
121
123
 
122
124
  Set a different shortcut with `OPTCHAT_INSPECT_KEY=ctrl+shift+a pi`. If another extension supplies a custom editor, OptChat leaves its Down key alone; use the shortcut or commands instead.
123
125
 
124
- **Agents** lists runs as a tree with state, elapsed time, current tool, and last activity. Navigate with **Up/Down**, **Page Up/Down**, **Home/End**, and press **M** to pick the subagent model. Press **D** twice to delete the selected agent and the agents it started, with their transcripts. An agent that is still working is stopped first, and its stop report reaches its parent before it is deleted. The report tells the parent that you dropped the task, so it does not delegate it again. The main agent can no longer resume a deleted agent. Deleting a connected window that is still open stops it like closing the window; its handoff still reaches the main agent, but the transcript the handoff points to is deleted.
126
+ **Agents** lists runs as a tree with state, elapsed time, current tool, and last activity. Navigate with **Up/Down**, **Page Up/Down**, **Home/End**, and press **M** to pick the subagent model. Press **D** twice to delete the selected agent and the agents it started, with their transcripts. An agent that is still working is stopped first, and its stop report reaches its parent before it is deleted. The report tells the parent that you dropped the task, so it does not delegate it again. Deleting a finished agent sends no report, so memory logs `[id] deleted by the user` right away, without a model turn. The main agent can no longer resume a deleted agent. Deleting a connected window that is still open stops it like closing the window; its handoff still reaches the main agent, but the transcript the handoff points to is deleted.
125
127
 
126
128
  **Enter** swaps the screen to that agent's conversation, drawn with Pi's own chat components, so it reads like the main chat: its task, replies, and collapsed tool calls, following live output. Typing and **Enter** send it guidance. Guidance from the main agent and reports from the agent's own agents show in labelled boxes, so only your own messages look typed. **Escape** goes back to the main chat.
127
129
 
@@ -211,6 +213,8 @@ Each profile folder is a local Git repository, committed after each turn and on
211
213
 
212
214
  To delete a profile, delete its folder. Your original Pi sessions are kept in Pi's normal session directory.
213
215
 
216
+ Memory reaches disk through one seam, `Store` in `src/store.ts`: the log, summary nodes, saved view and images, which is what would travel between machines. Runs, recovery journals, imports and their pointer, usage, settings and `AGENTS.md` stay local files. `FileStore` (the layout above) is the only store today; another, such as Postgres, would implement the same few operations, and its lease (take, heartbeat, release) is optional: without one, OptChat keeps its machine-local profile lock. Import records (`imports/*.json`, the staging generations and the `active-memory.json` pointer) are per machine, so a second machine with the same `~/.claude` or Codex history on disk may offer those sessions for import again.
217
+
214
218
  ## Windows
215
219
 
216
220
  The same install and commands work on Windows, and the offline tests run there too. What differs:
@@ -234,7 +238,7 @@ The same install and commands work on Windows, and the offline tests run there t
234
238
 
235
239
  ## How it differs from the recipe
236
240
 
237
- `src/prompts.ts` holds every prompt sent to a model (the recipe's four, the subagent instruction paragraphs, the compaction task and its retry, the handoff prompt, the import note), each with a comment on where it goes; tool descriptions stay with their tools. The recipe's prompts are lightly adapted. The main agent's turn rules are the revised recipe's (2026-10-08) word for word, without its paragraph on computers, with reports starting "[id] " instead of "[Name]", and with one added sentence: don't poll anything slow (CI, a deploy) yourself, hand the watching to a subagent; the view doc keeps the original's description of the view, drops its "No message appears in full" line (untrue with Previous exchange on) and its list of when to zoom, adds one sentence on zooming out from a message to the summaries above it, and ends with the revised recipe's rule that the view is the truth and zoom the only way through it; the compactor prompt is the revised recipe's intro, view and Compactions sections, with OptChat's kinds (`talk` for replies, subagent reports as `work`) and without its turn-only sections, and with its value list reworded so the size limit is a ceiling rather than a target (bare approvals are recorded as the decision they made, lines don't narrate what wasn't seen yet, and a tool result is described by what its output shows rather than by a cause guessed from an exit code or an empty match; an A/B found less filler and no recall loss, see #129); unlike the recipe, the compactor doesn't get your instructions, since an A/B found they added nothing (#112); and the subagent prompt is the original's plus a closing note on time: report progress on long tasks with tell_parent, and say so before waiting on something slow, so the main agent can go on. OptChat keeps the recipe's numbers: 512-byte summary nodes (by default summaries up to 640 bytes are accepted without a retry, as long as they are smaller than what they replace; see Summary size tolerance), a memory view that grows a line per message and, past 128,000 bytes, merges in one batch down to 64,000 (so between batches it only grows at its end and each turn reads the last one's view from the cache; the view is saved and reloaded as it was), binary merges, 8 compression workers, fixed retry delays, 5 shortening attempts, and a 30,000-character tool output cap. Compactions get their own view, as in the 2026-10-08 revision of the recipe: the memory view merged further, to between 16,000 and 32,000 bytes with the same sawtooth, ending at the line being built. A message's summary starts once fewer than 8 lines before it are unsummarized, so up to 8 compactions run at once on one cached view. An import logs its messages the same way, waiting only while 8 messages and due merges are unbuilt, so the merges keep up. Where the recipe ends a compaction's view at the first line not summarized yet, OptChat shows that line as a placeholder and keeps the lines after it: cutting there also hid the short lines that follow, such as an echo's own tool call, and an A/B on real turns scored those summaries lower (see #101). The compactor's task and its "Too long" retry are the recipe's, verbatim, with a 512-dash ruler for the size. Anthropic requests split the view into blocks of 4 lines with cache marks on the last whole one and on the blocks 20 and 40 before it, so a turn that adds up to 240 lines of tool calls and results still reads the last view from the cache, and when that view is not cached yet, one compactor call goes first and the others wait until it starts answering, so they read the cache instead of each writing it. See `docs/victor-recipe.md` for notes.
241
+ `src/prompts.ts` holds every prompt sent to a model (the recipe's four, the subagent instruction paragraphs, the compaction task and its retry, the handoff prompt, the import note), each with a comment on where it goes; tool descriptions stay with their tools. The recipe's prompts are lightly adapted. The main agent's turn rules are the revised recipe's (2026-10-08) word for word, without its paragraph on computers, with reports starting "[id] " instead of "[Name]", and with two added sentences: don't poll anything slow (CI, a deploy) yourself, hand the watching to a subagent, and a message starting "[OptChat status]" is OptChat's own note, not the user; the view doc keeps the original's description of the view, drops its "No message appears in full" line (untrue with Previous exchange on) and its list of when to zoom, adds one sentence on zooming out from a message to the summaries above it, and ends with the revised recipe's rule that the view is the truth and zoom the only way through it; the compactor prompt is the revised recipe's intro, view and Compactions sections, with OptChat's kinds (`talk` for replies, subagent reports as `work`) and without its turn-only sections, and with its value list reworded so the size limit is a ceiling rather than a target (bare approvals are recorded as the decision they made, lines don't narrate what wasn't seen yet, and a tool result is described by what its output shows rather than by a cause guessed from an exit code or an empty match; an A/B found less filler and no recall loss, see #129); unlike the recipe, the compactor doesn't get your instructions, since an A/B found they added nothing (#112); and the subagent prompt is the original's plus a closing note on time: report progress on long tasks with tell_parent, and say so before waiting on something slow, so the main agent can go on. OptChat keeps the recipe's numbers: 512-byte summary nodes (by default summaries up to 640 bytes are accepted without a retry, as long as they are smaller than what they replace; see Summary size tolerance), a memory view that grows a line per message and, past 128,000 bytes, merges in one batch down to 64,000 (so between batches it only grows at its end and each turn reads the last one's view from the cache; the view is saved and reloaded as it was), binary merges, 8 compression workers, fixed retry delays, 5 shortening attempts, and a 30,000-character tool output cap. Compactions get their own view, as in the 2026-10-08 revision of the recipe: the memory view merged further, to between 16,000 and 32,000 bytes with the same sawtooth, ending at the line being built. A message's summary starts once fewer than 8 lines before it are unsummarized, so up to 8 compactions run at once on one cached view. An import logs its messages the same way, waiting only while 8 messages and due merges are unbuilt, so the merges keep up. Where the recipe ends a compaction's view at the first line not summarized yet, OptChat shows that line as a placeholder and keeps the lines after it: cutting there also hid the short lines that follow, such as an echo's own tool call, and an A/B on real turns scored those summaries lower (see #101). The compactor's task and its "Too long" retry are the recipe's, verbatim, with a 512-dash ruler for the size. Anthropic requests split the view into blocks of 4 lines with cache marks on the last whole one and on the blocks 20 and 40 before it, so a turn that adds up to 240 lines of tool calls and results still reads the last view from the cache, and when that view is not cached yet, one compactor call goes first and the others wait until it starts answering, so they read the cache instead of each writing it. See `docs/victor-recipe.md` for notes.
238
242
 
239
243
  Each run's context is the memory view, the previous exchange, and your new message. Deliberate additions (the ones that change the recipe's behaviour are settings, see [Settings](#settings)):
240
244
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-optchat",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
4
4
  "type": "module",
5
5
  "pi": {
6
6
  "extensions": [
package/src/agents.ts CHANGED
@@ -11,9 +11,9 @@ import { memoryTools, searchTool } from './tools.ts';
11
11
  import { type Memory } from './memory.ts';
12
12
  import type { ModelChoice } from './compactor.ts';
13
13
  import { cachePayload } from './cache.ts';
14
- import { CUT_OFF, RunHistory, transition, sessionMessages, type RunInfo, type RunState, type FinishReason } from './runs.ts';
14
+ import { CUT_OFF, RunHistory, isRunning, transition, sessionMessages, type RunInfo, type RunState, type FinishReason } from './runs.ts';
15
15
  import { UsageLedger } from './usage.ts';
16
- import { textContent } from './transcript.ts';
16
+ import { fullChat, textContent } from './transcript.ts';
17
17
  import { Type } from 'typebox';
18
18
  import { result } from './tools.ts';
19
19
  import type { HandoffEvidence } from './handoff.ts';
@@ -107,6 +107,13 @@ export class Children {
107
107
  const file = this.history.records.get(id)?.sessionFile;
108
108
  return file ? sessionMessages(file) : [];
109
109
  }
110
+ /** A run's messages for zoom, live or finished; undefined for no such run. A subagent `reader` sees only its own descendants, as with tell. */
111
+ transcript(id: string, reader?: string) {
112
+ const known = reader ? this.history.descendants(reader).some(run => run.id === id) : this.running.has(id) || this.history.records.has(id);
113
+ return known ? this.messages(id) : undefined;
114
+ }
115
+ /** Runs at work now: running, waiting for children, or stopping; not paused. */
116
+ get working() { return [...this.running.values()].filter(live => isRunning(live.info)).map(live => live.info); }
110
117
  subscribe(listener: () => void) { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; }
111
118
  private changed() { for (const listener of this.listeners) listener(); }
112
119
  private save(info: RunInfo) { this.history.save(info); this.changed(); }
@@ -235,7 +242,7 @@ export class Children {
235
242
  await loader.reload();
236
243
  const { session } = await (this.options.createSession ?? createAgentSession)({ cwd: directory, resourceLoader: loader, settingsManager,
237
244
  model: o.model, thinkingLevel: o.thinking, sessionManager: o.sessionManager,
238
- customTools: [...memoryTools(() => this.memory), ...(memorySearch ? [searchTool(() => this.memory)] : []), ...(delegates ? this.delegationTools(id, directory, subagentLevels, maxAgents) : []), this.parentTool(id, parentId, connected)],
245
+ customTools: [...memoryTools(() => this.memory, run => this.transcript(run, id)), ...(memorySearch ? [searchTool(() => this.memory)] : []), ...(delegates ? this.delegationTools(id, directory, subagentLevels, maxAgents) : []), this.parentTool(id, parentId, connected)],
239
246
  excludeTools: delegates ? [] : ['spawn', 'tell'],
240
247
  });
241
248
  // Callers track the session only after this returns: clean up here if its extensions fail to start.
@@ -376,7 +383,8 @@ export class Children {
376
383
  if (this.deleting.has(info.id)) info.report = 'Deleted by the user while it was working. The user dropped this task on purpose: do not redo it or delegate it again unless they ask.';
377
384
  // A metadata failure must not suppress delivery of the actual result.
378
385
  try { this.save(info); } catch (error) { this.warn(`Could not save run metadata: ${String(error)}`); }
379
- let text = `[${info.id}] ${info.report}`;
386
+ // A deleted run's transcript is gone.
387
+ let text = `[${info.id}] ${info.report}${this.deleting.has(info.id) ? '' : `\n\n${fullChat(info.id)}`}`;
380
388
  const batch = live.batch;
381
389
  let count: number | undefined;
382
390
  if (batch) {
@@ -583,7 +591,7 @@ export class Children {
583
591
  : `No transcript available. Run metadata: ${join(this.profileDirectory, 'runs', `${record.id}.optchat.json`)}`;
584
592
  return `[${record.id}] ${record.state}${record.parentId ? ` · parent ${record.parentId}` : ''}\n${location}${transcriptError ? `\nTranscript read failed: ${transcriptError}` : ''}`;
585
593
  }).join('\n');
586
- handoff.text = `[${run.id}] Connected conversation ${handoff.reason === 'complete' ? 'completed by user' : `interrupted (${handoff.reason})`}. This describes the conversation ending, not proof that every task succeeded.\n${summary}\n${source}`;
594
+ handoff.text = `[${run.id}] Connected conversation ${handoff.reason === 'complete' ? 'completed by user' : `interrupted (${handoff.reason})`}. This describes the conversation ending, not proof that every task succeeded.\n${summary}\n${source}\n\n${fullChat(run.id)}`;
587
595
  run.report = handoff.text; this.save(run);
588
596
  }
589
597
  await this.report(handoff.text, { once: true });
@@ -607,6 +615,8 @@ export class Children {
607
615
  async remove(id: string) {
608
616
  if (!this.history.records.has(id)) throw new Error(`No subagent ${id}.`);
609
617
  if (this.deleting.has(id)) throw new Error(`${id} is already being deleted.`);
618
+ // A run that is live while being deleted reports its own deletion, even one a tell was resuming; a finished one has nothing left to say, so memory is told here.
619
+ let reports = false;
610
620
  const runs = () => [id, ...this.history.descendants(id).map(run => run.id)], marked = new Set<string>();
611
621
  // An overlapping delete of a descendant keeps its own marks, so neither delete clears the other's.
612
622
  const mark = () => { for (const run of runs()) if (!this.deleting.has(run)) { marked.add(run); this.deleting.add(run); } };
@@ -617,6 +627,7 @@ export class Children {
617
627
  await Promise.allSettled(this.launches);
618
628
  const live = runs().flatMap(run => this.running.get(run) ?? []);
619
629
  if (!live.length) break;
630
+ if (live.some(run => run.info.id === id)) reports = true;
620
631
  mark();
621
632
  await Promise.allSettled(live.map(run => this.stop(run.info.id)));
622
633
  await Promise.allSettled(live.map(run => run.completion));
@@ -625,6 +636,7 @@ export class Children {
625
636
  const records = runs().flatMap(run => this.history.records.get(run) ?? []);
626
637
  if (records.some(run => run.connected && !run.handoff?.delivered)) throw new Error(`${id} has a connected conversation whose handoff has not reached the main agent yet, so it was kept.`);
627
638
  for (const run of records.reverse()) this.history.remove(run.id); // Children first, so a failure never leaves one without its parent.
639
+ if (!reports) this.memory.append('work', `[${id}] deleted by the user`);
628
640
  } finally { for (const run of marked) this.deleting.delete(run); this.changed(); }
629
641
  }
630
642
  async close() {
package/src/browser.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { join } from 'node:path';
2
- import { atomicWrite } from './memory.ts';
2
+ import { atomicWrite } from './store.ts';
3
3
  import { type Memory } from './memory.ts';
4
4
 
5
5
  // One self-contained, read-only HTML snapshot of a profile's memory.
package/src/cache.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { isView } from './memory.ts';
2
+ import { AT_WORK } from './prompts.ts';
2
3
 
3
4
  export function record(value: unknown): value is Record<string, unknown> {
4
5
  return typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -20,6 +21,15 @@ export function splitView(text: string) {
20
21
  * 60 blocks (240 lines). With the end of the request, that is Anthropic's limit of 4 marks. */
21
22
  const MARKS = [0, 20, 40];
22
23
 
24
+ /** The status line's own message, in Pi's context and in Anthropic's payload alike. */
25
+ export const isAtWork = (message: unknown) => record(message) && message.role === 'user' && Array.isArray(message.content) && message.content.length === 1
26
+ && record(message.content[0]) && typeof message.content[0].text === 'string' && message.content[0].text.startsWith(AT_WORK);
27
+ function lastBlock(message: Record<string, unknown>) {
28
+ if (typeof message.content === 'string') message.content = [{ type: 'text', text: message.content }];
29
+ const block: unknown = Array.isArray(message.content) ? message.content.at(-1) : undefined;
30
+ return record(block) ? block : undefined;
31
+ }
32
+
23
33
  /** Anthropic: marks on the view (see MARKS) plus automatic end-of-request caching, so the next call finds the last one's entry
24
34
  * and pays only for the lines after it. */
25
35
  export function cachePayload(payload: unknown): unknown {
@@ -46,6 +56,11 @@ export function cachePayload(payload: unknown): unknown {
46
56
  delete message.cache_control;
47
57
  if (Array.isArray(message.content)) for (const item of message.content) if (record(item) && !view.has(item)) delete item.cache_control;
48
58
  }
49
- payload.cache_control = { type: 'ephemeral' };
59
+ // The status line changes between calls, so the turn's mark goes on the block before it rather than at the end of the request.
60
+ // Pi may end the request with an empty system message carrying settings (effort); it holds nothing to cache.
61
+ const [before, last] = messages.filter(message => !record(message) || message.role !== 'system').slice(-2);
62
+ const turn = isAtWork(last) && record(before) && before.role === 'user' ? lastBlock(before) : undefined;
63
+ if (turn) turn.cache_control = { type: 'ephemeral' };
64
+ else payload.cache_control = { type: 'ephemeral' };
50
65
  return payload;
51
66
  }
package/src/checkpoint.ts CHANGED
@@ -2,7 +2,7 @@ import { execFile } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
3
  import { existsSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
- import { atomicWrite } from './memory.ts';
5
+ import { atomicWrite } from './store.ts';
6
6
  const exec = promisify(execFile);
7
7
 
8
8
  export async function checkpoint(directory: string) {
package/src/images.ts CHANGED
@@ -1,16 +1,10 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { readFileSync, readdirSync } from 'node:fs';
3
- import { join } from 'node:path';
4
2
  import type { ImageContent } from '@earendil-works/pi-ai';
5
3
  import { resizeImage } from '@earendil-works/pi-coding-agent';
6
- import { atomicWrite } from './memory.ts';
4
+ import { isMime, type Store } from './store.ts';
7
5
 
8
6
  /** Kept images fit 2048 px on the long side and ~1.5 MB (2 MB as base64), re-encoded as JPEG 85 only when they don't already. */
9
7
  export const IMAGE_LIMITS = { maxWidth: 2048, maxHeight: 2048, maxBytes: 2 * 1024 * 1024, jpegQuality: 85 };
10
- const EXTENSIONS = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp' } as const;
11
- type Mime = keyof typeof EXTENSIONS;
12
- const isMime = (mime: string): mime is Mime => Object.hasOwn(EXTENSIONS, mime);
13
- const MIMES = new Map<string, Mime>((Object.keys(EXTENSIONS) as Mime[]).map(mime => [EXTENSIONS[mime], mime]));
14
8
 
15
9
  const hash = (image: ImageContent) => createHash('sha256').update(image.data).digest('hex').slice(0, 16);
16
10
  /** How a message's image reads in memory: named by the image as the message holds it, before any shrinking, so it always gets the same name. */
@@ -19,26 +13,22 @@ const REF = /\[image ([0-9a-f]{16})\]/g;
19
13
 
20
14
  export const isImage = (part: unknown): part is ImageContent => typeof part === 'object' && part !== null
21
15
  && 'type' in part && part.type === 'image' && 'data' in part && typeof part.data === 'string' && 'mimeType' in part && typeof part.mimeType === 'string';
22
- const stored = (directory: string, name: string) => {
23
- try { return readdirSync(join(directory, 'images')).find(file => file.startsWith(`${name}.`) && MIMES.has(file.slice(name.length + 1))); } catch { return undefined; }
24
- };
25
16
 
26
- /** Keeps each image of a message's content under `images/`, once per distinct image, shrunk to IMAGE_LIMITS. */
27
- export async function saveImages(directory: string, content: unknown) {
17
+ /** Keeps each image of a message's content in the store, once per distinct image, shrunk to IMAGE_LIMITS. */
18
+ export async function saveImages(store: Store, content: unknown) {
28
19
  if (!Array.isArray(content)) return;
29
20
  for (const image of content.filter(isImage)) {
30
21
  const name = hash(image);
31
- if (stored(directory, name)) continue;
22
+ if (await store.image(name)) continue;
32
23
  // Pi's own resizer (Photon, WASM), as its read tool uses: it returns small images untouched, and null if it can't decode.
33
24
  const kept = await resizeImage(Buffer.from(image.data, 'base64'), image.mimeType, IMAGE_LIMITS) ?? image;
34
- if (isMime(kept.mimeType)) atomicWrite(join(directory, 'images', `${name}.${EXTENSIONS[kept.mimeType]}`), Buffer.from(kept.data, 'base64'));
25
+ if (isMime(kept.mimeType)) await store.putImage(name, kept.mimeType, Buffer.from(kept.data, 'base64'));
35
26
  }
36
27
  }
37
28
 
38
29
  /** The kept images a text refers to, in order, each once; ones never kept are skipped. */
39
- export function loadImages(directory: string, text: string): ImageContent[] {
40
- return [...new Set(Array.from(text.matchAll(REF), match => match[1]))].flatMap(name => {
41
- const file = stored(directory, name), mimeType = file && MIMES.get(file.slice(name.length + 1));
42
- return file && mimeType ? [{ type: 'image' as const, data: readFileSync(join(directory, 'images', file)).toString('base64'), mimeType }] : [];
43
- });
30
+ export async function loadImages(store: Store, text: string): Promise<ImageContent[]> {
31
+ const names = [...new Set(Array.from(text.matchAll(REF), match => match[1]))];
32
+ return (await Promise.all(names.map(name => store.image(name))))
33
+ .flatMap(kept => kept ? [{ type: 'image' as const, data: Buffer.from(kept.data).toString('base64'), mimeType: kept.mimeType }] : []);
44
34
  }
package/src/import/job.ts CHANGED
@@ -2,7 +2,7 @@ import { existsSync, readFileSync, rmSync, cpSync, mkdirSync } from 'node:fs';
2
2
  import { join, relative, sep } from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { Memory, bytes, isEntry, type Entry, type Compressor } from '../memory.ts';
5
- import { atomicWrite } from '../memory.ts';
5
+ import { atomicWrite } from '../store.ts';
6
6
  import { record } from '../cache.ts';
7
7
  import { copyKey, type ImportedEntry } from './sources.ts';
8
8
  import { DEFAULT_SETTINGS } from '../settings.ts';
@@ -106,7 +106,7 @@ export async function runImport(dir: string, compress: Compressor, signal: Abort
106
106
  const plan: unknown[] = readFileSync(join(path, STAGED), 'utf8').split('\n').filter(Boolean).map(line => JSON.parse(line));
107
107
  // Every line must parse, and the plan must be whole and in order, so a damaged plan can't drop messages silently.
108
108
  if (!plan.every(isEntry) || plan.length !== job.total || plan.some((e, i) => e.i !== i)) throw new Error('Import staging data is invalid; original memory remains intact.');
109
- const memory = new Memory(path, compress, () => {}, undefined, jobs);
109
+ const memory = await Memory.open(path, compress, () => {}, undefined, jobs);
110
110
  const report = () => progress({ messages: memory.root.length - memory.pending, total: plan.length, summaries: memory.tree.size, error: memory.lastError });
111
111
  const timer = setInterval(report, 500);
112
112
  try {
package/src/inbox.ts CHANGED
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  import { existsSync, readFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { record } from './cache.ts';
5
- import { atomicWrite } from './memory.ts';
5
+ import { atomicWrite } from './store.ts';
6
6
  import type { Memory } from './memory.ts';
7
7
 
8
8
  interface Arrival { id: string; text: string; date: string }
package/src/index.ts CHANGED
@@ -6,13 +6,14 @@ import { getSupportedThinkingLevels } from '@earendil-works/pi-ai';
6
6
  import type { AgentMessage } from '@earendil-works/pi-agent-core';
7
7
  import { parseSkillBlock, type ExtensionAPI, type ExtensionContext, type ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
8
8
  import { Type } from 'typebox';
9
- import { atomicWrite, Memory } from './memory.ts';
9
+ import { Memory } from './memory.ts';
10
+ import { atomicWrite, storeFor } from './store.ts';
10
11
  import { createCompressor } from './compactor.ts';
11
- import { createProfile, instructions, isWindows, lastProfile, listProfiles, loadConfig, lockProfile, profilePath, rememberProfile, saveConfig, ProfileBusyError, type ProfileConfig } from './profiles.ts';
12
+ import { createProfile, holdProfile, instructions, isWindows, lastProfile, listProfiles, loadConfig, profilePath, rememberProfile, saveConfig, ProfileBusyError, type ProfileConfig } from './profiles.ts';
12
13
  import { allowSearch, CONTINUITY, IMPORT_GUIDANCE, MASTER, SEARCH_DOC, VIEW_DOC } from './prompts.ts';
13
14
  import { cachePayload, record } from './cache.ts';
14
15
  import { saveImages } from './images.ts';
15
- import { asUser, boundedMessage, buildContext, logMessage, previousExchange, reportReceipt, REPORT_RECEIPT, REPORT_TYPE, RUN_BOUNDARY, textContent, typedText } from './transcript.ts';
16
+ import { asUser, boundedMessage, atWork, buildContext, logMessage, previousExchange, reportReceipt, REPORT_RECEIPT, REPORT_TYPE, RUN_BOUNDARY, textContent, typedText } from './transcript.ts';
16
17
  import { registerReportRenderer, type ReportDetails } from './report-message.ts';
17
18
  import { memoryTools, result, searchTool } from './tools.ts';
18
19
  import { Children, CWD_DOC, loadedBuiltins } from './agents.ts';
@@ -176,7 +177,7 @@ export default function optchat(pi: ExtensionAPI) {
176
177
  const openProfile = async (name: string, ctx: ExtensionContext) => {
177
178
  const dir = profilePath(name);
178
179
  if (!existsSync(dir)) throw new Error(`Profile ${name} does not exist. Use /optchat profile to create it.`);
179
- const unlock = await lockProfile(dir, `${name} · PID ${process.pid} · ${hostname()} · ${ctx.cwd}`);
180
+ const unlock = await holdProfile(storeFor(dir), dir, name, `${name} · PID ${process.pid} · ${hostname()} · ${ctx.cwd}`, warning => ctx.ui.notify(warning, 'warning'));
180
181
  let openingMemory: Memory | undefined;
181
182
  try {
182
183
  const config = loadConfig(dir);
@@ -186,7 +187,7 @@ export default function optchat(pi: ExtensionAPI) {
186
187
  const pending = join(dir, 'pending-reports.json');
187
188
  const saved: unknown = existsSync(pending) ? JSON.parse(readFileSync(pending, 'utf8')) : [];
188
189
  if (!Array.isArray(saved) || !saved.every(isPendingReport)) throw new Error('Invalid pending report journal.');
189
- const memory = new Memory(memoryDirectory(dir), createCompressor(ctx.modelRegistry, () => config.compactor, message => {
190
+ const memory = await Memory.open(memoryDirectory(dir), createCompressor(ctx.modelRegistry, () => config.compactor, message => {
190
191
  usage.compression(message, 'compactor', sessionId);
191
192
  status(ctx);
192
193
  }, () => config.summaryAcceptBytes), warning => ctx.ui.notify(warning, 'error'));
@@ -328,7 +329,7 @@ export default function optchat(pi: ExtensionAPI) {
328
329
  const message = asUser(bounded);
329
330
  // Before the message is logged, so the image its text names is already there for zoom; zoom's own images are kept already.
330
331
  if (message.role === 'user' || message.role === 'toolResult' && message.toolName !== 'zoom') {
331
- try { await saveImages(active.memory.directory, message.content); }
332
+ try { await saveImages(active.memory.store, message.content); }
332
333
  catch (error) { ctx.ui.notify(`OptChat could not keep an image: ${errorText(error)}`, 'warning'); }
333
334
  }
334
335
  if (!active) return;
@@ -379,7 +380,8 @@ export default function optchat(pi: ExtensionAPI) {
379
380
  view = a.memory.render(); // Capture old history before logging the new input.
380
381
  flush();
381
382
  }
382
- return { messages: buildContext(event.messages, run, view, promptFor(prompt, a.config), previous) };
383
+ // Built on every call, unlike the view, so it is never out of date; it goes last, after everything cached.
384
+ return { messages: buildContext(event.messages, run, view, promptFor(prompt, a.config), previous, atWork(a.children.working)) };
383
385
  } catch (error) {
384
386
  // Pi catches extension errors. Explicitly abort so it cannot fall back to old context.
385
387
  ctx.abort();
@@ -415,7 +417,8 @@ export default function optchat(pi: ExtensionAPI) {
415
417
  });
416
418
  registerConnectedRenderer(pi);
417
419
  registerReportRenderer(pi);
418
- for (const tool of memoryTools(() => required().memory)) pi.registerTool(tool);
420
+ const [zoom, date] = memoryTools(() => required().memory, id => required().children.transcript(id));
421
+ pi.registerTool(zoom); pi.registerTool(date);
419
422
  pi.registerTool({ ...searchTool(() => required().memory), defaultActive: false });
420
423
  // The tool and its prompt line change together, once per toggle, so the cached prefix is otherwise stable.
421
424
  // Synced on save too: a report turn started while idle reuses the tool set without before_agent_start.
package/src/memory.ts CHANGED
@@ -1,6 +1,5 @@
1
- import { closeSync, existsSync, fstatSync, fsyncSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, statSync, writeFileSync, writeSync } from 'node:fs';
2
- import { dirname, join, resolve } from 'node:path';
3
1
  import { EventEmitter } from 'node:events';
2
+ import { FileStore, storeFor, type Saved, type Store } from './store.ts';
4
3
 
5
4
  export const NODE = 512;
6
5
  export const VIEW = 128_000;
@@ -34,20 +33,6 @@ export function cap(text: string, limit = CAP) {
34
33
  const tail = text.slice(/[\udc00-\udfff]/.test(text[text.length - half]) ? text.length - half + 1 : text.length - half);
35
34
  return head + notice(text.length - head.length - tail.length) + tail;
36
35
  }
37
- export function localDay(date = new Date()) {
38
- return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
39
- }
40
- export function atomicWrite(file: string, text: string | Uint8Array) {
41
- mkdirSync(resolve(file, '..'), { recursive: true, mode: 0o700 });
42
- const temporary = `${file}.${process.pid}.tmp`;
43
- // One read-write handle: Windows refuses fsync on a read-only one, and it cannot open a directory at all, so there the file flush is the whole guarantee.
44
- const fd = openSync(temporary, 'w+', 0o600);
45
- try { writeFileSync(fd, text); fsyncSync(fd); } finally { closeSync(fd); }
46
- renameSync(temporary, file);
47
- if (process.platform === 'win32') return;
48
- const parent = openSync(resolve(file, '..'), 'r');
49
- try { fsyncSync(parent); } finally { closeSync(parent); }
50
- }
51
36
  /** The index of the view's most due pair of sibling lines whose parent is built, or -1 (recipe §3.2). A pair is as due as
52
37
  * long ago it ended, in its own line size; equal pairs go oldest first. This is the order of Taelin's rollback push. */
53
38
  export function mostDue(view: readonly Part[], total: number, built: (part: Part) => boolean) {
@@ -94,33 +79,6 @@ class Sawtooth {
94
79
  return this.parts.length < lines;
95
80
  }
96
81
  }
97
- const otherWriter = (file: string) => new Error(`Another process wrote ${file}. Close every other Pi on this profile and restart Pi; nothing was written.`);
98
- /** With `size`, refuses to append unless the file still has that size. A last line left without its newline is ended first. Returns the new size. */
99
- export function appendJson(file: string, value: unknown, size?: number) {
100
- const fd = openSync(file, 'a+', 0o600);
101
- try {
102
- const length = fstatSync(fd).size, last = Buffer.alloc(1);
103
- if (size !== undefined && length !== size) throw otherWriter(file);
104
- const torn = length > 0 && readSync(fd, last, 0, 1, length - 1) === 1 && last[0] !== 0x0a;
105
- const data = Buffer.from((torn ? '\n' : '') + JSON.stringify(value) + '\n');
106
- if (writeSync(fd, data) !== data.length) throw new Error(`Incomplete write: ${file}`);
107
- fsyncSync(fd);
108
- return fstatSync(fd).size;
109
- } finally { closeSync(fd); }
110
- }
111
- function records(dir: string, warn: (s: string) => void): unknown[] {
112
- if (!existsSync(dir)) return [];
113
- const result: unknown[] = [];
114
- for (const name of readdirSync(dir).filter(n => n.endsWith('.jsonl')).sort()) {
115
- const file = join(dir, name);
116
- for (const [index, line] of readFileSync(file, 'utf8').split('\n').entries()) {
117
- if (!line.trim()) continue;
118
- try { result.push(JSON.parse(line)); }
119
- catch { warn(`Skipped damaged JSON at ${file}:${index + 1}`); }
120
- }
121
- }
122
- return result;
123
- }
124
82
  function object(value: unknown): value is Record<string, unknown> {
125
83
  return typeof value === 'object' && value !== null;
126
84
  }
@@ -146,7 +104,6 @@ export class Memory {
146
104
  private readonly busy = new Map<number, Promise<void>>();
147
105
  private readonly retryAt = new Map<number, number>();
148
106
  private readonly reported = new Set<number>();
149
- private readonly lastSeenBytes = new Map<string, number>();
150
107
  /** Messages without their node, and merges whose halves are built, oldest first, so the scheduler never scans the tree for work (recipe §4). */
151
108
  private readonly unbuilt = new Set<number>();
152
109
  private readonly merges = new Map<number, Part>();
@@ -157,21 +114,34 @@ export class Memory {
157
114
  private stopped = false;
158
115
  private readonly chat: Sawtooth;
159
116
  private readonly compaction: Sawtooth;
117
+ /** The last store write still running, if any. Once an append fails, later writes are skipped and appends throw, as after a failed file write. */
118
+ private writing?: Promise<void>;
119
+ private failed?: string;
120
+ /** Where this memory's local files live: all of it for a FileStore, and the browser page, runs and imports for any store. */
121
+ readonly directory: string;
122
+ readonly store: Store;
160
123
  lastError?: string;
161
124
 
162
- /** `jobs`: summaries built at once, and how many unbuilt lines a new message's node waits behind. Chat keeps 8; imports pass the "Import: summaries at once" setting. */
163
- constructor(readonly directory: string, private readonly compress: Compressor,
125
+ /** Opens the memory in `directory` through the store `storeFor` picks, after its load. */
126
+ static async open(directory: string, compress: Compressor, warn: (s: string) => void = console.error, budget = VIEW, jobs = 8) {
127
+ const store = storeFor(directory, warn);
128
+ return new Memory({ directory, store, saved: await store.load() }, compress, warn, budget, jobs);
129
+ }
130
+ /** A directory alone opens a FileStore there. `jobs`: summaries built at once, and how many unbuilt lines a new message's node
131
+ * waits behind. Chat keeps 8; imports pass the "Import: summaries at once" setting. */
132
+ constructor(source: string | { directory: string; store: Store; saved: Saved }, private readonly compress: Compressor,
164
133
  private readonly warn: (s: string) => void = console.error,
165
134
  readonly budget = VIEW, private readonly jobs = 8, private readonly retryMs = 10_000) {
166
135
  this.chat = new Sawtooth(budget, budget / 2, part => this.node(part));
167
136
  this.compaction = new Sawtooth(budget / 4, budget / 8, part => this.node(part));
168
- for (const sub of ['main', 'tree']) mkdirSync(join(directory, sub), { recursive: true, mode: 0o700 });
169
- const main = join(directory, 'main'), log = records(main, warn);
170
- for (const name of readdirSync(main).filter(n => n.endsWith('.jsonl'))) this.lastSeenBytes.set(join(main, name), statSync(join(main, name)).size);
137
+ let saved: Saved;
138
+ if (typeof source === 'string') { const store = new FileStore(source, warn); this.directory = source; this.store = store; saved = store.load(); }
139
+ else ({ directory: this.directory, store: this.store, saved } = source);
140
+ const log = saved.entries;
171
141
  const entries = log.every(isEntry) ? log.sort((a, b) => a.i - b.i) : undefined;
172
142
  if (!entries || entries.some((entry, i) => entry.i !== i)) throw new Error('Invalid/noncontiguous OptChat log; refusing to change it.');
173
143
  for (const value of entries) this.root.push({ ...value, size: bytes(`${value.kind}: ${value.text}`) });
174
- for (const value of records(join(directory, 'tree'), warn)) {
144
+ for (const value of saved.tree) {
175
145
  if (!isSummary(value) || value.l < 0 || value.i < 0 || end(value) > this.root.length)
176
146
  throw new Error('Invalid OptChat summary record.');
177
147
  this.tree.set(key(value), { ...value, size: lineBytes(value.text) });
@@ -179,34 +149,43 @@ export class Memory {
179
149
  for (const node of this.tree.values()) this.queueParent(node);
180
150
  for (let i = 0; i < this.root.length; i++) if (!this.node({ l: 0, i })) this.unbuilt.add(i);
181
151
  // Refolding the log gives a different view than the live one, and every cache entry would miss, so the view is saved.
182
- const loaded = this.load();
152
+ const loaded = this.load(saved.view);
183
153
  for (const part of this.view) this.compaction.push(part);
184
154
  this.compaction.fit(loaded, true);
185
155
  for (let i = loaded; i < this.root.length; i++) { this.push(i); this.fit(i + 1); }
186
156
  this.fit(); this.save(); this.schedule();
187
157
  }
188
- private checkLog(next: string) {
189
- const main = dirname(next), names = readdirSync(main).filter(n => n.endsWith('.jsonl'));
190
- if (names.length !== this.lastSeenBytes.size || names.some(n => statSync(join(main, n)).size !== this.lastSeenBytes.get(join(main, n)))) throw otherWriter(next);
191
- }
192
158
  append(kind: Kind, text: string, date = new Date().toISOString(), receipt?: string, origin?: Origin) {
193
159
  if (this.stopped) throw new Error('Memory is closed.');
160
+ if (this.failed) throw new Error(`Memory could not be saved: ${this.failed}`);
194
161
  const entry: Entry = { i: this.root.length, kind, text, date, size: bytes(`${kind}: ${text}`), ...(receipt ? { receipt } : {}), ...(origin ? { origin } : {}) };
195
- const file = join(this.directory, 'main', `${localDay()}.jsonl`);
196
- if (!this.lastSeenBytes.has(file)) this.checkLog(file);
197
- this.lastSeenBytes.set(file, appendJson(file, entry, this.lastSeenBytes.get(file) ?? 0));
162
+ void this.enqueue(() => this.store.append(entry), error => {
163
+ this.failed ??= error instanceof Error ? error.message : String(error);
164
+ this.warn(`Memory could not be saved: ${this.failed}`);
165
+ });
198
166
  this.root.push(entry); this.push(entry.i); if (this.fit()) this.save(); this.schedule();
199
167
  return entry;
200
168
  }
201
169
  node(part: Part) { return this.tree.get(key(part)); }
202
170
  private text(part: Part) { return this.node(part)?.text ?? UNBUILT; }
171
+ /** Runs a store write at once when none is running, so a store that finishes its writes as it returns (FileStore) throws here
172
+ * as before; otherwise after the running ones, so writes land in the order Memory makes them. Returns the queued write, settled
173
+ * through `failed`, unless it finished at once. */
174
+ private enqueue(write: () => Promise<void> | undefined, failed: (error: unknown) => void) {
175
+ const run = () => this.failed ? undefined : write();
176
+ const started = this.writing ? this.writing.then(run) : run();
177
+ if (!started) return;
178
+ const settled = started.catch(failed);
179
+ const tail: Promise<void> = settled.catch(() => {}).then(() => { if (this.writing === tail) this.writing = undefined; });
180
+ this.writing = tail;
181
+ return settled;
182
+ }
203
183
  /** Takes the saved view if it still tiles the log from the start, and returns how many messages it covers. */
204
- private load() {
205
- const file = join(this.directory, 'view.json');
206
- if (!existsSync(file)) return 0;
207
- const rebuild = () => { this.warn(`Rebuilt the memory view: ${file} does not match the log.`); return 0; };
184
+ private load(view: string | undefined) {
185
+ if (view === undefined) return 0;
186
+ const rebuild = () => { this.warn('Rebuilt the memory view: the saved one does not match the log.'); return 0; };
208
187
  let saved: unknown;
209
- try { saved = JSON.parse(readFileSync(file, 'utf8')); } catch { return rebuild(); }
188
+ try { saved = JSON.parse(view); } catch { return rebuild(); }
210
189
  if (!Array.isArray(saved)) return rebuild();
211
190
  const parts: Part[] = [];
212
191
  let covered = 0;
@@ -223,8 +202,9 @@ export class Memory {
223
202
  /** Saved when it merges: a view that only grew is the saved one plus a line per later message, as `load` replays it.
224
203
  * The log stays authoritative, so a failed save only warns. */
225
204
  private save() {
226
- try { atomicWrite(join(this.directory, 'view.json'), JSON.stringify(this.view.map(p => [p.l, p.i]))); }
227
- catch (error) { this.warn(`Could not save the memory view: ${error instanceof Error ? error.message : String(error)}`); }
205
+ const failed = (error: unknown) => { this.warn(`Could not save the memory view: ${error instanceof Error ? error.message : String(error)}`); };
206
+ try { void this.enqueue(() => this.store.saveView(JSON.stringify(this.view.map(p => [p.l, p.i]))), failed); }
207
+ catch (error) { failed(error); }
228
208
  }
229
209
  private push(i: number) {
230
210
  const part = { l: 0, i };
@@ -304,7 +284,9 @@ export class Memory {
304
284
  if (this.stopped) return;
305
285
  if (!text) throw new Error('Compactor returned an empty summary.');
306
286
  const node = { ...part, text, size: lineBytes(text) };
307
- appendJson(join(this.directory, 'tree', `${localDay()}.jsonl`), node);
287
+ // A failed node write fails the build, which is retried.
288
+ const written = this.enqueue(() => this.store.appendNode(node), error => { throw error; });
289
+ if (written) await written;
308
290
  this.tree.set(key(part), node); this.retryAt.delete(key(part)); this.merges.delete(key(part)); this.queueParent(part);
309
291
  if (!part.l) { this.unbuilt.delete(part.i); this.chat.built(part.i); this.compaction.built(part.i); }
310
292
  if (!this.retryAt.size) this.lastError = undefined;
@@ -388,6 +370,6 @@ export class Memory {
388
370
  }
389
371
  async close() {
390
372
  this.stopped = true; this.controller.abort(); clearTimeout(this.retryTimer);
391
- this.events.emit('change'); await Promise.allSettled(this.busy.values());
373
+ this.events.emit('change'); await Promise.allSettled(this.busy.values()); await this.writing;
392
374
  }
393
375
  }
package/src/profiles.ts CHANGED
@@ -5,7 +5,7 @@ import { homedir } from 'node:os';
5
5
  import { join, resolve } from 'node:path';
6
6
  import type { ModelChoice } from './compactor.ts';
7
7
  import { record } from './cache.ts';
8
- import { atomicWrite } from './memory.ts';
8
+ import { atomicWrite, type Store } from './store.ts';
9
9
  import { DEFAULT_SETTINGS, readSettings, type Settings } from './settings.ts';
10
10
 
11
11
  export const isWindows = process.platform === 'win32';
@@ -127,3 +127,18 @@ export async function lockProfile(dir: string, description: string) {
127
127
  if (!isWindows) { try { chmodSync(socketPath, 0o600); } catch (error) { await unlock(); throw error; } }
128
128
  return unlock;
129
129
  }
130
+
131
+ /** How often a held lease is renewed; a store lets one lapse after about 30 seconds without renewal. */
132
+ export const HEARTBEAT_MS = 10_000;
133
+ /** Holds the profile until the returned function is called: through the store's lease if it has one, else the machine-local lock. */
134
+ export async function holdProfile(store: Pick<Store, 'lock'>, dir: string, profile: string, description: string, warn: (text: string) => void) {
135
+ const { lock } = store;
136
+ if (!lock) return lockProfile(dir, description);
137
+ const owner = await lock.lease(profile, description);
138
+ if (owner !== undefined) throw new ProfileBusyError(owner);
139
+ const beat = setInterval(() => {
140
+ lock.heartbeat().catch((error: unknown) => { warn(`Could not renew the profile lease: ${error instanceof Error ? error.message : String(error)}`); });
141
+ }, HEARTBEAT_MS);
142
+ beat.unref();
143
+ return async () => { clearInterval(beat); await lock.release(); };
144
+ }
package/src/prompts.ts CHANGED
@@ -70,6 +70,8 @@ further along than it was, and never narrate the process of summarizing
70
70
  ("output unseen", "read-only", "unconfirmed") unless that uncertainty is itself
71
71
  a fact OptChat must know. If told the line is too long, shorten it. Non-ASCII
72
72
  characters cost 2-4 bytes.`;
73
+ /** Labels the line naming running agents, last in every main-agent request while any run (src/transcript.ts); never stored. */
74
+ export const AT_WORK = '[OptChat status] Agents at work now: ';
73
75
  export const MASTER = `You are OptChat, an AI agent that works for one user in a single chat that
74
76
  never ends. Each turn starts with the view below, followed by the user's new
75
77
  message.
@@ -84,7 +86,8 @@ message starting "[id] ", between your tool calls or as a new turn. Never wait
84
86
  for one (no sleep, no polling): go on, or end your turn and tell the user what
85
87
  is running. The same goes for anything slow, such as CI or a deploy: don't sit
86
88
  polling it yourself at the end of a task, start a subagent to watch it, and
87
- move on.`;
89
+ move on. A message starting "[OptChat status]" is OptChat's own note on which
90
+ subagents are running, not the user.`;
88
91
  export const VIEW_DOC = `The view: the whole chat between OptChat and the user, oldest first, inside
89
92
  <chat> tags, as one-line summaries. Each line is
90
93
 
@@ -1,6 +1,6 @@
1
1
  import { getMarkdownTheme, type ExtensionAPI, type Theme } from '@earendil-works/pi-coding-agent';
2
2
  import { Box, Markdown, Text } from '@earendil-works/pi-tui';
3
- import { REPORT_TYPE, textContent } from './transcript.ts';
3
+ import { REPORT_TYPE, textContent, withoutFullChat } from './transcript.ts';
4
4
 
5
5
  const HEAD = /^\[([0-9a-f]{8})\] (?:(Message from subagent \(still running\)|Connected agent message): )?/;
6
6
 
@@ -9,6 +9,7 @@ export interface ReportDetails { count?: number }
9
9
 
10
10
  /** Split "[id] Message from subagent (still running): body" into a short label and the body. */
11
11
  export function reportParts(text: string, count = 1) {
12
+ text = withoutFullChat(text); // For the agent: the user has the Agents page.
12
13
  if (count > 1) return { label: `${count} subagent reports`, body: text };
13
14
  const match = HEAD.exec(text);
14
15
  if (!match) return { label: 'subagent', body: text };
package/src/runs.ts CHANGED
@@ -2,7 +2,7 @@ import { existsSync, readFileSync, readdirSync, rmSync } from 'node:fs';
2
2
  import { basename, join } from 'node:path';
3
3
  import { SessionManager } from '@earendil-works/pi-coding-agent';
4
4
  import type { AgentMessage } from '@earendil-works/pi-agent-core';
5
- import { atomicWrite } from './memory.ts';
5
+ import { atomicWrite } from './store.ts';
6
6
  import { record } from './cache.ts';
7
7
  import { textContent } from './transcript.ts';
8
8
 
package/src/store.ts ADDED
@@ -0,0 +1,125 @@
1
+ import { closeSync, existsSync, fstatSync, fsyncSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, statSync, writeFileSync, writeSync } from 'node:fs';
2
+ import { dirname, join, resolve } from 'node:path';
3
+ import type { Entry, Summary } from './memory.ts';
4
+
5
+ /** The image types memory keeps, and the file extension each is saved under. */
6
+ export const EXTENSIONS = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp' } as const;
7
+ export type Mime = keyof typeof EXTENSIONS;
8
+ export const isMime = (mime: string): mime is Mime => Object.hasOwn(EXTENSIONS, mime);
9
+ const MIMES = new Map<string, Mime>((Object.keys(EXTENSIONS) as Mime[]).map(mime => [EXTENSIONS[mime], mime]));
10
+
11
+ /** What a store gives back when a memory opens. Memory validates every record itself; `view` is the JSON `saveView` was last given. */
12
+ export interface Saved { entries: unknown[]; tree: unknown[]; view?: string }
13
+ /** A write is done when it returns: a store that writes behind returns a promise instead, which Memory awaits in order. */
14
+ type Write = Promise<void> | undefined;
15
+ type Maybe<T> = T | Promise<T>;
16
+
17
+ /** Cross-machine exclusivity. Implement it only if you want it: without it OptChat keeps its machine-local lock (a socket in the
18
+ * profile, or a named pipe on Windows), so two Pis on one machine still can't share a profile. */
19
+ export interface Lease {
20
+ /** Takes the profile for `holder` (who and where, shown to anyone refused), or returns the current holder.
21
+ * A lease not renewed for about 30 seconds may be taken by someone else. */
22
+ lease(profile: string, holder: string): Promise<string | undefined>;
23
+ /** Renews the lease. OptChat calls it every 10 seconds while it holds the profile and only warns when it fails, so fencing is
24
+ * the store's job: once its lease may have lapsed, it must refuse writes (for example with a fencing token checked on each one),
25
+ * since a paused process cannot be trusted to stop itself in time. */
26
+ heartbeat(): Promise<void>;
27
+ release(): Promise<void>;
28
+ }
29
+
30
+ /** Where one memory keeps what travels between machines: its message log, summary nodes, saved view and images. Everything else
31
+ * in a profile (runs, recovery journals, the active-memory pointer and imports, usage, settings, AGENTS.md) stays on the machine.
32
+ * Memory loads once when it opens, then works in RAM and issues every write in order. */
33
+ export interface Store {
34
+ load(): Maybe<Saved>;
35
+ /** Appends a message. Throws, writing nothing, if someone else appended since this store loaded. */
36
+ append(entry: Entry): Write;
37
+ /** Appends a summary node. */
38
+ appendNode(node: Summary): Write;
39
+ /** Replaces the saved view. */
40
+ saveView(view: string): Write;
41
+ putImage(name: string, mimeType: Mime, data: Uint8Array): Write;
42
+ image(name: string): Maybe<{ mimeType: Mime; data: Uint8Array } | undefined>;
43
+ lock?: Lease;
44
+ }
45
+
46
+ /** The one place a profile's store is chosen. A `store` setting will plug in here; until then every profile keeps its memory in files. */
47
+ export const storeFor = (directory: string, warn: (s: string) => void = console.error): Store => new FileStore(directory, warn);
48
+
49
+ export function localDay(date = new Date()) {
50
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
51
+ }
52
+ export function atomicWrite(file: string, text: string | Uint8Array) {
53
+ mkdirSync(resolve(file, '..'), { recursive: true, mode: 0o700 });
54
+ const temporary = `${file}.${process.pid}.tmp`;
55
+ // One read-write handle: Windows refuses fsync on a read-only one, and it cannot open a directory at all, so there the file flush is the whole guarantee.
56
+ const fd = openSync(temporary, 'w+', 0o600);
57
+ try { writeFileSync(fd, text); fsyncSync(fd); } finally { closeSync(fd); }
58
+ renameSync(temporary, file);
59
+ if (process.platform === 'win32') return;
60
+ const parent = openSync(resolve(file, '..'), 'r');
61
+ try { fsyncSync(parent); } finally { closeSync(parent); }
62
+ }
63
+ const otherWriter = (file: string) => new Error(`Another process wrote ${file}. Close every other Pi on this profile and restart Pi; nothing was written.`);
64
+ /** With `size`, refuses to append unless the file still has that size. A last line left without its newline is ended first. Returns the new size. */
65
+ export function appendJson(file: string, value: unknown, size?: number) {
66
+ const fd = openSync(file, 'a+', 0o600);
67
+ try {
68
+ const length = fstatSync(fd).size, last = Buffer.alloc(1);
69
+ if (size !== undefined && length !== size) throw otherWriter(file);
70
+ const torn = length > 0 && readSync(fd, last, 0, 1, length - 1) === 1 && last[0] !== 0x0a;
71
+ const data = Buffer.from((torn ? '\n' : '') + JSON.stringify(value) + '\n');
72
+ if (writeSync(fd, data) !== data.length) throw new Error(`Incomplete write: ${file}`);
73
+ fsyncSync(fd);
74
+ return fstatSync(fd).size;
75
+ } finally { closeSync(fd); }
76
+ }
77
+ function records(dir: string, warn: (s: string) => void): unknown[] {
78
+ if (!existsSync(dir)) return [];
79
+ const result: unknown[] = [];
80
+ for (const name of readdirSync(dir).filter(n => n.endsWith('.jsonl')).sort()) {
81
+ const file = join(dir, name);
82
+ for (const [index, line] of readFileSync(file, 'utf8').split('\n').entries()) {
83
+ if (!line.trim()) continue;
84
+ try { result.push(JSON.parse(line)); }
85
+ catch { warn(`Skipped damaged JSON at ${file}:${index + 1}`); }
86
+ }
87
+ }
88
+ return result;
89
+ }
90
+
91
+ /** Memory as files in one directory: `main/` and `tree/` hold dated JSONL, each line fsynced as it is appended; `view.json` and
92
+ * `images/<hash>.<ext>` are replaced atomically. Every write is done when it returns. */
93
+ export class FileStore implements Store {
94
+ /** Each log file's size as this store last left it, so a write by another process is refused instead of interleaved. */
95
+ private readonly lastSeenBytes = new Map<string, number>();
96
+ constructor(readonly directory: string, private readonly warn: (s: string) => void = console.error) {}
97
+ load(): Saved {
98
+ for (const sub of ['main', 'tree']) mkdirSync(join(this.directory, sub), { recursive: true, mode: 0o700 });
99
+ const main = join(this.directory, 'main'), entries = records(main, this.warn);
100
+ for (const name of readdirSync(main).filter(n => n.endsWith('.jsonl'))) this.lastSeenBytes.set(join(main, name), statSync(join(main, name)).size);
101
+ const view = join(this.directory, 'view.json');
102
+ // An unreadable view is rebuilt, as a damaged one is.
103
+ let saved: string | undefined;
104
+ if (existsSync(view)) try { saved = readFileSync(view, 'utf8'); } catch { saved = ''; }
105
+ return { entries, tree: records(join(this.directory, 'tree'), this.warn), view: saved };
106
+ }
107
+ append(entry: Entry): undefined {
108
+ const file = join(this.directory, 'main', `${localDay()}.jsonl`);
109
+ if (!this.lastSeenBytes.has(file)) this.checkLog(file);
110
+ this.lastSeenBytes.set(file, appendJson(file, entry, this.lastSeenBytes.get(file) ?? 0));
111
+ }
112
+ private checkLog(next: string) {
113
+ const main = dirname(next), names = readdirSync(main).filter(n => n.endsWith('.jsonl'));
114
+ if (names.length !== this.lastSeenBytes.size || names.some(n => statSync(join(main, n)).size !== this.lastSeenBytes.get(join(main, n)))) throw otherWriter(next);
115
+ }
116
+ appendNode(node: Summary): undefined { appendJson(join(this.directory, 'tree', `${localDay()}.jsonl`), node); }
117
+ saveView(view: string): undefined { atomicWrite(join(this.directory, 'view.json'), view); }
118
+ putImage(name: string, mimeType: Mime, data: Uint8Array): undefined { atomicWrite(join(this.directory, 'images', `${name}.${EXTENSIONS[mimeType]}`), data); }
119
+ image(name: string) {
120
+ let file: string | undefined;
121
+ try { file = readdirSync(join(this.directory, 'images')).find(f => f.startsWith(`${name}.`) && MIMES.has(f.slice(name.length + 1))); } catch { return undefined; }
122
+ const mimeType = file && MIMES.get(file.slice(name.length + 1));
123
+ return file && mimeType ? { mimeType, data: readFileSync(join(this.directory, 'images', file)) } : undefined;
124
+ }
125
+ }
package/src/tools.ts CHANGED
@@ -1,6 +1,8 @@
1
- import { Type } from 'typebox';
1
+ import { Type, type Static } from 'typebox';
2
+ import type { AgentMessage } from '@earendil-works/pi-agent-core';
2
3
  import { flat, PAGE, start, type Memory } from './memory.ts';
3
4
  import { loadImages } from './images.ts';
5
+ import { runTranscript } from './transcript.ts';
4
6
  export const result = (text: string) => ({ content: [{ type: 'text' as const, text }], details: {} });
5
7
  export const SEARCH_PAGE = 20;
6
8
  const SNIPPET = 200;
@@ -22,20 +24,39 @@ export function searchPage(memory: Memory, text: string, before?: number) {
22
24
  return `${hits.length} ${older}${hits.length === 1 ? 'message contains' : 'messages contain'} "${text}", newest first:\n${lines.join('\n')}${more}`;
23
25
  }
24
26
 
25
- export function memoryTools(memory: () => Memory) {
27
+ const zoomParameters = Type.Object({ id: Type.Union([Type.Integer({ minimum: 0 }), Type.String({ minLength: 1 })]), n: Type.Optional(Type.Integer({ minimum: 1 })),
28
+ offset: Type.Optional(Type.Integer({ minimum: 0 })), limit: Type.Optional(Type.Integer({ minimum: 1, maximum: PAGE })) });
29
+
30
+ /** A page of a run's transcript, saying where the next one starts. */
31
+ export function runPage(text: string, offset = 0, limit = PAGE) {
32
+ if (offset > text.length) throw new Error(`This transcript has ${text.length} characters; offset must be 0 to ${text.length}.`);
33
+ // Never split a surrogate pair: a page starts on its first half and ends after its second.
34
+ const from = /[\udc00-\udfff]/.test(text[offset] ?? '') ? offset - 1 : offset;
35
+ let to = Math.min(text.length, from + limit);
36
+ if (to < text.length && /[\ud800-\udbff]/.test(text[to - 1])) to += to - 1 === from ? 1 : -1;
37
+ return `${text.slice(from, to)}\n[characters ${from}-${to} of ${text.length}${to < text.length ? `; go on with offset ${to}` : ''}]`;
38
+ }
39
+
40
+ /** `runs` gives a subagent's messages by run id, live or finished; undefined for no such run. */
41
+ export function memoryTools(memory: () => Memory, runs?: (id: string) => readonly AgentMessage[] | undefined) {
26
42
  return [
27
- { 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.`,
28
- parameters: Type.Object({ id: Type.Integer({ minimum: 0 }), n: Type.Integer({ minimum: 1 }),
29
- offset: Type.Optional(Type.Integer({ minimum: 0 })), limit: Type.Optional(Type.Integer({ minimum: 1, maximum: PAGE })) }),
30
- async execute(_id: string, args: { id: number; n: number; offset?: number; limit?: number }) {
31
- const m = memory(), text = m.zoom(args.id, args.n, args.offset, args.limit), page = result(text);
43
+ { 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 (the default) 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. zoom("<run id>") gives a subagent's whole chat so far, in the same pages.`,
44
+ parameters: zoomParameters,
45
+ async execute(_id: string, { id, n = 1, offset, limit }: Static<typeof zoomParameters>) {
46
+ if (typeof id === 'string') {
47
+ const messages = runs?.(id);
48
+ if (messages) return result(runPage(runTranscript(messages), offset, limit));
49
+ // A model may quote a message id; eight digits can be a run id (src/agents.ts), so those never open a message.
50
+ if (!/^\d+$/.test(id) || id.length === 8) throw new Error(`No run ${id}.`);
51
+ }
52
+ const m = memory(), text = m.zoom(Number(id), n, offset, limit), page = result(text);
32
53
  // A message's images come back with its text, as read returns a PNG; summaries stay text.
33
- return args.n === 1 ? { ...page, content: [...page.content, ...loadImages(m.directory, text)] } : page;
54
+ return n === 1 ? { ...page, content: [...page.content, ...await loadImages(m.store, text)] } : page;
34
55
  } },
35
56
  { name: 'date', label: 'Memory date', description: 'The date and time of message id.',
36
57
  parameters: Type.Object({ id: Type.Integer({ minimum: 0 }) }),
37
58
  async execute(_id: string, args: { id: number }) { return result(memory().date(args.id)); } },
38
- ];
59
+ ] as const;
39
60
  }
40
61
 
41
62
  export const searchTool = (memory: () => Memory) => ({ name: 'search', label: 'Search memory',
package/src/transcript.ts CHANGED
@@ -2,7 +2,8 @@ import { createHash } from 'node:crypto';
2
2
  import type { AgentMessage } from '@earendil-works/pi-agent-core';
3
3
  import type { SessionEntry } from '@earendil-works/pi-coding-agent';
4
4
  import { getCurrentSystemMessage, type SystemMessage, type UserMessage } from '@earendil-works/pi-ai';
5
- import { CAP, cap, type Memory } from './memory.ts';
5
+ import { CAP, cap, flat, isView, type Memory } from './memory.ts';
6
+ import { AT_WORK } from './prompts.ts';
6
7
  import { record } from './cache.ts';
7
8
  import { DEFAULT_SETTINGS } from './settings.ts';
8
9
  import { imageRef, isImage } from './images.ts';
@@ -124,9 +125,45 @@ function latestExchange(branch: readonly SessionEntry[]) {
124
125
  return latest;
125
126
  }
126
127
 
127
- /** Keep one completed exchange plus the current run; all other history comes from the view. */
128
+ /** Ends every subagent report, so the parent knows it can check the report against what the child did. */
129
+ export const fullChat = (id: string) => `Full chat: zoom("${id}")`;
130
+ export const withoutFullChat = (text: string) => text.replace(/\n\nFull chat: zoom\("[\w-]+"\)$/gm, '');
131
+ const TASK = '\n</chat>\n\nYour task:\n';
132
+ /** Tool calls and results in a run transcript keep their head and tail, so a page holds many steps. */
133
+ const STEP = 1_000;
134
+ /** A subagent's chat for zoom, as `kind|text` lines: its task without the memory view, replies, tool calls and results, and what it was told. */
135
+ export function runTranscript(messages: readonly AgentMessage[]) {
136
+ const task = messages.find(message => message.role === 'user');
137
+ return messages.flatMap(message => {
138
+ if (message.role === 'user') {
139
+ // Only the task prompt holds the memory view (src/agents.ts): `${view}\n\nYour task:\n${task}`.
140
+ const text = textContent(message.content, false), at = text.indexOf(TASK);
141
+ return [`user|${message === task && isView(text) && at >= 0 ? text.slice(at + '\n</chat>\n\n'.length) : text}`];
142
+ }
143
+ if (message.role === 'toolResult') return [`echo|${cap(`${message.toolName}: ${textContent(message.content, false)}`, STEP)}`];
144
+ if (message.role !== 'assistant') return [];
145
+ return [...message.content.flatMap(block => block.type === 'text' && block.text.trim() ? [`talk|${block.text}`]
146
+ : block.type === 'toolCall' ? [`tool|${cap(`${block.name} ${JSON.stringify(block.arguments)}`, STEP)}`] : []),
147
+ ...message.stopReason === 'error' || message.stopReason === 'aborted' ? [`echo|Agent ${message.stopReason}: ${message.errorMessage ?? 'No further details'}`] : []];
148
+ }).join('\n');
149
+ }
150
+
151
+ /** The status line naming running agents by id and the first words of their task; none while nothing runs. */
152
+ export function atWork(runs: readonly { id: string; task: string }[]) {
153
+ if (!runs.length) return undefined;
154
+ return AT_WORK + runs.map(({ id, task }) => {
155
+ const words = flat(task).replaceAll('"', "'").split(/\s+/).filter(Boolean), start = words.slice(0, 6).join(' ');
156
+ // Six words, but never more than 60 characters: a task can be one long word.
157
+ const preview = [...start].slice(0, 60).join('');
158
+ return `${id} "${preview}${words.length > 6 || preview.length < start.length ? '…' : ''}"`;
159
+ }).join(', ') + '.';
160
+ }
161
+
162
+ /** Keep one completed exchange plus the current run; all other history comes from the view.
163
+ * `status` goes last, after everything cached, so it never moves the cached prefix (see cachePayload). It comes with new input only
164
+ * (the user, a report, a restart note): a call that follows a tool result is the same turn, which already had it. */
128
165
  export function buildContext(canonical: AgentMessage[], run: AgentMessage[], view: string, prompt: string,
129
- previous: readonly AgentMessage[] = []): AgentMessage[] {
166
+ previous: readonly AgentMessage[] = [], status?: string): AgentMessage[] {
130
167
  const system = getCurrentSystemMessage(canonical);
131
168
  const head: SystemMessage = { role: 'system', content: prompt, toolsAdded: system?.toolsAdded, timestamp: 0 };
132
169
  if (!run.some(m => m.role === 'user')) throw new Error('OptChat has no current user message; refusing to send historical context.');
@@ -136,5 +173,5 @@ export function buildContext(canonical: AgentMessage[], run: AgentMessage[], vie
136
173
  injected = true;
137
174
  return { ...message, content: [{ type: 'text' as const, text: view }, ...(typeof message.content === 'string' ? [{ type: 'text' as const, text: message.content }] : message.content)] };
138
175
  });
139
- return [head, ...messages];
176
+ return [head, ...messages, ...status && messages.at(-1)?.role !== 'toolResult' ? [{ role: 'user' as const, content: [{ type: 'text' as const, text: status }], timestamp: 0 }] : []];
140
177
  }
package/src/usage.ts CHANGED
@@ -4,7 +4,7 @@ import { join } from 'node:path';
4
4
  import type { AssistantMessage, Usage } from '@earendil-works/pi-ai';
5
5
  import type { SessionEntry } from '@earendil-works/pi-coding-agent';
6
6
  import { record } from './cache.ts';
7
- import { appendJson } from './memory.ts';
7
+ import { appendJson } from './store.ts';
8
8
 
9
9
  export type UsageRole = 'main' | 'subagent' | 'compactor' | 'import';
10
10
  export const ranges = ['This session', 'Last hour', 'Today', 'Last 7 days', 'All time'] as const;