mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-21 12:00:15 +00:00
mom: rewrite message handling - log.jsonl and context.jsonl sync
- log.jsonl is source of truth, context.jsonl syncs from it at run start - Backfill fetches missing messages from Slack API on startup - Messages sent while mom is busy are logged and synced on next run - Channel chatter (no @mention) logged but doesn't trigger processing - Pre-startup messages (replayed by Slack) logged but not processed - Stop command executes immediately, not queued - Session header written immediately on new session creation - Deduplicate messages by timestamp - Strip @mentions from backfilled messages - Remove old slack.ts and main.ts, rename *-new.ts versions
This commit is contained in:
parent
e513127b3b
commit
99fe4802ef
5 changed files with 1142 additions and 969 deletions
|
|
@ -1,17 +1,22 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { join, resolve } from "path";
|
||||
import { type AgentRunner, createAgentRunner } from "./agent.js";
|
||||
import { type AgentRunner, getOrCreateRunner } from "./agent.js";
|
||||
import { syncLogToContext } from "./context.js";
|
||||
import * as log from "./log.js";
|
||||
import { parseSandboxArg, type SandboxConfig, validateSandbox } from "./sandbox.js";
|
||||
import { MomBot, type SlackContext } from "./slack.js";
|
||||
import { type MomHandler, type SlackBot, SlackBot as SlackBotClass, type SlackEvent } from "./slack.js";
|
||||
import { ChannelStore } from "./store.js";
|
||||
|
||||
// ============================================================================
|
||||
// Config
|
||||
// ============================================================================
|
||||
|
||||
const MOM_SLACK_APP_TOKEN = process.env.MOM_SLACK_APP_TOKEN;
|
||||
const MOM_SLACK_BOT_TOKEN = process.env.MOM_SLACK_BOT_TOKEN;
|
||||
const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
|
||||
const ANTHROPIC_OAUTH_TOKEN = process.env.ANTHROPIC_OAUTH_TOKEN;
|
||||
|
||||
// Parse command line arguments
|
||||
function parseArgs(): { workingDir: string; sandbox: SandboxConfig } {
|
||||
const args = process.argv.slice(2);
|
||||
let sandbox: SandboxConfig = { type: "host" };
|
||||
|
|
@ -22,30 +27,14 @@ function parseArgs(): { workingDir: string; sandbox: SandboxConfig } {
|
|||
if (arg.startsWith("--sandbox=")) {
|
||||
sandbox = parseSandboxArg(arg.slice("--sandbox=".length));
|
||||
} else if (arg === "--sandbox") {
|
||||
const next = args[++i];
|
||||
if (!next) {
|
||||
console.error("Error: --sandbox requires a value (host or docker:<container-name>)");
|
||||
process.exit(1);
|
||||
}
|
||||
sandbox = parseSandboxArg(next);
|
||||
sandbox = parseSandboxArg(args[++i] || "");
|
||||
} else if (!arg.startsWith("-")) {
|
||||
workingDir = arg;
|
||||
} else {
|
||||
console.error(`Unknown option: ${arg}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (!workingDir) {
|
||||
console.error("Usage: mom [--sandbox=host|docker:<container-name>] <working-directory>");
|
||||
console.error("");
|
||||
console.error("Options:");
|
||||
console.error(" --sandbox=host Run tools directly on host (default)");
|
||||
console.error(" --sandbox=docker:<container> Run tools in Docker container");
|
||||
console.error("");
|
||||
console.error("Examples:");
|
||||
console.error(" mom ./data");
|
||||
console.error(" mom --sandbox=docker:mom-sandbox ./data");
|
||||
console.error("Usage: mom [--sandbox=host|docker:<name>] <working-directory>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
|
@ -54,101 +43,210 @@ function parseArgs(): { workingDir: string; sandbox: SandboxConfig } {
|
|||
|
||||
const { workingDir, sandbox } = parseArgs();
|
||||
|
||||
log.logStartup(workingDir, sandbox.type === "host" ? "host" : `docker:${sandbox.container}`);
|
||||
|
||||
if (!MOM_SLACK_APP_TOKEN || !MOM_SLACK_BOT_TOKEN || (!ANTHROPIC_API_KEY && !ANTHROPIC_OAUTH_TOKEN)) {
|
||||
console.error("Missing required environment variables:");
|
||||
if (!MOM_SLACK_APP_TOKEN) console.error(" - MOM_SLACK_APP_TOKEN (xapp-...)");
|
||||
if (!MOM_SLACK_BOT_TOKEN) console.error(" - MOM_SLACK_BOT_TOKEN (xoxb-...)");
|
||||
if (!ANTHROPIC_API_KEY && !ANTHROPIC_OAUTH_TOKEN) console.error(" - ANTHROPIC_API_KEY or ANTHROPIC_OAUTH_TOKEN");
|
||||
console.error("Missing env: MOM_SLACK_APP_TOKEN, MOM_SLACK_BOT_TOKEN, ANTHROPIC_API_KEY or ANTHROPIC_OAUTH_TOKEN");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Validate sandbox configuration
|
||||
await validateSandbox(sandbox);
|
||||
|
||||
// Track active agent runs per channel
|
||||
const activeRuns = new Map<string, { runner: AgentRunner; context: SlackContext; stopContext?: SlackContext }>();
|
||||
// ============================================================================
|
||||
// State (per channel)
|
||||
// ============================================================================
|
||||
|
||||
async function handleMessage(ctx: SlackContext, _source: "channel" | "dm"): Promise<void> {
|
||||
const channelId = ctx.message.channel;
|
||||
const messageText = ctx.message.text.toLowerCase().trim();
|
||||
|
||||
const logCtx = {
|
||||
channelId: ctx.message.channel,
|
||||
userName: ctx.message.userName,
|
||||
channelName: ctx.channelName,
|
||||
};
|
||||
|
||||
// Check for stop command
|
||||
if (messageText === "stop") {
|
||||
const active = activeRuns.get(channelId);
|
||||
if (active) {
|
||||
log.logStopRequest(logCtx);
|
||||
// Post a NEW message saying "Stopping..."
|
||||
await ctx.respond("_Stopping..._");
|
||||
// Store this context to update it to "Stopped" later
|
||||
active.stopContext = ctx;
|
||||
// Abort the runner
|
||||
active.runner.abort();
|
||||
} else {
|
||||
await ctx.respond("_Nothing running._");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if already running in this channel
|
||||
if (activeRuns.has(channelId)) {
|
||||
await ctx.respond("_Already working on something. Say `@mom stop` to cancel._");
|
||||
return;
|
||||
}
|
||||
|
||||
log.logUserMessage(logCtx, ctx.message.text);
|
||||
const channelDir = join(workingDir, channelId);
|
||||
|
||||
const runner = createAgentRunner(sandbox);
|
||||
activeRuns.set(channelId, { runner, context: ctx });
|
||||
|
||||
await ctx.setTyping(true);
|
||||
await ctx.setWorking(true);
|
||||
|
||||
const result = await runner.run(ctx, channelDir, ctx.store);
|
||||
|
||||
// Remove working indicator
|
||||
await ctx.setWorking(false);
|
||||
|
||||
// Handle different stop reasons
|
||||
const active = activeRuns.get(channelId);
|
||||
if (result.stopReason === "aborted") {
|
||||
// Replace the STOP message with "Stopped"
|
||||
if (active?.stopContext) {
|
||||
await active.stopContext.setWorking(false);
|
||||
await active.stopContext.replaceMessage("_Stopped_");
|
||||
}
|
||||
} else if (result.stopReason === "error") {
|
||||
// Agent encountered an error
|
||||
log.logAgentError(logCtx, "Agent stopped with error");
|
||||
}
|
||||
// "stop", "length", "toolUse" are normal completions - nothing extra to do
|
||||
|
||||
activeRuns.delete(channelId);
|
||||
interface ChannelState {
|
||||
running: boolean;
|
||||
runner: AgentRunner;
|
||||
store: ChannelStore;
|
||||
stopRequested: boolean;
|
||||
stopMessageTs?: string;
|
||||
}
|
||||
|
||||
const bot = new MomBot(
|
||||
{
|
||||
async onChannelMention(ctx) {
|
||||
await handleMessage(ctx, "channel");
|
||||
const channelStates = new Map<string, ChannelState>();
|
||||
|
||||
function getState(channelId: string): ChannelState {
|
||||
let state = channelStates.get(channelId);
|
||||
if (!state) {
|
||||
const channelDir = join(workingDir, channelId);
|
||||
state = {
|
||||
running: false,
|
||||
runner: getOrCreateRunner(sandbox, channelId, channelDir),
|
||||
store: new ChannelStore({ workingDir, botToken: MOM_SLACK_BOT_TOKEN! }),
|
||||
stopRequested: false,
|
||||
};
|
||||
channelStates.set(channelId, state);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Create SlackContext adapter
|
||||
// ============================================================================
|
||||
|
||||
function createSlackContext(event: SlackEvent, slack: SlackBot, state: ChannelState) {
|
||||
let messageTs: string | null = null;
|
||||
let accumulatedText = "";
|
||||
let isWorking = true;
|
||||
const workingIndicator = " ...";
|
||||
let updatePromise = Promise.resolve();
|
||||
|
||||
const user = slack.getUser(event.user);
|
||||
|
||||
return {
|
||||
message: {
|
||||
text: event.text,
|
||||
rawText: event.text,
|
||||
user: event.user,
|
||||
userName: user?.userName,
|
||||
channel: event.channel,
|
||||
ts: event.ts,
|
||||
attachments: [],
|
||||
},
|
||||
channelName: slack.getChannel(event.channel)?.name,
|
||||
store: state.store,
|
||||
channels: slack.getAllChannels().map((c) => ({ id: c.id, name: c.name })),
|
||||
users: slack.getAllUsers().map((u) => ({ id: u.id, userName: u.userName, displayName: u.displayName })),
|
||||
|
||||
respond: async (text: string, shouldLog = true) => {
|
||||
updatePromise = updatePromise.then(async () => {
|
||||
accumulatedText = accumulatedText ? accumulatedText + "\n" + text : text;
|
||||
const displayText = isWorking ? accumulatedText + workingIndicator : accumulatedText;
|
||||
|
||||
if (messageTs) {
|
||||
await slack.updateMessage(event.channel, messageTs, displayText);
|
||||
} else {
|
||||
messageTs = await slack.postMessage(event.channel, displayText);
|
||||
}
|
||||
|
||||
if (shouldLog && messageTs) {
|
||||
slack.logBotResponse(event.channel, text, messageTs);
|
||||
}
|
||||
});
|
||||
await updatePromise;
|
||||
},
|
||||
|
||||
async onDirectMessage(ctx) {
|
||||
await handleMessage(ctx, "dm");
|
||||
replaceMessage: async (text: string) => {
|
||||
updatePromise = updatePromise.then(async () => {
|
||||
accumulatedText = text;
|
||||
const displayText = isWorking ? accumulatedText + workingIndicator : accumulatedText;
|
||||
if (messageTs) {
|
||||
await slack.updateMessage(event.channel, messageTs, displayText);
|
||||
} else {
|
||||
messageTs = await slack.postMessage(event.channel, displayText);
|
||||
}
|
||||
});
|
||||
await updatePromise;
|
||||
},
|
||||
|
||||
respondInThread: async (text: string) => {
|
||||
updatePromise = updatePromise.then(async () => {
|
||||
if (messageTs) {
|
||||
await slack.postInThread(event.channel, messageTs, text);
|
||||
}
|
||||
});
|
||||
await updatePromise;
|
||||
},
|
||||
|
||||
setTyping: async (isTyping: boolean) => {
|
||||
if (isTyping && !messageTs) {
|
||||
accumulatedText = "_Thinking_";
|
||||
messageTs = await slack.postMessage(event.channel, accumulatedText + workingIndicator);
|
||||
}
|
||||
},
|
||||
|
||||
uploadFile: async (filePath: string, title?: string) => {
|
||||
await slack.uploadFile(event.channel, filePath, title);
|
||||
},
|
||||
|
||||
setWorking: async (working: boolean) => {
|
||||
updatePromise = updatePromise.then(async () => {
|
||||
isWorking = working;
|
||||
if (messageTs) {
|
||||
const displayText = isWorking ? accumulatedText + workingIndicator : accumulatedText;
|
||||
await slack.updateMessage(event.channel, messageTs, displayText);
|
||||
}
|
||||
});
|
||||
await updatePromise;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Handler
|
||||
// ============================================================================
|
||||
|
||||
const handler: MomHandler = {
|
||||
isRunning(channelId: string): boolean {
|
||||
const state = channelStates.get(channelId);
|
||||
return state?.running ?? false;
|
||||
},
|
||||
{
|
||||
appToken: MOM_SLACK_APP_TOKEN,
|
||||
botToken: MOM_SLACK_BOT_TOKEN,
|
||||
workingDir,
|
||||
|
||||
async handleStop(channelId: string, slack: SlackBot): Promise<void> {
|
||||
const state = channelStates.get(channelId);
|
||||
if (state?.running) {
|
||||
state.stopRequested = true;
|
||||
state.runner.abort();
|
||||
const ts = await slack.postMessage(channelId, "_Stopping..._");
|
||||
state.stopMessageTs = ts; // Save for updating later
|
||||
} else {
|
||||
await slack.postMessage(channelId, "_Nothing running_");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
async handleEvent(event: SlackEvent, slack: SlackBot): Promise<void> {
|
||||
const state = getState(event.channel);
|
||||
const channelDir = join(workingDir, event.channel);
|
||||
|
||||
// Start run
|
||||
state.running = true;
|
||||
state.stopRequested = false;
|
||||
|
||||
log.logInfo(`[${event.channel}] Starting run: ${event.text.substring(0, 50)}`);
|
||||
|
||||
try {
|
||||
// SYNC context from log.jsonl BEFORE processing
|
||||
// This adds any messages that were logged while mom wasn't running
|
||||
// Exclude messages >= current ts (will be handled by agent)
|
||||
const syncedCount = syncLogToContext(channelDir, event.ts);
|
||||
if (syncedCount > 0) {
|
||||
log.logInfo(`[${event.channel}] Synced ${syncedCount} messages from log to context`);
|
||||
}
|
||||
|
||||
// Create context adapter
|
||||
const ctx = createSlackContext(event, slack, state);
|
||||
|
||||
// Run the agent
|
||||
await ctx.setTyping(true);
|
||||
await ctx.setWorking(true);
|
||||
const result = await state.runner.run(ctx as any, state.store);
|
||||
await ctx.setWorking(false);
|
||||
|
||||
if (result.stopReason === "aborted" && state.stopRequested) {
|
||||
if (state.stopMessageTs) {
|
||||
await slack.updateMessage(event.channel, state.stopMessageTs, "_Stopped_");
|
||||
state.stopMessageTs = undefined;
|
||||
} else {
|
||||
await slack.postMessage(event.channel, "_Stopped_");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log.logWarning(`[${event.channel}] Run error`, err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
state.running = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Start
|
||||
// ============================================================================
|
||||
|
||||
log.logStartup(workingDir, sandbox.type === "host" ? "host" : `docker:${sandbox.container}`);
|
||||
|
||||
const bot = new SlackBotClass(handler, {
|
||||
appToken: MOM_SLACK_APP_TOKEN,
|
||||
botToken: MOM_SLACK_BOT_TOKEN,
|
||||
workingDir,
|
||||
});
|
||||
|
||||
bot.start();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue