@link-assistant/agent 0.0.8

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 (133) hide show
  1. package/EXAMPLES.md +383 -0
  2. package/LICENSE +24 -0
  3. package/MODELS.md +95 -0
  4. package/README.md +388 -0
  5. package/TOOLS.md +134 -0
  6. package/package.json +89 -0
  7. package/src/agent/agent.ts +150 -0
  8. package/src/agent/generate.txt +75 -0
  9. package/src/auth/index.ts +64 -0
  10. package/src/bun/index.ts +96 -0
  11. package/src/bus/global.ts +10 -0
  12. package/src/bus/index.ts +119 -0
  13. package/src/cli/bootstrap.js +41 -0
  14. package/src/cli/bootstrap.ts +17 -0
  15. package/src/cli/cmd/agent.ts +165 -0
  16. package/src/cli/cmd/cmd.ts +5 -0
  17. package/src/cli/cmd/export.ts +88 -0
  18. package/src/cli/cmd/mcp.ts +80 -0
  19. package/src/cli/cmd/models.ts +58 -0
  20. package/src/cli/cmd/run.ts +359 -0
  21. package/src/cli/cmd/stats.ts +276 -0
  22. package/src/cli/error.ts +27 -0
  23. package/src/command/index.ts +73 -0
  24. package/src/command/template/initialize.txt +10 -0
  25. package/src/config/config.ts +705 -0
  26. package/src/config/markdown.ts +41 -0
  27. package/src/file/ripgrep.ts +391 -0
  28. package/src/file/time.ts +38 -0
  29. package/src/file/watcher.ts +75 -0
  30. package/src/file.ts +6 -0
  31. package/src/flag/flag.ts +19 -0
  32. package/src/format/formatter.ts +248 -0
  33. package/src/format/index.ts +137 -0
  34. package/src/global/index.ts +52 -0
  35. package/src/id/id.ts +72 -0
  36. package/src/index.js +371 -0
  37. package/src/mcp/index.ts +289 -0
  38. package/src/patch/index.ts +622 -0
  39. package/src/project/bootstrap.ts +22 -0
  40. package/src/project/instance.ts +67 -0
  41. package/src/project/project.ts +105 -0
  42. package/src/project/state.ts +65 -0
  43. package/src/provider/models-macro.ts +11 -0
  44. package/src/provider/models.ts +98 -0
  45. package/src/provider/opencode.js +47 -0
  46. package/src/provider/provider.ts +636 -0
  47. package/src/provider/transform.ts +241 -0
  48. package/src/server/project.ts +48 -0
  49. package/src/server/server.ts +249 -0
  50. package/src/session/agent.js +204 -0
  51. package/src/session/compaction.ts +249 -0
  52. package/src/session/index.ts +380 -0
  53. package/src/session/message-v2.ts +758 -0
  54. package/src/session/message.ts +189 -0
  55. package/src/session/processor.ts +356 -0
  56. package/src/session/prompt/anthropic-20250930.txt +166 -0
  57. package/src/session/prompt/anthropic.txt +105 -0
  58. package/src/session/prompt/anthropic_spoof.txt +1 -0
  59. package/src/session/prompt/beast.txt +147 -0
  60. package/src/session/prompt/build-switch.txt +5 -0
  61. package/src/session/prompt/codex.txt +318 -0
  62. package/src/session/prompt/copilot-gpt-5.txt +143 -0
  63. package/src/session/prompt/gemini.txt +155 -0
  64. package/src/session/prompt/grok-code.txt +1 -0
  65. package/src/session/prompt/plan.txt +8 -0
  66. package/src/session/prompt/polaris.txt +107 -0
  67. package/src/session/prompt/qwen.txt +109 -0
  68. package/src/session/prompt/summarize-turn.txt +5 -0
  69. package/src/session/prompt/summarize.txt +10 -0
  70. package/src/session/prompt/title.txt +25 -0
  71. package/src/session/prompt.ts +1390 -0
  72. package/src/session/retry.ts +53 -0
  73. package/src/session/revert.ts +108 -0
  74. package/src/session/status.ts +75 -0
  75. package/src/session/summary.ts +179 -0
  76. package/src/session/system.ts +138 -0
  77. package/src/session/todo.ts +36 -0
  78. package/src/snapshot/index.ts +197 -0
  79. package/src/storage/storage.ts +226 -0
  80. package/src/tool/bash.ts +193 -0
  81. package/src/tool/bash.txt +121 -0
  82. package/src/tool/batch.ts +173 -0
  83. package/src/tool/batch.txt +28 -0
  84. package/src/tool/codesearch.ts +123 -0
  85. package/src/tool/codesearch.txt +12 -0
  86. package/src/tool/edit.ts +604 -0
  87. package/src/tool/edit.txt +10 -0
  88. package/src/tool/glob.ts +65 -0
  89. package/src/tool/glob.txt +6 -0
  90. package/src/tool/grep.ts +116 -0
  91. package/src/tool/grep.txt +8 -0
  92. package/src/tool/invalid.ts +17 -0
  93. package/src/tool/ls.ts +110 -0
  94. package/src/tool/ls.txt +1 -0
  95. package/src/tool/multiedit.ts +46 -0
  96. package/src/tool/multiedit.txt +41 -0
  97. package/src/tool/patch.ts +188 -0
  98. package/src/tool/patch.txt +1 -0
  99. package/src/tool/read.ts +201 -0
  100. package/src/tool/read.txt +12 -0
  101. package/src/tool/registry.ts +87 -0
  102. package/src/tool/task.ts +126 -0
  103. package/src/tool/task.txt +60 -0
  104. package/src/tool/todo.ts +39 -0
  105. package/src/tool/todoread.txt +14 -0
  106. package/src/tool/todowrite.txt +167 -0
  107. package/src/tool/tool.ts +66 -0
  108. package/src/tool/webfetch.ts +171 -0
  109. package/src/tool/webfetch.txt +14 -0
  110. package/src/tool/websearch.ts +133 -0
  111. package/src/tool/websearch.txt +11 -0
  112. package/src/tool/write.ts +33 -0
  113. package/src/tool/write.txt +8 -0
  114. package/src/util/binary.ts +41 -0
  115. package/src/util/context.ts +25 -0
  116. package/src/util/defer.ts +12 -0
  117. package/src/util/error.ts +54 -0
  118. package/src/util/eventloop.ts +20 -0
  119. package/src/util/filesystem.ts +69 -0
  120. package/src/util/fn.ts +11 -0
  121. package/src/util/iife.ts +3 -0
  122. package/src/util/keybind.ts +79 -0
  123. package/src/util/lazy.ts +11 -0
  124. package/src/util/locale.ts +39 -0
  125. package/src/util/lock.ts +98 -0
  126. package/src/util/log.ts +177 -0
  127. package/src/util/queue.ts +19 -0
  128. package/src/util/rpc.ts +42 -0
  129. package/src/util/scrap.ts +10 -0
  130. package/src/util/signal.ts +12 -0
  131. package/src/util/timeout.ts +14 -0
  132. package/src/util/token.ts +7 -0
  133. package/src/util/wildcard.ts +54 -0
@@ -0,0 +1,1390 @@
1
+ import path from "path"
2
+ import os from "os"
3
+ import fs from "fs/promises"
4
+ import z from "zod"
5
+ import { Identifier } from "../id/id"
6
+ import { MessageV2 } from "./message-v2"
7
+ import { Log } from "../util/log"
8
+ import { SessionRevert } from "./revert"
9
+ import { Session } from "."
10
+ import { Agent } from "../agent/agent"
11
+ import { Provider } from "../provider/provider"
12
+ import {
13
+ generateText,
14
+ streamText,
15
+ type ModelMessage,
16
+ type Tool as AITool,
17
+ tool,
18
+ wrapLanguageModel,
19
+ stepCountIs,
20
+ jsonSchema,
21
+ } from "ai"
22
+ import { SessionCompaction } from "./compaction"
23
+ import { Instance } from "../project/instance"
24
+ import { Bus } from "../bus"
25
+ import { ProviderTransform } from "../provider/transform"
26
+ import { SystemPrompt } from "./system"
27
+
28
+ import PROMPT_PLAN from "../session/prompt/plan.txt"
29
+ import BUILD_SWITCH from "../session/prompt/build-switch.txt"
30
+ import { defer } from "../util/defer"
31
+ import { mergeDeep, pipe } from "remeda"
32
+ import { ToolRegistry } from "../tool/registry"
33
+ import { Wildcard } from "../util/wildcard"
34
+ import { MCP } from "../mcp"
35
+ import { ReadTool } from "../tool/read"
36
+ import { ListTool } from "../tool/ls"
37
+ import { FileTime } from "../file/time"
38
+ import { ulid } from "ulid"
39
+ import { spawn } from "child_process"
40
+ import { Command } from "../command"
41
+ import { $, fileURLToPath } from "bun"
42
+ import { ConfigMarkdown } from "../config/markdown"
43
+ import { SessionSummary } from "./summary"
44
+ import { NamedError } from "../util/error"
45
+ import { fn } from "../util/fn"
46
+ import { SessionProcessor } from "./processor"
47
+ import { TaskTool } from "../tool/task"
48
+ import { SessionStatus } from "./status"
49
+
50
+ export namespace SessionPrompt {
51
+ const log = Log.create({ service: "session.prompt" })
52
+ export const OUTPUT_TOKEN_MAX = 32_000
53
+
54
+ const state = Instance.state(
55
+ () => {
56
+ const data: Record<
57
+ string,
58
+ {
59
+ abort: AbortController
60
+ callbacks: {
61
+ resolve(input: MessageV2.WithParts): void
62
+ reject(): void
63
+ }[]
64
+ }
65
+ > = {}
66
+ return data
67
+ },
68
+ async (current) => {
69
+ for (const item of Object.values(current)) {
70
+ item.abort.abort()
71
+ }
72
+ },
73
+ )
74
+
75
+ export function assertNotBusy(sessionID: string) {
76
+ const match = state()[sessionID]
77
+ if (match) throw new Session.BusyError(sessionID)
78
+ }
79
+
80
+ export const PromptInput = z.object({
81
+ sessionID: Identifier.schema("session"),
82
+ messageID: Identifier.schema("message").optional(),
83
+ model: z
84
+ .object({
85
+ providerID: z.string(),
86
+ modelID: z.string(),
87
+ })
88
+ .optional(),
89
+ agent: z.string().optional(),
90
+ noReply: z.boolean().optional(),
91
+ system: z.string().optional(),
92
+ appendSystem: z.string().optional(),
93
+ tools: z.record(z.string(), z.boolean()).optional(),
94
+ parts: z.array(
95
+ z.discriminatedUnion("type", [
96
+ MessageV2.TextPart.omit({
97
+ messageID: true,
98
+ sessionID: true,
99
+ })
100
+ .partial({
101
+ id: true,
102
+ })
103
+ .meta({
104
+ ref: "TextPartInput",
105
+ }),
106
+ MessageV2.FilePart.omit({
107
+ messageID: true,
108
+ sessionID: true,
109
+ })
110
+ .partial({
111
+ id: true,
112
+ })
113
+ .meta({
114
+ ref: "FilePartInput",
115
+ }),
116
+ MessageV2.AgentPart.omit({
117
+ messageID: true,
118
+ sessionID: true,
119
+ })
120
+ .partial({
121
+ id: true,
122
+ })
123
+ .meta({
124
+ ref: "AgentPartInput",
125
+ }),
126
+ MessageV2.SubtaskPart.omit({
127
+ messageID: true,
128
+ sessionID: true,
129
+ })
130
+ .partial({
131
+ id: true,
132
+ })
133
+ .meta({
134
+ ref: "SubtaskPartInput",
135
+ }),
136
+ ]),
137
+ ),
138
+ })
139
+ export type PromptInput = z.infer<typeof PromptInput>
140
+
141
+ export async function resolvePromptParts(template: string): Promise<PromptInput["parts"]> {
142
+ const parts: PromptInput["parts"] = [
143
+ {
144
+ type: "text",
145
+ text: template,
146
+ },
147
+ ]
148
+ const files = ConfigMarkdown.files(template)
149
+ await Promise.all(
150
+ files.map(async (match) => {
151
+ const name = match[1]
152
+ const filepath = name.startsWith("~/")
153
+ ? path.join(os.homedir(), name.slice(2))
154
+ : path.resolve(Instance.worktree, name)
155
+
156
+ const stats = await fs.stat(filepath).catch(() => undefined)
157
+ if (!stats) {
158
+ const agent = await Agent.get(name)
159
+ if (agent) {
160
+ parts.push({
161
+ type: "agent",
162
+ name: agent.name,
163
+ })
164
+ }
165
+ return
166
+ }
167
+
168
+ if (stats.isDirectory()) {
169
+ parts.push({
170
+ type: "file",
171
+ url: `file://${filepath}`,
172
+ filename: name,
173
+ mime: "application/x-directory",
174
+ })
175
+ return
176
+ }
177
+
178
+ parts.push({
179
+ type: "file",
180
+ url: `file://${filepath}`,
181
+ filename: name,
182
+ mime: "text/plain",
183
+ })
184
+ }),
185
+ )
186
+ return parts
187
+ }
188
+
189
+ export const prompt = fn(PromptInput, async (input) => {
190
+ const session = await Session.get(input.sessionID)
191
+ await SessionRevert.cleanup(session)
192
+
193
+ const message = await createUserMessage(input)
194
+ await Session.touch(input.sessionID)
195
+
196
+ if (input.noReply) {
197
+ return message
198
+ }
199
+
200
+ return loop(input.sessionID)
201
+ })
202
+
203
+ function start(sessionID: string) {
204
+ const s = state()
205
+ if (s[sessionID]) return
206
+ const controller = new AbortController()
207
+ s[sessionID] = {
208
+ abort: controller,
209
+ callbacks: [],
210
+ }
211
+ return controller.signal
212
+ }
213
+
214
+ export function cancel(sessionID: string) {
215
+ log.info("cancel", { sessionID })
216
+ const s = state()
217
+ const match = s[sessionID]
218
+ if (!match) return
219
+ match.abort.abort()
220
+ for (const item of match.callbacks) {
221
+ item.reject()
222
+ }
223
+ delete s[sessionID]
224
+ SessionStatus.set(sessionID, { type: "idle" })
225
+ return
226
+ }
227
+
228
+ export const loop = fn(Identifier.schema("session"), async (sessionID) => {
229
+ const abort = start(sessionID)
230
+ if (!abort) {
231
+ return new Promise<MessageV2.WithParts>((resolve, reject) => {
232
+ const callbacks = state()[sessionID].callbacks
233
+ callbacks.push({ resolve, reject })
234
+ })
235
+ }
236
+
237
+ using _ = defer(() => cancel(sessionID))
238
+
239
+ let step = 0
240
+ while (true) {
241
+ log.info("loop", { step, sessionID })
242
+ if (abort.aborted) break
243
+ let msgs = await MessageV2.filterCompacted(MessageV2.stream(sessionID))
244
+
245
+ let lastUser: MessageV2.User | undefined
246
+ let lastAssistant: MessageV2.Assistant | undefined
247
+ let lastFinished: MessageV2.Assistant | undefined
248
+ let tasks: (MessageV2.CompactionPart | MessageV2.SubtaskPart)[] = []
249
+ for (let i = msgs.length - 1; i >= 0; i--) {
250
+ const msg = msgs[i]
251
+ if (!lastUser && msg.info.role === "user") lastUser = msg.info as MessageV2.User
252
+ if (!lastAssistant && msg.info.role === "assistant") lastAssistant = msg.info as MessageV2.Assistant
253
+ if (!lastFinished && msg.info.role === "assistant" && msg.info.finish)
254
+ lastFinished = msg.info as MessageV2.Assistant
255
+ if (lastUser && lastFinished) break
256
+ const task = msg.parts.filter((part) => part.type === "compaction" || part.type === "subtask")
257
+ if (task && !lastFinished) {
258
+ tasks.push(...task)
259
+ }
260
+ }
261
+
262
+ if (!lastUser) throw new Error("No user message found in stream. This should never happen.")
263
+ if (lastAssistant?.finish && lastAssistant.finish !== "tool-calls" && lastUser.id < lastAssistant.id) {
264
+ log.info("exiting loop", { sessionID })
265
+ break
266
+ }
267
+
268
+ step++
269
+ if (step === 1)
270
+ ensureTitle({
271
+ session: await Session.get(sessionID),
272
+ modelID: lastUser.model.modelID,
273
+ providerID: lastUser.model.providerID,
274
+ message: msgs.find((m) => m.info.role === "user")!,
275
+ history: msgs,
276
+ })
277
+
278
+ const model = await Provider.getModel(lastUser.model.providerID, lastUser.model.modelID)
279
+ const task = tasks.pop()
280
+
281
+ // pending subtask
282
+ // TODO: centralize "invoke tool" logic
283
+ if (task?.type === "subtask") {
284
+ const taskTool = await TaskTool.init()
285
+ const assistantMessage = (await Session.updateMessage({
286
+ id: Identifier.ascending("message"),
287
+ role: "assistant",
288
+ parentID: lastUser.id,
289
+ sessionID,
290
+ mode: task.agent,
291
+ path: {
292
+ cwd: Instance.directory,
293
+ root: Instance.worktree,
294
+ },
295
+ cost: 0,
296
+ tokens: {
297
+ input: 0,
298
+ output: 0,
299
+ reasoning: 0,
300
+ cache: { read: 0, write: 0 },
301
+ },
302
+ modelID: model.modelID,
303
+ providerID: model.providerID,
304
+ time: {
305
+ created: Date.now(),
306
+ },
307
+ })) as MessageV2.Assistant
308
+ let part = (await Session.updatePart({
309
+ id: Identifier.ascending("part"),
310
+ messageID: assistantMessage.id,
311
+ sessionID: assistantMessage.sessionID,
312
+ type: "tool",
313
+ callID: ulid(),
314
+ tool: TaskTool.id,
315
+ state: {
316
+ status: "running",
317
+ input: {
318
+ prompt: task.prompt,
319
+ description: task.description,
320
+ subagent_type: task.agent,
321
+ },
322
+ time: {
323
+ start: Date.now(),
324
+ },
325
+ },
326
+ })) as MessageV2.ToolPart
327
+ const result = await taskTool
328
+ .execute(
329
+ {
330
+ prompt: task.prompt,
331
+ description: task.description,
332
+ subagent_type: task.agent,
333
+ },
334
+ {
335
+ agent: task.agent,
336
+ messageID: assistantMessage.id,
337
+ sessionID: sessionID,
338
+ abort,
339
+ async metadata(input) {
340
+ await Session.updatePart({
341
+ ...part,
342
+ type: "tool",
343
+ state: {
344
+ ...part.state,
345
+ ...input,
346
+ },
347
+ } satisfies MessageV2.ToolPart)
348
+ },
349
+ },
350
+ )
351
+ .catch(() => {})
352
+ assistantMessage.finish = "tool-calls"
353
+ assistantMessage.time.completed = Date.now()
354
+ await Session.updateMessage(assistantMessage)
355
+ if (result && part.state.status === "running") {
356
+ await Session.updatePart({
357
+ ...part,
358
+ state: {
359
+ status: "completed",
360
+ input: part.state.input,
361
+ title: result.title,
362
+ metadata: result.metadata,
363
+ output: result.output,
364
+ attachments: result.attachments,
365
+ time: {
366
+ ...part.state.time,
367
+ end: Date.now(),
368
+ },
369
+ },
370
+ } satisfies MessageV2.ToolPart)
371
+ }
372
+ if (!result) {
373
+ await Session.updatePart({
374
+ ...part,
375
+ state: {
376
+ status: "error",
377
+ error: "Tool execution failed",
378
+ time: {
379
+ start: part.state.status === "running" ? part.state.time.start : Date.now(),
380
+ end: Date.now(),
381
+ },
382
+ metadata: part.metadata,
383
+ input: part.state.input,
384
+ },
385
+ } satisfies MessageV2.ToolPart)
386
+ }
387
+ continue
388
+ }
389
+
390
+ // pending compaction
391
+ if (task?.type === "compaction") {
392
+ const result = await SessionCompaction.process({
393
+ messages: msgs,
394
+ parentID: lastUser.id,
395
+ abort,
396
+ model: {
397
+ providerID: model.providerID,
398
+ modelID: model.modelID,
399
+ },
400
+ sessionID,
401
+ })
402
+ if (result === "stop") break
403
+ continue
404
+ }
405
+
406
+ // context overflow, needs compaction
407
+ if (
408
+ lastFinished &&
409
+ lastFinished.summary !== true &&
410
+ SessionCompaction.isOverflow({ tokens: lastFinished.tokens, model: model.info })
411
+ ) {
412
+ await SessionCompaction.create({
413
+ sessionID,
414
+ model: lastUser.model,
415
+ })
416
+ continue
417
+ }
418
+
419
+ // normal processing
420
+ const agent = await Agent.get(lastUser.agent)
421
+ msgs = insertReminders({
422
+ messages: msgs,
423
+ agent,
424
+ })
425
+ const processor = SessionProcessor.create({
426
+ assistantMessage: (await Session.updateMessage({
427
+ id: Identifier.ascending("message"),
428
+ parentID: lastUser.id,
429
+ role: "assistant",
430
+ mode: agent.name,
431
+ path: {
432
+ cwd: Instance.directory,
433
+ root: Instance.worktree,
434
+ },
435
+ cost: 0,
436
+ tokens: {
437
+ input: 0,
438
+ output: 0,
439
+ reasoning: 0,
440
+ cache: { read: 0, write: 0 },
441
+ },
442
+ modelID: model.modelID,
443
+ providerID: model.providerID,
444
+ time: {
445
+ created: Date.now(),
446
+ },
447
+ sessionID,
448
+ })) as MessageV2.Assistant,
449
+ sessionID: sessionID,
450
+ model: model.info,
451
+ providerID: model.providerID,
452
+ abort,
453
+ })
454
+ const system = await resolveSystemPrompt({
455
+ providerID: model.providerID,
456
+ modelID: model.info.id,
457
+ agent,
458
+ system: lastUser.system,
459
+ appendSystem: lastUser.appendSystem,
460
+ })
461
+ const tools = await resolveTools({
462
+ agent,
463
+ sessionID,
464
+ model: lastUser.model,
465
+ tools: lastUser.tools,
466
+ processor,
467
+ })
468
+ const params = {
469
+ temperature: model.info.temperature
470
+ ? (agent.temperature ?? ProviderTransform.temperature(model.providerID, model.modelID))
471
+ : undefined,
472
+ topP: agent.topP ?? ProviderTransform.topP(model.providerID, model.modelID),
473
+ options: {
474
+ ...ProviderTransform.options(model.providerID, model.modelID, model.npm ?? "", sessionID),
475
+ ...model.info.options,
476
+ ...agent.options,
477
+ },
478
+ }
479
+
480
+ if (step === 1) {
481
+ SessionSummary.summarize({
482
+ sessionID: sessionID,
483
+ messageID: lastUser.id,
484
+ })
485
+ }
486
+
487
+ const result = await processor.process(() =>
488
+ streamText({
489
+ onError(error) {
490
+ log.error("stream error", {
491
+ error,
492
+ })
493
+ },
494
+ async experimental_repairToolCall(input) {
495
+ const lower = input.toolCall.toolName.toLowerCase()
496
+ if (lower !== input.toolCall.toolName && tools[lower]) {
497
+ log.info("repairing tool call", {
498
+ tool: input.toolCall.toolName,
499
+ repaired: lower,
500
+ })
501
+ return {
502
+ ...input.toolCall,
503
+ toolName: lower,
504
+ }
505
+ }
506
+ return {
507
+ ...input.toolCall,
508
+ input: JSON.stringify({
509
+ tool: input.toolCall.toolName,
510
+ error: input.error.message,
511
+ }),
512
+ toolName: "invalid",
513
+ }
514
+ },
515
+ headers: {
516
+ ...(model.providerID === "opencode"
517
+ ? {
518
+ "x-opencode-session": sessionID,
519
+ "x-opencode-request": lastUser.id,
520
+ }
521
+ : undefined),
522
+ ...model.info.headers,
523
+ },
524
+ // set to 0, we handle loop
525
+ maxRetries: 0,
526
+ activeTools: Object.keys(tools).filter((x) => x !== "invalid"),
527
+ maxOutputTokens: ProviderTransform.maxOutputTokens(
528
+ model.providerID,
529
+ params.options,
530
+ model.info.limit.output,
531
+ OUTPUT_TOKEN_MAX,
532
+ ),
533
+ abortSignal: abort,
534
+ providerOptions: ProviderTransform.providerOptions(model.npm, model.providerID, params.options),
535
+ stopWhen: stepCountIs(1),
536
+ temperature: params.temperature,
537
+ topP: params.topP,
538
+ messages: [
539
+ ...system.map(
540
+ (x): ModelMessage => ({
541
+ role: "system",
542
+ content: x,
543
+ }),
544
+ ),
545
+ ...MessageV2.toModelMessage(
546
+ msgs.filter((m) => {
547
+ if (m.info.role !== "assistant" || m.info.error === undefined) {
548
+ return true
549
+ }
550
+ if (
551
+ MessageV2.AbortedError.isInstance(m.info.error) &&
552
+ m.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning")
553
+ ) {
554
+ return true
555
+ }
556
+
557
+ return false
558
+ }),
559
+ ),
560
+ ],
561
+ tools: model.info.tool_call === false ? undefined : tools,
562
+ model: wrapLanguageModel({
563
+ model: model.language,
564
+ middleware: [
565
+ {
566
+ async transformParams(args) {
567
+ if (args.type === "stream") {
568
+ // @ts-expect-error
569
+ args.params.prompt = ProviderTransform.message(args.params.prompt, model.providerID, model.modelID)
570
+ }
571
+ return args.params
572
+ },
573
+ },
574
+ ],
575
+ }),
576
+ }),
577
+ )
578
+ if (result === "stop") break
579
+ continue
580
+ }
581
+ SessionCompaction.prune({ sessionID })
582
+ for await (const item of MessageV2.stream(sessionID)) {
583
+ if (item.info.role === "user") continue
584
+ const queued = state()[sessionID]?.callbacks ?? []
585
+ for (const q of queued) {
586
+ q.resolve(item)
587
+ }
588
+ return item
589
+ }
590
+ throw new Error("Impossible")
591
+ })
592
+
593
+ async function resolveModel(input: { model: PromptInput["model"]; agent: Agent.Info }) {
594
+ if (input.model) {
595
+ return input.model
596
+ }
597
+ if (input.agent.model) {
598
+ return input.agent.model
599
+ }
600
+ return Provider.defaultModel()
601
+ }
602
+
603
+ async function resolveSystemPrompt(input: {
604
+ system?: string
605
+ appendSystem?: string
606
+ agent: Agent.Info
607
+ providerID: string
608
+ modelID: string
609
+ }) {
610
+ let system = SystemPrompt.header(input.providerID)
611
+ system.push(
612
+ ...(() => {
613
+ if (input.system) return [input.system]
614
+ const base = input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.modelID)
615
+ if (input.appendSystem) {
616
+ return [base[0] + "\n" + input.appendSystem]
617
+ }
618
+ return base
619
+ })(),
620
+ )
621
+ if (!input.system) {
622
+ system.push(...(await SystemPrompt.environment()))
623
+ system.push(...(await SystemPrompt.custom()))
624
+ }
625
+ // max 2 system prompt messages for caching purposes
626
+ const [first, ...rest] = system
627
+ system = [first, rest.join("\n")]
628
+ return system
629
+ }
630
+
631
+ async function resolveTools(input: {
632
+ agent: Agent.Info
633
+ model: {
634
+ providerID: string
635
+ modelID: string
636
+ }
637
+ sessionID: string
638
+ tools?: Record<string, boolean>
639
+ processor: SessionProcessor.Info
640
+ }) {
641
+ const tools: Record<string, AITool> = {}
642
+ const enabledTools = pipe(
643
+ input.agent.tools,
644
+ mergeDeep(await ToolRegistry.enabled(input.model.providerID, input.model.modelID, input.agent)),
645
+ mergeDeep(input.tools ?? {}),
646
+ )
647
+ for (const item of await ToolRegistry.tools(input.model.providerID, input.model.modelID)) {
648
+ if (Wildcard.all(item.id, enabledTools) === false) continue
649
+ const schema = ProviderTransform.schema(
650
+ input.model.providerID,
651
+ input.model.modelID,
652
+ z.toJSONSchema(item.parameters),
653
+ )
654
+ tools[item.id] = tool({
655
+ id: item.id as any,
656
+ description: item.description,
657
+ inputSchema: jsonSchema(schema as any),
658
+ async execute(args, options) {
659
+ const result = await item.execute(args, {
660
+ sessionID: input.sessionID,
661
+ abort: options.abortSignal!,
662
+ messageID: input.processor.message.id,
663
+ callID: options.toolCallId,
664
+ extra: input.model,
665
+ agent: input.agent.name,
666
+ metadata: async (val) => {
667
+ const match = input.processor.partFromToolCall(options.toolCallId)
668
+ if (match && match.state.status === "running") {
669
+ await Session.updatePart({
670
+ ...match,
671
+ state: {
672
+ title: val.title,
673
+ metadata: val.metadata,
674
+ status: "running",
675
+ input: args,
676
+ time: {
677
+ start: Date.now(),
678
+ },
679
+ },
680
+ })
681
+ }
682
+ },
683
+ })
684
+ return result
685
+ },
686
+ toModelOutput(result) {
687
+ return {
688
+ type: "text",
689
+ value: result.output,
690
+ }
691
+ },
692
+ })
693
+ }
694
+
695
+ for (const [key, item] of Object.entries(await MCP.tools())) {
696
+ if (Wildcard.all(key, enabledTools) === false) continue
697
+ const execute = item.execute
698
+ if (!execute) continue
699
+ item.execute = async (args, opts) => {
700
+ const result = await execute(args, opts)
701
+
702
+ const textParts: string[] = []
703
+ const attachments: MessageV2.FilePart[] = []
704
+
705
+ for (const item of result.content) {
706
+ if (item.type === "text") {
707
+ textParts.push(item.text)
708
+ } else if (item.type === "image") {
709
+ attachments.push({
710
+ id: Identifier.ascending("part"),
711
+ sessionID: input.sessionID,
712
+ messageID: input.processor.message.id,
713
+ type: "file",
714
+ mime: item.mimeType,
715
+ url: `data:${item.mimeType};base64,${item.data}`,
716
+ })
717
+ }
718
+ // Add support for other types if needed
719
+ }
720
+
721
+ return {
722
+ title: "",
723
+ metadata: result.metadata ?? {},
724
+ output: textParts.join("\n\n"),
725
+ attachments,
726
+ content: result.content, // directly return content to preserve ordering when outputting to model
727
+ }
728
+ }
729
+ item.toModelOutput = (result) => {
730
+ return {
731
+ type: "text",
732
+ value: result.output,
733
+ }
734
+ }
735
+ tools[key] = item
736
+ }
737
+ return tools
738
+ }
739
+
740
+ async function createUserMessage(input: PromptInput) {
741
+ const agent = await Agent.get(input.agent ?? "build")
742
+ const info: MessageV2.Info = {
743
+ id: input.messageID ?? Identifier.ascending("message"),
744
+ role: "user",
745
+ sessionID: input.sessionID,
746
+ time: {
747
+ created: Date.now(),
748
+ },
749
+ tools: input.tools,
750
+ system: input.system,
751
+ appendSystem: input.appendSystem,
752
+ agent: agent.name,
753
+ model: await resolveModel({
754
+ model: input.model,
755
+ agent,
756
+ }),
757
+ }
758
+
759
+ const parts = await Promise.all(
760
+ input.parts.map(async (part): Promise<MessageV2.Part[]> => {
761
+ if (part.type === "file") {
762
+ const url = new URL(part.url)
763
+ switch (url.protocol) {
764
+ case "data:":
765
+ if (part.mime === "text/plain") {
766
+ return [
767
+ {
768
+ id: Identifier.ascending("part"),
769
+ messageID: info.id,
770
+ sessionID: input.sessionID,
771
+ type: "text",
772
+ synthetic: true,
773
+ text: `Called the Read tool with the following input: ${JSON.stringify({ filePath: part.filename })}`,
774
+ },
775
+ {
776
+ id: Identifier.ascending("part"),
777
+ messageID: info.id,
778
+ sessionID: input.sessionID,
779
+ type: "text",
780
+ synthetic: true,
781
+ text: Buffer.from(part.url, "base64url").toString(),
782
+ },
783
+ {
784
+ ...part,
785
+ id: part.id ?? Identifier.ascending("part"),
786
+ messageID: info.id,
787
+ sessionID: input.sessionID,
788
+ },
789
+ ]
790
+ }
791
+ break
792
+ case "file:":
793
+ log.info("file", { mime: part.mime })
794
+ // have to normalize, symbol search returns absolute paths
795
+ // Decode the pathname since URL constructor doesn't automatically decode it
796
+ const filepath = fileURLToPath(part.url)
797
+ const stat = await Bun.file(filepath).stat()
798
+
799
+ if (stat.isDirectory()) {
800
+ part.mime = "application/x-directory"
801
+ }
802
+
803
+ if (part.mime === "text/plain") {
804
+ let offset: number | undefined = undefined
805
+ let limit: number | undefined = undefined
806
+ const range = {
807
+ start: url.searchParams.get("start"),
808
+ end: url.searchParams.get("end"),
809
+ }
810
+ if (range.start != null) {
811
+ const filePathURI = part.url.split("?")[0]
812
+ let start = parseInt(range.start)
813
+ let end = range.end ? parseInt(range.end) : undefined
814
+ offset = Math.max(start - 1, 0)
815
+ if (end) {
816
+ limit = end - offset
817
+ }
818
+ }
819
+ const args = { filePath: filepath, offset, limit }
820
+
821
+ const pieces: MessageV2.Part[] = [
822
+ {
823
+ id: Identifier.ascending("part"),
824
+ messageID: info.id,
825
+ sessionID: input.sessionID,
826
+ type: "text",
827
+ synthetic: true,
828
+ text: `Called the Read tool with the following input: ${JSON.stringify(args)}`,
829
+ },
830
+ ]
831
+
832
+ await ReadTool.init()
833
+ .then(async (t) => {
834
+ const result = await t.execute(args, {
835
+ sessionID: input.sessionID,
836
+ abort: new AbortController().signal,
837
+ agent: input.agent!,
838
+ messageID: info.id,
839
+ extra: { bypassCwdCheck: true, ...info.model },
840
+ metadata: async () => {},
841
+ })
842
+ pieces.push(
843
+ {
844
+ id: Identifier.ascending("part"),
845
+ messageID: info.id,
846
+ sessionID: input.sessionID,
847
+ type: "text",
848
+ synthetic: true,
849
+ text: result.output,
850
+ },
851
+ {
852
+ ...part,
853
+ id: part.id ?? Identifier.ascending("part"),
854
+ messageID: info.id,
855
+ sessionID: input.sessionID,
856
+ },
857
+ )
858
+ })
859
+ .catch((error) => {
860
+ log.error("failed to read file", { error })
861
+ const message = error instanceof Error ? error.message : error.toString()
862
+ Bus.publish(Session.Event.Error, {
863
+ sessionID: input.sessionID,
864
+ error: new NamedError.Unknown({
865
+ message,
866
+ }).toObject(),
867
+ })
868
+ pieces.push({
869
+ id: Identifier.ascending("part"),
870
+ messageID: info.id,
871
+ sessionID: input.sessionID,
872
+ type: "text",
873
+ synthetic: true,
874
+ text: `Read tool failed to read ${filepath} with the following error: ${message}`,
875
+ })
876
+ })
877
+
878
+ return pieces
879
+ }
880
+
881
+ if (part.mime === "application/x-directory") {
882
+ const args = { path: filepath }
883
+ const result = await ListTool.init().then((t) =>
884
+ t.execute(args, {
885
+ sessionID: input.sessionID,
886
+ abort: new AbortController().signal,
887
+ agent: input.agent!,
888
+ messageID: info.id,
889
+ extra: { bypassCwdCheck: true },
890
+ metadata: async () => {},
891
+ }),
892
+ )
893
+ return [
894
+ {
895
+ id: Identifier.ascending("part"),
896
+ messageID: info.id,
897
+ sessionID: input.sessionID,
898
+ type: "text",
899
+ synthetic: true,
900
+ text: `Called the list tool with the following input: ${JSON.stringify(args)}`,
901
+ },
902
+ {
903
+ id: Identifier.ascending("part"),
904
+ messageID: info.id,
905
+ sessionID: input.sessionID,
906
+ type: "text",
907
+ synthetic: true,
908
+ text: result.output,
909
+ },
910
+ {
911
+ ...part,
912
+ id: part.id ?? Identifier.ascending("part"),
913
+ messageID: info.id,
914
+ sessionID: input.sessionID,
915
+ },
916
+ ]
917
+ }
918
+
919
+ const file = Bun.file(filepath)
920
+ FileTime.read(input.sessionID, filepath)
921
+ return [
922
+ {
923
+ id: Identifier.ascending("part"),
924
+ messageID: info.id,
925
+ sessionID: input.sessionID,
926
+ type: "text",
927
+ text: `Called the Read tool with the following input: {\"filePath\":\"${filepath}\"}`,
928
+ synthetic: true,
929
+ },
930
+ {
931
+ id: part.id ?? Identifier.ascending("part"),
932
+ messageID: info.id,
933
+ sessionID: input.sessionID,
934
+ type: "file",
935
+ url: `data:${part.mime};base64,` + Buffer.from(await file.bytes()).toString("base64"),
936
+ mime: part.mime,
937
+ filename: part.filename!,
938
+ source: part.source,
939
+ },
940
+ ]
941
+ }
942
+ }
943
+
944
+ if (part.type === "agent") {
945
+ return [
946
+ {
947
+ id: Identifier.ascending("part"),
948
+ ...part,
949
+ messageID: info.id,
950
+ sessionID: input.sessionID,
951
+ },
952
+ {
953
+ id: Identifier.ascending("part"),
954
+ messageID: info.id,
955
+ sessionID: input.sessionID,
956
+ type: "text",
957
+ synthetic: true,
958
+ text:
959
+ "Use the above message and context to generate a prompt and call the task tool with subagent: " +
960
+ part.name,
961
+ },
962
+ ]
963
+ }
964
+
965
+ return [
966
+ {
967
+ id: Identifier.ascending("part"),
968
+ ...part,
969
+ messageID: info.id,
970
+ sessionID: input.sessionID,
971
+ },
972
+ ]
973
+ }),
974
+ ).then((x) => x.flat())
975
+
976
+ await Session.updateMessage(info)
977
+ for (const part of parts) {
978
+ await Session.updatePart(part)
979
+ }
980
+
981
+ return {
982
+ info,
983
+ parts,
984
+ }
985
+ }
986
+
987
+ function insertReminders(input: { messages: MessageV2.WithParts[]; agent: Agent.Info }) {
988
+ const userMessage = input.messages.findLast((msg) => msg.info.role === "user")
989
+ if (!userMessage) return input.messages
990
+ if (input.agent.name === "plan") {
991
+ userMessage.parts.push({
992
+ id: Identifier.ascending("part"),
993
+ messageID: userMessage.info.id,
994
+ sessionID: userMessage.info.sessionID,
995
+ type: "text",
996
+ text: PROMPT_PLAN,
997
+ synthetic: true,
998
+ })
999
+ }
1000
+ const wasPlan = input.messages.some((msg) => msg.info.role === "assistant" && msg.info.mode === "plan")
1001
+ if (wasPlan && input.agent.name === "build") {
1002
+ userMessage.parts.push({
1003
+ id: Identifier.ascending("part"),
1004
+ messageID: userMessage.info.id,
1005
+ sessionID: userMessage.info.sessionID,
1006
+ type: "text",
1007
+ text: BUILD_SWITCH,
1008
+ synthetic: true,
1009
+ })
1010
+ }
1011
+ return input.messages
1012
+ }
1013
+
1014
+ export const ShellInput = z.object({
1015
+ sessionID: Identifier.schema("session"),
1016
+ agent: z.string(),
1017
+ command: z.string(),
1018
+ })
1019
+ export type ShellInput = z.infer<typeof ShellInput>
1020
+ export async function shell(input: ShellInput) {
1021
+ const session = await Session.get(input.sessionID)
1022
+ if (session.revert) {
1023
+ SessionRevert.cleanup(session)
1024
+ }
1025
+ const agent = await Agent.get(input.agent)
1026
+ const model = await resolveModel({ agent, model: undefined })
1027
+ const userMsg: MessageV2.User = {
1028
+ id: Identifier.ascending("message"),
1029
+ sessionID: input.sessionID,
1030
+ time: {
1031
+ created: Date.now(),
1032
+ },
1033
+ role: "user",
1034
+ agent: input.agent,
1035
+ model: {
1036
+ providerID: model.providerID,
1037
+ modelID: model.modelID,
1038
+ },
1039
+ }
1040
+ await Session.updateMessage(userMsg)
1041
+ const userPart: MessageV2.Part = {
1042
+ type: "text",
1043
+ id: Identifier.ascending("part"),
1044
+ messageID: userMsg.id,
1045
+ sessionID: input.sessionID,
1046
+ text: "The following tool was executed by the user",
1047
+ synthetic: true,
1048
+ }
1049
+ await Session.updatePart(userPart)
1050
+
1051
+ const msg: MessageV2.Assistant = {
1052
+ id: Identifier.ascending("message"),
1053
+ sessionID: input.sessionID,
1054
+ parentID: userMsg.id,
1055
+ mode: input.agent,
1056
+ cost: 0,
1057
+ path: {
1058
+ cwd: Instance.directory,
1059
+ root: Instance.worktree,
1060
+ },
1061
+ time: {
1062
+ created: Date.now(),
1063
+ },
1064
+ role: "assistant",
1065
+ tokens: {
1066
+ input: 0,
1067
+ output: 0,
1068
+ reasoning: 0,
1069
+ cache: { read: 0, write: 0 },
1070
+ },
1071
+ modelID: model.modelID,
1072
+ providerID: model.providerID,
1073
+ }
1074
+ await Session.updateMessage(msg)
1075
+ const part: MessageV2.Part = {
1076
+ type: "tool",
1077
+ id: Identifier.ascending("part"),
1078
+ messageID: msg.id,
1079
+ sessionID: input.sessionID,
1080
+ tool: "bash",
1081
+ callID: ulid(),
1082
+ state: {
1083
+ status: "running",
1084
+ time: {
1085
+ start: Date.now(),
1086
+ },
1087
+ input: {
1088
+ command: input.command,
1089
+ },
1090
+ },
1091
+ }
1092
+ await Session.updatePart(part)
1093
+ const shell = process.env["SHELL"] ?? "bash"
1094
+ const shellName = path.basename(shell)
1095
+
1096
+ const invocations: Record<string, { args: string[] }> = {
1097
+ nu: {
1098
+ args: ["-c", input.command],
1099
+ },
1100
+ fish: {
1101
+ args: ["-c", input.command],
1102
+ },
1103
+ zsh: {
1104
+ args: [
1105
+ "-c",
1106
+ "-l",
1107
+ `
1108
+ [[ -f ~/.zshenv ]] && source ~/.zshenv >/dev/null 2>&1 || true
1109
+ [[ -f "\${ZDOTDIR:-$HOME}/.zshrc" ]] && source "\${ZDOTDIR:-$HOME}/.zshrc" >/dev/null 2>&1 || true
1110
+ ${input.command}
1111
+ `,
1112
+ ],
1113
+ },
1114
+ bash: {
1115
+ args: [
1116
+ "-c",
1117
+ "-l",
1118
+ `
1119
+ [[ -f ~/.bashrc ]] && source ~/.bashrc >/dev/null 2>&1 || true
1120
+ ${input.command}
1121
+ `,
1122
+ ],
1123
+ },
1124
+ // Fallback: any shell that doesn't match those above
1125
+ "": {
1126
+ args: ["-c", "-l", `${input.command}`],
1127
+ },
1128
+ }
1129
+
1130
+ const matchingInvocation = invocations[shellName] ?? invocations[""]
1131
+ const args = matchingInvocation?.args
1132
+
1133
+ const proc = spawn(shell, args, {
1134
+ cwd: Instance.directory,
1135
+ detached: true,
1136
+ stdio: ["ignore", "pipe", "pipe"],
1137
+ env: {
1138
+ ...process.env,
1139
+ TERM: "dumb",
1140
+ },
1141
+ })
1142
+
1143
+ let output = ""
1144
+
1145
+ proc.stdout?.on("data", (chunk) => {
1146
+ output += chunk.toString()
1147
+ if (part.state.status === "running") {
1148
+ part.state.metadata = {
1149
+ output: output,
1150
+ description: "",
1151
+ }
1152
+ Session.updatePart(part)
1153
+ }
1154
+ })
1155
+
1156
+ proc.stderr?.on("data", (chunk) => {
1157
+ output += chunk.toString()
1158
+ if (part.state.status === "running") {
1159
+ part.state.metadata = {
1160
+ output: output,
1161
+ description: "",
1162
+ }
1163
+ Session.updatePart(part)
1164
+ }
1165
+ })
1166
+
1167
+ await new Promise<void>((resolve) => {
1168
+ proc.on("close", () => {
1169
+ resolve()
1170
+ })
1171
+ })
1172
+ msg.time.completed = Date.now()
1173
+ await Session.updateMessage(msg)
1174
+ if (part.state.status === "running") {
1175
+ part.state = {
1176
+ status: "completed",
1177
+ time: {
1178
+ ...part.state.time,
1179
+ end: Date.now(),
1180
+ },
1181
+ input: part.state.input,
1182
+ title: "",
1183
+ metadata: {
1184
+ output,
1185
+ description: "",
1186
+ },
1187
+ output,
1188
+ }
1189
+ await Session.updatePart(part)
1190
+ }
1191
+ return { info: msg, parts: [part] }
1192
+ }
1193
+
1194
+ export const CommandInput = z.object({
1195
+ messageID: Identifier.schema("message").optional(),
1196
+ sessionID: Identifier.schema("session"),
1197
+ agent: z.string().optional(),
1198
+ model: z.string().optional(),
1199
+ arguments: z.string(),
1200
+ command: z.string(),
1201
+ })
1202
+ export type CommandInput = z.infer<typeof CommandInput>
1203
+ const bashRegex = /!`([^`]+)`/g
1204
+ const argsRegex = /(?:[^\s"']+|"[^"]*"|'[^']*')+/g
1205
+ const placeholderRegex = /\$(\d+)/g
1206
+ const quoteTrimRegex = /^["']|["']$/g
1207
+ /**
1208
+ * Regular expression to match @ file references in text
1209
+ * Matches @ followed by file paths, excluding commas, periods at end of sentences, and backticks
1210
+ * Does not match when preceded by word characters or backticks (to avoid email addresses and quoted references)
1211
+ */
1212
+
1213
+ export async function command(input: CommandInput) {
1214
+ log.info("command", input)
1215
+ const command = await Command.get(input.command)
1216
+ const agentName = command.agent ?? input.agent ?? "build"
1217
+
1218
+ const raw = input.arguments.match(argsRegex) ?? []
1219
+ const args = raw.map((arg) => arg.replace(quoteTrimRegex, ""))
1220
+
1221
+ const placeholders = command.template.match(placeholderRegex) ?? []
1222
+ let last = 0
1223
+ for (const item of placeholders) {
1224
+ const value = Number(item.slice(1))
1225
+ if (value > last) last = value
1226
+ }
1227
+
1228
+ // Let the final placeholder swallow any extra arguments so prompts read naturally
1229
+ const withArgs = command.template.replaceAll(placeholderRegex, (_, index) => {
1230
+ const position = Number(index)
1231
+ const argIndex = position - 1
1232
+ if (argIndex >= args.length) return ""
1233
+ if (position === last) return args.slice(argIndex).join(" ")
1234
+ return args[argIndex]
1235
+ })
1236
+ let template = withArgs.replaceAll("$ARGUMENTS", input.arguments)
1237
+
1238
+ const shell = ConfigMarkdown.shell(template)
1239
+ if (shell.length > 0) {
1240
+ const results = await Promise.all(
1241
+ shell.map(async ([, cmd]) => {
1242
+ try {
1243
+ return await $`${{ raw: cmd }}`.nothrow().text()
1244
+ } catch (error) {
1245
+ return `Error executing command: ${error instanceof Error ? error.message : String(error)}`
1246
+ }
1247
+ }),
1248
+ )
1249
+ let index = 0
1250
+ template = template.replace(bashRegex, () => results[index++])
1251
+ }
1252
+ template = template.trim()
1253
+
1254
+ const model = await (async () => {
1255
+ if (command.model) {
1256
+ return Provider.parseModel(command.model)
1257
+ }
1258
+ if (command.agent) {
1259
+ const cmdAgent = await Agent.get(command.agent)
1260
+ if (cmdAgent.model) {
1261
+ return cmdAgent.model
1262
+ }
1263
+ }
1264
+ if (input.model) {
1265
+ return Provider.parseModel(input.model)
1266
+ }
1267
+ return await Provider.defaultModel()
1268
+ })()
1269
+ const agent = await Agent.get(agentName)
1270
+
1271
+ const parts =
1272
+ (agent.mode === "subagent" && command.subtask !== false) || command.subtask === true
1273
+ ? [
1274
+ {
1275
+ type: "subtask" as const,
1276
+ agent: agent.name,
1277
+ description: command.description ?? "",
1278
+ // TODO: how can we make task tool accept a more complex input?
1279
+ prompt: await resolvePromptParts(template).then((x) => x.find((y) => y.type === "text")?.text ?? ""),
1280
+ },
1281
+ ]
1282
+ : await resolvePromptParts(template)
1283
+
1284
+ const result = (await prompt({
1285
+ sessionID: input.sessionID,
1286
+ messageID: input.messageID,
1287
+ model,
1288
+ agent: agentName,
1289
+ parts,
1290
+ })) as MessageV2.WithParts
1291
+
1292
+ Bus.publish(Command.Event.Executed, {
1293
+ name: input.command,
1294
+ sessionID: input.sessionID,
1295
+ arguments: input.arguments,
1296
+ messageID: result.info.id,
1297
+ })
1298
+
1299
+ return result
1300
+ }
1301
+
1302
+ // TODO: wire this back up
1303
+ async function ensureTitle(input: {
1304
+ session: Session.Info
1305
+ message: MessageV2.WithParts
1306
+ history: MessageV2.WithParts[]
1307
+ providerID: string
1308
+ modelID: string
1309
+ }) {
1310
+ if (input.session.parentID) return
1311
+ if (!Session.isDefaultTitle(input.session.title)) return
1312
+ const isFirst =
1313
+ input.history.filter((m) => m.info.role === "user" && !m.parts.every((p) => "synthetic" in p && p.synthetic))
1314
+ .length === 1
1315
+ if (!isFirst) return
1316
+ const small =
1317
+ (await Provider.getSmallModel(input.providerID)) ?? (await Provider.getModel(input.providerID, input.modelID))
1318
+ const options = {
1319
+ ...ProviderTransform.options(small.providerID, small.modelID, small.npm ?? "", input.session.id),
1320
+ ...small.info.options,
1321
+ }
1322
+ if (small.providerID === "openai" || small.modelID.includes("gpt-5")) {
1323
+ if (small.modelID.includes("5.1")) {
1324
+ options["reasoningEffort"] = "low"
1325
+ } else {
1326
+ options["reasoningEffort"] = "minimal"
1327
+ }
1328
+ }
1329
+ if (small.providerID === "google") {
1330
+ options["thinkingConfig"] = {
1331
+ thinkingBudget: 0,
1332
+ }
1333
+ }
1334
+ await generateText({
1335
+ maxOutputTokens: small.info.reasoning ? 1500 : 20,
1336
+ providerOptions: ProviderTransform.providerOptions(small.npm, small.providerID, options),
1337
+ messages: [
1338
+ ...SystemPrompt.title(small.providerID).map(
1339
+ (x): ModelMessage => ({
1340
+ role: "system",
1341
+ content: x,
1342
+ }),
1343
+ ),
1344
+ {
1345
+ role: "user" as const,
1346
+ content: `
1347
+ The following is the text to summarize:
1348
+ `,
1349
+ },
1350
+ ...MessageV2.toModelMessage([
1351
+ {
1352
+ info: {
1353
+ id: Identifier.ascending("message"),
1354
+ role: "user",
1355
+ sessionID: input.session.id,
1356
+ time: {
1357
+ created: Date.now(),
1358
+ },
1359
+ agent: input.message.info.role === "user" ? input.message.info.agent : "build",
1360
+ model: {
1361
+ providerID: input.providerID,
1362
+ modelID: input.modelID,
1363
+ },
1364
+ },
1365
+ parts: input.message.parts,
1366
+ },
1367
+ ]),
1368
+ ],
1369
+ headers: small.info.headers,
1370
+ model: small.language,
1371
+ })
1372
+ .then((result) => {
1373
+ if (result.text)
1374
+ return Session.update(input.session.id, (draft) => {
1375
+ const cleaned = result.text
1376
+ .replace(/<think>[\s\S]*?<\/think>\s*/g, "")
1377
+ .split("\n")
1378
+ .map((line) => line.trim())
1379
+ .find((line) => line.length > 0)
1380
+ if (!cleaned) return
1381
+
1382
+ const title = cleaned.length > 100 ? cleaned.substring(0, 97) + "..." : cleaned
1383
+ draft.title = title
1384
+ })
1385
+ })
1386
+ .catch((error) => {
1387
+ log.error("failed to generate title", { error, model: small.info.id })
1388
+ })
1389
+ }
1390
+ }