@geminixiang/mama 0.1.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.
Files changed (83) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +158 -0
  3. package/dist/adapter.d.ts +38 -0
  4. package/dist/adapter.d.ts.map +1 -0
  5. package/dist/adapter.js +2 -0
  6. package/dist/adapter.js.map +1 -0
  7. package/dist/adapters/slack/bot.d.ts +130 -0
  8. package/dist/adapters/slack/bot.d.ts.map +1 -0
  9. package/dist/adapters/slack/bot.js +516 -0
  10. package/dist/adapters/slack/bot.js.map +1 -0
  11. package/dist/adapters/slack/context.d.ts +11 -0
  12. package/dist/adapters/slack/context.d.ts.map +1 -0
  13. package/dist/adapters/slack/context.js +178 -0
  14. package/dist/adapters/slack/context.js.map +1 -0
  15. package/dist/adapters/slack/index.d.ts +3 -0
  16. package/dist/adapters/slack/index.d.ts.map +1 -0
  17. package/dist/adapters/slack/index.js +3 -0
  18. package/dist/adapters/slack/index.js.map +1 -0
  19. package/dist/adapters/slack/tools/attach.d.ts +12 -0
  20. package/dist/adapters/slack/tools/attach.d.ts.map +1 -0
  21. package/dist/adapters/slack/tools/attach.js +38 -0
  22. package/dist/adapters/slack/tools/attach.js.map +1 -0
  23. package/dist/agent.d.ts +26 -0
  24. package/dist/agent.d.ts.map +1 -0
  25. package/dist/agent.js +763 -0
  26. package/dist/agent.js.map +1 -0
  27. package/dist/config.d.ts +10 -0
  28. package/dist/config.d.ts.map +1 -0
  29. package/dist/config.js +54 -0
  30. package/dist/config.js.map +1 -0
  31. package/dist/context.d.ts +34 -0
  32. package/dist/context.d.ts.map +1 -0
  33. package/dist/context.js +110 -0
  34. package/dist/context.js.map +1 -0
  35. package/dist/download.d.ts +2 -0
  36. package/dist/download.d.ts.map +1 -0
  37. package/dist/download.js +89 -0
  38. package/dist/download.js.map +1 -0
  39. package/dist/events.d.ts +57 -0
  40. package/dist/events.d.ts.map +1 -0
  41. package/dist/events.js +310 -0
  42. package/dist/events.js.map +1 -0
  43. package/dist/log.d.ts +39 -0
  44. package/dist/log.d.ts.map +1 -0
  45. package/dist/log.js +222 -0
  46. package/dist/log.js.map +1 -0
  47. package/dist/main.d.ts +3 -0
  48. package/dist/main.d.ts.map +1 -0
  49. package/dist/main.js +247 -0
  50. package/dist/main.js.map +1 -0
  51. package/dist/sandbox.d.ts +34 -0
  52. package/dist/sandbox.d.ts.map +1 -0
  53. package/dist/sandbox.js +183 -0
  54. package/dist/sandbox.js.map +1 -0
  55. package/dist/store.d.ts +60 -0
  56. package/dist/store.d.ts.map +1 -0
  57. package/dist/store.js +180 -0
  58. package/dist/store.js.map +1 -0
  59. package/dist/tools/bash.d.ts +10 -0
  60. package/dist/tools/bash.d.ts.map +1 -0
  61. package/dist/tools/bash.js +78 -0
  62. package/dist/tools/bash.js.map +1 -0
  63. package/dist/tools/edit.d.ts +11 -0
  64. package/dist/tools/edit.d.ts.map +1 -0
  65. package/dist/tools/edit.js +131 -0
  66. package/dist/tools/edit.js.map +1 -0
  67. package/dist/tools/index.d.ts +7 -0
  68. package/dist/tools/index.d.ts.map +1 -0
  69. package/dist/tools/index.js +19 -0
  70. package/dist/tools/index.js.map +1 -0
  71. package/dist/tools/read.d.ts +11 -0
  72. package/dist/tools/read.d.ts.map +1 -0
  73. package/dist/tools/read.js +134 -0
  74. package/dist/tools/read.js.map +1 -0
  75. package/dist/tools/truncate.d.ts +57 -0
  76. package/dist/tools/truncate.d.ts.map +1 -0
  77. package/dist/tools/truncate.js +184 -0
  78. package/dist/tools/truncate.js.map +1 -0
  79. package/dist/tools/write.d.ts +10 -0
  80. package/dist/tools/write.d.ts.map +1 -0
  81. package/dist/tools/write.js +33 -0
  82. package/dist/tools/write.js.map +1 -0
  83. package/package.json +57 -0
package/dist/agent.js ADDED
@@ -0,0 +1,763 @@
1
+ import { Agent } from "@mariozechner/pi-agent-core";
2
+ import { getModel } from "@mariozechner/pi-ai";
3
+ import { AgentSession, AuthStorage, convertToLlm, createExtensionRuntime, formatSkillsForPrompt, loadSkillsFromDir, ModelRegistry, SessionManager, } from "@mariozechner/pi-coding-agent";
4
+ import { existsSync, mkdirSync, readFileSync } from "fs";
5
+ import { mkdir, readFile, writeFile } from "fs/promises";
6
+ import { homedir } from "os";
7
+ import { join } from "path";
8
+ import { loadAgentConfig } from "./config.js";
9
+ import { createMamaSettingsManager, syncLogToSessionManager } from "./context.js";
10
+ import * as log from "./log.js";
11
+ import { createExecutor } from "./sandbox.js";
12
+ import { createMamaTools } from "./tools/index.js";
13
+ const IMAGE_MIME_TYPES = {
14
+ jpg: "image/jpeg",
15
+ jpeg: "image/jpeg",
16
+ png: "image/png",
17
+ gif: "image/gif",
18
+ webp: "image/webp",
19
+ };
20
+ function getImageMimeType(filename) {
21
+ return IMAGE_MIME_TYPES[filename.toLowerCase().split(".").pop() || ""];
22
+ }
23
+ async function getMemory(channelDir) {
24
+ const parts = [];
25
+ // Read workspace-level memory (shared across all channels)
26
+ const workspaceMemoryPath = join(channelDir, "..", "MEMORY.md");
27
+ if (existsSync(workspaceMemoryPath)) {
28
+ try {
29
+ const content = (await readFile(workspaceMemoryPath, "utf-8")).trim();
30
+ if (content) {
31
+ parts.push(`### Global Workspace Memory\n${content}`);
32
+ }
33
+ }
34
+ catch (error) {
35
+ log.logWarning("Failed to read workspace memory", `${workspaceMemoryPath}: ${error}`);
36
+ }
37
+ }
38
+ // Read channel-specific memory
39
+ const channelMemoryPath = join(channelDir, "MEMORY.md");
40
+ if (existsSync(channelMemoryPath)) {
41
+ try {
42
+ const content = (await readFile(channelMemoryPath, "utf-8")).trim();
43
+ if (content) {
44
+ parts.push(`### Channel-Specific Memory\n${content}`);
45
+ }
46
+ }
47
+ catch (error) {
48
+ log.logWarning("Failed to read channel memory", `${channelMemoryPath}: ${error}`);
49
+ }
50
+ }
51
+ if (parts.length === 0) {
52
+ return "(no working memory yet)";
53
+ }
54
+ return parts.join("\n\n");
55
+ }
56
+ function loadMamaSkills(channelDir, workspacePath) {
57
+ const skillMap = new Map();
58
+ // channelDir is the host path (e.g., /Users/.../data/C0A34FL8PMH)
59
+ // hostWorkspacePath is the parent directory on host
60
+ // workspacePath is the container path (e.g., /workspace)
61
+ const hostWorkspacePath = join(channelDir, "..");
62
+ // Helper to translate host paths to container paths
63
+ const translatePath = (hostPath) => {
64
+ if (hostPath.startsWith(hostWorkspacePath)) {
65
+ return workspacePath + hostPath.slice(hostWorkspacePath.length);
66
+ }
67
+ return hostPath;
68
+ };
69
+ // Load workspace-level skills (global)
70
+ const workspaceSkillsDir = join(hostWorkspacePath, "skills");
71
+ for (const skill of loadSkillsFromDir({ dir: workspaceSkillsDir, source: "workspace" }).skills) {
72
+ // Translate paths to container paths for system prompt
73
+ skill.filePath = translatePath(skill.filePath);
74
+ skill.baseDir = translatePath(skill.baseDir);
75
+ skillMap.set(skill.name, skill);
76
+ }
77
+ // Load channel-specific skills (override workspace skills on collision)
78
+ const channelSkillsDir = join(channelDir, "skills");
79
+ for (const skill of loadSkillsFromDir({ dir: channelSkillsDir, source: "channel" }).skills) {
80
+ skill.filePath = translatePath(skill.filePath);
81
+ skill.baseDir = translatePath(skill.baseDir);
82
+ skillMap.set(skill.name, skill);
83
+ }
84
+ return Array.from(skillMap.values());
85
+ }
86
+ function buildSystemPrompt(workspacePath, channelId, memory, sandboxConfig, platform, skills, maxUsersInPrompt) {
87
+ const channelPath = `${workspacePath}/${channelId}`;
88
+ const isDocker = sandboxConfig.type === "docker";
89
+ // Format channel mappings
90
+ const channelMappings = platform.channels.length > 0
91
+ ? platform.channels.map((c) => `${c.id}\t#${c.name}`).join("\n")
92
+ : "(no channels loaded)";
93
+ // Format user mappings (limited to maxUsersInPrompt)
94
+ const limitedUsers = platform.users.slice(0, maxUsersInPrompt);
95
+ const userCountNote = platform.users.length > maxUsersInPrompt ? ` (showing ${maxUsersInPrompt} of ${platform.users.length})` : "";
96
+ const userMappings = limitedUsers.length > 0
97
+ ? limitedUsers.map((u) => `${u.id}\t@${u.userName}\t${u.displayName}`).join("\n")
98
+ : "(no users loaded)";
99
+ const envDescription = isDocker
100
+ ? `You are running inside a Docker container (Alpine Linux).
101
+ - Bash working directory: / (use cd or absolute paths)
102
+ - Install tools with: apk add <package>
103
+ - Your changes persist across sessions`
104
+ : `You are running directly on the host machine.
105
+ - Bash working directory: ${process.cwd()}
106
+ - Be careful with system modifications`;
107
+ return `You are mama, a ${platform.name} bot assistant. Be concise. No emojis.
108
+
109
+ ## Context
110
+ - For current date/time, use: date
111
+ - You have access to previous conversation context including tool results from prior turns.
112
+ - For older history beyond your context, search log.jsonl (contains user messages and your final responses, but not tool results).
113
+
114
+ ${platform.formattingGuide}
115
+
116
+ ## Platform IDs
117
+ Channels: ${channelMappings}
118
+
119
+ Users: ${userMappings}${userCountNote}
120
+
121
+ When mentioning users, use <@username> format (e.g., <@mario>).
122
+
123
+ ## Environment
124
+ ${envDescription}
125
+
126
+ ## Workspace Layout
127
+ ${workspacePath}/
128
+ ├── MEMORY.md # Global memory (all channels)
129
+ ├── skills/ # Global CLI tools you create
130
+ └── ${channelId}/ # This channel
131
+ ├── MEMORY.md # Channel-specific memory
132
+ ├── log.jsonl # Message history (no tool results)
133
+ ├── attachments/ # User-shared files
134
+ ├── scratch/ # Your working directory
135
+ └── skills/ # Channel-specific tools
136
+
137
+ ## Skills (Custom CLI Tools)
138
+ You can create reusable CLI tools for recurring tasks (email, APIs, data processing, etc.).
139
+
140
+ ### Creating Skills
141
+ Store in \`${workspacePath}/skills/<name>/\` (global) or \`${channelPath}/skills/<name>/\` (channel-specific).
142
+ Each skill directory needs a \`SKILL.md\` with YAML frontmatter:
143
+
144
+ \`\`\`markdown
145
+ ---
146
+ name: skill-name
147
+ description: Short description of what this skill does
148
+ ---
149
+
150
+ # Skill Name
151
+
152
+ Usage instructions, examples, etc.
153
+ Scripts are in: {baseDir}/
154
+ \`\`\`
155
+
156
+ \`name\` and \`description\` are required. Use \`{baseDir}\` as placeholder for the skill's directory path.
157
+
158
+ ### Available Skills
159
+ ${skills.length > 0 ? formatSkillsForPrompt(skills) : "(no skills installed yet)"}
160
+
161
+ ## Events
162
+ You can schedule events that wake you up at specific times or when external things happen. Events are JSON files in \`${workspacePath}/events/\`.
163
+
164
+ ### Event Types
165
+
166
+ **Immediate** - Triggers as soon as harness sees the file. Use in scripts/webhooks to signal external events.
167
+ \`\`\`json
168
+ {"type": "immediate", "channelId": "${channelId}", "text": "New GitHub issue opened"}
169
+ \`\`\`
170
+
171
+ **One-shot** - Triggers once at a specific time. Use for reminders.
172
+ \`\`\`json
173
+ {"type": "one-shot", "channelId": "${channelId}", "text": "Remind Mario about dentist", "at": "2025-12-15T09:00:00+01:00"}
174
+ \`\`\`
175
+
176
+ **Periodic** - Triggers on a cron schedule. Use for recurring tasks.
177
+ \`\`\`json
178
+ {"type": "periodic", "channelId": "${channelId}", "text": "Check inbox and summarize", "schedule": "0 9 * * 1-5", "timezone": "${Intl.DateTimeFormat().resolvedOptions().timeZone}"}
179
+ \`\`\`
180
+
181
+ ### Cron Format
182
+ \`minute hour day-of-month month day-of-week\`
183
+ - \`0 9 * * *\` = daily at 9:00
184
+ - \`0 9 * * 1-5\` = weekdays at 9:00
185
+ - \`30 14 * * 1\` = Mondays at 14:30
186
+ - \`0 0 1 * *\` = first of each month at midnight
187
+
188
+ ### Timezones
189
+ All \`at\` timestamps must include offset (e.g., \`+01:00\`). Periodic events use IANA timezone names. The harness runs in ${Intl.DateTimeFormat().resolvedOptions().timeZone}. When users mention times without timezone, assume ${Intl.DateTimeFormat().resolvedOptions().timeZone}.
190
+
191
+ ### Creating Events
192
+ Use unique filenames to avoid overwriting existing events. Include a timestamp or random suffix:
193
+ \`\`\`bash
194
+ cat > ${workspacePath}/events/dentist-reminder-$(date +%s).json << 'EOF'
195
+ {"type": "one-shot", "channelId": "${channelId}", "text": "Dentist tomorrow", "at": "2025-12-14T09:00:00+01:00"}
196
+ EOF
197
+ \`\`\`
198
+ Or check if file exists first before creating.
199
+
200
+ ### Managing Events
201
+ - List: \`ls ${workspacePath}/events/\`
202
+ - View: \`cat ${workspacePath}/events/foo.json\`
203
+ - Delete/cancel: \`rm ${workspacePath}/events/foo.json\`
204
+
205
+ ### When Events Trigger
206
+ You receive a message like:
207
+ \`\`\`
208
+ [EVENT:dentist-reminder.json:one-shot:2025-12-14T09:00:00+01:00] Dentist tomorrow
209
+ \`\`\`
210
+ Immediate and one-shot events auto-delete after triggering. Periodic events persist until you delete them.
211
+
212
+ ### Silent Completion
213
+ For periodic events where there's nothing to report, respond with just \`[SILENT]\` (no other text). This deletes the status message and posts nothing to the platform. Use this to avoid spamming the channel when periodic checks find nothing actionable.
214
+
215
+ ### Debouncing
216
+ When writing programs that create immediate events (email watchers, webhook handlers, etc.), always debounce. If 50 emails arrive in a minute, don't create 50 immediate events. Instead collect events over a window and create ONE immediate event summarizing what happened, or just signal "new activity, check inbox" rather than per-item events. Or simpler: use a periodic event to check for new items every N minutes instead of immediate events.
217
+
218
+ ### Limits
219
+ Maximum 5 events can be queued. Don't create excessive immediate or periodic events.
220
+
221
+ ## Memory
222
+ Write to MEMORY.md files to persist context across conversations.
223
+ - Global (${workspacePath}/MEMORY.md): skills, preferences, project info
224
+ - Channel (${channelPath}/MEMORY.md): channel-specific decisions, ongoing work
225
+ Update when you learn something important or when asked to remember something.
226
+
227
+ ### Current Memory
228
+ ${memory}
229
+
230
+ ## System Configuration Log
231
+ Maintain ${workspacePath}/SYSTEM.md to log all environment modifications:
232
+ - Installed packages (apk add, npm install, pip install)
233
+ - Environment variables set
234
+ - Config files modified (~/.gitconfig, cron jobs, etc.)
235
+ - Skill dependencies installed
236
+
237
+ Update this file whenever you modify the environment. On fresh container, read it first to restore your setup.
238
+
239
+ ## Log Queries (for older history)
240
+ Format: \`{"date":"...","ts":"...","user":"...","userName":"...","text":"...","isBot":false}\`
241
+ The log contains user messages and your final responses (not tool calls/results).
242
+ ${isDocker ? "Install jq: apk add jq" : ""}
243
+
244
+ \`\`\`bash
245
+ # Recent messages
246
+ tail -30 log.jsonl | jq -c '{date: .date[0:19], user: (.userName // .user), text}'
247
+
248
+ # Search for specific topic
249
+ grep -i "topic" log.jsonl | jq -c '{date: .date[0:19], user: (.userName // .user), text}'
250
+
251
+ # Messages from specific user
252
+ grep '"userName":"mario"' log.jsonl | tail -20 | jq -c '{date: .date[0:19], text}'
253
+ \`\`\`
254
+
255
+ ## Tools
256
+ - bash: Run shell commands (primary tool). Install packages as needed.
257
+ - read: Read files
258
+ - write: Create/overwrite files
259
+ - edit: Surgical file edits
260
+ - attach: Share files to the platform
261
+
262
+ Each tool requires a "label" parameter (shown to user).
263
+ `;
264
+ }
265
+ function truncate(text, maxLen) {
266
+ if (text.length <= maxLen)
267
+ return text;
268
+ return `${text.substring(0, maxLen - 3)}...`;
269
+ }
270
+ function extractToolResultText(result) {
271
+ if (typeof result === "string") {
272
+ return result;
273
+ }
274
+ if (result &&
275
+ typeof result === "object" &&
276
+ "content" in result &&
277
+ Array.isArray(result.content)) {
278
+ const content = result.content;
279
+ const textParts = [];
280
+ for (const part of content) {
281
+ if (part.type === "text" && part.text) {
282
+ textParts.push(part.text);
283
+ }
284
+ }
285
+ if (textParts.length > 0) {
286
+ return textParts.join("\n");
287
+ }
288
+ }
289
+ return JSON.stringify(result);
290
+ }
291
+ function formatToolArgsForSlack(_toolName, args) {
292
+ const lines = [];
293
+ for (const [key, value] of Object.entries(args)) {
294
+ if (key === "label")
295
+ continue;
296
+ if (key === "path" && typeof value === "string") {
297
+ const offset = args.offset;
298
+ const limit = args.limit;
299
+ if (offset !== undefined && limit !== undefined) {
300
+ lines.push(`${value}:${offset}-${offset + limit}`);
301
+ }
302
+ else {
303
+ lines.push(value);
304
+ }
305
+ continue;
306
+ }
307
+ if (key === "offset" || key === "limit")
308
+ continue;
309
+ if (typeof value === "string") {
310
+ lines.push(value);
311
+ }
312
+ else {
313
+ lines.push(JSON.stringify(value));
314
+ }
315
+ }
316
+ return lines.join("\n");
317
+ }
318
+ // ============================================================================
319
+ // Agent runner
320
+ // ============================================================================
321
+ /**
322
+ * Create a new AgentRunner for a channel.
323
+ * Sets up the session and subscribes to events once.
324
+ *
325
+ * Runner caching is handled by the caller (channelStates in main.ts).
326
+ * This is a stateless factory function.
327
+ */
328
+ export async function createRunner(sandboxConfig, sessionKey, channelId, channelDir, workspaceDir) {
329
+ const agentConfig = loadAgentConfig(workspaceDir);
330
+ const executor = createExecutor(sandboxConfig);
331
+ const workspacePath = executor.getWorkspacePath(channelDir.replace(`/${channelId}`, ""));
332
+ // Create tools (per-runner, with per-runner upload function setter)
333
+ const { tools, setUploadFunction } = createMamaTools(executor);
334
+ // Resolve model from config
335
+ // Use 'as any' cast because agentConfig.provider/model are plain strings,
336
+ // while getModel() has constrained generic types for known providers.
337
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
338
+ const model = getModel(agentConfig.provider, agentConfig.model);
339
+ // Initial system prompt (will be updated each run with fresh memory/channels/users/skills)
340
+ const memory = await getMemory(channelDir);
341
+ const skills = loadMamaSkills(channelDir, workspacePath);
342
+ const emptyPlatform = { name: "slack", formattingGuide: "", channels: [], users: [] };
343
+ const systemPrompt = buildSystemPrompt(workspacePath, channelId, memory, sandboxConfig, emptyPlatform, skills, agentConfig.maxUsersInPrompt ?? 50);
344
+ // Create session manager and settings manager
345
+ // Per-session context file: {channelDir}/sessions/{rootTs}/context.jsonl
346
+ const rootTs = sessionKey.includes(":") ? sessionKey.split(":").pop() : sessionKey;
347
+ const sessionDir = join(channelDir, "sessions", rootTs);
348
+ mkdirSync(sessionDir, { recursive: true });
349
+ const contextFile = join(sessionDir, "context.jsonl");
350
+ const sessionManager = SessionManager.open(contextFile, channelDir);
351
+ const settingsManager = createMamaSettingsManager(join(channelDir, ".."));
352
+ // Create AuthStorage and ModelRegistry
353
+ // Auth stored outside workspace so agent can't access it
354
+ const authStorage = AuthStorage.create(join(homedir(), ".pi", "mama", "auth.json"));
355
+ const modelRegistry = new ModelRegistry(authStorage);
356
+ // Create agent
357
+ const agent = new Agent({
358
+ initialState: {
359
+ systemPrompt,
360
+ model,
361
+ thinkingLevel: agentConfig.thinkingLevel ?? "off",
362
+ tools,
363
+ },
364
+ convertToLlm,
365
+ getApiKey: async () => {
366
+ const key = await modelRegistry.getApiKey(model);
367
+ if (!key)
368
+ throw new Error(`No API key for provider "${model.provider}". Set the appropriate environment variable or configure via auth.json`);
369
+ return key;
370
+ },
371
+ });
372
+ // Load existing messages
373
+ const loadedSession = sessionManager.buildSessionContext();
374
+ if (loadedSession.messages.length > 0) {
375
+ agent.replaceMessages(loadedSession.messages);
376
+ log.logInfo(`[${channelId}] Loaded ${loadedSession.messages.length} messages from context.jsonl`);
377
+ }
378
+ const resourceLoader = {
379
+ getExtensions: () => ({ extensions: [], errors: [], runtime: createExtensionRuntime() }),
380
+ getSkills: () => ({ skills: [], diagnostics: [] }),
381
+ getPrompts: () => ({ prompts: [], diagnostics: [] }),
382
+ getThemes: () => ({ themes: [], diagnostics: [] }),
383
+ getAgentsFiles: () => ({ agentsFiles: [] }),
384
+ getSystemPrompt: () => systemPrompt,
385
+ getAppendSystemPrompt: () => [],
386
+ getPathMetadata: () => new Map(),
387
+ extendResources: () => { },
388
+ reload: async () => { },
389
+ };
390
+ const baseToolsOverride = Object.fromEntries(tools.map((tool) => [tool.name, tool]));
391
+ // Create AgentSession wrapper
392
+ const session = new AgentSession({
393
+ agent,
394
+ sessionManager,
395
+ settingsManager,
396
+ cwd: process.cwd(),
397
+ modelRegistry,
398
+ resourceLoader,
399
+ baseToolsOverride,
400
+ });
401
+ // Mutable per-run state - event handler references this
402
+ const runState = {
403
+ responseCtx: null,
404
+ logCtx: null,
405
+ queue: null,
406
+ pendingTools: new Map(),
407
+ totalUsage: {
408
+ input: 0,
409
+ output: 0,
410
+ cacheRead: 0,
411
+ cacheWrite: 0,
412
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
413
+ },
414
+ stopReason: "stop",
415
+ errorMessage: undefined,
416
+ };
417
+ // Subscribe to events ONCE
418
+ session.subscribe(async (event) => {
419
+ // Skip if no active run
420
+ if (!runState.responseCtx || !runState.logCtx || !runState.queue)
421
+ return;
422
+ const { responseCtx, logCtx, queue, pendingTools } = runState;
423
+ if (event.type === "tool_execution_start") {
424
+ const agentEvent = event;
425
+ const args = agentEvent.args;
426
+ const label = args.label || agentEvent.toolName;
427
+ pendingTools.set(agentEvent.toolCallId, {
428
+ toolName: agentEvent.toolName,
429
+ args: agentEvent.args,
430
+ startTime: Date.now(),
431
+ });
432
+ log.logToolStart(logCtx, agentEvent.toolName, label, agentEvent.args);
433
+ queue.enqueue(() => responseCtx.respond(`_→ ${label}_`), "tool label");
434
+ }
435
+ else if (event.type === "tool_execution_end") {
436
+ const agentEvent = event;
437
+ const resultStr = extractToolResultText(agentEvent.result);
438
+ const pending = pendingTools.get(agentEvent.toolCallId);
439
+ pendingTools.delete(agentEvent.toolCallId);
440
+ const durationMs = pending ? Date.now() - pending.startTime : 0;
441
+ if (agentEvent.isError) {
442
+ log.logToolError(logCtx, agentEvent.toolName, durationMs, resultStr);
443
+ }
444
+ else {
445
+ log.logToolSuccess(logCtx, agentEvent.toolName, durationMs, resultStr);
446
+ }
447
+ // Post args + result to thread
448
+ const label = pending?.args ? pending.args.label : undefined;
449
+ const argsFormatted = pending
450
+ ? formatToolArgsForSlack(agentEvent.toolName, pending.args)
451
+ : "(args not found)";
452
+ const duration = (durationMs / 1000).toFixed(1);
453
+ let threadMessage = `*${agentEvent.isError ? "✗" : "✓"} ${agentEvent.toolName}*`;
454
+ if (label)
455
+ threadMessage += `: ${label}`;
456
+ threadMessage += ` (${duration}s)\n`;
457
+ if (argsFormatted)
458
+ threadMessage += `\`\`\`\n${argsFormatted}\n\`\`\`\n`;
459
+ threadMessage += `*Result:*\n\`\`\`\n${resultStr}\n\`\`\``;
460
+ queue.enqueueMessage(threadMessage, "thread", "tool result thread", false);
461
+ if (agentEvent.isError) {
462
+ queue.enqueue(() => responseCtx.respond(`_Error: ${truncate(resultStr, 200)}_`), "tool error");
463
+ }
464
+ }
465
+ else if (event.type === "message_start") {
466
+ const agentEvent = event;
467
+ if (agentEvent.message.role === "assistant") {
468
+ log.logResponseStart(logCtx);
469
+ }
470
+ }
471
+ else if (event.type === "message_end") {
472
+ const agentEvent = event;
473
+ if (agentEvent.message.role === "assistant") {
474
+ const assistantMsg = agentEvent.message;
475
+ if (assistantMsg.stopReason) {
476
+ runState.stopReason = assistantMsg.stopReason;
477
+ }
478
+ if (assistantMsg.errorMessage) {
479
+ runState.errorMessage = assistantMsg.errorMessage;
480
+ }
481
+ if (assistantMsg.usage) {
482
+ runState.totalUsage.input += assistantMsg.usage.input;
483
+ runState.totalUsage.output += assistantMsg.usage.output;
484
+ runState.totalUsage.cacheRead += assistantMsg.usage.cacheRead;
485
+ runState.totalUsage.cacheWrite += assistantMsg.usage.cacheWrite;
486
+ runState.totalUsage.cost.input += assistantMsg.usage.cost.input;
487
+ runState.totalUsage.cost.output += assistantMsg.usage.cost.output;
488
+ runState.totalUsage.cost.cacheRead += assistantMsg.usage.cost.cacheRead;
489
+ runState.totalUsage.cost.cacheWrite += assistantMsg.usage.cost.cacheWrite;
490
+ runState.totalUsage.cost.total += assistantMsg.usage.cost.total;
491
+ }
492
+ const content = agentEvent.message.content;
493
+ const thinkingParts = [];
494
+ const textParts = [];
495
+ for (const part of content) {
496
+ if (part.type === "thinking") {
497
+ thinkingParts.push(part.thinking);
498
+ }
499
+ else if (part.type === "text") {
500
+ textParts.push(part.text);
501
+ }
502
+ }
503
+ const text = textParts.join("\n");
504
+ for (const thinking of thinkingParts) {
505
+ log.logThinking(logCtx, thinking);
506
+ queue.enqueueMessage(`_${thinking}_`, "main", "thinking main");
507
+ queue.enqueueMessage(`_${thinking}_`, "thread", "thinking thread", false);
508
+ }
509
+ if (text.trim()) {
510
+ log.logResponse(logCtx, text);
511
+ queue.enqueueMessage(text, "main", "response main");
512
+ queue.enqueueMessage(text, "thread", "response thread", false);
513
+ }
514
+ }
515
+ }
516
+ else if (event.type === "auto_compaction_start") {
517
+ log.logInfo(`Auto-compaction started (reason: ${event.reason})`);
518
+ queue.enqueue(() => responseCtx.respond("_Compacting context..._"), "compaction start");
519
+ }
520
+ else if (event.type === "auto_compaction_end") {
521
+ const compEvent = event;
522
+ if (compEvent.result) {
523
+ log.logInfo(`Auto-compaction complete: ${compEvent.result.tokensBefore} tokens compacted`);
524
+ }
525
+ else if (compEvent.aborted) {
526
+ log.logInfo("Auto-compaction aborted");
527
+ }
528
+ }
529
+ else if (event.type === "auto_retry_start") {
530
+ const retryEvent = event;
531
+ log.logWarning(`Retrying (${retryEvent.attempt}/${retryEvent.maxAttempts})`, retryEvent.errorMessage);
532
+ queue.enqueue(() => responseCtx.respond(`_Retrying (${retryEvent.attempt}/${retryEvent.maxAttempts})..._`), "retry");
533
+ }
534
+ });
535
+ // Message limit constant
536
+ const SLACK_MAX_LENGTH = 40000;
537
+ const splitForSlack = (text) => {
538
+ if (text.length <= SLACK_MAX_LENGTH)
539
+ return [text];
540
+ const parts = [];
541
+ let remaining = text;
542
+ let partNum = 1;
543
+ while (remaining.length > 0) {
544
+ const chunk = remaining.substring(0, SLACK_MAX_LENGTH - 50);
545
+ remaining = remaining.substring(SLACK_MAX_LENGTH - 50);
546
+ const suffix = remaining.length > 0 ? `\n_(continued ${partNum}...)_` : "";
547
+ parts.push(chunk + suffix);
548
+ partNum++;
549
+ }
550
+ return parts;
551
+ };
552
+ return {
553
+ async run(message, responseCtx, platform) {
554
+ // Extract channelId from sessionKey (format: "channelId:rootTs" or just "channelId")
555
+ const sessionChannel = message.sessionKey.split(":")[0];
556
+ // Ensure channel directory exists
557
+ await mkdir(channelDir, { recursive: true });
558
+ // Sync messages from log.jsonl that arrived while we were offline or busy
559
+ // Exclude the current message (it will be added via prompt())
560
+ // Default sync range is 2 days (handled by syncLogToSessionManager)
561
+ const syncedCount = await syncLogToSessionManager(sessionManager, channelDir, message.id);
562
+ if (syncedCount > 0) {
563
+ log.logInfo(`[${channelId}] Synced ${syncedCount} messages from log.jsonl`);
564
+ }
565
+ // Reload messages from context.jsonl
566
+ // This picks up any messages synced above
567
+ const reloadedSession = sessionManager.buildSessionContext();
568
+ if (reloadedSession.messages.length > 0) {
569
+ agent.replaceMessages(reloadedSession.messages);
570
+ log.logInfo(`[${channelId}] Reloaded ${reloadedSession.messages.length} messages from context`);
571
+ }
572
+ // Update system prompt with fresh memory, channel/user info, and skills
573
+ const memory = await getMemory(channelDir);
574
+ const skills = loadMamaSkills(channelDir, workspacePath);
575
+ const systemPrompt = buildSystemPrompt(workspacePath, channelId, memory, sandboxConfig, platform, skills, agentConfig.maxUsersInPrompt ?? 50);
576
+ session.agent.setSystemPrompt(systemPrompt);
577
+ // Set up file upload function
578
+ setUploadFunction(async (filePath, title) => {
579
+ const hostPath = translateToHostPath(filePath, channelDir, workspacePath, channelId);
580
+ await responseCtx.uploadFile(hostPath, title);
581
+ });
582
+ // Reset per-run state
583
+ runState.responseCtx = responseCtx;
584
+ runState.logCtx = {
585
+ channelId: sessionChannel,
586
+ userName: message.userName,
587
+ channelName: undefined,
588
+ };
589
+ runState.pendingTools.clear();
590
+ runState.totalUsage = {
591
+ input: 0,
592
+ output: 0,
593
+ cacheRead: 0,
594
+ cacheWrite: 0,
595
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
596
+ };
597
+ runState.stopReason = "stop";
598
+ runState.errorMessage = undefined;
599
+ // Create queue for this run
600
+ let queueChain = Promise.resolve();
601
+ runState.queue = {
602
+ enqueue(fn, errorContext) {
603
+ queueChain = queueChain.then(async () => {
604
+ try {
605
+ await fn();
606
+ }
607
+ catch (err) {
608
+ const errMsg = err instanceof Error ? err.message : String(err);
609
+ log.logWarning(`API error (${errorContext})`, errMsg);
610
+ try {
611
+ await responseCtx.respondInThread(`_Error: ${errMsg}_`);
612
+ }
613
+ catch {
614
+ // Ignore
615
+ }
616
+ }
617
+ });
618
+ },
619
+ enqueueMessage(text, target, errorContext, _doLog = true) {
620
+ const parts = splitForSlack(text);
621
+ for (const part of parts) {
622
+ this.enqueue(() => (target === "main" ? responseCtx.respond(part) : responseCtx.respondInThread(part)), errorContext);
623
+ }
624
+ },
625
+ };
626
+ // Log context info
627
+ log.logInfo(`Context sizes - system: ${systemPrompt.length} chars, memory: ${memory.length} chars`);
628
+ log.logInfo(`Channels: ${platform.channels.length}, Users: ${platform.users.length}`);
629
+ // Build user message with timestamp and username prefix
630
+ // Format: "[YYYY-MM-DD HH:MM:SS+HH:MM] [username]: message" so LLM knows when and who
631
+ const now = new Date();
632
+ const pad = (n) => n.toString().padStart(2, "0");
633
+ const offset = -now.getTimezoneOffset();
634
+ const offsetSign = offset >= 0 ? "+" : "-";
635
+ const offsetHours = pad(Math.floor(Math.abs(offset) / 60));
636
+ const offsetMins = pad(Math.abs(offset) % 60);
637
+ const timestamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}${offsetSign}${offsetHours}:${offsetMins}`;
638
+ let userMessage = `[${timestamp}] [${message.userName || "unknown"}]: ${message.text}`;
639
+ const imageAttachments = [];
640
+ const nonImagePaths = [];
641
+ for (const a of message.attachments || []) {
642
+ // a.localPath is the path relative to the workspace (same as old a.local)
643
+ const fullPath = `${workspacePath}/${a.localPath}`;
644
+ const mimeType = getImageMimeType(a.localPath);
645
+ if (mimeType && existsSync(fullPath)) {
646
+ try {
647
+ imageAttachments.push({
648
+ type: "image",
649
+ mimeType,
650
+ data: readFileSync(fullPath).toString("base64"),
651
+ });
652
+ }
653
+ catch {
654
+ nonImagePaths.push(fullPath);
655
+ }
656
+ }
657
+ else {
658
+ nonImagePaths.push(fullPath);
659
+ }
660
+ }
661
+ if (nonImagePaths.length > 0) {
662
+ userMessage += `\n\n<slack_attachments>\n${nonImagePaths.join("\n")}\n</slack_attachments>`;
663
+ }
664
+ // Debug: write context to last_prompt.jsonl
665
+ const debugContext = {
666
+ systemPrompt,
667
+ messages: session.messages,
668
+ newUserMessage: userMessage,
669
+ imageAttachmentCount: imageAttachments.length,
670
+ };
671
+ await writeFile(join(channelDir, "last_prompt.jsonl"), JSON.stringify(debugContext, null, 2));
672
+ await session.prompt(userMessage, imageAttachments.length > 0 ? { images: imageAttachments } : undefined);
673
+ // Wait for queued messages
674
+ await queueChain;
675
+ // Handle error case - update main message and post error to thread
676
+ if (runState.stopReason === "error" && runState.errorMessage) {
677
+ try {
678
+ await responseCtx.replaceResponse("_Sorry, something went wrong_");
679
+ await responseCtx.respondInThread(`_Error: ${runState.errorMessage}_`);
680
+ }
681
+ catch (err) {
682
+ const errMsg = err instanceof Error ? err.message : String(err);
683
+ log.logWarning("Failed to post error message", errMsg);
684
+ }
685
+ }
686
+ else {
687
+ // Final message update
688
+ const messages = session.messages;
689
+ const lastAssistant = messages.filter((m) => m.role === "assistant").pop();
690
+ const finalText = lastAssistant?.content
691
+ .filter((c) => c.type === "text")
692
+ .map((c) => c.text)
693
+ .join("\n") || "";
694
+ // Check for [SILENT] marker - delete message and thread instead of posting
695
+ if (finalText.trim() === "[SILENT]" || finalText.trim().startsWith("[SILENT]")) {
696
+ try {
697
+ await responseCtx.deleteResponse();
698
+ log.logInfo("Silent response - deleted message and thread");
699
+ }
700
+ catch (err) {
701
+ const errMsg = err instanceof Error ? err.message : String(err);
702
+ log.logWarning("Failed to delete message for silent response", errMsg);
703
+ }
704
+ }
705
+ else if (finalText.trim()) {
706
+ try {
707
+ const mainText = finalText.length > SLACK_MAX_LENGTH
708
+ ? `${finalText.substring(0, SLACK_MAX_LENGTH - 50)}\n\n_(see thread for full response)_`
709
+ : finalText;
710
+ await responseCtx.replaceResponse(mainText);
711
+ }
712
+ catch (err) {
713
+ const errMsg = err instanceof Error ? err.message : String(err);
714
+ log.logWarning("Failed to replace message with final text", errMsg);
715
+ }
716
+ }
717
+ }
718
+ // Log usage summary with context info
719
+ if (runState.totalUsage.cost.total > 0) {
720
+ // Get last non-aborted assistant message for context calculation
721
+ const messages = session.messages;
722
+ const lastAssistantMessage = messages
723
+ .slice()
724
+ .reverse()
725
+ .find((m) => m.role === "assistant" && m.stopReason !== "aborted");
726
+ const contextTokens = lastAssistantMessage
727
+ ? lastAssistantMessage.usage.input +
728
+ lastAssistantMessage.usage.output +
729
+ lastAssistantMessage.usage.cacheRead +
730
+ lastAssistantMessage.usage.cacheWrite
731
+ : 0;
732
+ const contextWindow = model.contextWindow || 200000;
733
+ const summary = log.logUsageSummary(runState.logCtx, runState.totalUsage, contextTokens, contextWindow);
734
+ runState.queue.enqueue(() => responseCtx.respondInThread(summary), "usage summary");
735
+ await queueChain;
736
+ }
737
+ // Clear run state
738
+ runState.responseCtx = null;
739
+ runState.logCtx = null;
740
+ runState.queue = null;
741
+ return { stopReason: runState.stopReason, errorMessage: runState.errorMessage };
742
+ },
743
+ abort() {
744
+ session.abort();
745
+ },
746
+ };
747
+ }
748
+ /**
749
+ * Translate container path back to host path for file operations
750
+ */
751
+ function translateToHostPath(containerPath, channelDir, workspacePath, channelId) {
752
+ if (workspacePath === "/workspace") {
753
+ const prefix = `/workspace/${channelId}/`;
754
+ if (containerPath.startsWith(prefix)) {
755
+ return join(channelDir, containerPath.slice(prefix.length));
756
+ }
757
+ if (containerPath.startsWith("/workspace/")) {
758
+ return join(channelDir, "..", containerPath.slice("/workspace/".length));
759
+ }
760
+ }
761
+ return containerPath;
762
+ }
763
+ //# sourceMappingURL=agent.js.map