pi-optchat 0.11.1 → 0.11.3

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.
@@ -0,0 +1,167 @@
1
+ import { isMime } from '../core/index.js';
2
+ /** A Durable Object's SQLite refuses a row over 2 MB. A message's text is stored as JSON in parts of at most this many UTF-16 units,
3
+ * so a part is at most ~1.5 MB of UTF-8 however the text is written, and a huge pasted email still fits. */
4
+ const PART = 512 * 1024;
5
+ /** Log, tree and view rows carry their generation `g`: an import builds a new one beside the live one. `optchat_state` names the
6
+ * live one, the pending import's (`target`, NULL when none) and the last one handed out; the planned log is in `optchat_plan`.
7
+ * Images are shared. */
8
+ const TABLES = `CREATE TABLE IF NOT EXISTS optchat_log (g INTEGER NOT NULL, i INTEGER NOT NULL, part INTEGER NOT NULL, meta TEXT, text TEXT NOT NULL, PRIMARY KEY (g, i, part));
9
+ CREATE TABLE IF NOT EXISTS optchat_tree (g INTEGER NOT NULL, l INTEGER NOT NULL, i INTEGER NOT NULL, size INTEGER NOT NULL, text TEXT NOT NULL, PRIMARY KEY (g, l, i));
10
+ CREATE TABLE IF NOT EXISTS optchat_view (g INTEGER PRIMARY KEY, json TEXT NOT NULL);
11
+ CREATE TABLE IF NOT EXISTS optchat_images (name TEXT PRIMARY KEY, mime TEXT NOT NULL, data BLOB NOT NULL);
12
+ CREATE TABLE IF NOT EXISTS optchat_state (id INTEGER PRIMARY KEY CHECK (id = 0), live INTEGER NOT NULL, last INTEGER NOT NULL DEFAULT 0, target INTEGER, mode TEXT, total INTEGER);
13
+ CREATE TABLE IF NOT EXISTS optchat_plan (i INTEGER NOT NULL, part INTEGER NOT NULL, meta TEXT, text TEXT NOT NULL, PRIMARY KEY (i, part));
14
+ INSERT OR IGNORE INTO optchat_state (id, live) VALUES (0, 0);`;
15
+ /** Splits `text` every PART units, never between the two halves of a surrogate pair. */
16
+ function parts(text) {
17
+ const result = [];
18
+ for (let at = 0; at < text.length;) {
19
+ let end = Math.min(text.length, at + PART);
20
+ if (end < text.length && /[\ud800-\udbff]/.test(text[end - 1]))
21
+ end--;
22
+ result.push(text.slice(at, end));
23
+ at = end;
24
+ }
25
+ return result;
26
+ }
27
+ /** An entry as rows of `(part, meta, text)`: its text as JSON in parts, its metadata on the first. */
28
+ const rows = ({ text, ...meta }) => parts(JSON.stringify(text)).map((chunk, part) => [part, part ? null : JSON.stringify(meta), chunk]);
29
+ /** Texts are stored as JSON, which keeps an unpaired surrogate that SQLite's TEXT would replace. A text that isn't there throws. */
30
+ function texts(rows, what) {
31
+ const found = [...rows].map(row => typeof row.text === 'string' ? row.text : '');
32
+ if (!found.length)
33
+ throw new Error(`No ${what} in this store.`);
34
+ const text = JSON.parse(found.join(''));
35
+ if (typeof text !== 'string')
36
+ throw new Error(`Invalid ${what} in this store.`);
37
+ return text;
38
+ }
39
+ /** OptChat's memory in any Chord `SqliteDatabase`, such as Node's (`NodeSqliteDatabase`) or a Durable Object's. Give it the
40
+ * database Pi Durable's storage uses, so both share one queue: in a Durable Object, the `DurableObjectSqliteDatabase` over
41
+ * `ctx.storage` (direct `ctx.storage.sql` writes could be rolled back with a Pi transaction). `read` reads texts from the same
42
+ * database synchronously; it only reads rows whose writes have finished, so it needs no queue. Its tables are `optchat_*`, beside
43
+ * Pi's `pi_*`. Memory loads only metadata and node sizes; texts stay here. Messages are split so no row passes a Durable Object's
44
+ * 2 MB limit; images go to `images` when given, otherwise to `optchat_images`, one row each. */
45
+ export class SqliteStore {
46
+ db;
47
+ read;
48
+ options;
49
+ #g = 0;
50
+ constructor(db, read, options = {}) {
51
+ this.db = db;
52
+ this.read = read;
53
+ this.options = options;
54
+ }
55
+ async load() {
56
+ await this.db.exec(TABLES);
57
+ if (this.options.generation !== undefined)
58
+ this.#g = this.options.generation;
59
+ else {
60
+ const state = await this.db.get('SELECT live, target FROM optchat_state');
61
+ this.#g = state?.live ?? 0;
62
+ // An old memory may still write to its generation until the object restarts after a switch, so old rows go only here.
63
+ await this.db.transaction(async (tx) => {
64
+ for (const table of ['log', 'tree', 'view'])
65
+ await tx.run(`DELETE FROM optchat_${table} WHERE g NOT IN (?, ?)`, this.#g, state?.target ?? this.#g);
66
+ });
67
+ }
68
+ const log = await this.db.all('SELECT meta FROM optchat_log WHERE g = ? AND part = 0 ORDER BY i', this.#g);
69
+ const tree = await this.db.all('SELECT l, i, size FROM optchat_tree WHERE g = ?', this.#g);
70
+ const view = await this.db.get('SELECT json FROM optchat_view WHERE g = ?', this.#g);
71
+ return { entries: log.map(row => JSON.parse(row.meta)), tree, view: view?.json };
72
+ }
73
+ text(i) { return texts(this.read('SELECT text FROM optchat_log WHERE g = ? AND i = ? ORDER BY part', this.#g, i), `message ${i}`); }
74
+ summary({ l, i }) { return texts(this.read('SELECT text FROM optchat_tree WHERE g = ? AND l = ? AND i = ?', this.#g, l, i), `summary ${l}/${i}`); }
75
+ /** The primary key refuses a second message at an index, so a store that was not the last to append writes nothing. */
76
+ async append(entry) {
77
+ try {
78
+ await this.db.transaction(async (tx) => {
79
+ for (const row of rows(entry))
80
+ await tx.run('INSERT INTO optchat_log (g, i, part, meta, text) VALUES (?, ?, ?, ?, ?)', this.#g, entry.i, ...row);
81
+ });
82
+ }
83
+ catch (error) {
84
+ if (await this.db.get('SELECT 1 FROM optchat_log WHERE g = ? AND i = ? AND part = 0', this.#g, entry.i))
85
+ throw new Error(`Someone else already saved message ${entry.i}; nothing was written.`, { cause: error });
86
+ throw error;
87
+ }
88
+ }
89
+ async appendNode(node) {
90
+ await this.db.run('INSERT OR REPLACE INTO optchat_tree (g, l, i, size, text) VALUES (?, ?, ?, ?, ?)', this.#g, node.l, node.i, node.size, JSON.stringify(node.text));
91
+ }
92
+ async saveView(view) {
93
+ await this.db.run('INSERT OR REPLACE INTO optchat_view (g, json) VALUES (?, ?)', this.#g, view);
94
+ }
95
+ async putImage(name, mimeType, data) {
96
+ if (this.options.images)
97
+ return this.options.images.putImage(name, mimeType, data);
98
+ await this.db.run('INSERT OR REPLACE INTO optchat_images (name, mime, data) VALUES (?, ?, ?)', name, mimeType, data);
99
+ }
100
+ async image(name) {
101
+ if (this.options.images)
102
+ return this.options.images.image(name);
103
+ const row = await this.db.get('SELECT mime, data FROM optchat_images WHERE name = ?', name);
104
+ return row && isMime(row.mime) ? { mimeType: row.mime, data: row.data } : undefined;
105
+ }
106
+ }
107
+ /** The rows `optchatImport` keeps: which generation is live, the pending import and its plan. */
108
+ export function importTables(db, read) {
109
+ const state = async (tx = db) => {
110
+ await tx.exec(TABLES);
111
+ const row = await tx.get('SELECT live, target, mode, total FROM optchat_state');
112
+ const job = row?.target != null && row.mode && row.total != null ? { generation: row.target, mode: row.mode, total: row.total } : undefined;
113
+ return { live: row?.live ?? 0, job };
114
+ };
115
+ return {
116
+ state: () => state(),
117
+ /** Saves the plan, `entries` in order, made from generation `from`, and makes it the pending import of a new generation. A
118
+ * generation is never handed out twice, so a late call about a discarded one can't touch the next. Append's new generation starts with the live one's first `kept` messages, the nodes
119
+ * over them, and its view. Refused unless `from` is still live: an import went live since. */
120
+ stage: (from, mode, entries, kept) => db.transaction(async (tx) => {
121
+ const { live, job } = await state(tx);
122
+ if (job)
123
+ throw new Error('An import is pending: let it finish, or discard it.');
124
+ if (live !== from)
125
+ throw new Error('An import went live: restart before the next one.');
126
+ const generation = ((await tx.get('SELECT last FROM optchat_state'))?.last ?? live) + 1;
127
+ await tx.run('DELETE FROM optchat_plan');
128
+ let total = 0;
129
+ for (const entry of entries) {
130
+ total++;
131
+ for (const row of rows(entry))
132
+ await tx.run('INSERT INTO optchat_plan (i, part, meta, text) VALUES (?, ?, ?, ?)', entry.i, ...row);
133
+ }
134
+ if (kept) {
135
+ // Nodes only over the messages copied: a message still being written isn't, and a node past the log would refuse to open.
136
+ // The view keeps its cached prefix; one that covers more than was copied is rebuilt.
137
+ await tx.run('INSERT INTO optchat_log (g, i, part, meta, text) SELECT ?, i, part, meta, text FROM optchat_log WHERE g = ? AND i < ?', generation, live, kept);
138
+ await tx.run(`INSERT INTO optchat_tree (g, l, i, size, text) SELECT ?, l, i, size, text FROM optchat_tree
139
+ WHERE g = ? AND ((i + 1) << l) <= (SELECT COUNT(*) FROM optchat_log WHERE g = ? AND part = 0)`, generation, live, generation);
140
+ await tx.run('INSERT INTO optchat_view (g, json) SELECT ?, json FROM optchat_view WHERE g = ?', generation, live);
141
+ }
142
+ await tx.run('UPDATE optchat_state SET last = ?, target = ?, mode = ?, total = ?', generation, generation, mode, total);
143
+ return { generation, mode, total };
144
+ }),
145
+ /** Planned message `i`, as saved; validated where it is used. */
146
+ planned(i) {
147
+ const [first] = read('SELECT meta FROM optchat_plan WHERE i = ? AND part = 0', i);
148
+ if (typeof first?.meta !== 'string')
149
+ throw new Error(`No planned message ${i} in this store.`);
150
+ return { ...JSON.parse(first.meta), text: texts(read('SELECT text FROM optchat_plan WHERE i = ? ORDER BY part', i), `planned message ${i}`) };
151
+ },
152
+ /** Makes `generation` live and drops its plan, in one transaction; refused if its import was discarded meanwhile. */
153
+ activate: (generation) => db.transaction(async (tx) => {
154
+ if ((await state(tx)).job?.generation !== generation)
155
+ throw new Error('The import was discarded.');
156
+ await tx.run('UPDATE optchat_state SET live = target, target = NULL, mode = NULL, total = NULL');
157
+ await tx.run('DELETE FROM optchat_plan');
158
+ }),
159
+ /** Drops the pending import if it is still `generation`'s; its rows go when the live store next loads. */
160
+ discard: (generation) => db.transaction(async (tx) => {
161
+ if ((await state(tx)).job?.generation !== generation)
162
+ return;
163
+ await tx.run('UPDATE optchat_state SET target = NULL, mode = NULL, total = NULL');
164
+ await tx.run('DELETE FROM optchat_plan');
165
+ }),
166
+ };
167
+ }
package/docs/core.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  Core imports nothing from Pi or Node at runtime: only `typebox` (the tools' schemas) and pi-ai's types. `test/core-boundary.test.ts` keeps it that way. Pi's packages are optional peer dependencies, so installing `pi-optchat` outside Pi pulls in none of them.
6
6
 
7
- It ships as TypeScript, like the extension, so the host needs a bundler (Wrangler, esbuild, tsx). A host that type-checks with `tsc` needs `allowImportingTsExtensions`.
7
+ `pi-optchat/core` and `pi-optchat/durable` ship as compiled JavaScript with declarations, so a host needs no special tsconfig or bundler.
8
8
 
9
9
  ## What a host supplies
10
10
 
package/docs/durable.md CHANGED
@@ -16,7 +16,7 @@ memoryExtension.attach(harness, context);
16
16
 
17
17
  With the Agents SDK's `PiHarness` this goes in its `harness` factory, which supplies `storage`, `context` and `onWake` and resumes the Harness when an alarm wakes the object. A host without it passes an `onWake` that arms an alarm, and calls `harness.resume()` on open and on each alarm, or a task owed from before an eviction never runs.
18
18
 
19
- `examples/durable-object` is a Worker built this way, importing `pi-optchat/core` and `pi-optchat/durable` as an app would (linked with `file:../..`), with the counters and endpoints its `proof.mjs` uses: it runs the Worker under `wrangler dev` with real model calls and checks a multi-turn chat, restarts and the cache.
19
+ `examples/durable-object` is a Worker built this way, importing `pi-optchat/core` and `pi-optchat/durable` as an app would (linked with `file:../..`, so run `npm run build` in the repo first: the package's `./core` and `./durable` are the built `dist/`), with the counters and endpoints its `proof.mjs` uses: it runs the Worker under `wrangler dev` with real model calls and checks a multi-turn chat, restarts and the cache.
20
20
 
21
21
  ## How it works
22
22
 
package/package.json CHANGED
@@ -1,11 +1,17 @@
1
1
  {
2
2
  "name": "pi-optchat",
3
- "version": "0.11.1",
3
+ "version": "0.11.3",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.ts",
7
- "./core": "./src/core/index.ts",
8
- "./durable": "./src/durable/index.ts",
7
+ "./core": {
8
+ "types": "./dist/core/index.d.ts",
9
+ "import": "./dist/core/index.js"
10
+ },
11
+ "./durable": {
12
+ "types": "./dist/durable/index.d.ts",
13
+ "import": "./dist/durable/index.js"
14
+ },
9
15
  "./package.json": "./package.json"
10
16
  },
11
17
  "pi": {
@@ -16,10 +22,13 @@
16
22
  },
17
23
  "scripts": {
18
24
  "check": "tsc --noEmit",
25
+ "build": "tsc -p tsconfig.build.json",
26
+ "prepack": "npm run build",
19
27
  "test": "tsx --import ./test/support.ts --test test/*.test.ts",
20
28
  "test:live": "tsx test/live.ts"
21
29
  },
22
30
  "devDependencies": {
31
+ "@cloudflare/workers-types": "^5.20261031.1",
23
32
  "@earendil-works/chord": "1.4.0",
24
33
  "@earendil-works/pi-agent-core": "1.4.0",
25
34
  "@earendil-works/pi-ai": "1.4.0",
@@ -80,6 +89,7 @@
80
89
  },
81
90
  "files": [
82
91
  "src",
92
+ "dist",
83
93
  "docs/*.md",
84
94
  "README.md",
85
95
  "LICENSE",
package/src/index.ts CHANGED
@@ -47,7 +47,7 @@ async function printReply(text: string) {
47
47
  catch (error) { if (!record(error) || error.code !== 'EAGAIN') throw error; await new Promise(resolve => setTimeout(resolve, 10)); }
48
48
  }
49
49
  }
50
- interface Active { name: string; dir: string; config: ProfileConfig; memory: Memory; inbox: Inbox; children: Children; usage: UsageLedger; unlock: () => Promise<void> }
50
+ interface Active { name: string; session: string; dir: string; config: ProfileConfig; memory: Memory; inbox: Inbox; children: Children; usage: UsageLedger; unlock: () => Promise<void> }
51
51
 
52
52
  /** OptChat's own version, read from the package.json beside src/ (in the checkout and the npm package alike); none if unreadable. */
53
53
  export const version = ((): string | undefined => {
@@ -73,6 +73,8 @@ export default function optchat(pi: ExtensionAPI) {
73
73
  let view: string | undefined;
74
74
  let prompt = '';
75
75
  let runStarted = false;
76
+ /** The run was started by a custom message (`sendMessage` with `triggerTurn` while idle): its first message is that input, shown or hidden. */
77
+ let wake = false;
76
78
  let fault: string | undefined;
77
79
  /** Reports not yet in memory. `count`: how many subagent reports the text joins. `batch`: held while the rest of that spawn runs, not sent yet.
78
80
  * `steered`: last sent into a running turn, where Esc or a dequeue can clear it from Pi's queue unseen. */
@@ -133,7 +135,12 @@ export default function optchat(pi: ExtensionAPI) {
133
135
  pi.on('thinking_level_select', (_event, ctx) => rememberMain(ctx));
134
136
  const restoreMain = async (ctx: ExtensionContext, { name, config: { main } }: Active) => {
135
137
  restored = undefined;
136
- if (!main || pinnedModel || launch.models && !ctx.scopedModels.some(({ model }) => model.provider === main.provider && model.id === main.model)) return;
138
+ if (!main) {
139
+ // Nothing saved yet: record where Pi is, so later opens keep this model instead of following Pi's default.
140
+ if (!pinnedModel) rememberMain(ctx);
141
+ return;
142
+ }
143
+ if (pinnedModel || launch.models && !ctx.scopedModels.some(({ model }) => model.provider === main.provider && model.id === main.model)) return;
137
144
  const model = ctx.modelRegistry.find(main.provider, main.model), thinking = launch.thinking ?? main.thinking;
138
145
  restoring = true;
139
146
  try {
@@ -223,6 +230,10 @@ export default function optchat(pi: ExtensionAPI) {
223
230
  const openProfile = async (name: string, ctx: ExtensionContext) => {
224
231
  const dir = profilePath(name);
225
232
  if (!existsSync(dir)) throw new Error(`Profile ${name} does not exist. Use /optchat profile to create it.`);
233
+ // Pi's RPC mode binds extensions twice after new_session/switch_session/fork/clone (setRebindSession, then rebindSession()),
234
+ // so session_start can arrive again for the session this profile already serves. Taking the lock again would meet our own socket.
235
+ // Only the same session is kept: another session without a session_shutdown still fails loudly below.
236
+ if (active?.name === name && active.session === ctx.sessionManager.getSessionId()) { await restoreMain(ctx, active); status(ctx); return; }
226
237
  const unlock = await holdProfile(storeFor(dir), dir, name, `${name} · PID ${process.pid} · ${hostname()} · ${ctx.cwd}`, warning => ctx.ui.notify(warning, 'warning'));
227
238
  let openingMemory: Memory | undefined;
228
239
  try {
@@ -250,7 +261,7 @@ export default function optchat(pi: ExtensionAPI) {
250
261
  .filter(r => !loggedReports.has(reportReceipt(r.text)));
251
262
  atomicWrite(pending, JSON.stringify(reports));
252
263
  rememberProfile(name);
253
- active = { name, dir, config, memory, inbox, children, usage, unlock }; fault = undefined; idle = () => ctx.isIdle();
264
+ active = { name, session: sessionId, dir, config, memory, inbox, children, usage, unlock }; fault = undefined; idle = () => ctx.isIdle();
254
265
  await restoreMain(ctx, active);
255
266
  if (ctx.mode === 'tui') unmountNavigation = mountNavigation(ctx, children, memory, shortcut, page => { void inspect(ctx, page); });
256
267
  // A one-shot run (`pi -p`, JSON) exits after its own prompt, which would end every conversation connected to it.
@@ -406,7 +417,7 @@ export default function optchat(pi: ExtensionAPI) {
406
417
  pi.on('before_agent_start', (event, ctx) => {
407
418
  if (!active) return; // Plain Pi run: leave Pi's own prompt untouched.
408
419
  const a = active;
409
- flush(); run = []; logged = 0; keep = 0; view = undefined; runStarted = true;
420
+ flush(); run = []; logged = 0; keep = 0; view = undefined; runStarted = true; wake = event.messageType === 'custom';
410
421
  previous = a.config.previousExchange ? previousExchange(ctx.sessionManager.getBranch(), a.config.previousExchangeKB * 1000) : [];
411
422
  pi.appendEntry(RUN_BOUNDARY, { state: 'start' });
412
423
  // Pi's own prompt sections (AGENTS.md files, skills, cwd) stay; the profile's instructions go last.
@@ -420,7 +431,8 @@ export default function optchat(pi: ExtensionAPI) {
420
431
  pi.on('message_end', async (event, ctx) => {
421
432
  if (!active || !runStarted) return;
422
433
  const bounded = boundedMessage(event.message);
423
- const message = asUser(bounded);
434
+ // Pi puts the message that starts a run first, before any context it or other extensions inject.
435
+ const message = asUser(bounded, wake); if (bounded.role !== 'system') wake = false;
424
436
  // Before the message is logged, so the image its text names is already there for zoom; zoom's own images are kept already.
425
437
  if (message.role === 'user' || message.role === 'toolResult' && message.toolName !== 'zoom') {
426
438
  try { await saveImages(active.memory.store, message.content); }
package/src/profiles.ts CHANGED
@@ -13,7 +13,7 @@ export const isWindows = process.platform === 'win32';
13
13
  const PIPE_PREFIX = '\\\\.\\pipe\\';
14
14
 
15
15
  export const dataHome = () => resolve(process.env.OPTCHAT_HOME ?? join(homedir(), '.optchat'));
16
- /** `main` is the last model and thinking level picked in Pi while this profile was open; absent until one is picked. */
16
+ /** `main` is the last model and thinking level picked in Pi while this profile was open, recorded on the first open too. */
17
17
  export interface ProfileConfig extends Settings { compactor: ModelChoice; subagent: ModelChoice; main?: ModelChoice }
18
18
  export const defaults: ProfileConfig = {
19
19
  compactor: { provider: 'anthropic', model: 'claude-sonnet-5-5', thinking: 'medium' },
package/src/transcript.ts CHANGED
@@ -24,13 +24,13 @@ export function typedText(content: unknown) {
24
24
  const text = textContent(content, false);
25
25
  return { text, bare: text.replace(/\n\n\[Image[ :][^\n]*\](?:\n\[Image[ :][^\n]*\])*$/, '') };
26
26
  }
27
- /** Pi's convertToLlm sends every custom message to the model as a user message. A shown one is part of the chat, so it
28
- * becomes a user message here too; another extension's starts with "[customType] ", as the recipe marks background work, so
29
- * the compactor never takes it for the user's words. A hidden one (display false), such as context an extension injects
30
- * each turn, stays custom: the model still sees it, and memory and the replay leave it out. Reports reach the model and the
31
- * previous-exchange replay as user messages; memory logs them as `work`. */
32
- export function asUser(message: AgentMessage): AgentMessage {
33
- if (message.role !== 'custom' || !message.display) return message;
27
+ /** Pi's convertToLlm sends every custom message to the model as a user message. One that is shown, or that starts the run
28
+ * (`input`, shown or hidden), is part of the chat, so it becomes a user message here too; another extension's starts with
29
+ * "[customType] ", as the recipe marks background work, so the compactor never takes it for the user's words. Any other hidden
30
+ * one (display false), such as context an extension injects each turn, stays custom: the model still sees it, and memory and
31
+ * the replay leave it out. Reports reach the model and the previous-exchange replay as user messages; memory logs them as `work`. */
32
+ export function asUser(message: AgentMessage, input = false): AgentMessage {
33
+ if (message.role !== 'custom' || !message.display && !input) return message;
34
34
  const { content, customType, timestamp } = message;
35
35
  if (customType === REPORT_TYPE) return { role: 'user', content, timestamp };
36
36
  const tag = `[${customType}] `;
@@ -57,7 +57,14 @@ export function previousExchange(branch: readonly SessionEntry[], limit = PREVIO
57
57
  function latestExchange(branch: readonly SessionEntry[]) {
58
58
  let end = -1;
59
59
  let legacyEnd = branch.length;
60
- const messages = (entries: readonly SessionEntry[]) => entries.flatMap(entry => entry.type === 'message' ? [asUser(entry.message)] : entry.type === 'custom_message' ? [asUser({ role: 'custom', customType: entry.customType, content: entry.content, display: entry.display, timestamp: Date.parse(entry.timestamp) })] : []);
60
+ // A run's first message is its input, a hidden custom one too: Pi puts it before any context it or other extensions inject.
61
+ const messages = (entries: readonly SessionEntry[], run = false) => entries.flatMap(entry => {
62
+ const message: AgentMessage | undefined = entry.type === 'message' ? entry.message : entry.type === 'custom_message'
63
+ ? { role: 'custom', customType: entry.customType, content: entry.content, display: entry.display, timestamp: Date.parse(entry.timestamp) } : undefined;
64
+ if (!message) return [];
65
+ const input = run; if (message.role !== 'system') run = false;
66
+ return [asUser(message, input)];
67
+ });
61
68
  for (let i = branch.length - 1; i >= 0; i--) {
62
69
  const entry = branch[i];
63
70
  if (entry.type !== 'custom' || entry.customType !== RUN_BOUNDARY || !record(entry.data)) continue;
@@ -65,7 +72,7 @@ function latestExchange(branch: readonly SessionEntry[]) {
65
72
  if (entry.data.state === 'end') end = i;
66
73
  else if (entry.data.state === 'start') {
67
74
  if (end >= 0) {
68
- const exchange = completedExchange(messages(branch.slice(i + 1, end)), imageRef);
75
+ const exchange = completedExchange(messages(branch.slice(i + 1, end), true), imageRef);
69
76
  if (exchange.length) return exchange;
70
77
  }
71
78
  end = -1;