@mariozechner/pi-mom 0.10.1 → 0.11.0

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}
173
+ Users: ${userMappings}
116
174
 
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)
175
+ When mentioning users, use <@username> format (e.g., <@mario>).
125
176
 
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
- }
136
-
137
- **⚠️ CRITICAL: Efficient Log Queries (Avoid Context Overflow)**
138
-
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.
235
+ - write: Create/overwrite files
236
+ - edit: Surgical file edits
237
+ - attach: Share files to Slack
210
238
 
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
216
-
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,13 +358,51 @@ 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
+ };
337
378
  // Promise queue to ensure ctx.respond/respondInThread calls execute in order
379
+ // Handles errors gracefully by posting to thread instead of crashing
338
380
  const queue = {
339
381
  chain: Promise.resolve(),
340
- enqueue(fn) {
341
- const result = this.chain.then(fn);
342
- this.chain = result.then(() => { }, () => { }); // swallow errors for chain
343
- return result;
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
+ }
344
406
  },
345
407
  flush() {
346
408
  return this.chain;
@@ -363,14 +425,14 @@ export function createAgentRunner(sandboxConfig) {
363
425
  // Log to jsonl
364
426
  await store.logMessage(ctx.message.channel, {
365
427
  date: new Date().toISOString(),
366
- ts: Date.now().toString(),
428
+ ts: toSlackTs(),
367
429
  user: "bot",
368
430
  text: `[Tool] ${event.toolName}: ${JSON.stringify(event.args)}`,
369
431
  attachments: [],
370
432
  isBot: true,
371
433
  });
372
434
  // Show label in main message only
373
- queue.enqueue(() => ctx.respond(`_→ ${label}_`));
435
+ queue.enqueue(() => ctx.respond(`_→ ${label}_`, false), "tool label");
374
436
  break;
375
437
  }
376
438
  case "tool_execution_end": {
@@ -388,7 +450,7 @@ export function createAgentRunner(sandboxConfig) {
388
450
  // Log to jsonl
389
451
  await store.logMessage(ctx.message.channel, {
390
452
  date: new Date().toISOString(),
391
- ts: Date.now().toString(),
453
+ ts: toSlackTs(),
392
454
  user: "bot",
393
455
  text: `[Tool Result] ${event.toolName}: ${event.isError ? "ERROR: " : ""}${truncate(resultStr, 1000)}`,
394
456
  attachments: [],
@@ -400,7 +462,6 @@ export function createAgentRunner(sandboxConfig) {
400
462
  ? formatToolArgsForSlack(event.toolName, pending.args)
401
463
  : "(args not found)";
402
464
  const duration = (durationMs / 1000).toFixed(1);
403
- const threadResult = truncate(resultStr, 2000);
404
465
  let threadMessage = `*${event.isError ? "✗" : "✓"} ${event.toolName}*`;
405
466
  if (label) {
406
467
  threadMessage += `: ${label}`;
@@ -409,11 +470,11 @@ export function createAgentRunner(sandboxConfig) {
409
470
  if (argsFormatted) {
410
471
  threadMessage += "```\n" + argsFormatted + "\n```\n";
411
472
  }
412
- threadMessage += "*Result:*\n```\n" + threadResult + "\n```";
413
- queue.enqueue(() => ctx.respondInThread(threadMessage));
473
+ threadMessage += "*Result:*\n```\n" + resultStr + "\n```";
474
+ queue.enqueueMessage(threadMessage, "thread", "tool result thread", false);
414
475
  // Show brief error in main message if failed
415
476
  if (event.isError) {
416
- queue.enqueue(() => ctx.respond(`_Error: ${truncate(resultStr, 200)}_`));
477
+ queue.enqueue(() => ctx.respond(`_Error: ${truncate(resultStr, 200)}_`, false), "tool error");
417
478
  }
418
479
  break;
419
480
  }
@@ -461,14 +522,14 @@ export function createAgentRunner(sandboxConfig) {
461
522
  // Post thinking to main message and thread
462
523
  for (const thinking of thinkingParts) {
463
524
  log.logThinking(logCtx, thinking);
464
- queue.enqueue(() => ctx.respond(`_${thinking}_`));
465
- queue.enqueue(() => ctx.respondInThread(`_${thinking}_`));
525
+ queue.enqueueMessage(`_${thinking}_`, "main", "thinking main");
526
+ queue.enqueueMessage(`_${thinking}_`, "thread", "thinking thread", false);
466
527
  }
467
528
  // Post text to main message and thread
468
529
  if (text.trim()) {
469
530
  log.logResponse(logCtx, text);
470
- queue.enqueue(() => ctx.respond(text));
471
- queue.enqueue(() => ctx.respondInThread(text));
531
+ queue.enqueueMessage(text, "main", "response main");
532
+ queue.enqueueMessage(text, "thread", "response thread", false);
472
533
  }
473
534
  }
474
535
  break;
@@ -476,11 +537,16 @@ export function createAgentRunner(sandboxConfig) {
476
537
  });
477
538
  // Run the agent with user's message
478
539
  // Prepend recent messages to the user prompt (not system prompt) for better caching
479
- 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` +
480
542
  `Format: date TAB user TAB text TAB attachments\n\n` +
481
- `${recentMessages}\n\n` +
482
- `---\n\n` +
483
- `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");
484
550
  await agent.prompt(userPrompt);
485
551
  // Wait for all queued respond calls to complete
486
552
  await queue.flush();
@@ -492,12 +558,22 @@ export function createAgentRunner(sandboxConfig) {
492
558
  .map((c) => c.text)
493
559
  .join("\n") || "";
494
560
  if (finalText.trim()) {
495
- await ctx.replaceMessage(finalText);
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
+ }
496
572
  }
497
573
  // Log usage summary if there was any usage
498
574
  if (totalUsage.cost.total > 0) {
499
575
  const summary = log.logUsageSummary(logCtx, totalUsage);
500
- queue.enqueue(() => ctx.respondInThread(summary));
576
+ queue.enqueue(() => ctx.respondInThread(summary), "usage summary");
501
577
  await queue.flush();
502
578
  }
503
579
  return { stopReason };