Release v0.18.5

This commit is contained in:
Mario Zechner 2025-12-12 10:00:57 +01:00
parent 29cbf16218
commit 44e9b1c8e9
14 changed files with 274 additions and 99 deletions

View file

@ -1,6 +1,14 @@
# Changelog
## [Unreleased]
## [0.18.5] - 2025-12-12
### Added
- `--download <channel-id>` flag to download a channel's full history including thread replies as plain text
### Fixed
- Error handling: when agent returns `stopReason: "error"`, main message is updated to "Sorry, something went wrong" and error details are posted to the thread
## [0.18.4] - 2025-12-11

View file

@ -1,6 +1,6 @@
{
"name": "@mariozechner/pi-mom",
"version": "0.18.4",
"version": "0.18.5",
"description": "Slack bot that delegates messages to the pi coding agent",
"type": "module",
"bin": {
@ -21,9 +21,9 @@
},
"dependencies": {
"@anthropic-ai/sandbox-runtime": "^0.0.16",
"@mariozechner/pi-agent-core": "^0.18.4",
"@mariozechner/pi-ai": "^0.18.4",
"@mariozechner/pi-coding-agent": "^0.18.4",
"@mariozechner/pi-agent-core": "^0.18.5",
"@mariozechner/pi-ai": "^0.18.5",
"@mariozechner/pi-coding-agent": "^0.18.5",
"@sinclair/typebox": "^0.34.0",
"@slack/socket-mode": "^2.0.0",
"@slack/web-api": "^7.0.0",

View file

@ -42,7 +42,11 @@ export interface PendingMessage {
}
export interface AgentRunner {
run(ctx: SlackContext, store: ChannelStore, pendingMessages?: PendingMessage[]): Promise<{ stopReason: string }>;
run(
ctx: SlackContext,
store: ChannelStore,
pendingMessages?: PendingMessage[],
): Promise<{ stopReason: string; errorMessage?: string }>;
abort(): void;
}
@ -346,6 +350,7 @@ function createRunner(sandboxConfig: SandboxConfig, channelId: string, channelDi
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
errorMessage: undefined as string | undefined,
};
// Subscribe to events ONCE
@ -412,6 +417,9 @@ function createRunner(sandboxConfig: SandboxConfig, channelId: string, channelDi
if (assistantMsg.stopReason) {
runState.stopReason = assistantMsg.stopReason;
}
if (assistantMsg.errorMessage) {
runState.errorMessage = assistantMsg.errorMessage;
}
if (assistantMsg.usage) {
runState.totalUsage.input += assistantMsg.usage.input;
@ -492,7 +500,7 @@ function createRunner(sandboxConfig: SandboxConfig, channelId: string, channelDi
ctx: SlackContext,
_store: ChannelStore,
_pendingMessages?: PendingMessage[],
): Promise<{ stopReason: string }> {
): Promise<{ stopReason: string; errorMessage?: string }> {
// Ensure channel directory exists
await mkdir(channelDir, { recursive: true });
@ -538,6 +546,7 @@ function createRunner(sandboxConfig: SandboxConfig, channelId: string, channelDi
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
runState.stopReason = "stop";
runState.errorMessage = undefined;
// Create queue for this run
let queueChain = Promise.resolve();
@ -595,25 +604,36 @@ function createRunner(sandboxConfig: SandboxConfig, channelId: string, channelDi
// Wait for queued messages
await queueChain;
// Final message update
const messages = session.messages;
const lastAssistant = messages.filter((m) => m.role === "assistant").pop();
const finalText =
lastAssistant?.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("\n") || "";
if (finalText.trim()) {
// Handle error case - update main message and post error to thread
if (runState.stopReason === "error" && runState.errorMessage) {
try {
const mainText =
finalText.length > SLACK_MAX_LENGTH
? finalText.substring(0, SLACK_MAX_LENGTH - 50) + "\n\n_(see thread for full response)_"
: finalText;
await ctx.replaceMessage(mainText);
await ctx.replaceMessage("_Sorry, something went wrong_");
await ctx.respondInThread(`_Error: ${runState.errorMessage}_`);
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
log.logWarning("Failed to replace message with final text", errMsg);
log.logWarning("Failed to post error message", errMsg);
}
} else {
// Final message update
const messages = session.messages;
const lastAssistant = messages.filter((m) => m.role === "assistant").pop();
const finalText =
lastAssistant?.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("\n") || "";
if (finalText.trim()) {
try {
const mainText =
finalText.length > SLACK_MAX_LENGTH
? finalText.substring(0, SLACK_MAX_LENGTH - 50) + "\n\n_(see thread for full response)_"
: finalText;
await ctx.replaceMessage(mainText);
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
log.logWarning("Failed to replace message with final text", errMsg);
}
}
}
@ -644,7 +664,7 @@ function createRunner(sandboxConfig: SandboxConfig, channelId: string, channelDi
runState.logCtx = null;
runState.queue = null;
return { stopReason: runState.stopReason };
return { stopReason: runState.stopReason, errorMessage: runState.errorMessage };
},
abort(): void {

View file

@ -0,0 +1,117 @@
import { LogLevel, WebClient } from "@slack/web-api";
interface Message {
ts: string;
user?: string;
text?: string;
thread_ts?: string;
reply_count?: number;
files?: Array<{ name: string; url_private?: string }>;
}
function formatTs(ts: string): string {
const date = new Date(parseFloat(ts) * 1000);
return date
.toISOString()
.replace("T", " ")
.replace(/\.\d+Z$/, "");
}
function formatMessage(ts: string, user: string, text: string, indent = ""): string {
const prefix = `[${formatTs(ts)}] ${user}: `;
const lines = text.split("\n");
const firstLine = `${indent}${prefix}${lines[0]}`;
if (lines.length === 1) return firstLine;
// All continuation lines get same indent as content start
const contentIndent = indent + " ".repeat(prefix.length);
return [firstLine, ...lines.slice(1).map((l) => contentIndent + l)].join("\n");
}
export async function downloadChannel(channelId: string, botToken: string): Promise<void> {
const client = new WebClient(botToken, { logLevel: LogLevel.ERROR });
console.error(`Fetching channel info for ${channelId}...`);
// Get channel info
let channelName = channelId;
try {
const info = await client.conversations.info({ channel: channelId });
channelName = (info.channel as any)?.name || channelId;
} catch {
// DM channels don't have names, that's fine
}
console.error(`Downloading history for #${channelName} (${channelId})...`);
// Fetch all messages
const messages: Message[] = [];
let cursor: string | undefined;
do {
const response = await client.conversations.history({
channel: channelId,
limit: 200,
cursor,
});
if (response.messages) {
messages.push(...(response.messages as Message[]));
}
cursor = response.response_metadata?.next_cursor;
console.error(` Fetched ${messages.length} messages...`);
} while (cursor);
// Reverse to chronological order
messages.reverse();
// Build map of thread replies
const threadReplies = new Map<string, Message[]>();
const threadsToFetch = messages.filter((m) => m.reply_count && m.reply_count > 0);
console.error(`Fetching ${threadsToFetch.length} threads...`);
for (let i = 0; i < threadsToFetch.length; i++) {
const parent = threadsToFetch[i];
console.error(` Thread ${i + 1}/${threadsToFetch.length} (${parent.reply_count} replies)...`);
const replies: Message[] = [];
let threadCursor: string | undefined;
do {
const response = await client.conversations.replies({
channel: channelId,
ts: parent.ts,
limit: 200,
cursor: threadCursor,
});
if (response.messages) {
// Skip the first message (it's the parent)
replies.push(...(response.messages as Message[]).slice(1));
}
threadCursor = response.response_metadata?.next_cursor;
} while (threadCursor);
threadReplies.set(parent.ts, replies);
}
// Output messages with thread replies interleaved
let totalReplies = 0;
for (const msg of messages) {
// Output the message
console.log(formatMessage(msg.ts, msg.user || "unknown", msg.text || ""));
// Output thread replies right after parent (indented)
const replies = threadReplies.get(msg.ts);
if (replies) {
for (const reply of replies) {
console.log(formatMessage(reply.ts, reply.user || "unknown", reply.text || "", " "));
totalReplies++;
}
}
}
console.error(`Done! ${messages.length} messages, ${totalReplies} thread replies`);
}

View file

@ -3,6 +3,7 @@
import { join, resolve } from "path";
import { type AgentRunner, getOrCreateRunner } from "./agent.js";
import { syncLogToContext } from "./context.js";
import { downloadChannel } from "./download.js";
import * as log from "./log.js";
import { parseSandboxArg, type SandboxConfig, validateSandbox } from "./sandbox.js";
import { type MomHandler, type SlackBot, SlackBot as SlackBotClass, type SlackEvent } from "./slack.js";
@ -17,10 +18,17 @@ 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;
function parseArgs(): { workingDir: string; sandbox: SandboxConfig } {
interface ParsedArgs {
workingDir?: string;
sandbox: SandboxConfig;
downloadChannel?: string;
}
function parseArgs(): ParsedArgs {
const args = process.argv.slice(2);
let sandbox: SandboxConfig = { type: "host" };
let workingDir: string | undefined;
let downloadChannelId: string | undefined;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
@ -28,20 +36,42 @@ function parseArgs(): { workingDir: string; sandbox: SandboxConfig } {
sandbox = parseSandboxArg(arg.slice("--sandbox=".length));
} else if (arg === "--sandbox") {
sandbox = parseSandboxArg(args[++i] || "");
} else if (arg.startsWith("--download=")) {
downloadChannelId = arg.slice("--download=".length);
} else if (arg === "--download") {
downloadChannelId = args[++i];
} else if (!arg.startsWith("-")) {
workingDir = arg;
}
}
if (!workingDir) {
console.error("Usage: mom [--sandbox=host|docker:<name>] <working-directory>");
process.exit(1);
}
return { workingDir: resolve(workingDir), sandbox };
return {
workingDir: workingDir ? resolve(workingDir) : undefined,
sandbox,
downloadChannel: downloadChannelId,
};
}
const { workingDir, sandbox } = parseArgs();
const parsedArgs = parseArgs();
// Handle --download mode
if (parsedArgs.downloadChannel) {
if (!MOM_SLACK_BOT_TOKEN) {
console.error("Missing env: MOM_SLACK_BOT_TOKEN");
process.exit(1);
}
await downloadChannel(parsedArgs.downloadChannel, MOM_SLACK_BOT_TOKEN);
process.exit(0);
}
// Normal bot mode - require working dir
if (!parsedArgs.workingDir) {
console.error("Usage: mom [--sandbox=host|docker:<name>] <working-directory>");
console.error(" mom --download <channel-id>");
process.exit(1);
}
const { workingDir, sandbox } = { workingDir: parsedArgs.workingDir, sandbox: parsedArgs.sandbox };
if (!MOM_SLACK_APP_TOKEN || !MOM_SLACK_BOT_TOKEN || (!ANTHROPIC_API_KEY && !ANTHROPIC_OAUTH_TOKEN)) {
console.error("Missing env: MOM_SLACK_APP_TOKEN, MOM_SLACK_BOT_TOKEN, ANTHROPIC_API_KEY or ANTHROPIC_OAUTH_TOKEN");