@oh-my-pi/pi-mom 1.337.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/CHANGELOG.md +277 -0
- package/README.md +517 -0
- package/dev.sh +30 -0
- package/docker.sh +95 -0
- package/docs/artifacts-server.md +475 -0
- package/docs/events.md +307 -0
- package/docs/new.md +977 -0
- package/docs/sandbox.md +153 -0
- package/docs/slack-bot-minimal-guide.md +399 -0
- package/docs/v86.md +319 -0
- package/package.json +44 -0
- package/scripts/migrate-timestamps.ts +121 -0
- package/src/agent.ts +860 -0
- package/src/context.ts +636 -0
- package/src/download.ts +117 -0
- package/src/events.ts +383 -0
- package/src/log.ts +271 -0
- package/src/main.ts +332 -0
- package/src/sandbox.ts +215 -0
- package/src/slack.ts +623 -0
- package/src/store.ts +234 -0
- package/src/tools/attach.ts +47 -0
- package/src/tools/bash.ts +99 -0
- package/src/tools/edit.ts +165 -0
- package/src/tools/index.ts +19 -0
- package/src/tools/read.ts +165 -0
- package/src/tools/truncate.ts +236 -0
- package/src/tools/write.ts +45 -0
- package/tsconfig.build.json +9 -0
package/src/log.ts
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
|
|
3
|
+
export interface LogContext {
|
|
4
|
+
channelId: string;
|
|
5
|
+
userName?: string;
|
|
6
|
+
channelName?: string; // For display like #dev-team vs C16HET4EQ
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function timestamp(): string {
|
|
10
|
+
const now = new Date();
|
|
11
|
+
const hh = String(now.getHours()).padStart(2, "0");
|
|
12
|
+
const mm = String(now.getMinutes()).padStart(2, "0");
|
|
13
|
+
const ss = String(now.getSeconds()).padStart(2, "0");
|
|
14
|
+
return `[${hh}:${mm}:${ss}]`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function formatContext(ctx: LogContext): string {
|
|
18
|
+
// DMs: [DM:username]
|
|
19
|
+
// Channels: [#channel-name:username] or [C16HET4EQ:username] if no name
|
|
20
|
+
if (ctx.channelId.startsWith("D")) {
|
|
21
|
+
return `[DM:${ctx.userName || ctx.channelId}]`;
|
|
22
|
+
}
|
|
23
|
+
const channel = ctx.channelName || ctx.channelId;
|
|
24
|
+
const user = ctx.userName || "unknown";
|
|
25
|
+
return `[${channel.startsWith("#") ? channel : `#${channel}`}:${user}]`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function truncate(text: string, maxLen: number): string {
|
|
29
|
+
if (text.length <= maxLen) return text;
|
|
30
|
+
return `${text.substring(0, maxLen)}\n(truncated at ${maxLen} chars)`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function formatToolArgs(args: Record<string, unknown>): string {
|
|
34
|
+
const lines: string[] = [];
|
|
35
|
+
|
|
36
|
+
for (const [key, value] of Object.entries(args)) {
|
|
37
|
+
// Skip the label - it's already shown in the tool name
|
|
38
|
+
if (key === "label") continue;
|
|
39
|
+
|
|
40
|
+
// For read tool, format path with offset/limit
|
|
41
|
+
if (key === "path" && typeof value === "string") {
|
|
42
|
+
const offset = args.offset as number | undefined;
|
|
43
|
+
const limit = args.limit as number | undefined;
|
|
44
|
+
if (offset !== undefined && limit !== undefined) {
|
|
45
|
+
lines.push(`${value}:${offset}-${offset + limit}`);
|
|
46
|
+
} else {
|
|
47
|
+
lines.push(value);
|
|
48
|
+
}
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Skip offset/limit since we already handled them
|
|
53
|
+
if (key === "offset" || key === "limit") continue;
|
|
54
|
+
|
|
55
|
+
// For other values, format them
|
|
56
|
+
if (typeof value === "string") {
|
|
57
|
+
// Multi-line strings get indented
|
|
58
|
+
if (value.includes("\n")) {
|
|
59
|
+
lines.push(value);
|
|
60
|
+
} else {
|
|
61
|
+
lines.push(value);
|
|
62
|
+
}
|
|
63
|
+
} else {
|
|
64
|
+
lines.push(JSON.stringify(value));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return lines.join("\n");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// User messages
|
|
72
|
+
export function logUserMessage(ctx: LogContext, text: string): void {
|
|
73
|
+
console.log(chalk.green(`${timestamp()} ${formatContext(ctx)} ${text}`));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Tool execution
|
|
77
|
+
export function logToolStart(ctx: LogContext, toolName: string, label: string, args: Record<string, unknown>): void {
|
|
78
|
+
const formattedArgs = formatToolArgs(args);
|
|
79
|
+
console.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} ↳ ${toolName}: ${label}`));
|
|
80
|
+
if (formattedArgs) {
|
|
81
|
+
// Indent the args
|
|
82
|
+
const indented = formattedArgs
|
|
83
|
+
.split("\n")
|
|
84
|
+
.map((line) => ` ${line}`)
|
|
85
|
+
.join("\n");
|
|
86
|
+
console.log(chalk.dim(indented));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function logToolSuccess(ctx: LogContext, toolName: string, durationMs: number, result: string): void {
|
|
91
|
+
const duration = (durationMs / 1000).toFixed(1);
|
|
92
|
+
console.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} ✓ ${toolName} (${duration}s)`));
|
|
93
|
+
|
|
94
|
+
const truncated = truncate(result, 1000);
|
|
95
|
+
if (truncated) {
|
|
96
|
+
const indented = truncated
|
|
97
|
+
.split("\n")
|
|
98
|
+
.map((line) => ` ${line}`)
|
|
99
|
+
.join("\n");
|
|
100
|
+
console.log(chalk.dim(indented));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function logToolError(ctx: LogContext, toolName: string, durationMs: number, error: string): void {
|
|
105
|
+
const duration = (durationMs / 1000).toFixed(1);
|
|
106
|
+
console.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} ✗ ${toolName} (${duration}s)`));
|
|
107
|
+
|
|
108
|
+
const truncated = truncate(error, 1000);
|
|
109
|
+
const indented = truncated
|
|
110
|
+
.split("\n")
|
|
111
|
+
.map((line) => ` ${line}`)
|
|
112
|
+
.join("\n");
|
|
113
|
+
console.log(chalk.dim(indented));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Response streaming
|
|
117
|
+
export function logResponseStart(ctx: LogContext): void {
|
|
118
|
+
console.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} → Streaming response...`));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function logThinking(ctx: LogContext, thinking: string): void {
|
|
122
|
+
console.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} 💭 Thinking`));
|
|
123
|
+
const truncated = truncate(thinking, 1000);
|
|
124
|
+
const indented = truncated
|
|
125
|
+
.split("\n")
|
|
126
|
+
.map((line) => ` ${line}`)
|
|
127
|
+
.join("\n");
|
|
128
|
+
console.log(chalk.dim(indented));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function logResponse(ctx: LogContext, text: string): void {
|
|
132
|
+
console.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} 💬 Response`));
|
|
133
|
+
const truncated = truncate(text, 1000);
|
|
134
|
+
const indented = truncated
|
|
135
|
+
.split("\n")
|
|
136
|
+
.map((line) => ` ${line}`)
|
|
137
|
+
.join("\n");
|
|
138
|
+
console.log(chalk.dim(indented));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Attachments
|
|
142
|
+
export function logDownloadStart(ctx: LogContext, filename: string, localPath: string): void {
|
|
143
|
+
console.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} ↓ Downloading attachment`));
|
|
144
|
+
console.log(chalk.dim(` ${filename} → ${localPath}`));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function logDownloadSuccess(ctx: LogContext, sizeKB: number): void {
|
|
148
|
+
console.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} ✓ Downloaded (${sizeKB.toLocaleString()} KB)`));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function logDownloadError(ctx: LogContext, filename: string, error: string): void {
|
|
152
|
+
console.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} ✗ Download failed`));
|
|
153
|
+
console.log(chalk.dim(` ${filename}: ${error}`));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Control
|
|
157
|
+
export function logStopRequest(ctx: LogContext): void {
|
|
158
|
+
console.log(chalk.green(`${timestamp()} ${formatContext(ctx)} stop`));
|
|
159
|
+
console.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} ⊗ Stop requested - aborting`));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// System
|
|
163
|
+
export function logInfo(message: string): void {
|
|
164
|
+
console.log(chalk.blue(`${timestamp()} [system] ${message}`));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function logWarning(message: string, details?: string): void {
|
|
168
|
+
console.log(chalk.yellow(`${timestamp()} [system] ⚠ ${message}`));
|
|
169
|
+
if (details) {
|
|
170
|
+
const indented = details
|
|
171
|
+
.split("\n")
|
|
172
|
+
.map((line) => ` ${line}`)
|
|
173
|
+
.join("\n");
|
|
174
|
+
console.log(chalk.dim(indented));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function logAgentError(ctx: LogContext | "system", error: string): void {
|
|
179
|
+
const context = ctx === "system" ? "[system]" : formatContext(ctx);
|
|
180
|
+
console.log(chalk.yellow(`${timestamp()} ${context} ✗ Agent error`));
|
|
181
|
+
const indented = error
|
|
182
|
+
.split("\n")
|
|
183
|
+
.map((line) => ` ${line}`)
|
|
184
|
+
.join("\n");
|
|
185
|
+
console.log(chalk.dim(indented));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Usage summary
|
|
189
|
+
export function logUsageSummary(
|
|
190
|
+
ctx: LogContext,
|
|
191
|
+
usage: {
|
|
192
|
+
input: number;
|
|
193
|
+
output: number;
|
|
194
|
+
cacheRead: number;
|
|
195
|
+
cacheWrite: number;
|
|
196
|
+
cost: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number };
|
|
197
|
+
},
|
|
198
|
+
contextTokens?: number,
|
|
199
|
+
contextWindow?: number,
|
|
200
|
+
): string {
|
|
201
|
+
const formatTokens = (count: number): string => {
|
|
202
|
+
if (count < 1000) return count.toString();
|
|
203
|
+
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
|
|
204
|
+
if (count < 1000000) return `${Math.round(count / 1000)}k`;
|
|
205
|
+
return `${(count / 1000000).toFixed(1)}M`;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const lines: string[] = [];
|
|
209
|
+
lines.push("*Usage Summary*");
|
|
210
|
+
lines.push(`Tokens: ${usage.input.toLocaleString()} in, ${usage.output.toLocaleString()} out`);
|
|
211
|
+
if (usage.cacheRead > 0 || usage.cacheWrite > 0) {
|
|
212
|
+
lines.push(`Cache: ${usage.cacheRead.toLocaleString()} read, ${usage.cacheWrite.toLocaleString()} write`);
|
|
213
|
+
}
|
|
214
|
+
if (contextTokens && contextWindow) {
|
|
215
|
+
const contextPercent = ((contextTokens / contextWindow) * 100).toFixed(1);
|
|
216
|
+
lines.push(`Context: ${formatTokens(contextTokens)} / ${formatTokens(contextWindow)} (${contextPercent}%)`);
|
|
217
|
+
}
|
|
218
|
+
lines.push(
|
|
219
|
+
`Cost: $${usage.cost.input.toFixed(4)} in, $${usage.cost.output.toFixed(4)} out` +
|
|
220
|
+
(usage.cacheRead > 0 || usage.cacheWrite > 0
|
|
221
|
+
? `, $${usage.cost.cacheRead.toFixed(4)} cache read, $${usage.cost.cacheWrite.toFixed(4)} cache write`
|
|
222
|
+
: ""),
|
|
223
|
+
);
|
|
224
|
+
lines.push(`*Total: $${usage.cost.total.toFixed(4)}*`);
|
|
225
|
+
|
|
226
|
+
const summary = lines.join("\n");
|
|
227
|
+
|
|
228
|
+
// Log to console
|
|
229
|
+
console.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} 💰 Usage`));
|
|
230
|
+
console.log(
|
|
231
|
+
chalk.dim(
|
|
232
|
+
` ${usage.input.toLocaleString()} in + ${usage.output.toLocaleString()} out` +
|
|
233
|
+
(usage.cacheRead > 0 || usage.cacheWrite > 0
|
|
234
|
+
? ` (${usage.cacheRead.toLocaleString()} cache read, ${usage.cacheWrite.toLocaleString()} cache write)`
|
|
235
|
+
: "") +
|
|
236
|
+
` = $${usage.cost.total.toFixed(4)}`,
|
|
237
|
+
),
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
return summary;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Startup (no context needed)
|
|
244
|
+
export function logStartup(workingDir: string, sandbox: string): void {
|
|
245
|
+
console.log("Starting mom bot...");
|
|
246
|
+
console.log(` Working directory: ${workingDir}`);
|
|
247
|
+
console.log(` Sandbox: ${sandbox}`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function logConnected(): void {
|
|
251
|
+
console.log("⚡️ Mom bot connected and listening!");
|
|
252
|
+
console.log("");
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function logDisconnected(): void {
|
|
256
|
+
console.log("Mom bot disconnected.");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Backfill
|
|
260
|
+
export function logBackfillStart(channelCount: number): void {
|
|
261
|
+
console.log(chalk.blue(`${timestamp()} [system] Backfilling ${channelCount} channels...`));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function logBackfillChannel(channelName: string, messageCount: number): void {
|
|
265
|
+
console.log(chalk.blue(`${timestamp()} [system] #${channelName}: ${messageCount} messages`));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function logBackfillComplete(totalMessages: number, durationMs: number): void {
|
|
269
|
+
const duration = (durationMs / 1000).toFixed(1);
|
|
270
|
+
console.log(chalk.blue(`${timestamp()} [system] Backfill complete: ${totalMessages} messages in ${duration}s`));
|
|
271
|
+
}
|
package/src/main.ts
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { join, resolve } from "path";
|
|
4
|
+
import { type AgentRunner, getOrCreateRunner } from "./agent.js";
|
|
5
|
+
import { syncLogToContext } from "./context.js";
|
|
6
|
+
import { downloadChannel } from "./download.js";
|
|
7
|
+
import { createEventsWatcher } from "./events.js";
|
|
8
|
+
import * as log from "./log.js";
|
|
9
|
+
import { parseSandboxArg, type SandboxConfig, validateSandbox } from "./sandbox.js";
|
|
10
|
+
import { type MomHandler, type SlackBot, SlackBot as SlackBotClass, type SlackEvent } from "./slack.js";
|
|
11
|
+
import { ChannelStore } from "./store.js";
|
|
12
|
+
|
|
13
|
+
// ============================================================================
|
|
14
|
+
// Config
|
|
15
|
+
// ============================================================================
|
|
16
|
+
|
|
17
|
+
const MOM_SLACK_APP_TOKEN = process.env.MOM_SLACK_APP_TOKEN;
|
|
18
|
+
const MOM_SLACK_BOT_TOKEN = process.env.MOM_SLACK_BOT_TOKEN;
|
|
19
|
+
|
|
20
|
+
interface ParsedArgs {
|
|
21
|
+
workingDir?: string;
|
|
22
|
+
sandbox: SandboxConfig;
|
|
23
|
+
downloadChannel?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parseArgs(): ParsedArgs {
|
|
27
|
+
const args = process.argv.slice(2);
|
|
28
|
+
let sandbox: SandboxConfig = { type: "host" };
|
|
29
|
+
let workingDir: string | undefined;
|
|
30
|
+
let downloadChannelId: string | undefined;
|
|
31
|
+
|
|
32
|
+
for (let i = 0; i < args.length; i++) {
|
|
33
|
+
const arg = args[i];
|
|
34
|
+
if (arg.startsWith("--sandbox=")) {
|
|
35
|
+
sandbox = parseSandboxArg(arg.slice("--sandbox=".length));
|
|
36
|
+
} else if (arg === "--sandbox") {
|
|
37
|
+
sandbox = parseSandboxArg(args[++i] || "");
|
|
38
|
+
} else if (arg.startsWith("--download=")) {
|
|
39
|
+
downloadChannelId = arg.slice("--download=".length);
|
|
40
|
+
} else if (arg === "--download") {
|
|
41
|
+
downloadChannelId = args[++i];
|
|
42
|
+
} else if (!arg.startsWith("-")) {
|
|
43
|
+
workingDir = arg;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
workingDir: workingDir ? resolve(workingDir) : undefined,
|
|
49
|
+
sandbox,
|
|
50
|
+
downloadChannel: downloadChannelId,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const parsedArgs = parseArgs();
|
|
55
|
+
|
|
56
|
+
// Handle --download mode
|
|
57
|
+
if (parsedArgs.downloadChannel) {
|
|
58
|
+
if (!MOM_SLACK_BOT_TOKEN) {
|
|
59
|
+
console.error("Missing env: MOM_SLACK_BOT_TOKEN");
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
await downloadChannel(parsedArgs.downloadChannel, MOM_SLACK_BOT_TOKEN);
|
|
63
|
+
process.exit(0);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Normal bot mode - require working dir
|
|
67
|
+
if (!parsedArgs.workingDir) {
|
|
68
|
+
console.error("Usage: mom [--sandbox=host|docker:<name>] <working-directory>");
|
|
69
|
+
console.error(" mom --download <channel-id>");
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const { workingDir, sandbox } = { workingDir: parsedArgs.workingDir, sandbox: parsedArgs.sandbox };
|
|
74
|
+
|
|
75
|
+
if (!MOM_SLACK_APP_TOKEN || !MOM_SLACK_BOT_TOKEN) {
|
|
76
|
+
console.error("Missing env: MOM_SLACK_APP_TOKEN, MOM_SLACK_BOT_TOKEN");
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
await validateSandbox(sandbox);
|
|
81
|
+
|
|
82
|
+
// ============================================================================
|
|
83
|
+
// State (per channel)
|
|
84
|
+
// ============================================================================
|
|
85
|
+
|
|
86
|
+
interface ChannelState {
|
|
87
|
+
running: boolean;
|
|
88
|
+
runner: AgentRunner;
|
|
89
|
+
store: ChannelStore;
|
|
90
|
+
stopRequested: boolean;
|
|
91
|
+
stopMessageTs?: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const channelStates = new Map<string, ChannelState>();
|
|
95
|
+
|
|
96
|
+
function getState(channelId: string): ChannelState {
|
|
97
|
+
let state = channelStates.get(channelId);
|
|
98
|
+
if (!state) {
|
|
99
|
+
const channelDir = join(workingDir, channelId);
|
|
100
|
+
state = {
|
|
101
|
+
running: false,
|
|
102
|
+
runner: getOrCreateRunner(sandbox, channelId, channelDir),
|
|
103
|
+
store: new ChannelStore({ workingDir, botToken: MOM_SLACK_BOT_TOKEN! }),
|
|
104
|
+
stopRequested: false,
|
|
105
|
+
};
|
|
106
|
+
channelStates.set(channelId, state);
|
|
107
|
+
}
|
|
108
|
+
return state;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ============================================================================
|
|
112
|
+
// Create SlackContext adapter
|
|
113
|
+
// ============================================================================
|
|
114
|
+
|
|
115
|
+
function createSlackContext(event: SlackEvent, slack: SlackBot, state: ChannelState, isEvent?: boolean) {
|
|
116
|
+
let messageTs: string | null = null;
|
|
117
|
+
const threadMessageTs: string[] = [];
|
|
118
|
+
let accumulatedText = "";
|
|
119
|
+
let isWorking = true;
|
|
120
|
+
const workingIndicator = " ...";
|
|
121
|
+
let updatePromise = Promise.resolve();
|
|
122
|
+
|
|
123
|
+
const user = slack.getUser(event.user);
|
|
124
|
+
|
|
125
|
+
// Extract event filename for status message
|
|
126
|
+
const eventFilename = isEvent ? event.text.match(/^\[EVENT:([^:]+):/)?.[1] : undefined;
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
message: {
|
|
130
|
+
text: event.text,
|
|
131
|
+
rawText: event.text,
|
|
132
|
+
user: event.user,
|
|
133
|
+
userName: user?.userName,
|
|
134
|
+
channel: event.channel,
|
|
135
|
+
ts: event.ts,
|
|
136
|
+
attachments: (event.attachments || []).map((a) => ({ local: a.local })),
|
|
137
|
+
},
|
|
138
|
+
channelName: slack.getChannel(event.channel)?.name,
|
|
139
|
+
store: state.store,
|
|
140
|
+
channels: slack.getAllChannels().map((c) => ({ id: c.id, name: c.name })),
|
|
141
|
+
users: slack.getAllUsers().map((u) => ({ id: u.id, userName: u.userName, displayName: u.displayName })),
|
|
142
|
+
|
|
143
|
+
respond: async (text: string, shouldLog = true) => {
|
|
144
|
+
updatePromise = updatePromise.then(async () => {
|
|
145
|
+
accumulatedText = accumulatedText ? `${accumulatedText}\n${text}` : text;
|
|
146
|
+
const displayText = isWorking ? accumulatedText + workingIndicator : accumulatedText;
|
|
147
|
+
|
|
148
|
+
if (messageTs) {
|
|
149
|
+
await slack.updateMessage(event.channel, messageTs, displayText);
|
|
150
|
+
} else {
|
|
151
|
+
messageTs = await slack.postMessage(event.channel, displayText);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (shouldLog && messageTs) {
|
|
155
|
+
slack.logBotResponse(event.channel, text, messageTs);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
await updatePromise;
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
replaceMessage: async (text: string) => {
|
|
162
|
+
updatePromise = updatePromise.then(async () => {
|
|
163
|
+
accumulatedText = text;
|
|
164
|
+
const displayText = isWorking ? accumulatedText + workingIndicator : accumulatedText;
|
|
165
|
+
if (messageTs) {
|
|
166
|
+
await slack.updateMessage(event.channel, messageTs, displayText);
|
|
167
|
+
} else {
|
|
168
|
+
messageTs = await slack.postMessage(event.channel, displayText);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
await updatePromise;
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
respondInThread: async (text: string) => {
|
|
175
|
+
updatePromise = updatePromise.then(async () => {
|
|
176
|
+
if (messageTs) {
|
|
177
|
+
const ts = await slack.postInThread(event.channel, messageTs, text);
|
|
178
|
+
threadMessageTs.push(ts);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
await updatePromise;
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
setTyping: async (isTyping: boolean) => {
|
|
185
|
+
if (isTyping && !messageTs) {
|
|
186
|
+
updatePromise = updatePromise.then(async () => {
|
|
187
|
+
if (!messageTs) {
|
|
188
|
+
accumulatedText = eventFilename ? `_Starting event: ${eventFilename}_` : "_Thinking_";
|
|
189
|
+
messageTs = await slack.postMessage(event.channel, accumulatedText + workingIndicator);
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
await updatePromise;
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
uploadFile: async (filePath: string, title?: string) => {
|
|
197
|
+
await slack.uploadFile(event.channel, filePath, title);
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
setWorking: async (working: boolean) => {
|
|
201
|
+
updatePromise = updatePromise.then(async () => {
|
|
202
|
+
isWorking = working;
|
|
203
|
+
if (messageTs) {
|
|
204
|
+
const displayText = isWorking ? accumulatedText + workingIndicator : accumulatedText;
|
|
205
|
+
await slack.updateMessage(event.channel, messageTs, displayText);
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
await updatePromise;
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
deleteMessage: async () => {
|
|
212
|
+
updatePromise = updatePromise.then(async () => {
|
|
213
|
+
// Delete thread messages first (in reverse order)
|
|
214
|
+
for (let i = threadMessageTs.length - 1; i >= 0; i--) {
|
|
215
|
+
try {
|
|
216
|
+
await slack.deleteMessage(event.channel, threadMessageTs[i]);
|
|
217
|
+
} catch {
|
|
218
|
+
// Ignore errors deleting thread messages
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
threadMessageTs.length = 0;
|
|
222
|
+
// Then delete main message
|
|
223
|
+
if (messageTs) {
|
|
224
|
+
await slack.deleteMessage(event.channel, messageTs);
|
|
225
|
+
messageTs = null;
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
await updatePromise;
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ============================================================================
|
|
234
|
+
// Handler
|
|
235
|
+
// ============================================================================
|
|
236
|
+
|
|
237
|
+
const handler: MomHandler = {
|
|
238
|
+
isRunning(channelId: string): boolean {
|
|
239
|
+
const state = channelStates.get(channelId);
|
|
240
|
+
return state?.running ?? false;
|
|
241
|
+
},
|
|
242
|
+
|
|
243
|
+
async handleStop(channelId: string, slack: SlackBot): Promise<void> {
|
|
244
|
+
const state = channelStates.get(channelId);
|
|
245
|
+
if (state?.running) {
|
|
246
|
+
state.stopRequested = true;
|
|
247
|
+
state.runner.abort();
|
|
248
|
+
const ts = await slack.postMessage(channelId, "_Stopping..._");
|
|
249
|
+
state.stopMessageTs = ts; // Save for updating later
|
|
250
|
+
} else {
|
|
251
|
+
await slack.postMessage(channelId, "_Nothing running_");
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
|
|
255
|
+
async handleEvent(event: SlackEvent, slack: SlackBot, isEvent?: boolean): Promise<void> {
|
|
256
|
+
const state = getState(event.channel);
|
|
257
|
+
const channelDir = join(workingDir, event.channel);
|
|
258
|
+
|
|
259
|
+
// Start run
|
|
260
|
+
state.running = true;
|
|
261
|
+
state.stopRequested = false;
|
|
262
|
+
|
|
263
|
+
log.logInfo(`[${event.channel}] Starting run: ${event.text.substring(0, 50)}`);
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
// SYNC context from log.jsonl BEFORE processing
|
|
267
|
+
// This adds any messages that were logged while mom wasn't running
|
|
268
|
+
// Exclude messages >= current ts (will be handled by agent)
|
|
269
|
+
const syncedCount = syncLogToContext(channelDir, event.ts);
|
|
270
|
+
if (syncedCount > 0) {
|
|
271
|
+
log.logInfo(`[${event.channel}] Synced ${syncedCount} messages from log to context`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Create context adapter
|
|
275
|
+
const ctx = createSlackContext(event, slack, state, isEvent);
|
|
276
|
+
|
|
277
|
+
// Run the agent
|
|
278
|
+
await ctx.setTyping(true);
|
|
279
|
+
await ctx.setWorking(true);
|
|
280
|
+
const result = await state.runner.run(ctx as any, state.store);
|
|
281
|
+
await ctx.setWorking(false);
|
|
282
|
+
|
|
283
|
+
if (result.stopReason === "aborted" && state.stopRequested) {
|
|
284
|
+
if (state.stopMessageTs) {
|
|
285
|
+
await slack.updateMessage(event.channel, state.stopMessageTs, "_Stopped_");
|
|
286
|
+
state.stopMessageTs = undefined;
|
|
287
|
+
} else {
|
|
288
|
+
await slack.postMessage(event.channel, "_Stopped_");
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
} catch (err) {
|
|
292
|
+
log.logWarning(`[${event.channel}] Run error`, err instanceof Error ? err.message : String(err));
|
|
293
|
+
} finally {
|
|
294
|
+
state.running = false;
|
|
295
|
+
}
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
// ============================================================================
|
|
300
|
+
// Start
|
|
301
|
+
// ============================================================================
|
|
302
|
+
|
|
303
|
+
log.logStartup(workingDir, sandbox.type === "host" ? "host" : `docker:${sandbox.container}`);
|
|
304
|
+
|
|
305
|
+
// Shared store for attachment downloads (also used per-channel in getState)
|
|
306
|
+
const sharedStore = new ChannelStore({ workingDir, botToken: MOM_SLACK_BOT_TOKEN! });
|
|
307
|
+
|
|
308
|
+
const bot = new SlackBotClass(handler, {
|
|
309
|
+
appToken: MOM_SLACK_APP_TOKEN,
|
|
310
|
+
botToken: MOM_SLACK_BOT_TOKEN,
|
|
311
|
+
workingDir,
|
|
312
|
+
store: sharedStore,
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
// Start events watcher
|
|
316
|
+
const eventsWatcher = createEventsWatcher(workingDir, bot);
|
|
317
|
+
eventsWatcher.start();
|
|
318
|
+
|
|
319
|
+
// Handle shutdown
|
|
320
|
+
process.on("SIGINT", () => {
|
|
321
|
+
log.logInfo("Shutting down...");
|
|
322
|
+
eventsWatcher.stop();
|
|
323
|
+
process.exit(0);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
process.on("SIGTERM", () => {
|
|
327
|
+
log.logInfo("Shutting down...");
|
|
328
|
+
eventsWatcher.stop();
|
|
329
|
+
process.exit(0);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
bot.start();
|