mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-17 10:02:23 +00:00
Release v0.18.5
This commit is contained in:
parent
29cbf16218
commit
44e9b1c8e9
14 changed files with 274 additions and 99 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
117
packages/mom/src/download.ts
Normal file
117
packages/mom/src/download.ts
Normal 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`);
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue