pi-optchat 0.10.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +4 -2
  2. package/docs/core.md +38 -0
  3. package/docs/durable.md +35 -0
  4. package/docs/recipe-differences.md +1 -1
  5. package/docs/storage.md +2 -2
  6. package/docs/subagents.md +5 -1
  7. package/docs/victor-recipe.md +4 -4
  8. package/package.json +38 -12
  9. package/src/agent-view.ts +5 -6
  10. package/src/agents.ts +19 -12
  11. package/src/browser.ts +11 -7
  12. package/src/compactor.ts +10 -97
  13. package/src/connected-window.ts +4 -4
  14. package/src/core/compactor.ts +119 -0
  15. package/src/core/images.ts +24 -0
  16. package/src/core/import.ts +84 -0
  17. package/src/core/index.ts +13 -0
  18. package/src/core/memory.ts +460 -0
  19. package/src/core/messages.ts +108 -0
  20. package/src/core/prompts.ts +153 -0
  21. package/src/core/store.ts +48 -0
  22. package/src/core/tools.ts +64 -0
  23. package/src/durable/import.ts +114 -0
  24. package/src/durable/index.ts +5 -0
  25. package/src/durable/optchat.ts +274 -0
  26. package/src/durable/store.ts +162 -0
  27. package/src/handoff.ts +4 -3
  28. package/src/images.ts +2 -12
  29. package/src/import/job.ts +19 -51
  30. package/src/import/sources.ts +6 -4
  31. package/src/import/ui.ts +3 -3
  32. package/src/inbox.ts +1 -1
  33. package/src/index.ts +26 -38
  34. package/src/inspector.ts +3 -1
  35. package/src/memory.ts +18 -416
  36. package/src/profiles.ts +3 -2
  37. package/src/prompts.ts +1 -149
  38. package/src/report-message.ts +5 -3
  39. package/src/runs.ts +6 -1
  40. package/src/settings.ts +3 -2
  41. package/src/store.ts +16 -42
  42. package/src/title.ts +3 -2
  43. package/src/tools.ts +5 -62
  44. package/src/transcript.ts +18 -101
  45. package/src/usage.ts +1 -1
  46. package/src/window-bridge.ts +9 -6
  47. /package/src/{cache.ts → core/cache.ts} +0 -0
@@ -2,7 +2,8 @@ import { getMarkdownTheme, type ExtensionAPI, type Theme } from '@earendil-works
2
2
  import { Box, Markdown, Text } from '@earendil-works/pi-tui';
3
3
  import { REPORT_TYPE, textContent, withoutFullChat } from './transcript.ts';
4
4
 
5
- const HEAD = /^\[([0-9a-f]{8})\] (?:(Message from subagent \(still running\)|Connected agent message): )?/;
5
+ /** `[id]` or `[id · title]` (runs.ts `reportHead`); titles hold no brackets. */
6
+ const HEAD = /^\[([0-9a-f]{8})(?: · ([^\]\n]*))?\] (?:(Message from subagent \(still running\)|Connected agent message): )?/;
6
7
 
7
8
  /** Sent with a report: `count` subagent reports joined in one message, when one spawn's reports arrive together. */
8
9
  export interface ReportDetails { count?: number }
@@ -13,8 +14,9 @@ export function reportParts(text: string, count = 1) {
13
14
  if (count > 1) return { label: `${count} subagent reports`, body: text };
14
15
  const match = HEAD.exec(text);
15
16
  if (!match) return { label: 'subagent', body: text };
16
- const kind = match[2] === undefined ? 'report' : match[2].startsWith('Message') ? 'still running' : 'connected window';
17
- return { label: `subagent ${match[1]} · ${kind}`, body: text.slice(match[0].length) };
17
+ const [, id, title, message] = match;
18
+ const kind = message === undefined ? 'report' : message.startsWith('Message') ? 'still running' : 'connected window';
19
+ return { label: `subagent ${id}${title ? ` · ${title}` : ''} · ${kind}`, body: text.slice(match[0].length) };
18
20
  }
19
21
 
20
22
  /** The theme's neutral grey, darkened on dark themes so the box recedes behind the conversation. */
package/src/runs.ts CHANGED
@@ -3,7 +3,7 @@ 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
5
  import { atomicWrite } from './store.ts';
6
- import { record } from './cache.ts';
6
+ import { record } from './core/cache.ts';
7
7
  import { textContent } from './transcript.ts';
8
8
 
9
9
  /** `paused`: interrupted by the user with nothing queued, alive and waiting for its next message. `interrupted` is a finished run cut off by Pi closing or a connected window ending. */
@@ -12,6 +12,8 @@ export type RunState = typeof runStates[number];
12
12
  export type FinishReason = 'complete' | 'disconnected' | 'owner-stopped' | 'failed';
13
13
  export interface RunInfo {
14
14
  id: string; task: string; cwd: string; model: string; thinking: string;
15
+ /** The spawning agent's short label for the task, shown instead of it where there is room for one line. */
16
+ title?: string;
15
17
  parentSession: string; sessionFile?: string; started: number; ended?: number;
16
18
  parentId?: string; depth: number;
17
19
  state: RunState; report?: string;
@@ -24,6 +26,8 @@ export interface RunInfo {
24
26
  guidance: Guidance[];
25
27
  }
26
28
  export type Guidance = { text: string; date: number; state: 'queued' | 'delivered' | 'undelivered'; from?: 'user' | 'manager'; sent?: string };
29
+ /** How a run's reports and messages start: `[id]`, or `[id · title]` (see `HEAD` in report-message.ts). */
30
+ export const reportHead = (run: { id: string; title?: string }) => `[${run.id}${run.title ? ` · ${run.title}` : ''}]`;
27
31
  /** The text the agent receives, which is how Pi's queue and the session know the message. */
28
32
  export const sentText = (g: Guidance) => g.sent ?? g.text;
29
33
  const moves: Record<RunState, readonly RunState[]> = {
@@ -56,6 +60,7 @@ function isRun(value: unknown): value is RunInfo {
56
60
  && typeof value.id === 'string' && /^[a-zA-Z0-9-]+$/.test(value.id)
57
61
  && runStates.includes(value.state as RunState) && typeof value.started === 'number' && Number.isFinite(value.started)
58
62
  && (value.ended === undefined || typeof value.ended === 'number')
63
+ && (value.title === undefined || typeof value.title === 'string')
59
64
  && (value.sessionFile === undefined || typeof value.sessionFile === 'string')
60
65
  && (value.report === undefined || typeof value.report === 'string')
61
66
  && (value.connected === undefined || typeof value.connected === 'boolean')
package/src/settings.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { NODE } from './memory.ts';
1
+ import { ACCEPTED_BYTES } from './core/compactor.ts';
2
+ import { NODE } from './core/memory.ts';
2
3
 
3
4
  /** Per-profile choices where OptChat departs from Victor's recipe. Each default is the recipe's behaviour unless noted. */
4
5
  export const SETTINGS = {
@@ -20,7 +21,7 @@ export const SETTINGS = {
20
21
  memorySearch: { kind: 'toggle', label: 'Memory search', default: false,
21
22
  description: 'Lets agents search original messages by plain text. Off: zoom and date only (Victor\'s recipe). Toggling re-caches the prompt once.',
22
23
  applies: 'Applies from the next turn and to new or resumed subagents.' },
23
- summaryAcceptBytes: { kind: 'number', label: 'Summary size tolerance', default: NODE * 1.25, min: NODE, unit: 'bytes',
24
+ summaryAcceptBytes: { kind: 'number', label: 'Summary size tolerance', default: ACCEPTED_BYTES, min: NODE, unit: 'bytes',
24
25
  description: `Summary lines up to this size are kept instead of retried (the compactor aims for ${NODE}). ${NODE}: strict (Victor's recipe).`,
25
26
  applies: 'Applies to the next summary.' },
26
27
  jobs: { kind: 'number', label: 'Summaries at once', default: 8, min: 1, max: 64, unit: '',
package/src/store.ts CHANGED
@@ -1,48 +1,10 @@
1
1
  import { closeSync, existsSync, fstatSync, fsyncSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, statSync, truncateSync, writeFileSync, writeSync } from 'node:fs';
2
2
  import { dirname, join, resolve } from 'node:path';
3
- import type { Entry, Summary } from './memory.ts';
3
+ import { isEntry, isSummary, type Entry, type Part, type Summary } from './core/memory.ts';
4
+ import { EXTENSIONS, type Mime, type Saved, type Store } from './core/store.ts';
4
5
 
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
6
  const MIMES = new Map<string, Mime>((Object.keys(EXTENSIONS) as Mime[]).map(mime => [EXTENSIONS[mime], mime]));
10
7
 
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
8
  /** 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
9
  export const storeFor = (directory: string, warn: (s: string) => void = console.error): Store => new FileStore(directory, warn);
48
10
 
@@ -97,6 +59,9 @@ function records(dir: string, warn: (s: string) => void): unknown[] {
97
59
  export class FileStore implements Store {
98
60
  /** Each log file's size as this store last left it, so a write by another process is refused instead of interleaved. */
99
61
  private readonly lastSeenBytes = new Map<string, number>();
62
+ /** Texts stay in RAM, as before lazy text: Pi has the room, and reads stay as fast. */
63
+ private readonly texts: string[] = [];
64
+ private readonly summaries = new Map<string, string>();
100
65
  constructor(readonly directory: string, private readonly warn: (s: string) => void = console.error) {}
101
66
  load(): Saved {
102
67
  for (const sub of ['main', 'tree']) mkdirSync(join(this.directory, sub), { recursive: true, mode: 0o700 });
@@ -106,18 +71,27 @@ export class FileStore implements Store {
106
71
  // An unreadable view is rebuilt, as a damaged one is.
107
72
  let saved: string | undefined;
108
73
  if (existsSync(view)) try { saved = readFileSync(view, 'utf8'); } catch { saved = ''; }
109
- return { entries, tree: records(join(this.directory, 'tree'), this.warn), view: saved };
74
+ const tree = records(join(this.directory, 'tree'), this.warn);
75
+ // Memory checks the rest; a record without its text is as broken here as before.
76
+ if (!entries.every(isEntry)) throw new Error('Invalid/noncontiguous OptChat log; refusing to change it.');
77
+ if (!tree.every(isSummary)) throw new Error('Invalid OptChat summary record.');
78
+ for (const { i, text } of entries) this.texts[i] = text;
79
+ for (const { l, i, text } of tree) this.summaries.set(`${l}:${i}`, text);
80
+ return { entries, tree, view: saved };
110
81
  }
82
+ text(i: number) { return this.texts[i]; }
83
+ summary({ l, i }: Part) { return this.summaries.get(`${l}:${i}`)!; }
111
84
  append(entry: Entry): undefined {
112
85
  const file = join(this.directory, 'main', `${localDay()}.jsonl`);
113
86
  if (!this.lastSeenBytes.has(file)) this.checkLog(file);
114
87
  this.lastSeenBytes.set(file, appendJson(file, entry, this.lastSeenBytes.get(file) ?? 0));
88
+ this.texts[entry.i] = entry.text;
115
89
  }
116
90
  private checkLog(next: string) {
117
91
  const main = dirname(next), names = readdirSync(main).filter(n => n.endsWith('.jsonl'));
118
92
  if (names.length !== this.lastSeenBytes.size || names.some(n => statSync(join(main, n)).size !== this.lastSeenBytes.get(join(main, n)))) throw otherWriter(next);
119
93
  }
120
- appendNode(node: Summary): undefined { appendJson(join(this.directory, 'tree', `${localDay()}.jsonl`), node); }
94
+ appendNode(node: Summary): undefined { appendJson(join(this.directory, 'tree', `${localDay()}.jsonl`), node); this.summaries.set(`${node.l}:${node.i}`, node.text); }
121
95
  saveView(view: string): undefined { atomicWrite(join(this.directory, 'view.json'), view); }
122
96
  putImage(name: string, mimeType: Mime, data: Uint8Array): undefined { atomicWrite(join(this.directory, 'images', `${name}.${EXTENSIONS[mimeType]}`), data); }
123
97
  image(name: string) {
package/src/title.ts CHANGED
@@ -4,8 +4,9 @@ export type WindowState = StatusState | 'done' | 'disconnected';
4
4
 
5
5
  const agents = (count: number) => count > 0 ? ` · ${count} ${count === 1 ? 'agent' : 'agents'}` : '';
6
6
  export const mainTitle = (profile: string, working: boolean, running: number) => `${working ? '● ' : ''}π ${profile}${agents(running)}`;
7
- export const windowTitle = (profile: string, state: WindowState) =>
8
- `${state === 'working' ? '● ' : ''}↳ ${profile}${state === 'done' || state === 'disconnected' ? ` · ${state}` : ''}`;
7
+ /** `label`: the spawn title of the subagent an attached window shows. */
8
+ export const windowTitle = (profile: string, state: WindowState, label?: string) =>
9
+ `${state === 'working' ? '● ' : ''}↳ ${profile}${label ? ` · ${label}` : ''}${state === 'done' || state === 'disconnected' ? ` · ${state}` : ''}`;
9
10
  /** A connected window's agent is working while it runs or stops, or waits on agents of its own; waiting with none means waiting for you. */
10
11
  export const statusState = (state: string, agents = 0): StatusState =>
11
12
  state === 'running' || state === 'stopping' || state === 'waiting' && agents > 0 ? 'working' : 'waiting';
package/src/tools.ts CHANGED
@@ -1,65 +1,8 @@
1
- import { Type, type Static } from 'typebox';
2
1
  import type { AgentMessage } from '@earendil-works/pi-agent-core';
3
- import { flat, PAGE, start, type Memory } from './memory.ts';
4
- import { loadImages } from './images.ts';
2
+ import type { Memory } from './core/memory.ts';
3
+ import { memoryTools as tools } from './core/tools.ts';
5
4
  import { runTranscript } from './transcript.ts';
6
- export const result = (text: string) => ({ content: [{ type: 'text' as const, text }], details: {} });
7
- export const SEARCH_PAGE = 20;
8
- const SNIPPET = 200;
9
5
 
10
- /** One page of hits, each with its id, the view line holding it, its date and a snippet around the first match; at most ~5 KB. */
11
- export function searchPage(memory: Memory, text: string, before?: number) {
12
- const hits = memory.search(text, before), older = before === undefined ? '' : 'older ';
13
- if (!hits.length) return `No ${older}messages contain "${text}".`;
14
- const page = hits.slice(0, SEARCH_PAGE), needle = text.toLowerCase();
15
- const lines = page.map(entry => {
16
- // Cut the original text, whose match may span lines, and flatten only the cut.
17
- const at = Math.max(0, entry.text.toLowerCase().indexOf(needle) - SNIPPET / 4);
18
- const snippet = flat(entry.text.slice(at, at + SNIPPET).replace(/^[\udc00-\udfff]|[\ud800-\udbff]$/g, ''));
19
- // The live view, which may have folded since the turn's snapshot: its lines are built, so zoom always opens them.
20
- const line = memory.covering(entry.i); // Named only when the hit is inside a summary line.
21
- return `${entry.i}${line?.l ? ` (in ${start(line)}+${2 ** line.l})` : ''} · ${new Date(entry.date).toString().slice(0, 21)} · ${entry.kind}: ${at ? '…' : ''}${snippet}${at + SNIPPET < entry.text.length ? '…' : ''}`;
22
- });
23
- const more = hits.length > page.length ? `\nOlder matches: search again with before: ${page[page.length - 1].i}.` : '';
24
- return `${hits.length} ${older}${hits.length === 1 ? 'message contains' : 'messages contain'} "${text}", newest first:\n${lines.join('\n')}${more}`;
25
- }
26
-
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) {
42
- return [
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);
53
- // A message's images come back with its text, as read returns a PNG; summaries stay text.
54
- return n === 1 ? { ...page, content: [...page.content, ...await loadImages(m.store, text)] } : page;
55
- } },
56
- { name: 'date', label: 'Memory date', description: 'The date and time of message id.',
57
- parameters: Type.Object({ id: Type.Integer({ minimum: 0 }) }),
58
- async execute(_id: string, args: { id: number }) { return result(memory().date(args.id)); } },
59
- ] as const;
60
- }
61
-
62
- export const searchTool = (memory: () => Memory) => ({ name: 'search', label: 'Search memory',
63
- description: `Find the original messages that contain text (plain text, any case), newest first, ${SEARCH_PAGE} at a time; before: id continues with older ones. zoom(id, 1) gives a hit whole.`,
64
- parameters: Type.Object({ text: Type.String({ minLength: 1 }), before: Type.Optional(Type.Integer({ minimum: 0 })) }),
65
- async execute(_id: string, args: { text: string; before?: number }) { return result(searchPage(memory(), args.text, args.before)); } });
6
+ /** zoom and date, where `runs` gives a subagent's messages by run id, live or finished; undefined for no such run. */
7
+ export const memoryTools = (memory: () => Memory, runs?: (id: string) => readonly AgentMessage[] | undefined) =>
8
+ tools(memory, runs && (id => { const messages = runs(id); return messages && runTranscript(messages); }));
package/src/transcript.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  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
- import { getCurrentSystemMessage, type SystemMessage, type UserMessage } from '@earendil-works/pi-ai';
5
- import { CAP, cap, flat, isView, type Memory } from './memory.ts';
6
- import { AT_WORK } from './prompts.ts';
7
- import { record } from './cache.ts';
4
+ import { getCurrentSystemMessage, type SystemMessage } from '@earendil-works/pi-ai';
5
+ import { cap, flat, isView, type Memory } from './core/memory.ts';
6
+ import { completedExchange, logMessage as log, textContent as text, withView } from './core/messages.ts';
7
+ import { AT_WORK } from './core/prompts.ts';
8
+ import { record } from './core/cache.ts';
8
9
  import { DEFAULT_SETTINGS } from './settings.ts';
9
- import { imageRef, isImage } from './images.ts';
10
+ import { imageRef } from './images.ts';
10
11
 
11
12
  export const RUN_BOUNDARY = 'optchat.run';
12
13
  /** Subagent traffic to the main agent: a custom message on screen, a plain user message to the model, `work` in memory. */
@@ -15,16 +16,8 @@ export const REPORT_RECEIPT = 'report:';
15
16
  /** A report's receipt in memory: it marks the entry as `work` and keeps a restart from delivering the report twice. */
16
17
  export const reportReceipt = (text: string) => REPORT_RECEIPT + createHash('sha256').update(text).digest('hex');
17
18
 
18
- export function textContent(content: unknown, images = true): string {
19
- if (typeof content === 'string') return content;
20
- if (!Array.isArray(content)) return '';
21
- return content.map((part: unknown) => {
22
- if (typeof part !== 'object' || part === null) return '';
23
- if ('type' in part && part.type === 'text' && 'text' in part && typeof part.text === 'string') return part.text;
24
- if (images && isImage(part)) return imageRef(part);
25
- return '';
26
- }).filter(Boolean).join('\n');
27
- }
19
+ /** A message's text, its images named by `imageRef` unless `images` is false. */
20
+ export const textContent = (content: unknown, images = true) => text(content, images ? imageRef : undefined);
28
21
  /** What the user typed, as Pi's input event (and so the inbox) saw it: no image references, and without the
29
22
  * `[Image …]` notes Pi appends after the text when it resizes, converts or omits an attached image. */
30
23
  export function typedText(content: unknown) {
@@ -46,41 +39,9 @@ export function asUser(message: AgentMessage): AgentMessage {
46
39
  if (first?.type === 'text') return { role: 'user', content: [{ ...first, text: tag + first.text }, ...rest], timestamp };
47
40
  return { role: 'user', content: [{ type: 'text', text: tag.trimEnd() }, ...content], timestamp };
48
41
  }
49
- export function logMessage(memory: Memory, message: AgentMessage, receipt?: string) {
50
- const date = new Date(message.timestamp).toISOString();
51
- if (message.role === 'user') memory.append(receipt?.startsWith(REPORT_RECEIPT) ? 'work' : 'user', textContent(message.content), date, receipt);
52
- else if (message.role === 'assistant') {
53
- for (const block of message.content) {
54
- if (block.type === 'text' && block.text.trim()) memory.append('talk', block.text, date);
55
- if (block.type === 'toolCall') memory.append('tool', `${block.name} ${JSON.stringify(block.arguments)}`, date);
56
- }
57
- if (message.stopReason === 'error' || message.stopReason === 'aborted')
58
- memory.append('echo', `Agent ${message.stopReason}: ${message.errorMessage ?? 'No further details'}`, date);
59
- } else if (message.role === 'toolResult')
60
- // A zoom's page already names the images it returns.
61
- memory.append('echo', cap(`${message.toolName}: ${textContent(message.content, message.toolName !== 'zoom')}`), date);
62
- }
63
- export function boundedMessage(message: AgentMessage): AgentMessage {
64
- if (message.role !== 'toolResult') return message;
65
- const text = message.content.filter(c => c.type === 'text').map(c => c.text).join('\n');
66
- if (text.length <= CAP) return message;
67
- return { ...message, content: [{ type: 'text', text: cap(text) }, ...message.content.filter(c => c.type === 'image')] };
68
- }
69
- /** The caller supplies a single settled run, including any steering after text-only replies. */
70
- function completedExchange(history: readonly AgentMessage[]) {
71
- const last = history.findLastIndex(m => m.role === 'user' || m.role === 'assistant');
72
- const answer = history[last];
73
- if (answer?.role !== 'assistant' || answer.stopReason !== 'stop'
74
- || answer.content.some(block => block.type === 'toolCall')) return [];
75
- const content = answer.content.flatMap(block => block.type === 'text' ? [{ type: 'text' as const, text: block.text }] : []);
76
- if (!content.some(block => block.text.trim())) return [];
77
- const requests: UserMessage[] = [];
78
- for (const message of history.slice(0, last)) {
79
- if (message.role === 'user') requests.push({ ...message, content: textContent(message.content) });
80
- }
81
- if (!requests.length) return [];
82
- return [...requests, { ...answer, content }];
83
- }
42
+ /** Logs a message to memory; a user message with a report's receipt as `work`. */
43
+ export const logMessage = (memory: Memory, message: AgentMessage, receipt?: string) =>
44
+ log(memory, message, { kind: receipt?.startsWith(REPORT_RECEIPT) ? 'work' : 'user', receipt, imageRef });
84
45
 
85
46
  /** Default limit in bytes of text (16 KB, ~4,000 tokens). Most exchanges are 1-7 KB; a larger one, usually a big paste, is
86
47
  * left out entirely and the model falls back to its summaries in the memory view, zooming for the full text if it needs it. */
@@ -104,7 +65,7 @@ function latestExchange(branch: readonly SessionEntry[]) {
104
65
  if (entry.data.state === 'end') end = i;
105
66
  else if (entry.data.state === 'start') {
106
67
  if (end >= 0) {
107
- const exchange = completedExchange(messages(branch.slice(i + 1, end)));
68
+ const exchange = completedExchange(messages(branch.slice(i + 1, end)), imageRef);
108
69
  if (exchange.length) return exchange;
109
70
  }
110
71
  end = -1;
@@ -118,7 +79,7 @@ function latestExchange(branch: readonly SessionEntry[]) {
118
79
  for (let i = 0; i < legacy.length; i++) {
119
80
  const message = legacy[i];
120
81
  if (message.role !== 'assistant' || message.stopReason === 'toolUse') continue;
121
- const exchange = completedExchange(legacy.slice(boundary, i + 1));
82
+ const exchange = completedExchange(legacy.slice(boundary, i + 1), imageRef);
122
83
  if (exchange.length) latest = exchange;
123
84
  boundary = i + 1;
124
85
  }
@@ -148,11 +109,11 @@ export function runTranscript(messages: readonly AgentMessage[]) {
148
109
  }).join('\n');
149
110
  }
150
111
 
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 }[]) {
112
+ /** The status line naming running agents by id and the first words of their title or task; none while nothing runs. */
113
+ export function atWork(runs: readonly { id: string; task: string; title?: string }[]) {
153
114
  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(' ');
115
+ return AT_WORK + runs.map(({ id, task, title }) => {
116
+ const words = flat(title ?? task).replaceAll('"', "'").split(/\s+/).filter(Boolean), start = words.slice(0, 6).join(' ');
156
117
  // Six words, but never more than 60 characters: a task can be one long word.
157
118
  const preview = [...start].slice(0, 60).join('');
158
119
  return `${id} "${preview}${words.length > 6 || preview.length < start.length ? '…' : ''}"`;
@@ -167,57 +128,13 @@ export function buildContext(canonical: AgentMessage[], run: AgentMessage[], vie
167
128
  const system = getCurrentSystemMessage(canonical);
168
129
  const head: SystemMessage = { role: 'system', content: prompt, toolsAdded: system?.toolsAdded, timestamp: 0 };
169
130
  if (!run.some(m => m.role === 'user')) throw new Error('OptChat has no current user message; refusing to send historical context.');
170
- let injected = false;
171
- const messages = [...previous, ...run].filter(m => m.role !== 'system').map(message => {
172
- if (message.role !== 'user' || injected) return message;
173
- injected = true;
174
- // OpenAI joins text blocks with nothing between them, so the user's words start their own paragraph after the view.
175
- const [first, ...rest] = typeof message.content === 'string' ? [{ type: 'text' as const, text: message.content }] : message.content;
176
- return { ...message, content: [{ type: 'text' as const, text: view }, ...first?.type === 'text' ? [{ ...first, text: `\n\n${first.text}` }] : first ? [first] : [], ...rest] };
177
- });
131
+ const messages = withView([...previous, ...run].filter(m => m.role !== 'system'), view);
178
132
  return [head, ...messages, ...status && messages.at(-1)?.role !== 'toolResult' ? [{ role: 'user' as const, content: [{ type: 'text' as const, text: status }], timestamp: 0 }] : []];
179
133
  }
180
134
 
181
- /** Pi's own estimate for an image when it decides to compact: 4,800 characters, about 1,200 tokens. */
182
- const IMAGE_CHARS = 4_800;
183
- /** Characters a message adds to a request: its text, thinking, tool-call arguments and images, as Pi estimates them. Framing is left out. */
184
- export function messageChars(message: AgentMessage) {
185
- if (!('content' in message)) return 0;
186
- const { content } = message;
187
- if (typeof content === 'string') return content.length;
188
- return content.reduce((sum, block) => sum + (block.type === 'text' ? block.text.length : block.type === 'thinking' ? block.thinking.length
189
- : block.type === 'toolCall' ? JSON.stringify(block.arguments).length : block.type === 'image' ? IMAGE_CHARS : 0), 0);
190
- }
191
135
  /** The share of the model's window the current turn's verbatim messages may fill before they move into memory (#146): the
192
136
  * profile's Turn refresh threshold, unless `OPTCHAT_REFRESH_AT` gives one (0 turns it off). */
193
137
  export function refreshFraction(setting: number, env = process.env.OPTCHAT_REFRESH_AT) {
194
138
  const override = env?.trim() ? Number(env) : NaN;
195
139
  return Number.isFinite(override) ? override : setting;
196
140
  }
197
- /** That share in characters, at ~4 characters a token; none when off or the window is unknown. */
198
- export function refreshBudget(contextWindow: number | undefined, fraction: number) {
199
- return contextWindow && fraction > 0 ? Math.floor(contextWindow * fraction * 4) : undefined;
200
- }
201
- const chars = (messages: readonly AgentMessage[]) => messages.reduce((sum, m) => sum + messageChars(m), 0);
202
- /** Where a refresh would cut the run: the last model reply, so its tool calls and their results stay whole. 0 when there is none after the task. */
203
- export function lastStep(run: readonly AgentMessage[]) {
204
- const task = run.findIndex(m => m.role === 'user'), step = run.findLastIndex(m => m.role === 'assistant');
205
- return step > task && task >= 0 ? step : 0;
206
- }
207
- /** The run's new `keep` and the size it moves into the view, when what is sent since the last refresh passes the budget.
208
- * What stays verbatim (the task and the last step) must fit in half the budget: a bigger last step, such as many parallel
209
- * reads, would otherwise pass the budget again on the next call and refresh every call. It waits for a smaller step instead. */
210
- export function refreshPoint(run: readonly AgentMessage[], keep: number, budget: number | undefined) {
211
- const cut = lastStep(run), task = run.find(m => m.role === 'user');
212
- if (!budget || cut <= keep) return undefined;
213
- const size = chars(run.slice(keep));
214
- if (size <= budget || (task ? messageChars(task) : 0) + chars(run.slice(cut)) > budget / 2) return undefined;
215
- return { cut, size };
216
- }
217
- /** The run as sent after a mid-turn refresh at `keep`: the task verbatim, then the run from `keep` on. Everything between is in the view. */
218
- export function refreshedRun(run: readonly AgentMessage[], keep: number, note: string): AgentMessage[] {
219
- const task = run.find(m => m.role === 'user');
220
- if (!keep || !task) return [...run];
221
- const content = typeof task.content === 'string' ? [{ type: 'text' as const, text: task.content }] : task.content;
222
- return [{ ...task, content: [...content, { type: 'text' as const, text: `\n\n${note}` }] }, ...run.slice(keep)];
223
- }
package/src/usage.ts CHANGED
@@ -3,7 +3,7 @@ import { appendFileSync, existsSync, readFileSync } from 'node:fs';
3
3
  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
- import { record } from './cache.ts';
6
+ import { record } from './core/cache.ts';
7
7
  import { appendJson } from './store.ts';
8
8
 
9
9
  export type UsageRole = 'main' | 'subagent' | 'compactor' | 'import';
@@ -2,7 +2,7 @@ import { chmodSync } from 'node:fs';
2
2
  import { createConnection, createServer, type Socket } from 'node:net';
3
3
  import { INTERRUPTED, type Children } from './agents.ts';
4
4
  import type { AgentMessage } from '@earendil-works/pi-agent-core';
5
- import { record } from './cache.ts';
5
+ import { record } from './core/cache.ts';
6
6
  import { checkSocketPath, isWindows, profileSocket, removeStaleSocket } from './profiles.ts';
7
7
  import { textContent } from './transcript.ts';
8
8
  import { isActiveRun, type RunInfo } from './runs.ts';
@@ -24,6 +24,8 @@ export interface LiveState { state: string; model: string; streaming?: AgentMess
24
24
  export interface WindowEvent {
25
25
  kind: 'event'; name: 'started' | 'message' | 'status' | 'finished'; text: string; from?: 'user' | 'agent';
26
26
  message?: AgentMessage; live?: LiveState;
27
+ /** On `started` in an attached window: the subagent's spawn title, if it has one. */
28
+ title?: string;
27
29
  }
28
30
  type Frame = Request | Reply | WindowEvent;
29
31
  const errorText = (error: unknown) => error instanceof Error ? error.message : String(error);
@@ -38,7 +40,8 @@ function parse(value: unknown): Frame {
38
40
  const name = value.name, action = value.action, from = value.from;
39
41
  if (value.kind === 'event' && (name === 'started' || name === 'message' || name === 'status' || name === 'finished') && typeof value.text === 'string')
40
42
  return { kind: 'event', name, text: value.text, from: from === 'user' || from === 'agent' ? from : undefined,
41
- message: isMessage(value.message) ? value.message : undefined, live: isLive(value.live) ? value.live : undefined };
43
+ message: isMessage(value.message) ? value.message : undefined, live: isLive(value.live) ? value.live : undefined,
44
+ title: typeof value.title === 'string' ? value.title : undefined };
42
45
  if (typeof value.id !== 'number' || !Number.isSafeInteger(value.id)) throw new Error('Invalid request ID');
43
46
  if (value.kind === 'reply' && (value.error === undefined || typeof value.error === 'string') && (value.text === undefined || typeof value.text === 'string'))
44
47
  return { kind: 'reply', id: value.id, error: value.error, text: value.text };
@@ -113,7 +116,7 @@ export async function serveWindows(directory: string, children: Children, availa
113
116
  const agents: AttachableAgent[] = available() ? children.history.list()
114
117
  .filter(info => !info.connected && !!children.live(info.id)
115
118
  && (info.state === 'running' || info.state === 'waiting' || info.state === 'paused'))
116
- .map(info => ({ id: info.id, task: info.task.slice(0, 200), state: info.state })) : [];
119
+ .map(info => ({ id: info.id, task: info.task.slice(0, 200), title: info.title, state: info.state })) : [];
117
120
  send({ kind: 'reply', id: frame.id, text: JSON.stringify(agents) }); return;
118
121
  } else if (frame.action === 'attach') {
119
122
  if (child) throw new Error('This window already has a conversation');
@@ -130,7 +133,7 @@ export async function serveWindows(directory: string, children: Children, availa
130
133
  // This socket borrows the existing session, never its lifetime, slot or report destination.
131
134
  // Other views (the main window's Agents page, other terminals) stay open alongside it.
132
135
  attached = true; child = id; finalMessages = live ? undefined : messages;
133
- send({ kind: 'event', name: 'started', text: child });
136
+ send({ kind: 'event', name: 'started', text: child, title: info.title });
134
137
  drainMessages(messages, info);
135
138
  } else if (frame.action === 'start') {
136
139
  if (child) throw new Error('This window already has a conversation');
@@ -295,7 +298,7 @@ export async function connectWindow(directory: string, event: (event: WindowEven
295
298
  };
296
299
  }
297
300
 
298
- export interface AttachableAgent { id: string; task: string; state: string }
301
+ export interface AttachableAgent { id: string; task: string; title?: string; state: string }
299
302
  /** Query the owner, not run metadata on disk: it knows which main/terminal views are actually open. */
300
303
  export async function listAttachableAgents(directory: string, timeout = 2000): Promise<AttachableAgent[]> {
301
304
  const deadline = Date.now() + timeout;
@@ -303,7 +306,7 @@ export async function listAttachableAgents(directory: string, timeout = 2000): P
303
306
  try {
304
307
  const agents: unknown = JSON.parse(await connection.request('list-agents', undefined, undefined, { timeout: Math.max(1, deadline - Date.now()) }) ?? '[]');
305
308
  if (!Array.isArray(agents) || !agents.every(agent => record(agent) && typeof agent.id === 'string'
306
- && typeof agent.task === 'string' && (agent.state === 'running' || agent.state === 'waiting' || agent.state === 'paused')))
309
+ && typeof agent.task === 'string' && (agent.title === undefined || typeof agent.title === 'string') && (agent.state === 'running' || agent.state === 'waiting' || agent.state === 'paused')))
307
310
  throw new Error('Invalid background agent list from the profile owner');
308
311
  return agents;
309
312
  } finally { connection.close(); }
File without changes