@mariozechner/pi-mom 0.18.1 → 0.18.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/agent.js CHANGED
@@ -1,12 +1,14 @@
1
1
  import { Agent, ProviderTransport } from "@mariozechner/pi-agent-core";
2
2
  import { getModel } from "@mariozechner/pi-ai";
3
+ import { AgentSession, messageTransformer } from "@mariozechner/pi-coding-agent";
3
4
  import { existsSync, readFileSync } from "fs";
4
5
  import { mkdir, writeFile } from "fs/promises";
5
6
  import { join } from "path";
7
+ import { MomSessionManager, MomSettingsManager } from "./context.js";
6
8
  import * as log from "./log.js";
7
9
  import { createExecutor } from "./sandbox.js";
8
10
  import { createMomTools, setUploadFunction } from "./tools/index.js";
9
- // Hardcoded model for now
11
+ // Hardcoded model for now - TODO: make configurable (issue #63)
10
12
  const model = getModel("anthropic", "claude-sonnet-4-5");
11
13
  /**
12
14
  * Convert Date.now() to Slack timestamp format (seconds.microseconds)
@@ -17,16 +19,14 @@ let tsCounter = 0;
17
19
  function toSlackTs() {
18
20
  const now = Date.now();
19
21
  if (now === lastTsMs) {
20
- // Same millisecond - increment counter for sub-ms ordering
21
22
  tsCounter++;
22
23
  }
23
24
  else {
24
- // New millisecond - reset counter
25
25
  lastTsMs = now;
26
26
  tsCounter = 0;
27
27
  }
28
28
  const seconds = Math.floor(now / 1000);
29
- const micros = (now % 1000) * 1000 + tsCounter; // ms to micros + counter
29
+ const micros = (now % 1000) * 1000 + tsCounter;
30
30
  return `${seconds}.${micros.toString().padStart(6, "0")}`;
31
31
  }
32
32
  function getAnthropicApiKey() {
@@ -36,77 +36,6 @@ function getAnthropicApiKey() {
36
36
  }
37
37
  return key;
38
38
  }
39
- function getRecentMessages(channelDir, turnCount) {
40
- const logPath = join(channelDir, "log.jsonl");
41
- if (!existsSync(logPath)) {
42
- return "(no message history yet)";
43
- }
44
- const content = readFileSync(logPath, "utf-8");
45
- const lines = content.trim().split("\n").filter(Boolean);
46
- if (lines.length === 0) {
47
- return "(no message history yet)";
48
- }
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) {
53
- try {
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) {
101
- const date = (msg.date || "").substring(0, 19);
102
- const user = msg.userName || msg.user || "";
103
- const text = msg.text || "";
104
- const attachments = (msg.attachments || []).map((a) => a.local).join(",");
105
- formatted.push(`${date}\t${user}\t${text}\t${attachments}`);
106
- }
107
- }
108
- return formatted.join("\n");
109
- }
110
39
  function getMemory(channelDir) {
111
40
  const parts = [];
112
41
  // Read workspace-level memory (shared across all channels)
@@ -155,13 +84,12 @@ function buildSystemPrompt(workspacePath, channelId, memory, sandboxConfig, chan
155
84
  : `You are running directly on the host machine.
156
85
  - Bash working directory: ${process.cwd()}
157
86
  - Be careful with system modifications`;
158
- const currentDate = new Date().toISOString().split("T")[0]; // YYYY-MM-DD
159
- const currentDateTime = new Date().toISOString(); // Full ISO 8601
160
87
  return `You are mom, a Slack bot assistant. Be concise. No emojis.
161
88
 
162
89
  ## Context
163
- - Date: ${currentDate} (${currentDateTime})
164
- - You receive the last 50 conversation turns. If you need older context, search log.jsonl.
90
+ - For current date/time, use: date
91
+ - You have access to previous conversation context including tool results from prior turns.
92
+ - For older history beyond your context, search log.jsonl (contains user messages and your final responses, but not tool results).
165
93
 
166
94
  ## Slack Formatting (mrkdwn, NOT Markdown)
167
95
  Bold: *text*, Italic: _text_, Code: \`code\`, Block: \`\`\`code\`\`\`, Links: <url|text>
@@ -183,7 +111,7 @@ ${workspacePath}/
183
111
  ├── skills/ # Global CLI tools you create
184
112
  └── ${channelId}/ # This channel
185
113
  ├── MEMORY.md # Channel-specific memory
186
- ├── log.jsonl # Full message history
114
+ ├── log.jsonl # Message history (no tool results)
187
115
  ├── attachments/ # User-shared files
188
116
  ├── scratch/ # Your working directory
189
117
  └── skills/ # Channel-specific tools
@@ -212,36 +140,26 @@ Maintain ${workspacePath}/SYSTEM.md to log all environment modifications:
212
140
 
213
141
  Update this file whenever you modify the environment. On fresh container, read it first to restore your setup.
214
142
 
215
- ## Log Queries (CRITICAL: limit output to avoid context overflow)
143
+ ## Log Queries (for older history)
216
144
  Format: \`{"date":"...","ts":"...","user":"...","userName":"...","text":"...","isBot":false}\`
217
- The log contains user messages AND your tool calls/results. Filter appropriately.
145
+ The log contains user messages and your final responses (not tool calls/results).
218
146
  ${isDocker ? "Install jq: apk add jq" : ""}
219
147
 
220
- **Conversation only (excludes tool calls/results) - use for summaries:**
221
148
  \`\`\`bash
222
- # Recent conversation (no [Tool] or [Tool Result] lines)
223
- grep -v '"text":"\\[Tool' log.jsonl | tail -30 | jq -c '{date: .date[0:19], user: (.userName // .user), text}'
149
+ # Recent messages
150
+ tail -30 log.jsonl | jq -c '{date: .date[0:19], user: (.userName // .user), text}'
224
151
 
225
- # Yesterday's conversation
226
- grep '"date":"2025-11-26' log.jsonl | grep -v '"text":"\\[Tool' | jq -c '{date: .date[0:19], user: (.userName // .user), text}'
152
+ # Search for specific topic
153
+ grep -i "topic" log.jsonl | jq -c '{date: .date[0:19], user: (.userName // .user), text}'
227
154
 
228
- # Specific user's messages
229
- grep '"userName":"mario"' log.jsonl | grep -v '"text":"\\[Tool' | tail -20 | jq -c '{date: .date[0:19], text}'
230
- \`\`\`
231
-
232
- **Full details (includes tool calls) - use when you need technical context:**
233
- \`\`\`bash
234
- # Raw recent entries
235
- tail -20 log.jsonl | jq -c '{date: .date[0:19], user: (.userName // .user), text}'
236
-
237
- # Count all messages
238
- wc -l log.jsonl
155
+ # Messages from specific user
156
+ grep '"userName":"mario"' log.jsonl | tail -20 | jq -c '{date: .date[0:19], text}'
239
157
  \`\`\`
240
158
 
241
159
  ## Tools
242
160
  - bash: Run shell commands (primary tool). Install packages as needed.
243
161
  - read: Read files
244
- - write: Create/overwrite files
162
+ - write: Create/overwrite files
245
163
  - edit: Surgical file edits
246
164
  - attach: Share files to Slack
247
165
 
@@ -254,11 +172,9 @@ function truncate(text, maxLen) {
254
172
  return text.substring(0, maxLen - 3) + "...";
255
173
  }
256
174
  function extractToolResultText(result) {
257
- // If it's already a string, return it
258
175
  if (typeof result === "string") {
259
176
  return result;
260
177
  }
261
- // If it's an object with content array (tool result format)
262
178
  if (result &&
263
179
  typeof result === "object" &&
264
180
  "content" in result &&
@@ -274,16 +190,13 @@ function extractToolResultText(result) {
274
190
  return textParts.join("\n");
275
191
  }
276
192
  }
277
- // Fallback to JSON
278
193
  return JSON.stringify(result);
279
194
  }
280
195
  function formatToolArgsForSlack(_toolName, args) {
281
196
  const lines = [];
282
197
  for (const [key, value] of Object.entries(args)) {
283
- // Skip the label - it's already shown
284
198
  if (key === "label")
285
199
  continue;
286
- // For read tool, format path with offset/limit
287
200
  if (key === "path" && typeof value === "string") {
288
201
  const offset = args.offset;
289
202
  const limit = args.limit;
@@ -295,10 +208,8 @@ function formatToolArgsForSlack(_toolName, args) {
295
208
  }
296
209
  continue;
297
210
  }
298
- // Skip offset/limit since we already handled them
299
211
  if (key === "offset" || key === "limit")
300
212
  continue;
301
- // For other values, format them
302
213
  if (typeof value === "string") {
303
214
  lines.push(value);
304
215
  }
@@ -308,259 +219,298 @@ function formatToolArgsForSlack(_toolName, args) {
308
219
  }
309
220
  return lines.join("\n");
310
221
  }
311
- export function createAgentRunner(sandboxConfig) {
312
- let agent = null;
222
+ // Cache runners per channel
223
+ const channelRunners = new Map();
224
+ /**
225
+ * Get or create an AgentRunner for a channel.
226
+ * Runners are cached - one per channel, persistent across messages.
227
+ */
228
+ export function getOrCreateRunner(sandboxConfig, channelId, channelDir) {
229
+ const existing = channelRunners.get(channelId);
230
+ if (existing)
231
+ return existing;
232
+ const runner = createRunner(sandboxConfig, channelId, channelDir);
233
+ channelRunners.set(channelId, runner);
234
+ return runner;
235
+ }
236
+ /**
237
+ * Create a new AgentRunner for a channel.
238
+ * Sets up the session and subscribes to events once.
239
+ */
240
+ function createRunner(sandboxConfig, channelId, channelDir) {
313
241
  const executor = createExecutor(sandboxConfig);
242
+ const workspacePath = executor.getWorkspacePath(channelDir.replace(`/${channelId}`, ""));
243
+ // Create tools
244
+ const tools = createMomTools(executor);
245
+ // Initial system prompt (will be updated each run with fresh memory/channels/users)
246
+ const memory = getMemory(channelDir);
247
+ const systemPrompt = buildSystemPrompt(workspacePath, channelId, memory, sandboxConfig, [], []);
248
+ // Create session manager and settings manager
249
+ // Pass model info so new sessions get a header written immediately
250
+ const sessionManager = new MomSessionManager(channelDir, {
251
+ provider: model.provider,
252
+ id: model.id,
253
+ thinkingLevel: "off",
254
+ });
255
+ const settingsManager = new MomSettingsManager(join(channelDir, ".."));
256
+ // Create agent
257
+ const agent = new Agent({
258
+ initialState: {
259
+ systemPrompt,
260
+ model,
261
+ thinkingLevel: "off",
262
+ tools,
263
+ },
264
+ messageTransformer,
265
+ transport: new ProviderTransport({
266
+ getApiKey: async () => getAnthropicApiKey(),
267
+ }),
268
+ });
269
+ // Load existing messages
270
+ const loadedSession = sessionManager.loadSession();
271
+ if (loadedSession.messages.length > 0) {
272
+ agent.replaceMessages(loadedSession.messages);
273
+ log.logInfo(`[${channelId}] Loaded ${loadedSession.messages.length} messages from context.jsonl`);
274
+ }
275
+ // Create AgentSession wrapper
276
+ const session = new AgentSession({
277
+ agent,
278
+ sessionManager: sessionManager,
279
+ settingsManager: settingsManager,
280
+ });
281
+ // Mutable per-run state - event handler references this
282
+ const runState = {
283
+ ctx: null,
284
+ logCtx: null,
285
+ queue: null,
286
+ pendingTools: new Map(),
287
+ totalUsage: {
288
+ input: 0,
289
+ output: 0,
290
+ cacheRead: 0,
291
+ cacheWrite: 0,
292
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
293
+ },
294
+ stopReason: "stop",
295
+ };
296
+ // Subscribe to events ONCE
297
+ session.subscribe(async (event) => {
298
+ // Skip if no active run
299
+ if (!runState.ctx || !runState.logCtx || !runState.queue)
300
+ return;
301
+ const { ctx, logCtx, queue, pendingTools } = runState;
302
+ if (event.type === "tool_execution_start") {
303
+ const agentEvent = event;
304
+ const args = agentEvent.args;
305
+ const label = args.label || agentEvent.toolName;
306
+ pendingTools.set(agentEvent.toolCallId, {
307
+ toolName: agentEvent.toolName,
308
+ args: agentEvent.args,
309
+ startTime: Date.now(),
310
+ });
311
+ log.logToolStart(logCtx, agentEvent.toolName, label, agentEvent.args);
312
+ queue.enqueue(() => ctx.respond(`_→ ${label}_`, false), "tool label");
313
+ }
314
+ else if (event.type === "tool_execution_end") {
315
+ const agentEvent = event;
316
+ const resultStr = extractToolResultText(agentEvent.result);
317
+ const pending = pendingTools.get(agentEvent.toolCallId);
318
+ pendingTools.delete(agentEvent.toolCallId);
319
+ const durationMs = pending ? Date.now() - pending.startTime : 0;
320
+ if (agentEvent.isError) {
321
+ log.logToolError(logCtx, agentEvent.toolName, durationMs, resultStr);
322
+ }
323
+ else {
324
+ log.logToolSuccess(logCtx, agentEvent.toolName, durationMs, resultStr);
325
+ }
326
+ // Post args + result to thread
327
+ const label = pending?.args ? pending.args.label : undefined;
328
+ const argsFormatted = pending
329
+ ? formatToolArgsForSlack(agentEvent.toolName, pending.args)
330
+ : "(args not found)";
331
+ const duration = (durationMs / 1000).toFixed(1);
332
+ let threadMessage = `*${agentEvent.isError ? "✗" : "✓"} ${agentEvent.toolName}*`;
333
+ if (label)
334
+ threadMessage += `: ${label}`;
335
+ threadMessage += ` (${duration}s)\n`;
336
+ if (argsFormatted)
337
+ threadMessage += "```\n" + argsFormatted + "\n```\n";
338
+ threadMessage += "*Result:*\n```\n" + resultStr + "\n```";
339
+ queue.enqueueMessage(threadMessage, "thread", "tool result thread", false);
340
+ if (agentEvent.isError) {
341
+ queue.enqueue(() => ctx.respond(`_Error: ${truncate(resultStr, 200)}_`, false), "tool error");
342
+ }
343
+ }
344
+ else if (event.type === "message_start") {
345
+ const agentEvent = event;
346
+ if (agentEvent.message.role === "assistant") {
347
+ log.logResponseStart(logCtx);
348
+ }
349
+ }
350
+ else if (event.type === "message_end") {
351
+ const agentEvent = event;
352
+ if (agentEvent.message.role === "assistant") {
353
+ const assistantMsg = agentEvent.message;
354
+ if (assistantMsg.stopReason) {
355
+ runState.stopReason = assistantMsg.stopReason;
356
+ }
357
+ if (assistantMsg.usage) {
358
+ runState.totalUsage.input += assistantMsg.usage.input;
359
+ runState.totalUsage.output += assistantMsg.usage.output;
360
+ runState.totalUsage.cacheRead += assistantMsg.usage.cacheRead;
361
+ runState.totalUsage.cacheWrite += assistantMsg.usage.cacheWrite;
362
+ runState.totalUsage.cost.input += assistantMsg.usage.cost.input;
363
+ runState.totalUsage.cost.output += assistantMsg.usage.cost.output;
364
+ runState.totalUsage.cost.cacheRead += assistantMsg.usage.cost.cacheRead;
365
+ runState.totalUsage.cost.cacheWrite += assistantMsg.usage.cost.cacheWrite;
366
+ runState.totalUsage.cost.total += assistantMsg.usage.cost.total;
367
+ }
368
+ const content = agentEvent.message.content;
369
+ const thinkingParts = [];
370
+ const textParts = [];
371
+ for (const part of content) {
372
+ if (part.type === "thinking") {
373
+ thinkingParts.push(part.thinking);
374
+ }
375
+ else if (part.type === "text") {
376
+ textParts.push(part.text);
377
+ }
378
+ }
379
+ const text = textParts.join("\n");
380
+ for (const thinking of thinkingParts) {
381
+ log.logThinking(logCtx, thinking);
382
+ queue.enqueueMessage(`_${thinking}_`, "main", "thinking main");
383
+ queue.enqueueMessage(`_${thinking}_`, "thread", "thinking thread", false);
384
+ }
385
+ if (text.trim()) {
386
+ log.logResponse(logCtx, text);
387
+ queue.enqueueMessage(text, "main", "response main");
388
+ queue.enqueueMessage(text, "thread", "response thread", false);
389
+ }
390
+ }
391
+ }
392
+ else if (event.type === "auto_compaction_start") {
393
+ log.logInfo(`Auto-compaction started (reason: ${event.reason})`);
394
+ queue.enqueue(() => ctx.respond("_Compacting context..._", false), "compaction start");
395
+ }
396
+ else if (event.type === "auto_compaction_end") {
397
+ const compEvent = event;
398
+ if (compEvent.result) {
399
+ log.logInfo(`Auto-compaction complete: ${compEvent.result.tokensBefore} tokens compacted`);
400
+ }
401
+ else if (compEvent.aborted) {
402
+ log.logInfo("Auto-compaction aborted");
403
+ }
404
+ }
405
+ else if (event.type === "auto_retry_start") {
406
+ const retryEvent = event;
407
+ log.logWarning(`Retrying (${retryEvent.attempt}/${retryEvent.maxAttempts})`, retryEvent.errorMessage);
408
+ queue.enqueue(() => ctx.respond(`_Retrying (${retryEvent.attempt}/${retryEvent.maxAttempts})..._`, false), "retry");
409
+ }
410
+ });
411
+ // Slack message limit
412
+ const SLACK_MAX_LENGTH = 40000;
413
+ const splitForSlack = (text) => {
414
+ if (text.length <= SLACK_MAX_LENGTH)
415
+ return [text];
416
+ const parts = [];
417
+ let remaining = text;
418
+ let partNum = 1;
419
+ while (remaining.length > 0) {
420
+ const chunk = remaining.substring(0, SLACK_MAX_LENGTH - 50);
421
+ remaining = remaining.substring(SLACK_MAX_LENGTH - 50);
422
+ const suffix = remaining.length > 0 ? `\n_(continued ${partNum}...)_` : "";
423
+ parts.push(chunk + suffix);
424
+ partNum++;
425
+ }
426
+ return parts;
427
+ };
314
428
  return {
315
- async run(ctx, channelDir, store) {
429
+ async run(ctx, _store, _pendingMessages) {
316
430
  // Ensure channel directory exists
317
431
  await mkdir(channelDir, { recursive: true });
318
- const channelId = ctx.message.channel;
319
- const workspacePath = executor.getWorkspacePath(channelDir.replace(`/${channelId}`, ""));
320
- const recentMessages = getRecentMessages(channelDir, 50);
432
+ // Reload messages from context.jsonl
433
+ // This picks up any messages synced from log.jsonl before this run
434
+ const reloadedSession = sessionManager.loadSession();
435
+ if (reloadedSession.messages.length > 0) {
436
+ agent.replaceMessages(reloadedSession.messages);
437
+ log.logInfo(`[${channelId}] Reloaded ${reloadedSession.messages.length} messages from context`);
438
+ }
439
+ // Update system prompt with fresh memory and channel/user info
321
440
  const memory = getMemory(channelDir);
322
441
  const systemPrompt = buildSystemPrompt(workspacePath, channelId, memory, sandboxConfig, ctx.channels, ctx.users);
323
- // Debug: log context sizes
324
- log.logInfo(`Context sizes - system: ${systemPrompt.length} chars, messages: ${recentMessages.length} chars, memory: ${memory.length} chars`);
325
- log.logInfo(`Channels: ${ctx.channels.length}, Users: ${ctx.users.length}`);
326
- // Set up file upload function for the attach tool
327
- // For Docker, we need to translate paths back to host
442
+ session.agent.setSystemPrompt(systemPrompt);
443
+ // Set up file upload function
328
444
  setUploadFunction(async (filePath, title) => {
329
445
  const hostPath = translateToHostPath(filePath, channelDir, workspacePath, channelId);
330
446
  await ctx.uploadFile(hostPath, title);
331
447
  });
332
- // Create tools with executor
333
- const tools = createMomTools(executor);
334
- // Create ephemeral agent
335
- agent = new Agent({
336
- initialState: {
337
- systemPrompt,
338
- model,
339
- thinkingLevel: "off",
340
- tools,
341
- },
342
- transport: new ProviderTransport({
343
- getApiKey: async () => getAnthropicApiKey(),
344
- }),
345
- });
346
- // Create logging context
347
- const logCtx = {
448
+ // Reset per-run state
449
+ runState.ctx = ctx;
450
+ runState.logCtx = {
348
451
  channelId: ctx.message.channel,
349
452
  userName: ctx.message.userName,
350
453
  channelName: ctx.channelName,
351
454
  };
352
- // Track pending tool calls to pair args with results and timing
353
- const pendingTools = new Map();
354
- // Track usage across all assistant messages in this run
355
- const totalUsage = {
455
+ runState.pendingTools.clear();
456
+ runState.totalUsage = {
356
457
  input: 0,
357
458
  output: 0,
358
459
  cacheRead: 0,
359
460
  cacheWrite: 0,
360
- cost: {
361
- input: 0,
362
- output: 0,
363
- cacheRead: 0,
364
- cacheWrite: 0,
365
- total: 0,
366
- },
367
- };
368
- // Track stop reason
369
- let stopReason = "stop";
370
- // Slack message limit is 40,000 characters - split into multiple messages if needed
371
- const SLACK_MAX_LENGTH = 40000;
372
- const splitForSlack = (text) => {
373
- if (text.length <= SLACK_MAX_LENGTH)
374
- return [text];
375
- const parts = [];
376
- let remaining = text;
377
- let partNum = 1;
378
- while (remaining.length > 0) {
379
- const chunk = remaining.substring(0, SLACK_MAX_LENGTH - 50);
380
- remaining = remaining.substring(SLACK_MAX_LENGTH - 50);
381
- const suffix = remaining.length > 0 ? `\n_(continued ${partNum}...)_` : "";
382
- parts.push(chunk + suffix);
383
- partNum++;
384
- }
385
- return parts;
461
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
386
462
  };
387
- // Promise queue to ensure ctx.respond/respondInThread calls execute in order
388
- // Handles errors gracefully by posting to thread instead of crashing
389
- const queue = {
390
- chain: Promise.resolve(),
463
+ runState.stopReason = "stop";
464
+ // Create queue for this run
465
+ let queueChain = Promise.resolve();
466
+ runState.queue = {
391
467
  enqueue(fn, errorContext) {
392
- this.chain = this.chain.then(async () => {
468
+ queueChain = queueChain.then(async () => {
393
469
  try {
394
470
  await fn();
395
471
  }
396
472
  catch (err) {
397
473
  const errMsg = err instanceof Error ? err.message : String(err);
398
474
  log.logWarning(`Slack API error (${errorContext})`, errMsg);
399
- // Try to post error to thread, but don't crash if that fails too
400
475
  try {
401
476
  await ctx.respondInThread(`_Error: ${errMsg}_`);
402
477
  }
403
478
  catch {
404
- // Ignore - we tried our best
479
+ // Ignore
405
480
  }
406
481
  }
407
482
  });
408
483
  },
409
- // Enqueue a message that may need splitting
410
- enqueueMessage(text, target, errorContext, log = true) {
484
+ enqueueMessage(text, target, errorContext, doLog = true) {
411
485
  const parts = splitForSlack(text);
412
486
  for (const part of parts) {
413
- this.enqueue(() => (target === "main" ? ctx.respond(part, log) : ctx.respondInThread(part)), errorContext);
487
+ this.enqueue(() => (target === "main" ? ctx.respond(part, doLog) : ctx.respondInThread(part)), errorContext);
414
488
  }
415
489
  },
416
- flush() {
417
- return this.chain;
418
- },
419
490
  };
420
- // Subscribe to events
421
- agent.subscribe(async (event) => {
422
- switch (event.type) {
423
- case "tool_execution_start": {
424
- const args = event.args;
425
- const label = args.label || event.toolName;
426
- // Store args to pair with result later
427
- pendingTools.set(event.toolCallId, {
428
- toolName: event.toolName,
429
- args: event.args,
430
- startTime: Date.now(),
431
- });
432
- // Log to console
433
- log.logToolStart(logCtx, event.toolName, label, event.args);
434
- // Log to jsonl
435
- await store.logMessage(ctx.message.channel, {
436
- date: new Date().toISOString(),
437
- ts: toSlackTs(),
438
- user: "bot",
439
- text: `[Tool] ${event.toolName}: ${JSON.stringify(event.args)}`,
440
- attachments: [],
441
- isBot: true,
442
- });
443
- // Show label in main message only
444
- queue.enqueue(() => ctx.respond(`_→ ${label}_`, false), "tool label");
445
- break;
446
- }
447
- case "tool_execution_end": {
448
- const resultStr = extractToolResultText(event.result);
449
- const pending = pendingTools.get(event.toolCallId);
450
- pendingTools.delete(event.toolCallId);
451
- const durationMs = pending ? Date.now() - pending.startTime : 0;
452
- // Log to console
453
- if (event.isError) {
454
- log.logToolError(logCtx, event.toolName, durationMs, resultStr);
455
- }
456
- else {
457
- log.logToolSuccess(logCtx, event.toolName, durationMs, resultStr);
458
- }
459
- // Log to jsonl
460
- await store.logMessage(ctx.message.channel, {
461
- date: new Date().toISOString(),
462
- ts: toSlackTs(),
463
- user: "bot",
464
- text: `[Tool Result] ${event.toolName}: ${event.isError ? "ERROR: " : ""}${resultStr}`,
465
- attachments: [],
466
- isBot: true,
467
- });
468
- // Post args + result together in thread
469
- const label = pending?.args ? pending.args.label : undefined;
470
- const argsFormatted = pending
471
- ? formatToolArgsForSlack(event.toolName, pending.args)
472
- : "(args not found)";
473
- const duration = (durationMs / 1000).toFixed(1);
474
- let threadMessage = `*${event.isError ? "✗" : "✓"} ${event.toolName}*`;
475
- if (label) {
476
- threadMessage += `: ${label}`;
477
- }
478
- threadMessage += ` (${duration}s)\n`;
479
- if (argsFormatted) {
480
- threadMessage += "```\n" + argsFormatted + "\n```\n";
481
- }
482
- threadMessage += "*Result:*\n```\n" + resultStr + "\n```";
483
- queue.enqueueMessage(threadMessage, "thread", "tool result thread", false);
484
- // Show brief error in main message if failed
485
- if (event.isError) {
486
- queue.enqueue(() => ctx.respond(`_Error: ${truncate(resultStr, 200)}_`, false), "tool error");
487
- }
488
- break;
489
- }
490
- case "message_update": {
491
- // No longer stream to console - just track that we're streaming
492
- break;
493
- }
494
- case "message_start":
495
- if (event.message.role === "assistant") {
496
- log.logResponseStart(logCtx);
497
- }
498
- break;
499
- case "message_end":
500
- if (event.message.role === "assistant") {
501
- const assistantMsg = event.message; // AssistantMessage type
502
- // Track stop reason
503
- if (assistantMsg.stopReason) {
504
- stopReason = assistantMsg.stopReason;
505
- }
506
- // Accumulate usage
507
- if (assistantMsg.usage) {
508
- totalUsage.input += assistantMsg.usage.input;
509
- totalUsage.output += assistantMsg.usage.output;
510
- totalUsage.cacheRead += assistantMsg.usage.cacheRead;
511
- totalUsage.cacheWrite += assistantMsg.usage.cacheWrite;
512
- totalUsage.cost.input += assistantMsg.usage.cost.input;
513
- totalUsage.cost.output += assistantMsg.usage.cost.output;
514
- totalUsage.cost.cacheRead += assistantMsg.usage.cost.cacheRead;
515
- totalUsage.cost.cacheWrite += assistantMsg.usage.cost.cacheWrite;
516
- totalUsage.cost.total += assistantMsg.usage.cost.total;
517
- }
518
- // Extract thinking and text from assistant message
519
- const content = event.message.content;
520
- const thinkingParts = [];
521
- const textParts = [];
522
- for (const part of content) {
523
- if (part.type === "thinking") {
524
- thinkingParts.push(part.thinking);
525
- }
526
- else if (part.type === "text") {
527
- textParts.push(part.text);
528
- }
529
- }
530
- const text = textParts.join("\n");
531
- // Post thinking to main message and thread
532
- for (const thinking of thinkingParts) {
533
- log.logThinking(logCtx, thinking);
534
- queue.enqueueMessage(`_${thinking}_`, "main", "thinking main");
535
- queue.enqueueMessage(`_${thinking}_`, "thread", "thinking thread", false);
536
- }
537
- // Post text to main message and thread
538
- if (text.trim()) {
539
- log.logResponse(logCtx, text);
540
- queue.enqueueMessage(text, "main", "response main");
541
- queue.enqueueMessage(text, "thread", "response thread", false);
542
- }
543
- }
544
- break;
545
- }
546
- });
547
- // Run the agent with user's message
548
- // Prepend recent messages to the user prompt (not system prompt) for better caching
549
- // The current message is already the last entry in recentMessages
550
- const userPrompt = `Conversation history (last 50 turns). Respond to the last message.\n` +
551
- `Format: date TAB user TAB text TAB attachments\n\n` +
552
- recentMessages;
553
- // Debug: write full context to file
554
- const toolDefs = tools.map((t) => ({ name: t.name, description: t.description, parameters: t.parameters }));
555
- const debugPrompt = `=== SYSTEM PROMPT (${systemPrompt.length} chars) ===\n\n${systemPrompt}\n\n` +
556
- `=== TOOL DEFINITIONS (${JSON.stringify(toolDefs).length} chars) ===\n\n${JSON.stringify(toolDefs, null, 2)}\n\n` +
557
- `=== USER PROMPT (${userPrompt.length} chars) ===\n\n${userPrompt}`;
558
- await writeFile(join(channelDir, "last_prompt.txt"), debugPrompt, "utf-8");
559
- await agent.prompt(userPrompt);
560
- // Wait for all queued respond calls to complete
561
- await queue.flush();
562
- // Get final assistant message text from agent state and replace main message
563
- const messages = agent.state.messages;
491
+ // Log context info
492
+ log.logInfo(`Context sizes - system: ${systemPrompt.length} chars, memory: ${memory.length} chars`);
493
+ log.logInfo(`Channels: ${ctx.channels.length}, Users: ${ctx.users.length}`);
494
+ // Build user message with username prefix
495
+ // Format: "[username]: message" so LLM knows who's talking
496
+ let userMessage = `[${ctx.message.userName || "unknown"}]: ${ctx.message.text}`;
497
+ // Add attachment paths if any
498
+ if (ctx.message.attachments && ctx.message.attachments.length > 0) {
499
+ const attachmentPaths = ctx.message.attachments.map((a) => a.local).join("\n");
500
+ userMessage += `\n\nAttachments:\n${attachmentPaths}`;
501
+ }
502
+ // Debug: write context to last_prompt.jsonl
503
+ const debugContext = {
504
+ systemPrompt,
505
+ messages: session.messages,
506
+ newUserMessage: userMessage,
507
+ };
508
+ await writeFile(join(channelDir, "last_prompt.jsonl"), JSON.stringify(debugContext, null, 2));
509
+ await session.prompt(userMessage);
510
+ // Wait for queued messages
511
+ await queueChain;
512
+ // Final message update
513
+ const messages = session.messages;
564
514
  const lastAssistant = messages.filter((m) => m.role === "assistant").pop();
565
515
  const finalText = lastAssistant?.content
566
516
  .filter((c) => c.type === "text")
@@ -568,7 +518,6 @@ export function createAgentRunner(sandboxConfig) {
568
518
  .join("\n") || "";
569
519
  if (finalText.trim()) {
570
520
  try {
571
- // For the main message, truncate if too long (full text is in thread)
572
521
  const mainText = finalText.length > SLACK_MAX_LENGTH
573
522
  ? finalText.substring(0, SLACK_MAX_LENGTH - 50) + "\n\n_(see thread for full response)_"
574
523
  : finalText;
@@ -579,16 +528,33 @@ export function createAgentRunner(sandboxConfig) {
579
528
  log.logWarning("Failed to replace message with final text", errMsg);
580
529
  }
581
530
  }
582
- // Log usage summary if there was any usage
583
- if (totalUsage.cost.total > 0) {
584
- const summary = log.logUsageSummary(logCtx, totalUsage);
585
- queue.enqueue(() => ctx.respondInThread(summary), "usage summary");
586
- await queue.flush();
531
+ // Log usage summary with context info
532
+ if (runState.totalUsage.cost.total > 0) {
533
+ // Get last non-aborted assistant message for context calculation
534
+ const messages = session.messages;
535
+ const lastAssistantMessage = messages
536
+ .slice()
537
+ .reverse()
538
+ .find((m) => m.role === "assistant" && m.stopReason !== "aborted");
539
+ const contextTokens = lastAssistantMessage
540
+ ? lastAssistantMessage.usage.input +
541
+ lastAssistantMessage.usage.output +
542
+ lastAssistantMessage.usage.cacheRead +
543
+ lastAssistantMessage.usage.cacheWrite
544
+ : 0;
545
+ const contextWindow = model.contextWindow || 200000;
546
+ const summary = log.logUsageSummary(runState.logCtx, runState.totalUsage, contextTokens, contextWindow);
547
+ runState.queue.enqueue(() => ctx.respondInThread(summary), "usage summary");
548
+ await queueChain;
587
549
  }
588
- return { stopReason };
550
+ // Clear run state
551
+ runState.ctx = null;
552
+ runState.logCtx = null;
553
+ runState.queue = null;
554
+ return { stopReason: runState.stopReason };
589
555
  },
590
556
  abort() {
591
- agent?.abort();
557
+ session.abort();
592
558
  },
593
559
  };
594
560
  }
@@ -597,17 +563,14 @@ export function createAgentRunner(sandboxConfig) {
597
563
  */
598
564
  function translateToHostPath(containerPath, channelDir, workspacePath, channelId) {
599
565
  if (workspacePath === "/workspace") {
600
- // Docker mode - translate /workspace/channelId/... to host path
601
566
  const prefix = `/workspace/${channelId}/`;
602
567
  if (containerPath.startsWith(prefix)) {
603
568
  return join(channelDir, containerPath.slice(prefix.length));
604
569
  }
605
- // Maybe it's just /workspace/...
606
570
  if (containerPath.startsWith("/workspace/")) {
607
571
  return join(channelDir, "..", containerPath.slice("/workspace/".length));
608
572
  }
609
573
  }
610
- // Host mode or already a host path
611
574
  return containerPath;
612
575
  }
613
576
  //# sourceMappingURL=agent.js.map