@mariozechner/pi-mom 0.10.0 → 0.10.2

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/dist/agent.js CHANGED
@@ -1,13 +1,34 @@
1
1
  import { Agent, ProviderTransport } from "@mariozechner/pi-agent-core";
2
2
  import { getModel } from "@mariozechner/pi-ai";
3
3
  import { existsSync, readFileSync } from "fs";
4
- import { mkdir } from "fs/promises";
4
+ import { mkdir, writeFile } from "fs/promises";
5
5
  import { join } from "path";
6
6
  import * as log from "./log.js";
7
7
  import { createExecutor } from "./sandbox.js";
8
8
  import { createMomTools, setUploadFunction } from "./tools/index.js";
9
9
  // Hardcoded model for now
10
10
  const model = getModel("anthropic", "claude-sonnet-4-5");
11
+ /**
12
+ * Convert Date.now() to Slack timestamp format (seconds.microseconds)
13
+ * Uses a monotonic counter to ensure ordering even within the same millisecond
14
+ */
15
+ let lastTsMs = 0;
16
+ let tsCounter = 0;
17
+ function toSlackTs() {
18
+ const now = Date.now();
19
+ if (now === lastTsMs) {
20
+ // Same millisecond - increment counter for sub-ms ordering
21
+ tsCounter++;
22
+ }
23
+ else {
24
+ // New millisecond - reset counter
25
+ lastTsMs = now;
26
+ tsCounter = 0;
27
+ }
28
+ const seconds = Math.floor(now / 1000);
29
+ const micros = (now % 1000) * 1000 + tsCounter; // ms to micros + counter
30
+ return `${seconds}.${micros.toString().padStart(6, "0")}`;
31
+ }
11
32
  function getAnthropicApiKey() {
12
33
  const key = process.env.ANTHROPIC_OAUTH_TOKEN || process.env.ANTHROPIC_API_KEY;
13
34
  if (!key) {
@@ -15,29 +36,74 @@ function getAnthropicApiKey() {
15
36
  }
16
37
  return key;
17
38
  }
18
- function getRecentMessages(channelDir, count) {
39
+ function getRecentMessages(channelDir, turnCount) {
19
40
  const logPath = join(channelDir, "log.jsonl");
20
41
  if (!existsSync(logPath)) {
21
42
  return "(no message history yet)";
22
43
  }
23
44
  const content = readFileSync(logPath, "utf-8");
24
45
  const lines = content.trim().split("\n").filter(Boolean);
25
- const recentLines = lines.slice(-count);
26
- if (recentLines.length === 0) {
46
+ if (lines.length === 0) {
27
47
  return "(no message history yet)";
28
48
  }
29
- // Format as TSV for more concise system prompt
30
- const formatted = [];
31
- for (const line of recentLines) {
49
+ // Parse all messages and sort by Slack timestamp
50
+ // (attachment downloads can cause out-of-order logging)
51
+ const messages = [];
52
+ for (const line of lines) {
32
53
  try {
33
- const msg = JSON.parse(line);
54
+ messages.push(JSON.parse(line));
55
+ }
56
+ catch { }
57
+ }
58
+ messages.sort((a, b) => {
59
+ const tsA = parseFloat(a.ts || "0");
60
+ const tsB = parseFloat(b.ts || "0");
61
+ return tsA - tsB;
62
+ });
63
+ // Group into "turns" - a turn is either:
64
+ // - A single user message (isBot: false)
65
+ // - A sequence of consecutive bot messages (isBot: true) coalesced into one turn
66
+ // We walk backwards to get the last N turns
67
+ const turns = [];
68
+ let currentTurn = [];
69
+ let lastWasBot = null;
70
+ for (let i = messages.length - 1; i >= 0; i--) {
71
+ const msg = messages[i];
72
+ const isBot = msg.isBot === true;
73
+ if (lastWasBot === null) {
74
+ // First message
75
+ currentTurn.unshift(msg);
76
+ lastWasBot = isBot;
77
+ }
78
+ else if (isBot && lastWasBot) {
79
+ // Consecutive bot messages - same turn
80
+ currentTurn.unshift(msg);
81
+ }
82
+ else {
83
+ // Transition - save current turn and start new one
84
+ turns.unshift(currentTurn);
85
+ currentTurn = [msg];
86
+ lastWasBot = isBot;
87
+ // Stop if we have enough turns
88
+ if (turns.length >= turnCount) {
89
+ break;
90
+ }
91
+ }
92
+ }
93
+ // Don't forget the last turn we were building
94
+ if (currentTurn.length > 0 && turns.length < turnCount) {
95
+ turns.unshift(currentTurn);
96
+ }
97
+ // Flatten turns back to messages and format as TSV
98
+ const formatted = [];
99
+ for (const turn of turns) {
100
+ for (const msg of turn) {
34
101
  const date = (msg.date || "").substring(0, 19);
35
- const user = msg.userName || msg.user;
102
+ const user = msg.userName || msg.user || "";
36
103
  const text = msg.text || "";
37
104
  const attachments = (msg.attachments || []).map((a) => a.local).join(",");
38
105
  formatted.push(`${date}\t${user}\t${text}\t${attachments}`);
39
106
  }
40
- catch (error) { }
41
107
  }
42
108
  return formatted.join("\n");
43
109
  }
@@ -74,148 +140,103 @@ function getMemory(channelDir) {
74
140
  }
75
141
  return parts.join("\n\n");
76
142
  }
77
- function buildSystemPrompt(workspacePath, channelId, memory, sandboxConfig) {
143
+ function buildSystemPrompt(workspacePath, channelId, memory, sandboxConfig, channels, users) {
78
144
  const channelPath = `${workspacePath}/${channelId}`;
79
145
  const isDocker = sandboxConfig.type === "docker";
146
+ // Format channel mappings
147
+ const channelMappings = channels.length > 0 ? channels.map((c) => `${c.id}\t#${c.name}`).join("\n") : "(no channels loaded)";
148
+ // Format user mappings
149
+ const userMappings = users.length > 0 ? users.map((u) => `${u.id}\t@${u.userName}\t${u.displayName}`).join("\n") : "(no users loaded)";
80
150
  const envDescription = isDocker
81
151
  ? `You are running inside a Docker container (Alpine Linux).
152
+ - Bash working directory: / (use cd or absolute paths)
82
153
  - Install tools with: apk add <package>
83
- - Your changes persist across sessions
84
- - You have full control over this container`
154
+ - Your changes persist across sessions`
85
155
  : `You are running directly on the host machine.
86
- - Be careful with system modifications
87
- - Use the system's package manager if needed`;
156
+ - Bash working directory: ${process.cwd()}
157
+ - Be careful with system modifications`;
88
158
  const currentDate = new Date().toISOString().split("T")[0]; // YYYY-MM-DD
89
159
  const currentDateTime = new Date().toISOString(); // Full ISO 8601
90
- return `You are mom, a helpful Slack bot assistant.
160
+ return `You are mom, a Slack bot assistant. Be concise. No emojis.
91
161
 
92
- ## Current Date and Time
93
- - Date: ${currentDate}
94
- - Full timestamp: ${currentDateTime}
95
- - Use this when working with dates or searching logs
162
+ ## Context
163
+ - Date: ${currentDate} (${currentDateTime})
164
+ - You receive the last 50 conversation turns. If you need older context, search log.jsonl.
96
165
 
97
- ## Communication Style
98
- - Be concise and professional
99
- - Do not use emojis unless the user communicates informally with you
100
- - Get to the point quickly
101
- - If you need clarification, ask directly
102
- - Use Slack's mrkdwn format (NOT standard Markdown):
103
- - Bold: *text* (single asterisks)
104
- - Italic: _text_
105
- - Strikethrough: ~text~
106
- - Code: \`code\`
107
- - Code block: \`\`\`code\`\`\`
108
- - Links: <url|text>
109
- - Do NOT use **double asterisks** or [markdown](links)
166
+ ## Slack Formatting (mrkdwn, NOT Markdown)
167
+ Bold: *text*, Italic: _text_, Code: \`code\`, Block: \`\`\`code\`\`\`, Links: <url|text>
168
+ Do NOT use **double asterisks** or [markdown](links).
110
169
 
111
- ## Your Environment
112
- ${envDescription}
170
+ ## Slack IDs
171
+ Channels: ${channelMappings}
113
172
 
114
- ## Your Workspace
115
- Your working directory is: ${channelPath}
116
-
117
- ### Directory Structure
118
- - ${workspacePath}/ - Root workspace (shared across all channels)
119
- - MEMORY.md - GLOBAL memory visible to all channels (write global info here)
120
- - ${channelId}/ - This channel's directory
121
- - MEMORY.md - CHANNEL-SPECIFIC memory (only visible in this channel)
122
- - scratch/ - Your working directory for files, repos, etc.
123
- - log.jsonl - Message history in JSONL format (one JSON object per line)
124
- - attachments/ - Files shared by users (managed by system, read-only)
125
-
126
- ### Message History Format
127
- Each line in log.jsonl contains:
128
- {
129
- "date": "2025-11-26T10:44:00.123Z", // ISO 8601 - easy to grep by date!
130
- "ts": "1732619040.123456", // Slack timestamp or epoch ms
131
- "user": "U123ABC", // User ID or "bot"
132
- "userName": "mario", // User handle (optional)
133
- "text": "message text",
134
- "isBot": false
135
- }
173
+ Users: ${userMappings}
136
174
 
137
- **⚠️ CRITICAL: Efficient Log Queries (Avoid Context Overflow)**
175
+ When mentioning users, use <@username> format (e.g., <@mario>).
138
176
 
139
- Log files can be VERY LARGE (100K+ lines). The problem is getting too MANY messages, not message length.
140
- Each message can be up to 10k chars - that's fine. Use head/tail to LIMIT NUMBER OF MESSAGES (10-50 at a time).
177
+ ## Environment
178
+ ${envDescription}
141
179
 
142
- **Install jq first (if not already):**
143
- \`\`\`bash
144
- ${isDocker ? "apk add jq" : "# jq should be available, or install via package manager"}
145
- \`\`\`
180
+ ## Workspace Layout
181
+ ${workspacePath}/
182
+ ├── MEMORY.md # Global memory (all channels)
183
+ ├── skills/ # Global CLI tools you create
184
+ └── ${channelId}/ # This channel
185
+ ├── MEMORY.md # Channel-specific memory
186
+ ├── log.jsonl # Full message history
187
+ ├── attachments/ # User-shared files
188
+ ├── scratch/ # Your working directory
189
+ └── skills/ # Channel-specific tools
146
190
 
147
- **Essential query patterns:**
148
- \`\`\`bash
149
- # Last N messages (compact JSON output)
150
- tail -20 log.jsonl | jq -c '{date: .date[0:19], user: (.userName // .user), text, attachments: [(.attachments // [])[].local]}'
191
+ ## Skills (Custom CLI Tools)
192
+ You can create reusable CLI tools for recurring tasks (email, APIs, data processing, etc.).
193
+ Store in \`${workspacePath}/skills/<name>/\` or \`${channelPath}/skills/<name>/\`.
194
+ Each skill needs a \`SKILL.md\` documenting usage. Read it before using a skill.
195
+ List skills in global memory so you remember them.
151
196
 
152
- # Or TSV format (easier to read)
153
- tail -20 log.jsonl | jq -r '[.date[0:19], (.userName // .user), .text, ((.attachments // []) | map(.local) | join(","))] | @tsv'
197
+ ## Memory
198
+ Write to MEMORY.md files to persist context across conversations.
199
+ - Global (${workspacePath}/MEMORY.md): skills, preferences, project info
200
+ - Channel (${channelPath}/MEMORY.md): channel-specific decisions, ongoing work
201
+ Update when you learn something important or when asked to remember something.
154
202
 
155
- # Search by date (LIMIT with head/tail!)
156
- grep '"date":"2025-11-26' log.jsonl | tail -30 | jq -c '{date: .date[0:19], user: (.userName // .user), text, attachments: [(.attachments // [])[].local]}'
203
+ ### Current Memory
204
+ ${memory}
157
205
 
158
- # Messages from specific user (count first, then limit)
159
- grep '"userName":"mario"' log.jsonl | wc -l # Check count first
160
- grep '"userName":"mario"' log.jsonl | tail -20 | jq -c '{date: .date[0:19], user: .userName, text, attachments: [(.attachments // [])[].local]}'
206
+ ## Log Queries (CRITICAL: limit output to avoid context overflow)
207
+ Format: \`{"date":"...","ts":"...","user":"...","userName":"...","text":"...","isBot":false}\`
208
+ The log contains user messages AND your tool calls/results. Filter appropriately.
209
+ ${isDocker ? "Install jq: apk add jq" : ""}
161
210
 
162
- # Only count (when you just need the number)
163
- grep '"isBot":false' log.jsonl | wc -l
211
+ **Conversation only (excludes tool calls/results) - use for summaries:**
212
+ \`\`\`bash
213
+ # Recent conversation (no [Tool] or [Tool Result] lines)
214
+ grep -v '"text":"\\[Tool' log.jsonl | tail -30 | jq -c '{date: .date[0:19], user: (.userName // .user), text}'
164
215
 
165
- # Messages with attachments only (limit!)
166
- grep '"attachments":[{' log.jsonl | tail -10 | jq -r '[.date[0:16], (.userName // .user), .text, (.attachments | map(.local) | join(","))] | @tsv'
167
- \`\`\`
216
+ # Yesterday's conversation
217
+ grep '"date":"2025-11-26' log.jsonl | grep -v '"text":"\\[Tool' | jq -c '{date: .date[0:19], user: (.userName // .user), text}'
168
218
 
169
- **KEY RULE:** Always pipe through 'head -N' or 'tail -N' to limit results BEFORE parsing with jq!
219
+ # Specific user's messages
220
+ grep '"userName":"mario"' log.jsonl | grep -v '"text":"\\[Tool' | tail -20 | jq -c '{date: .date[0:19], text}'
170
221
  \`\`\`
171
222
 
172
- **Date filtering:**
173
- - Today: grep '"date":"${currentDate}' log.jsonl
174
- - Yesterday: grep '"date":"2025-11-25' log.jsonl
175
- - Date range: grep '"date":"2025-11-(26|27|28)' log.jsonl
176
- - Time range: grep -E '"date":"2025-11-26T(09|10|11):' log.jsonl
177
-
178
- ### Working Memory System
179
- You can maintain working memory across conversations by writing MEMORY.md files.
180
-
181
- **IMPORTANT PATH RULES:**
182
- - Global memory (all channels): ${workspacePath}/MEMORY.md
183
- - Channel memory (this channel only): ${channelPath}/MEMORY.md
184
-
185
- **What to remember:**
186
- - Project details and architecture → Global memory
187
- - User preferences and coding style → Global memory
188
- - Channel-specific context → Channel memory
189
- - Recurring tasks and patterns → Appropriate memory file
190
- - Credentials locations (never actual secrets) → Global memory
191
- - Decisions made and their rationale → Appropriate memory file
192
-
193
- **When to update:**
194
- - After learning something important that will help in future conversations
195
- - When user asks you to remember something
196
- - When you discover project structure or conventions
223
+ **Full details (includes tool calls) - use when you need technical context:**
224
+ \`\`\`bash
225
+ # Raw recent entries
226
+ tail -20 log.jsonl | jq -c '{date: .date[0:19], user: (.userName // .user), text}'
197
227
 
198
- ### Current Working Memory
199
- ${memory}
228
+ # Count all messages
229
+ wc -l log.jsonl
230
+ \`\`\`
200
231
 
201
232
  ## Tools
202
- You have access to: bash, read, edit, write, attach tools.
203
- - bash: Run shell commands (this is your main tool)
233
+ - bash: Run shell commands (primary tool). Install packages as needed.
204
234
  - read: Read files
205
- - edit: Edit files surgically
206
- - write: Create/overwrite files
207
- - attach: Share a file with the user in Slack
208
-
209
- Each tool requires a "label" parameter - brief description shown to the user.
210
-
211
- ## Guidelines
212
- - Be concise and helpful
213
- - Use bash for most operations
214
- - If you need a tool, install it
215
- - If you need credentials, ask the user
235
+ - write: Create/overwrite files
236
+ - edit: Surgical file edits
237
+ - attach: Share files to Slack
216
238
 
217
- ## CRITICAL
218
- - DO NOT USE EMOJIS. KEEP YOUR RESPONSES AS SHORT AS POSSIBLE.
239
+ Each tool requires a "label" parameter (shown to user).
219
240
  `;
220
241
  }
221
242
  function truncate(text, maxLen) {
@@ -289,7 +310,10 @@ export function createAgentRunner(sandboxConfig) {
289
310
  const workspacePath = executor.getWorkspacePath(channelDir.replace(`/${channelId}`, ""));
290
311
  const recentMessages = getRecentMessages(channelDir, 50);
291
312
  const memory = getMemory(channelDir);
292
- const systemPrompt = buildSystemPrompt(workspacePath, channelId, memory, sandboxConfig);
313
+ const systemPrompt = buildSystemPrompt(workspacePath, channelId, memory, sandboxConfig, ctx.channels, ctx.users);
314
+ // Debug: log context sizes
315
+ log.logInfo(`Context sizes - system: ${systemPrompt.length} chars, messages: ${recentMessages.length} chars, memory: ${memory.length} chars`);
316
+ log.logInfo(`Channels: ${ctx.channels.length}, Users: ${ctx.users.length}`);
293
317
  // Set up file upload function for the attach tool
294
318
  // For Docker, we need to translate paths back to host
295
319
  setUploadFunction(async (filePath, title) => {
@@ -334,6 +358,56 @@ export function createAgentRunner(sandboxConfig) {
334
358
  };
335
359
  // Track stop reason
336
360
  let stopReason = "stop";
361
+ // Slack message limit is 40,000 characters - split into multiple messages if needed
362
+ const SLACK_MAX_LENGTH = 40000;
363
+ const splitForSlack = (text) => {
364
+ if (text.length <= SLACK_MAX_LENGTH)
365
+ return [text];
366
+ const parts = [];
367
+ let remaining = text;
368
+ let partNum = 1;
369
+ while (remaining.length > 0) {
370
+ const chunk = remaining.substring(0, SLACK_MAX_LENGTH - 50);
371
+ remaining = remaining.substring(SLACK_MAX_LENGTH - 50);
372
+ const suffix = remaining.length > 0 ? `\n_(continued ${partNum}...)_` : "";
373
+ parts.push(chunk + suffix);
374
+ partNum++;
375
+ }
376
+ return parts;
377
+ };
378
+ // Promise queue to ensure ctx.respond/respondInThread calls execute in order
379
+ // Handles errors gracefully by posting to thread instead of crashing
380
+ const queue = {
381
+ chain: Promise.resolve(),
382
+ enqueue(fn, errorContext) {
383
+ this.chain = this.chain.then(async () => {
384
+ try {
385
+ await fn();
386
+ }
387
+ catch (err) {
388
+ const errMsg = err instanceof Error ? err.message : String(err);
389
+ log.logWarning(`Slack API error (${errorContext})`, errMsg);
390
+ // Try to post error to thread, but don't crash if that fails too
391
+ try {
392
+ await ctx.respondInThread(`_Error: ${errMsg}_`);
393
+ }
394
+ catch {
395
+ // Ignore - we tried our best
396
+ }
397
+ }
398
+ });
399
+ },
400
+ // Enqueue a message that may need splitting
401
+ enqueueMessage(text, target, errorContext, log = true) {
402
+ const parts = splitForSlack(text);
403
+ for (const part of parts) {
404
+ this.enqueue(() => (target === "main" ? ctx.respond(part, log) : ctx.respondInThread(part)), errorContext);
405
+ }
406
+ },
407
+ flush() {
408
+ return this.chain;
409
+ },
410
+ };
337
411
  // Subscribe to events
338
412
  agent.subscribe(async (event) => {
339
413
  switch (event.type) {
@@ -351,14 +425,14 @@ export function createAgentRunner(sandboxConfig) {
351
425
  // Log to jsonl
352
426
  await store.logMessage(ctx.message.channel, {
353
427
  date: new Date().toISOString(),
354
- ts: Date.now().toString(),
428
+ ts: toSlackTs(),
355
429
  user: "bot",
356
430
  text: `[Tool] ${event.toolName}: ${JSON.stringify(event.args)}`,
357
431
  attachments: [],
358
432
  isBot: true,
359
433
  });
360
434
  // Show label in main message only
361
- await ctx.respond(`_${label}_`);
435
+ queue.enqueue(() => ctx.respond(`_${label}_`, false), "tool label");
362
436
  break;
363
437
  }
364
438
  case "tool_execution_end": {
@@ -376,7 +450,7 @@ export function createAgentRunner(sandboxConfig) {
376
450
  // Log to jsonl
377
451
  await store.logMessage(ctx.message.channel, {
378
452
  date: new Date().toISOString(),
379
- ts: Date.now().toString(),
453
+ ts: toSlackTs(),
380
454
  user: "bot",
381
455
  text: `[Tool Result] ${event.toolName}: ${event.isError ? "ERROR: " : ""}${truncate(resultStr, 1000)}`,
382
456
  attachments: [],
@@ -388,7 +462,6 @@ export function createAgentRunner(sandboxConfig) {
388
462
  ? formatToolArgsForSlack(event.toolName, pending.args)
389
463
  : "(args not found)";
390
464
  const duration = (durationMs / 1000).toFixed(1);
391
- const threadResult = truncate(resultStr, 2000);
392
465
  let threadMessage = `*${event.isError ? "✗" : "✓"} ${event.toolName}*`;
393
466
  if (label) {
394
467
  threadMessage += `: ${label}`;
@@ -397,11 +470,11 @@ export function createAgentRunner(sandboxConfig) {
397
470
  if (argsFormatted) {
398
471
  threadMessage += "```\n" + argsFormatted + "\n```\n";
399
472
  }
400
- threadMessage += "*Result:*\n```\n" + threadResult + "\n```";
401
- await ctx.respondInThread(threadMessage);
473
+ threadMessage += "*Result:*\n```\n" + resultStr + "\n```";
474
+ queue.enqueueMessage(threadMessage, "thread", "tool result thread", false);
402
475
  // Show brief error in main message if failed
403
476
  if (event.isError) {
404
- await ctx.respond(`_Error: ${truncate(resultStr, 200)}_`);
477
+ queue.enqueue(() => ctx.respond(`_Error: ${truncate(resultStr, 200)}_`, false), "tool error");
405
478
  }
406
479
  break;
407
480
  }
@@ -433,17 +506,30 @@ export function createAgentRunner(sandboxConfig) {
433
506
  totalUsage.cost.cacheWrite += assistantMsg.usage.cost.cacheWrite;
434
507
  totalUsage.cost.total += assistantMsg.usage.cost.total;
435
508
  }
436
- // Extract text from assistant message
509
+ // Extract thinking and text from assistant message
437
510
  const content = event.message.content;
438
- let text = "";
511
+ const thinkingParts = [];
512
+ const textParts = [];
439
513
  for (const part of content) {
440
- if (part.type === "text") {
441
- text += part.text;
514
+ if (part.type === "thinking") {
515
+ thinkingParts.push(part.thinking);
516
+ }
517
+ else if (part.type === "text") {
518
+ textParts.push(part.text);
442
519
  }
443
520
  }
521
+ const text = textParts.join("\n");
522
+ // Post thinking to main message and thread
523
+ for (const thinking of thinkingParts) {
524
+ log.logThinking(logCtx, thinking);
525
+ queue.enqueueMessage(`_${thinking}_`, "main", "thinking main");
526
+ queue.enqueueMessage(`_${thinking}_`, "thread", "thinking thread", false);
527
+ }
528
+ // Post text to main message and thread
444
529
  if (text.trim()) {
445
- await ctx.respond(text);
446
- log.logResponseComplete(logCtx, text.length);
530
+ log.logResponse(logCtx, text);
531
+ queue.enqueueMessage(text, "main", "response main");
532
+ queue.enqueueMessage(text, "thread", "response thread", false);
447
533
  }
448
534
  }
449
535
  break;
@@ -451,16 +537,44 @@ export function createAgentRunner(sandboxConfig) {
451
537
  });
452
538
  // Run the agent with user's message
453
539
  // Prepend recent messages to the user prompt (not system prompt) for better caching
454
- const userPrompt = `Recent conversation history (last 50 messages):\n` +
540
+ // The current message is already the last entry in recentMessages
541
+ const userPrompt = `Conversation history (last 50 turns). Respond to the last message.\n` +
455
542
  `Format: date TAB user TAB text TAB attachments\n\n` +
456
- `${recentMessages}\n\n` +
457
- `---\n\n` +
458
- `Current message: ${ctx.message.text || "(attached files)"}`;
543
+ recentMessages;
544
+ // Debug: write full context to file
545
+ const toolDefs = tools.map((t) => ({ name: t.name, description: t.description, parameters: t.parameters }));
546
+ const debugPrompt = `=== SYSTEM PROMPT (${systemPrompt.length} chars) ===\n\n${systemPrompt}\n\n` +
547
+ `=== TOOL DEFINITIONS (${JSON.stringify(toolDefs).length} chars) ===\n\n${JSON.stringify(toolDefs, null, 2)}\n\n` +
548
+ `=== USER PROMPT (${userPrompt.length} chars) ===\n\n${userPrompt}`;
549
+ await writeFile(join(channelDir, "last_prompt.txt"), debugPrompt, "utf-8");
459
550
  await agent.prompt(userPrompt);
551
+ // Wait for all queued respond calls to complete
552
+ await queue.flush();
553
+ // Get final assistant message text from agent state and replace main message
554
+ const messages = agent.state.messages;
555
+ const lastAssistant = messages.filter((m) => m.role === "assistant").pop();
556
+ const finalText = lastAssistant?.content
557
+ .filter((c) => c.type === "text")
558
+ .map((c) => c.text)
559
+ .join("\n") || "";
560
+ if (finalText.trim()) {
561
+ try {
562
+ // For the main message, truncate if too long (full text is in thread)
563
+ const mainText = finalText.length > SLACK_MAX_LENGTH
564
+ ? finalText.substring(0, SLACK_MAX_LENGTH - 50) + "\n\n_(see thread for full response)_"
565
+ : finalText;
566
+ await ctx.replaceMessage(mainText);
567
+ }
568
+ catch (err) {
569
+ const errMsg = err instanceof Error ? err.message : String(err);
570
+ log.logWarning("Failed to replace message with final text", errMsg);
571
+ }
572
+ }
460
573
  // Log usage summary if there was any usage
461
574
  if (totalUsage.cost.total > 0) {
462
575
  const summary = log.logUsageSummary(logCtx, totalUsage);
463
- await ctx.respondInThread(summary);
576
+ queue.enqueue(() => ctx.respondInThread(summary), "usage summary");
577
+ await queue.flush();
464
578
  }
465
579
  return { stopReason };
466
580
  },