@dreki-gg/pi-subagent 0.2.0 → 0.3.1

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,62 @@
1
+ import type { Message } from '@mariozechner/pi-ai';
2
+
3
+ export interface UsageStats {
4
+ input: number;
5
+ output: number;
6
+ cacheRead: number;
7
+ cacheWrite: number;
8
+ cost: number;
9
+ contextTokens: number;
10
+ turns: number;
11
+ }
12
+
13
+ export interface AgentResult {
14
+ agent: string;
15
+ task: string;
16
+ exitCode: number;
17
+ messages: Message[];
18
+ stderr: string;
19
+ usage: UsageStats;
20
+ model?: string;
21
+ stopReason?: string;
22
+ errorMessage?: string;
23
+ }
24
+
25
+ export type WorkflowId =
26
+ | 'scout-only'
27
+ | 'scout-and-plan'
28
+ | 'implement'
29
+ | 'implement-and-review'
30
+ | 'quick-fix'
31
+ | 'review';
32
+
33
+ export interface WorkflowDefinition {
34
+ id: WorkflowId;
35
+ label: string;
36
+ description: string;
37
+ phases: PhaseDefinition[];
38
+ }
39
+
40
+ export type PhaseKind = 'parallel' | 'single';
41
+
42
+ export interface PhaseDefinition {
43
+ kind: PhaseKind;
44
+ name: string;
45
+ agents: string[];
46
+ requiresConfirmation?: boolean;
47
+ taskTemplate: string;
48
+ }
49
+
50
+ export interface PhaseResult {
51
+ name: string;
52
+ kind: PhaseKind;
53
+ agents: AgentResult[];
54
+ skipped?: boolean;
55
+ }
56
+
57
+ export interface DelegateState {
58
+ synthesis: string;
59
+ workflow: WorkflowDefinition;
60
+ phases: PhaseResult[];
61
+ totalUsage: UsageStats;
62
+ }
@@ -0,0 +1,132 @@
1
+ import type { ExtensionCommandContext } from '@mariozechner/pi-coding-agent';
2
+ import type {
3
+ AgentResult,
4
+ DelegateState,
5
+ PhaseResult,
6
+ UsageStats,
7
+ WorkflowDefinition,
8
+ } from './delegate-types';
9
+ import { WORKFLOWS, suggestWorkflow } from './workflows';
10
+
11
+ function formatTokens(count: number): string {
12
+ if (count < 1000) return count.toString();
13
+ if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
14
+ if (count < 1000000) return `${Math.round(count / 1000)}k`;
15
+ return `${(count / 1000000).toFixed(1)}M`;
16
+ }
17
+
18
+ function formatUsage(usage: UsageStats): string {
19
+ const parts: string[] = [];
20
+ if (usage.turns) parts.push(`${usage.turns} turns`);
21
+ if (usage.input) parts.push(`↑${formatTokens(usage.input)}`);
22
+ if (usage.output) parts.push(`↓${formatTokens(usage.output)}`);
23
+ if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
24
+ if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`);
25
+ if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
26
+ return parts.join(' ');
27
+ }
28
+
29
+ function getFinalText(result: AgentResult): string {
30
+ for (let i = result.messages.length - 1; i >= 0; i--) {
31
+ const msg = result.messages[i];
32
+ if (msg.role === 'assistant') {
33
+ for (const part of msg.content) {
34
+ if (part.type === 'text') return part.text;
35
+ }
36
+ }
37
+ }
38
+ return '(no output)';
39
+ }
40
+
41
+ export async function confirmSynthesis(
42
+ ctx: ExtensionCommandContext,
43
+ synthesis: string,
44
+ ): Promise<boolean> {
45
+ return ctx.ui.confirm('Delegate — Task Synthesis', `${synthesis}\n\nProceed with this task?`);
46
+ }
47
+
48
+ export async function pickWorkflow(
49
+ ctx: ExtensionCommandContext,
50
+ synthesis: string,
51
+ ): Promise<WorkflowDefinition | null> {
52
+ const suggested = suggestWorkflow(synthesis);
53
+ const options = WORKFLOWS.map((wf) => {
54
+ const marker = wf.id === suggested ? ' ← suggested' : '';
55
+ return `${wf.label}${marker} — ${wf.description}`;
56
+ });
57
+
58
+ const choice = await ctx.ui.select('Delegate — Choose Workflow', options);
59
+ if (choice === undefined || choice === null) return null;
60
+
61
+ const selectedIndex = options.indexOf(choice as string);
62
+ if (selectedIndex < 0) return null;
63
+
64
+ return WORKFLOWS[selectedIndex];
65
+ }
66
+
67
+ export async function confirmPlan(
68
+ ctx: ExtensionCommandContext,
69
+ planText: string,
70
+ ): Promise<boolean> {
71
+ return ctx.ui.confirm('Delegate — Review Plan', `${planText}\n\nContinue with implementation?`);
72
+ }
73
+
74
+ export function formatPhaseHeader(
75
+ phaseName: string,
76
+ status: 'running' | 'done' | 'failed' | 'skipped',
77
+ ): string {
78
+ const icons = { running: '⏳', done: '✓', failed: '✗', skipped: '⊘' };
79
+ return `${icons[status]} ${phaseName}`;
80
+ }
81
+
82
+ export function formatAgentResult(result: AgentResult): string {
83
+ const icon = result.exitCode === 0 ? '✓' : '✗';
84
+ const output = getFinalText(result);
85
+ const preview = output.length > 200 ? `${output.slice(0, 200)}...` : output;
86
+ const usage = formatUsage(result.usage);
87
+ const model = result.model ? ` [${result.model}]` : '';
88
+
89
+ return `${icon} ${result.agent}${model}\n${preview}\n${usage}`;
90
+ }
91
+
92
+ export function formatPhaseSummary(phase: PhaseResult): string {
93
+ if (phase.skipped) return `⊘ ${phase.name} — skipped`;
94
+
95
+ const header = `── ${phase.name} (${phase.kind}) ──`;
96
+ const agentSummaries = phase.agents.map(formatAgentResult).join('\n\n');
97
+ return `${header}\n${agentSummaries}`;
98
+ }
99
+
100
+ export function formatFullSummary(state: DelegateState): string {
101
+ const sections = [
102
+ `── Synthesis ──\n${state.synthesis}`,
103
+ `── Workflow: ${state.workflow.label} ──`,
104
+ ...state.phases.map(formatPhaseSummary),
105
+ `── Usage ──\nTotal: ${formatUsage(state.totalUsage)}`,
106
+ ];
107
+
108
+ return sections.join('\n\n');
109
+ }
110
+
111
+ export function aggregateUsage(results: AgentResult[]): UsageStats {
112
+ const total: UsageStats = {
113
+ input: 0,
114
+ output: 0,
115
+ cacheRead: 0,
116
+ cacheWrite: 0,
117
+ cost: 0,
118
+ contextTokens: 0,
119
+ turns: 0,
120
+ };
121
+ for (const r of results) {
122
+ total.input += r.usage.input;
123
+ total.output += r.usage.output;
124
+ total.cacheRead += r.usage.cacheRead;
125
+ total.cacheWrite += r.usage.cacheWrite;
126
+ total.cost += r.usage.cost;
127
+ total.turns += r.usage.turns;
128
+ }
129
+ return total;
130
+ }
131
+
132
+ export { getFinalText };
@@ -13,20 +13,43 @@
13
13
  */
14
14
 
15
15
  import { spawn } from 'node:child_process';
16
+ import { existsSync } from 'node:fs';
16
17
  import * as fs from 'node:fs';
18
+ import { copyFile, mkdir, readdir, unlink } from 'node:fs/promises';
17
19
  import * as os from 'node:os';
20
+ import { join } from 'node:path';
18
21
  import * as path from 'node:path';
19
22
  import type { AgentToolResult } from '@mariozechner/pi-agent-core';
20
23
  import type { Message } from '@mariozechner/pi-ai';
21
24
  import { StringEnum } from '@mariozechner/pi-ai';
22
25
  import {
23
26
  type ExtensionAPI,
27
+ type ExtensionCommandContext,
28
+ getAgentDir,
24
29
  getMarkdownTheme,
25
30
  withFileMutationQueue,
26
31
  } from '@mariozechner/pi-coding-agent';
27
32
  import { Container, Markdown, Spacer, Text } from '@mariozechner/pi-tui';
28
33
  import { Type } from '@sinclair/typebox';
29
- import { type AgentConfig, type AgentScope, discoverAgents } from './agents.js';
34
+ import { type AgentConfig, type AgentScope, bundledAgentsDir, discoverAgents } from './agents.js';
35
+ import { runAgent, runParallel } from './delegate-executor.js';
36
+ import { buildSynthesisPrompt, extractRecentConversation } from './synthesis.js';
37
+ import type {
38
+ AgentResult,
39
+ DelegateState,
40
+ PhaseDefinition,
41
+ PhaseResult,
42
+ UsageStats as DelegateUsageStats,
43
+ } from './delegate-types.js';
44
+ import {
45
+ aggregateUsage,
46
+ confirmPlan,
47
+ confirmSynthesis,
48
+ formatFullSummary,
49
+ formatPhaseHeader,
50
+ getFinalText,
51
+ pickWorkflow,
52
+ } from './delegate-ui.js';
30
53
 
31
54
  const MAX_PARALLEL_TASKS = 8;
32
55
  const MAX_CONCURRENCY = 4;
@@ -233,13 +256,19 @@ async function writePromptToTempFile(
233
256
 
234
257
  function getPiInvocation(args: string[]): { command: string; args: string[] } {
235
258
  const currentScript = process.argv[1];
236
- if (currentScript && fs.existsSync(currentScript)) {
259
+
260
+ // Bun standalone binaries set argv[1] to a virtual FS path (e.g. /$bunfs/root/pi)
261
+ // that only resolves inside the running Bun process. Skip it — the binary IS the entry point.
262
+ const isBunVirtualPath = currentScript?.startsWith('/$bunfs/');
263
+
264
+ if (currentScript && !isBunVirtualPath && fs.existsSync(currentScript)) {
237
265
  return { command: process.execPath, args: [currentScript, ...args] };
238
266
  }
239
267
 
240
268
  const execName = path.basename(process.execPath).toLowerCase();
241
269
  const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
242
270
  if (!isGenericRuntime) {
271
+ // Standalone binary (e.g. pi compiled with Bun) — invoke directly
243
272
  return { command: process.execPath, args };
244
273
  }
245
274
 
@@ -696,8 +725,7 @@ export default function (pi: ExtensionAPI) {
696
725
  const successCount = results.filter((r) => r.exitCode === 0).length;
697
726
  const summaries = results.map((r) => {
698
727
  const output = getFinalOutput(r.messages);
699
- const preview = output.slice(0, 100) + (output.length > 100 ? '...' : '');
700
- return `[${r.agent}] ${r.exitCode === 0 ? 'completed' : 'failed'}: ${preview || '(no output)'}`;
728
+ return `[${r.agent}] ${r.exitCode === 0 ? 'completed' : 'failed'}: ${output || '(no output)'}`;
701
729
  });
702
730
  return {
703
731
  content: [
@@ -1083,4 +1111,274 @@ export default function (pi: ExtensionAPI) {
1083
1111
  return new Text(text?.type === 'text' ? text.text : '(no output)', 0, 0);
1084
1112
  },
1085
1113
  });
1114
+
1115
+ // ── Delegate commands ──────────────────────────────────────────────────────
1116
+
1117
+ function emptyDelegateUsage(): DelegateUsageStats {
1118
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 };
1119
+ }
1120
+
1121
+ function combineParallelOutputs(results: AgentResult[]): string {
1122
+ return results
1123
+ .map((r) => {
1124
+ const output = getFinalText(r);
1125
+ return `## ${r.agent}\n${output}`;
1126
+ })
1127
+ .join('\n\n');
1128
+ }
1129
+
1130
+ async function executePhase(
1131
+ cwd: string,
1132
+ phase: PhaseDefinition,
1133
+ synthesis: string,
1134
+ previousOutput: string,
1135
+ ctx: ExtensionCommandContext,
1136
+ ): Promise<{ results: AgentResult[]; output: string; aborted: boolean }> {
1137
+ const task = phase.taskTemplate
1138
+ .replace(/\{synthesis\}/g, synthesis)
1139
+ .replace(/\{previous\}/g, previousOutput);
1140
+
1141
+ ctx.ui.notify(
1142
+ `${formatPhaseHeader(phase.name, 'running')} ${phase.kind === 'parallel' ? `(${phase.agents.length} agents)` : phase.agents[0]}`,
1143
+ 'info',
1144
+ );
1145
+
1146
+ let results: AgentResult[];
1147
+ if (phase.kind === 'parallel') {
1148
+ results = await runParallel(
1149
+ cwd,
1150
+ phase.agents,
1151
+ task,
1152
+ (_phaseName, _agentName, _result) => {
1153
+ // Streaming updates happen per-message
1154
+ },
1155
+ phase.name,
1156
+ );
1157
+ } else {
1158
+ const result = await runAgent(cwd, phase.agents[0], task, undefined, phase.name);
1159
+ results = [result];
1160
+ }
1161
+
1162
+ const hasError = results.some((r) => r.exitCode !== 0 || r.stopReason === 'error');
1163
+ if (hasError) {
1164
+ const failedAgents = results
1165
+ .filter((r) => r.exitCode !== 0 || r.stopReason === 'error')
1166
+ .map((r) => `${r.agent}: ${r.errorMessage || r.stderr || '(unknown error)'}`)
1167
+ .join('\n');
1168
+
1169
+ ctx.ui.notify(`${formatPhaseHeader(phase.name, 'failed')}\n${failedAgents}`, 'error');
1170
+ return { results, output: combineParallelOutputs(results), aborted: true };
1171
+ }
1172
+
1173
+ const output = combineParallelOutputs(results);
1174
+ ctx.ui.notify(formatPhaseHeader(phase.name, 'done'), 'info');
1175
+
1176
+ if (phase.requiresConfirmation) {
1177
+ const planText = output;
1178
+ const proceed = await confirmPlan(ctx, planText);
1179
+ if (!proceed) {
1180
+ ctx.ui.notify('Delegate aborted by user after plan review.', 'warning');
1181
+ return { results, output, aborted: true };
1182
+ }
1183
+ }
1184
+
1185
+ return { results, output, aborted: false };
1186
+ }
1187
+
1188
+ async function listMdFiles(dir: string): Promise<Set<string>> {
1189
+ if (!existsSync(dir)) return new Set();
1190
+ try {
1191
+ const entries = await readdir(dir);
1192
+ return new Set(entries.filter((f) => f.endsWith('.md')).map((f) => f.replace(/\.md$/, '')));
1193
+ } catch {
1194
+ return new Set();
1195
+ }
1196
+ }
1197
+
1198
+ pi.registerCommand('delegate-agents', {
1199
+ description: 'List, customize, or reset delegate agents',
1200
+ handler: async (args, ctx) => {
1201
+ const userDir = join(getAgentDir(), 'agents');
1202
+ const parts = args?.trim().split(/\s+/) || [];
1203
+ const action = parts[0] || 'list';
1204
+
1205
+ if (action === 'list') {
1206
+ const bundled = await listMdFiles(bundledAgentsDir);
1207
+ const user = await listMdFiles(userDir);
1208
+ const allNames = new Set([...bundled, ...user]);
1209
+
1210
+ const lines: string[] = ['## Delegate Agents\n'];
1211
+ for (const name of [...allNames].sort()) {
1212
+ const isBundled = bundled.has(name);
1213
+ const isUser = user.has(name);
1214
+ if (isUser && isBundled) {
1215
+ lines.push(`- **${name}** — user override (bundled version available, \`/delegate-agents reset ${name}\` to restore)`);
1216
+ } else if (isUser) {
1217
+ lines.push(`- **${name}** — user-only`);
1218
+ } else {
1219
+ lines.push(`- **${name}** — bundled`);
1220
+ }
1221
+ }
1222
+ pi.sendMessage({ customType: 'delegate-agents-list', content: lines.join('\n'), display: true });
1223
+ return;
1224
+ }
1225
+
1226
+ if (action === 'reset') {
1227
+ const name = parts[1];
1228
+ if (!name) {
1229
+ ctx.ui.notify('Usage: /delegate-agents reset <name|--all>', 'warning');
1230
+ return;
1231
+ }
1232
+
1233
+ if (name === '--all') {
1234
+ const user = await listMdFiles(userDir);
1235
+ const bundled = await listMdFiles(bundledAgentsDir);
1236
+ let count = 0;
1237
+ for (const n of user) {
1238
+ if (bundled.has(n)) {
1239
+ await unlink(join(userDir, `${n}.md`));
1240
+ count++;
1241
+ }
1242
+ }
1243
+ ctx.ui.notify(count > 0 ? `Reset ${count} agent(s) to bundled versions.` : 'No user overrides to reset.', 'info');
1244
+ return;
1245
+ }
1246
+
1247
+ const userFile = join(userDir, `${name}.md`);
1248
+ const bundledFile = join(bundledAgentsDir, `${name}.md`);
1249
+
1250
+ if (!existsSync(userFile)) {
1251
+ ctx.ui.notify(`No user override for "${name}" — already using bundled version.`, 'info');
1252
+ return;
1253
+ }
1254
+ if (!existsSync(bundledFile)) {
1255
+ ctx.ui.notify(`"${name}" is user-only (no bundled version). Delete manually if needed.`, 'warning');
1256
+ return;
1257
+ }
1258
+
1259
+ await unlink(userFile);
1260
+ ctx.ui.notify(`Reset "${name}" — now using bundled version.`, 'info');
1261
+ return;
1262
+ }
1263
+
1264
+ if (action === 'edit') {
1265
+ const name = parts[1];
1266
+ if (!name) {
1267
+ ctx.ui.notify('Usage: /delegate-agents edit <name>', 'warning');
1268
+ return;
1269
+ }
1270
+
1271
+ const bundledFile = join(bundledAgentsDir, `${name}.md`);
1272
+ const userFile = join(userDir, `${name}.md`);
1273
+
1274
+ if (existsSync(userFile)) {
1275
+ ctx.ui.notify(`User override already exists: ${userFile}`, 'info');
1276
+ return;
1277
+ }
1278
+ if (!existsSync(bundledFile)) {
1279
+ ctx.ui.notify(`No bundled agent named "${name}".`, 'warning');
1280
+ return;
1281
+ }
1282
+
1283
+ await mkdir(userDir, { recursive: true });
1284
+ await copyFile(bundledFile, userFile);
1285
+ ctx.ui.notify(`Copied "${name}" to ${userFile} — edit it there to customize.`, 'info');
1286
+ return;
1287
+ }
1288
+
1289
+ ctx.ui.notify('Unknown action. Usage: /delegate-agents [list|reset <name|--all>|edit <name>]', 'warning');
1290
+ },
1291
+ });
1292
+
1293
+ pi.registerCommand('delegate', {
1294
+ description: 'Orchestrate subagent workflows — scouts, planners, workers, reviewers',
1295
+ handler: async (args, ctx) => {
1296
+ const explicitTask = args?.trim() || undefined;
1297
+
1298
+ // Step 1: Synthesize
1299
+ const conversation = extractRecentConversation(ctx);
1300
+ if (!conversation.trim() && !explicitTask) {
1301
+ ctx.ui.notify(
1302
+ 'No conversation context and no task specified. Usage: /delegate [task]',
1303
+ 'warning',
1304
+ );
1305
+ return;
1306
+ }
1307
+
1308
+ const prompt = buildSynthesisPrompt(conversation, explicitTask);
1309
+ ctx.ui.notify('⏳ Synthesizing task from conversation...', 'info');
1310
+
1311
+ const synthResult = await runAgent(ctx.cwd, 'planner', prompt);
1312
+ const synthesis = getFinalText(synthResult);
1313
+
1314
+ if (!synthesis.trim()) {
1315
+ ctx.ui.notify('Failed to synthesize task from conversation.', 'error');
1316
+ return;
1317
+ }
1318
+
1319
+ // Step 2: Confirm synthesis
1320
+ const synthOk = await confirmSynthesis(ctx, synthesis);
1321
+ if (!synthOk) {
1322
+ ctx.ui.notify('Delegate cancelled.', 'info');
1323
+ return;
1324
+ }
1325
+
1326
+ // Step 3: Pick workflow
1327
+ const workflow = await pickWorkflow(ctx, synthesis);
1328
+ if (!workflow) {
1329
+ ctx.ui.notify('Delegate cancelled — no workflow selected.', 'info');
1330
+ return;
1331
+ }
1332
+
1333
+ // Step 4: Execute phases
1334
+ const state: DelegateState = {
1335
+ synthesis,
1336
+ workflow,
1337
+ phases: [],
1338
+ totalUsage: emptyDelegateUsage(),
1339
+ };
1340
+
1341
+ let previousOutput = '';
1342
+
1343
+ ctx.ui.notify(`Starting workflow: ${workflow.label}`, 'info');
1344
+
1345
+ for (const phaseDef of workflow.phases) {
1346
+ const phaseResult = await executePhase(ctx.cwd, phaseDef, synthesis, previousOutput, ctx);
1347
+
1348
+ const phase: PhaseResult = {
1349
+ name: phaseDef.name,
1350
+ kind: phaseDef.kind,
1351
+ agents: phaseResult.results,
1352
+ };
1353
+
1354
+ state.phases.push(phase);
1355
+ const phaseUsage = aggregateUsage(phaseResult.results);
1356
+ state.totalUsage.input += phaseUsage.input;
1357
+ state.totalUsage.output += phaseUsage.output;
1358
+ state.totalUsage.cacheRead += phaseUsage.cacheRead;
1359
+ state.totalUsage.cacheWrite += phaseUsage.cacheWrite;
1360
+ state.totalUsage.cost += phaseUsage.cost;
1361
+ state.totalUsage.turns += phaseUsage.turns;
1362
+
1363
+ if (phaseResult.aborted) break;
1364
+
1365
+ previousOutput = phaseResult.output;
1366
+ }
1367
+
1368
+ // Step 5: Show full summary
1369
+ const summary = formatFullSummary(state);
1370
+ ctx.ui.notify(`Delegate complete — ${workflow.label}`, 'info');
1371
+
1372
+ pi.sendMessage({
1373
+ customType: 'delegate-summary',
1374
+ content: summary,
1375
+ display: true,
1376
+ details: {
1377
+ workflow: workflow.id,
1378
+ phaseCount: state.phases.length,
1379
+ totalUsage: state.totalUsage,
1380
+ },
1381
+ });
1382
+ },
1383
+ });
1086
1384
  }
@@ -0,0 +1,78 @@
1
+ import type { ExtensionCommandContext } from '@mariozechner/pi-coding-agent';
2
+
3
+ const MAX_MESSAGES = 40;
4
+
5
+ const SYNTHESIS_INSTRUCTION = `Synthesize the current conversation into a clear task specification for a team of subagents that will execute it.
6
+
7
+ Your synthesis MUST capture:
8
+
9
+ 1. **Goal** — What are we building, changing, or investigating?
10
+ 2. **Decisions made** — Every locked choice, preference, or constraint the user confirmed.
11
+ 3. **Constraints** — What was explicitly rejected and why.
12
+ 4. **Architecture** — The agreed structure, file layout, data flow, or integration shape.
13
+ 5. **Open questions** — Anything flagged but not resolved yet.
14
+ 6. **Intent** — The user's real motivation and the nuance behind the decisions.
15
+
16
+ Be specific and actionable. Do NOT summarize vaguely. Include concrete names, paths, tools, APIs, and patterns that were discussed.
17
+
18
+ The output should be usable by agents who have NOT seen this conversation.
19
+
20
+ Format:
21
+
22
+ ## Goal
23
+ <one paragraph>
24
+
25
+ ## Decisions
26
+ <bulleted list of every locked decision>
27
+
28
+ ## Constraints
29
+ <bulleted list of what was rejected and why>
30
+
31
+ ## Architecture
32
+ <concrete structure, files, data flow>
33
+
34
+ ## Open Questions
35
+ <anything unresolved>
36
+
37
+ ## Intent
38
+ <the user's real motivation>`;
39
+
40
+ export function extractRecentConversation(ctx: ExtensionCommandContext): string {
41
+ const entries = ctx.sessionManager.getBranch();
42
+ const messages: string[] = [];
43
+
44
+ for (const entry of entries) {
45
+ if (entry.type !== 'message') continue;
46
+ const msg = entry.message;
47
+ if (!msg) continue;
48
+
49
+ if (msg.role === 'user' || msg.role === 'assistant') {
50
+ const content = Array.isArray(msg.content) ? msg.content : [];
51
+ const textParts = content
52
+ .filter((part: { type: string }) => part.type === 'text')
53
+ .map((part: { type: string; text?: string }) => (part as { text: string }).text)
54
+ .join('\n');
55
+
56
+ if (textParts.trim()) {
57
+ messages.push(`[${msg.role}]\n${textParts.trim()}`);
58
+ }
59
+ }
60
+ }
61
+
62
+ return messages.slice(-MAX_MESSAGES).join('\n\n---\n\n');
63
+ }
64
+
65
+ export function buildSynthesisPrompt(conversation: string, explicitTask?: string): string {
66
+ const parts = [SYNTHESIS_INSTRUCTION];
67
+
68
+ if (explicitTask) {
69
+ parts.push(`\nThe user explicitly specified the task as:\n${explicitTask}`);
70
+ parts.push(
71
+ '\nUse this as the primary goal, but enrich it with relevant context from the conversation.',
72
+ );
73
+ }
74
+
75
+ parts.push(`\n\n## Conversation\n\n${conversation}`);
76
+
77
+ return parts.join('\n');
78
+ }