pi-optchat 0.8.2 → 0.8.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.
- package/package.json +1 -1
- package/src/agents.ts +13 -6
- package/src/compactor.ts +5 -1
- package/src/index.ts +13 -3
- package/src/memory.ts +2 -2
- package/src/menu.ts +61 -0
- package/src/settings-page.ts +3 -3
- package/src/store.ts +7 -3
- package/src/transcript.ts +3 -1
package/package.json
CHANGED
package/src/agents.ts
CHANGED
|
@@ -314,7 +314,10 @@ export class Children {
|
|
|
314
314
|
// Nothing to hand over: the run waits for its next message instead of ending, and whoever waits on its report hears why.
|
|
315
315
|
if (transition(info, 'paused')) {
|
|
316
316
|
this.save(info);
|
|
317
|
-
|
|
317
|
+
// In a grouped spawn the note takes the run's place, so its siblings' reports are not held until the user comes back; its later report arrives on its own.
|
|
318
|
+
const note = `[${info.id}] Interrupted by the user; it waits for their next message, so no report until then.`, batch = live.batch;
|
|
319
|
+
live.batch = undefined;
|
|
320
|
+
if (!info.connected) await (batch ? this.deliver(info, batch, note) : this.toParent(info.parentId, note))
|
|
318
321
|
.catch(error => this.warn(`Could not tell the parent about the interrupt: ${String(error)}`));
|
|
319
322
|
}
|
|
320
323
|
continue;
|
|
@@ -331,7 +334,8 @@ export class Children {
|
|
|
331
334
|
continue;
|
|
332
335
|
}
|
|
333
336
|
const last = session.messages.findLast(m => m.role === 'assistant');
|
|
334
|
-
|
|
337
|
+
// An error ends the turn, not the run: children still at work report to it and it answers them, as the main agent does.
|
|
338
|
+
if (!paused && last?.role === 'assistant' && (last.stopReason === 'error' || last.stopReason === 'aborted') && !live.pendingReports.length && !this.directChildren(info.id).length) break;
|
|
335
339
|
if (live.pendingGuidance.length) {
|
|
336
340
|
transition(info, 'running'); this.save(info);
|
|
337
341
|
await session.prompt(live.pendingGuidance.shift()!);
|
|
@@ -384,8 +388,10 @@ export class Children {
|
|
|
384
388
|
// A metadata failure must not suppress delivery of the actual result.
|
|
385
389
|
try { this.save(info); } catch (error) { this.warn(`Could not save run metadata: ${String(error)}`); }
|
|
386
390
|
// A deleted run's transcript is gone.
|
|
387
|
-
|
|
388
|
-
|
|
391
|
+
await this.deliver(info, live.batch, `[${info.id}] ${info.report}${this.deleting.has(info.id) ? '' : `\n\n${fullChat(info.id)}`}`);
|
|
392
|
+
}
|
|
393
|
+
/** Sends a run's report to its parent; in a grouped spawn, only once every child of the spawn has one. */
|
|
394
|
+
private async deliver(info: RunInfo, batch: LiveRun['batch'], text: string) {
|
|
389
395
|
let count: number | undefined;
|
|
390
396
|
if (batch) {
|
|
391
397
|
batch.reports.set(info.id, text);
|
|
@@ -395,7 +401,7 @@ export class Children {
|
|
|
395
401
|
}
|
|
396
402
|
if (info.parentId) {
|
|
397
403
|
const parent = this.running.get(info.parentId);
|
|
398
|
-
if (parent && parent.info.state !== 'stopping') { parent.pendingReports.push(text); this.changed(); }
|
|
404
|
+
if (parent && parent.info.state !== 'stopping') { parent.pendingReports.push(text); parent.wake?.(); this.changed(); }
|
|
399
405
|
return;
|
|
400
406
|
}
|
|
401
407
|
if (this.closing) this.memory.append('work', text);
|
|
@@ -548,7 +554,8 @@ export class Children {
|
|
|
548
554
|
}
|
|
549
555
|
if (orphans.length) lines.push(`Also cut off, but not resumed because their parent agent is not running: ${orphans.join(', ')}.`);
|
|
550
556
|
if (!resumed.length && !lines.length) return;
|
|
551
|
-
await this.report([`Pi restarted while subagents were working.${resumed.length ? ` Resumed ${resumed.join(', ')} from where ${resumed.length > 1 ? 'they' : 'it'} left off;
|
|
557
|
+
await this.report([`Pi restarted while subagents were working.${resumed.length ? ` Resumed ${resumed.join(', ')} from where ${resumed.length > 1 ? 'they' : 'it'} left off; ${this.settings.groupReports
|
|
558
|
+
? 'grouped reports do not survive a restart, so each one reports on its own.' : 'reports arrive as usual.'}` : ''}`, ...lines].join('\n'));
|
|
552
559
|
}
|
|
553
560
|
async finish(id: string, reason: FinishReason) {
|
|
554
561
|
const live = this.running.get(id);
|
package/src/compactor.ts
CHANGED
|
@@ -51,7 +51,8 @@ export function createCompressor(registry: ModelRegistry, choice: () => ModelCho
|
|
|
51
51
|
if (!model) throw new Error(`Compactor model unavailable: ${selected.provider}/${selected.model}. Use /optchat model.`);
|
|
52
52
|
const thinking = reasoningFor(model, selected.thinking);
|
|
53
53
|
const step = `${input.historical ? IMPORT_GUIDANCE + '\n\n' : ''}${compaction(input)}`;
|
|
54
|
-
|
|
54
|
+
// OpenAI joins text blocks with nothing between them, so the task starts its own paragraph after the view.
|
|
55
|
+
const messages: Message[] = [{ role: 'user', content: [{ type: 'text', text: input.context }, { type: 'text', text: `\n\n${step}` }], timestamp: Date.now() }];
|
|
55
56
|
const view = splitView(input.context);
|
|
56
57
|
const prefix = model.api === 'anthropic-messages' && view.length > 1 ? `${model.provider}/${model.id}/${thinking ?? 'off'}\n${view.slice(0, -1).join('')}` : undefined;
|
|
57
58
|
const tries: string[] = [];
|
|
@@ -71,6 +72,8 @@ export function createCompressor(registry: ModelRegistry, choice: () => ModelCho
|
|
|
71
72
|
} finally { warmed(false); }
|
|
72
73
|
onUsage(reply);
|
|
73
74
|
if (reply.stopReason === 'error' || reply.stopReason === 'aborted') throw new Error(reply.errorMessage ?? `Compactor ${reply.stopReason}`);
|
|
75
|
+
// A reply cut off at the token limit, or one that calls a tool, is no summary: ask again.
|
|
76
|
+
if (reply.stopReason === 'length' || reply.content.some(c => c.type === 'toolCall')) continue;
|
|
74
77
|
// The compactions' view shows each line under its id+n| head, which a line can copy.
|
|
75
78
|
const line = reply.content.filter(c => c.type === 'text').map(c => c.text).join('').trim().replace(/^\d+\+\d+\|\s*/, '');
|
|
76
79
|
if (!line) throw new Error('Compactor returned no text.');
|
|
@@ -81,6 +84,7 @@ export function createCompressor(registry: ModelRegistry, choice: () => ModelCho
|
|
|
81
84
|
const cut = Buffer.from(line).subarray(0, NODE).toString('utf8').replace(/\uFFFD$/, '');
|
|
82
85
|
messages.push({ role: 'user', content: tooLong(bytes(line), cut), timestamp: Date.now() });
|
|
83
86
|
}
|
|
87
|
+
if (!tries.length) throw new Error('Compactor returned no whole line in 5 tries.');
|
|
84
88
|
return tries.reduce((a, b) => bytes(a) <= bytes(b) ? a : b);
|
|
85
89
|
};
|
|
86
90
|
}
|
package/src/index.ts
CHANGED
|
@@ -31,6 +31,7 @@ import { openConnectedWindow, registerConnectedRenderer } from './connected-wind
|
|
|
31
31
|
import { createHandoffSummarizer } from './handoff.ts';
|
|
32
32
|
import { mainTitle, TabTitle } from './title.ts';
|
|
33
33
|
import { showModelPicker, showSettings } from './settings-page.ts';
|
|
34
|
+
import { showMenu } from './menu.ts';
|
|
34
35
|
|
|
35
36
|
const binding = 'optchat.profile';
|
|
36
37
|
const CONNECT_MODES = ['auto', 'join', 'off'] as const;
|
|
@@ -48,6 +49,15 @@ const promptFor = (prompt: string, { previousExchange, memorySearch }: ProfileCo
|
|
|
48
49
|
allowSearch(toggle(toggle(prompt, CONTINUITY, previousExchange, VIEW_DOC), SEARCH_DOC, memorySearch, previousExchange ? VIEW_DOC + CONTINUITY : VIEW_DOC), memorySearch);
|
|
49
50
|
interface Active { name: string; dir: string; config: ProfileConfig; memory: Memory; inbox: Inbox; children: Children; usage: UsageLedger; unlock: () => Promise<void> }
|
|
50
51
|
|
|
52
|
+
/** OptChat's own version, read from the package.json beside src/ (in the checkout and the npm package alike); none if unreadable. */
|
|
53
|
+
export const version = ((): string | undefined => {
|
|
54
|
+
try {
|
|
55
|
+
const data: unknown = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
56
|
+
return data && typeof data === 'object' && 'version' in data && typeof data.version === 'string' ? data.version : undefined;
|
|
57
|
+
} catch { return undefined; }
|
|
58
|
+
})();
|
|
59
|
+
const brand = version ? `OptChat v${version}` : 'OptChat';
|
|
60
|
+
|
|
51
61
|
export default function optchat(pi: ExtensionAPI) {
|
|
52
62
|
let active: Active | undefined;
|
|
53
63
|
let remote: Awaited<ReturnType<typeof openConnectedWindow>> | undefined;
|
|
@@ -209,7 +219,7 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
209
219
|
closeWindows = await serveWindows(dir, children, () => !stopping && !importing && !pendingImport(dir), deliverReport);
|
|
210
220
|
untitle = children.subscribe(() => showTitle(ctx)); showTitle(ctx);
|
|
211
221
|
status(ctx);
|
|
212
|
-
ctx.ui.notify(
|
|
222
|
+
ctx.ui.notify(`${brand} · ${name} · ${memory.root.length} messages\nCompactor: ${config.compactor.provider}/${config.compactor.model} (${config.compactor.thinking})`, 'info');
|
|
213
223
|
const queuedReports = [...reports];
|
|
214
224
|
if (!pendingImport(dir)) {
|
|
215
225
|
const recover = (work: Promise<void>, what: string) => work.catch(error => ctx.ui.notify(`${what}: ${errorText(error)}`, 'error'));
|
|
@@ -474,8 +484,8 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
474
484
|
let action = args.trim();
|
|
475
485
|
if (!action) {
|
|
476
486
|
const a = active;
|
|
477
|
-
|
|
478
|
-
|
|
487
|
+
action = await showMenu(ctx, brand, a && { profile: a.name, messages: a.memory.root.length, pending: a.memory.pending,
|
|
488
|
+
compactor: a.config.compactor, agents: a.config.subagent, error: a.memory.lastError }) ?? '';
|
|
479
489
|
}
|
|
480
490
|
if (action === 'import') {
|
|
481
491
|
const a = required();
|
package/src/memory.ts
CHANGED
|
@@ -334,7 +334,7 @@ export class Memory {
|
|
|
334
334
|
if (n === 1) {
|
|
335
335
|
const { kind, text } = this.root[id], page = Math.min(limit ?? PAGE, PAGE);
|
|
336
336
|
if (!Number.isSafeInteger(page) || page < 1) throw new Error('limit must be a positive integer.');
|
|
337
|
-
if (offset === undefined && text.length <= page) return `${id}+
|
|
337
|
+
if (offset === undefined && text.length <= page) return `${id}+1|${kind}: ${text}`;
|
|
338
338
|
if (offset !== undefined && (!Number.isSafeInteger(offset) || offset < 0 || offset >= Math.max(1, text.length)))
|
|
339
339
|
throw new Error(`Message ${id} has ${text.length} characters; offset must be 0 to ${Math.max(0, text.length - 1)}.`);
|
|
340
340
|
// Never split a surrogate pair: a page starts on its first half and ends after its second.
|
|
@@ -343,7 +343,7 @@ export class Memory {
|
|
|
343
343
|
// A one-unit page on a pair takes the whole pair, so the next offset always moves forward.
|
|
344
344
|
if (to < text.length && /[\ud800-\udbff]/.test(text[to - 1])) to += to - 1 === from ? 1 : -1;
|
|
345
345
|
const hint = text.length <= PAGE ? `\n[note: this message is only ${text.length.toLocaleString('en-US')} characters and fits in one zoom; offset/limit are for messages over ${PAGE.toLocaleString('en-US')}]` : '';
|
|
346
|
-
return `${id}+
|
|
346
|
+
return `${id}+1|${kind}: ${text.slice(from, to)}\n[showing characters ${from}-${to} of ${text.length}${to < text.length ? `; next page: offset ${to}` : ''}]${hint}`;
|
|
347
347
|
}
|
|
348
348
|
if (offset !== undefined) throw new Error('offset and limit page one message: use them with n = 1.');
|
|
349
349
|
const l = Math.log2(n) - 1, i = 2 * id / n;
|
package/src/menu.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { Box, SelectList, Spacer, Text } from '@earendil-works/pi-tui';
|
|
2
|
+
import type { ExtensionContext, Theme } from '@earendil-works/pi-coding-agent';
|
|
3
|
+
import type { ModelChoice } from './compactor.ts';
|
|
4
|
+
import { count, oneLine } from './inspector.ts';
|
|
5
|
+
import { frame, showModel } from './settings-page.ts';
|
|
6
|
+
|
|
7
|
+
/** What `/optchat` offers, in menu order; each is also `/optchat <action>`. */
|
|
8
|
+
export const ACTIONS = ['profile', 'settings', 'model', 'agents', 'usage', 'activity', 'instructions', 'browse', 'import'] as const;
|
|
9
|
+
export type MenuAction = typeof ACTIONS[number];
|
|
10
|
+
const DESCRIPTIONS: Record<MenuAction, string> = {
|
|
11
|
+
profile: 'Switch or create a profile',
|
|
12
|
+
settings: 'Models, subagent limits, memory options',
|
|
13
|
+
model: 'Compactor model and effort',
|
|
14
|
+
agents: 'Live agent tree and saved runs',
|
|
15
|
+
usage: 'Token usage and cost estimates',
|
|
16
|
+
activity: 'Memory gauge and running agents',
|
|
17
|
+
instructions: 'Edit this profile\'s AGENTS.md',
|
|
18
|
+
browse: 'Open a readable snapshot of memory',
|
|
19
|
+
import: 'Import history, or resume a paused import',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export interface MenuStatus { profile: string; messages: number; pending: number; compactor: ModelChoice; agents: ModelChoice; error?: string }
|
|
23
|
+
/** The status block as label and value; the plain menu outside the TUI shows the same rows. */
|
|
24
|
+
export const statusRows = (s: MenuStatus) => [
|
|
25
|
+
['Memory', `${count(s.messages)} messages · ${count(s.pending)} pending`],
|
|
26
|
+
// config.json only checks these are strings, so one with a newline or escape code must not break the block.
|
|
27
|
+
['Compactor', oneLine(showModel(s.compactor))],
|
|
28
|
+
['Agents', oneLine(showModel(s.agents))],
|
|
29
|
+
] as const;
|
|
30
|
+
const failing = (error: string) => `Summaries failing: ${oneLine(error)}`;
|
|
31
|
+
|
|
32
|
+
/** Framed like the settings page: title, the profile's status as aligned rows, then the actions with what each opens. */
|
|
33
|
+
export function optchatMenu(theme: Theme, title: string, status: MenuStatus | undefined, close: (action?: MenuAction) => void, redraw = () => {}) {
|
|
34
|
+
const list = new SelectList(ACTIONS.map(value => ({ value, label: value, description: DESCRIPTIONS[value] })), ACTIONS.length, {
|
|
35
|
+
selectedPrefix: t => theme.fg('accent', t), selectedText: t => theme.fg('accent', t),
|
|
36
|
+
description: t => theme.fg('muted', t), scrollInfo: t => theme.fg('muted', t), noMatch: t => theme.fg('muted', t) },
|
|
37
|
+
{ minPrimaryColumnWidth: 1, maxPrimaryColumnWidth: Math.max(...ACTIONS.map(a => a.length)) + 4 });
|
|
38
|
+
list.onSelect = item => close(ACTIONS.find(a => a === item.value));
|
|
39
|
+
list.onCancel = () => close();
|
|
40
|
+
const rows = status ? statusRows(status) : [];
|
|
41
|
+
const labels = Math.max(0, ...rows.map(([label]) => label.length));
|
|
42
|
+
const facts = status ? rows.map(([label, value]) => new Text(`${theme.fg('muted', label.padEnd(labels))} ${value}`, 1, 0)) : [new Text(theme.fg('muted', 'No active profile'), 1, 0)];
|
|
43
|
+
if (status?.error) facts.push(new Text(theme.fg('warning', failing(status.error)), 1, 0));
|
|
44
|
+
const actions = new Box(1, 0); actions.addChild(list);
|
|
45
|
+
const page = frame(theme, title, status?.profile, [new Spacer(1), ...facts, new Spacer(1), actions, new Spacer(1),
|
|
46
|
+
new Text(theme.fg('dim', '↑↓ select · Enter open · Esc close'), 1, 0)]);
|
|
47
|
+
return Object.assign(page, { handleInput: (data: string) => {
|
|
48
|
+
// j and k move too, as in Pi's own select dialog that this menu replaces.
|
|
49
|
+
const step = data === 'j' ? 1 : data === 'k' ? -1 : 0, at = ACTIONS.findIndex(a => a === list.getSelectedItem()?.value);
|
|
50
|
+
if (step) list.setSelectedIndex(Math.max(0, Math.min(ACTIONS.length - 1, at + step)));
|
|
51
|
+
else list.handleInput(data);
|
|
52
|
+
redraw();
|
|
53
|
+
} });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The menu in interactive Pi; elsewhere (RPC hosts) Pi's plain select with the same facts and actions. */
|
|
57
|
+
export function showMenu(ctx: ExtensionContext, title: string, status: MenuStatus | undefined): Promise<MenuAction | undefined> {
|
|
58
|
+
if (ctx.mode === 'tui') return ctx.ui.custom<MenuAction | undefined>((tui, theme, _keys, done) => optchatMenu(theme, title, status, done, () => tui.requestRender()));
|
|
59
|
+
const facts = status ? [status.profile, ...statusRows(status).map(([label, value]) => `${label}: ${value}`), ...status.error ? [failing(status.error)] : []] : ['No active profile'];
|
|
60
|
+
return ctx.ui.select([title, ...facts].join('\n'), [...ACTIONS]).then(choice => ACTIONS.find(a => a === choice));
|
|
61
|
+
}
|
package/src/settings-page.ts
CHANGED
|
@@ -11,7 +11,7 @@ const ROLES: Record<Role, { label: string; description: string; applies: string
|
|
|
11
11
|
subagent: { label: 'Subagent model', description: 'Runs every subagent. Shortcut: /optchat agents model.', applies: 'Applies to subagents started after this.' },
|
|
12
12
|
};
|
|
13
13
|
const modelName = (choice: ModelChoice) => `${choice.provider}/${choice.model}`;
|
|
14
|
-
const showModel = (choice: ModelChoice) => `${modelName(choice)} · ${choice.thinking}`;
|
|
14
|
+
export const showModel = (choice: ModelChoice) => `${modelName(choice)} · ${choice.thinking}`;
|
|
15
15
|
/** The value column: the model, then whether it is the default (shown short, without its provider). */
|
|
16
16
|
const modelValue = (theme: Theme, role: Role, choice: ModelChoice) => `${showModel(choice)}${theme.fg('dim', showModel(choice) === showModel(defaults[role])
|
|
17
17
|
? ' default' : ` default ${defaults[role].model} · ${defaults[role].thinking}`)}`;
|
|
@@ -114,9 +114,9 @@ function update(o: Options, patch: Partial<ProfileConfig>) {
|
|
|
114
114
|
return undefined;
|
|
115
115
|
}
|
|
116
116
|
/** Bordered, titled like Pi's own dialogs. */
|
|
117
|
-
function frame(theme: Theme, title: string, profile: string, body: Component[]) {
|
|
117
|
+
export function frame(theme: Theme, title: string, profile: string | undefined, body: Component[]) {
|
|
118
118
|
const page = new Container();
|
|
119
|
-
for (const child of [new DynamicBorder(s => theme.fg('border', s)), new Text(`${theme.bold(theme.fg('accent', title))}${theme.fg('muted', ` · ${profile}`)}`, 1, 0),
|
|
119
|
+
for (const child of [new DynamicBorder(s => theme.fg('border', s)), new Text(`${theme.bold(theme.fg('accent', title))}${profile ? theme.fg('muted', ` · ${profile}`) : ''}`, 1, 0),
|
|
120
120
|
...body, new DynamicBorder(s => theme.fg('border', s))]) page.addChild(child);
|
|
121
121
|
return page;
|
|
122
122
|
}
|
package/src/store.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { closeSync, existsSync, fstatSync, fsyncSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, statSync, writeFileSync, writeSync } from 'node:fs';
|
|
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
3
|
import type { Entry, Summary } from './memory.ts';
|
|
4
4
|
|
|
@@ -61,7 +61,8 @@ export function atomicWrite(file: string, text: string | Uint8Array) {
|
|
|
61
61
|
try { fsyncSync(parent); } finally { closeSync(parent); }
|
|
62
62
|
}
|
|
63
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.
|
|
64
|
+
/** With `size`, refuses to append unless the file still has that size. A last line left without its newline is ended first. A write
|
|
65
|
+
* cut short is truncated away before the error is thrown. Returns the new size. */
|
|
65
66
|
export function appendJson(file: string, value: unknown, size?: number) {
|
|
66
67
|
const fd = openSync(file, 'a+', 0o600);
|
|
67
68
|
try {
|
|
@@ -69,7 +70,10 @@ export function appendJson(file: string, value: unknown, size?: number) {
|
|
|
69
70
|
if (size !== undefined && length !== size) throw otherWriter(file);
|
|
70
71
|
const torn = length > 0 && readSync(fd, last, 0, 1, length - 1) === 1 && last[0] !== 0x0a;
|
|
71
72
|
const data = Buffer.from((torn ? '\n' : '') + JSON.stringify(value) + '\n');
|
|
72
|
-
|
|
73
|
+
// A cut-short write is undone, so the file keeps the size the caller checks against. Windows refuses to truncate through an
|
|
74
|
+
// append handle, so this goes by path; if the undo fails too, the write's own error is still the one thrown.
|
|
75
|
+
try { if (writeSync(fd, data) !== data.length) throw new Error(`Incomplete write: ${file}`); }
|
|
76
|
+
catch (error) { try { truncateSync(file, length); } catch {} throw error; }
|
|
73
77
|
fsyncSync(fd);
|
|
74
78
|
return fstatSync(fd).size;
|
|
75
79
|
} finally { closeSync(fd); }
|
package/src/transcript.ts
CHANGED
|
@@ -171,7 +171,9 @@ export function buildContext(canonical: AgentMessage[], run: AgentMessage[], vie
|
|
|
171
171
|
const messages = [...previous, ...run].filter(m => m.role !== 'system').map(message => {
|
|
172
172
|
if (message.role !== 'user' || injected) return message;
|
|
173
173
|
injected = true;
|
|
174
|
-
|
|
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] };
|
|
175
177
|
});
|
|
176
178
|
return [head, ...messages, ...status && messages.at(-1)?.role !== 'toolResult' ? [{ role: 'user' as const, content: [{ type: 'text' as const, text: status }], timestamp: 0 }] : []];
|
|
177
179
|
}
|