mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-21 20:04:55 +00:00
mom: Slack bot with abort support, streaming console output, removed sandbox
This commit is contained in:
parent
a7423b954e
commit
aa9e058249
22 changed files with 2741 additions and 58 deletions
242
packages/mom/src/agent.ts
Normal file
242
packages/mom/src/agent.ts
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
import { Agent, type AgentEvent, ProviderTransport } from "@mariozechner/pi-agent-core";
|
||||
import { getModel } from "@mariozechner/pi-ai";
|
||||
import { existsSync, readFileSync, rmSync } from "fs";
|
||||
import { mkdtemp } from "fs/promises";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
|
||||
import type { SlackContext } from "./slack.js";
|
||||
import type { ChannelStore } from "./store.js";
|
||||
import { momTools, setUploadFunction } from "./tools/index.js";
|
||||
|
||||
// Hardcoded model for now
|
||||
const model = getModel("anthropic", "claude-opus-4-5");
|
||||
|
||||
export interface AgentRunner {
|
||||
run(ctx: SlackContext, channelDir: string, store: ChannelStore): Promise<void>;
|
||||
abort(): void;
|
||||
}
|
||||
|
||||
function getAnthropicApiKey(): string {
|
||||
const key = process.env.ANTHROPIC_OAUTH_TOKEN || process.env.ANTHROPIC_API_KEY;
|
||||
if (!key) {
|
||||
throw new Error("ANTHROPIC_OAUTH_TOKEN or ANTHROPIC_API_KEY must be set");
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
function getRecentMessages(channelDir: string, count: number): string {
|
||||
const logPath = join(channelDir, "log.jsonl");
|
||||
if (!existsSync(logPath)) {
|
||||
return "(no message history yet)";
|
||||
}
|
||||
|
||||
const content = readFileSync(logPath, "utf-8");
|
||||
const lines = content.trim().split("\n").filter(Boolean);
|
||||
const recentLines = lines.slice(-count);
|
||||
|
||||
if (recentLines.length === 0) {
|
||||
return "(no message history yet)";
|
||||
}
|
||||
|
||||
return recentLines.join("\n");
|
||||
}
|
||||
|
||||
function buildSystemPrompt(channelDir: string, scratchpadDir: string, recentMessages: string): string {
|
||||
return `You are mom, a helpful Slack bot assistant.
|
||||
|
||||
## Communication Style
|
||||
- Be concise and professional
|
||||
- Do not use emojis unless the user communicates informally with you
|
||||
- Get to the point quickly
|
||||
- If you need clarification, ask directly
|
||||
- Use Slack's mrkdwn format (NOT standard Markdown):
|
||||
- Bold: *text* (single asterisks)
|
||||
- Italic: _text_
|
||||
- Strikethrough: ~text~
|
||||
- Code: \`code\`
|
||||
- Code block: \`\`\`code\`\`\`
|
||||
- Links: <url|text>
|
||||
- Do NOT use **double asterisks** or [markdown](links)
|
||||
|
||||
## Channel Data
|
||||
The channel's data directory is: ${channelDir}
|
||||
|
||||
### Message History
|
||||
- File: ${channelDir}/log.jsonl
|
||||
- Format: One JSON object per line (JSONL)
|
||||
- Each line has: {"ts", "user", "userName", "displayName", "text", "attachments", "isBot"}
|
||||
- "ts" is the Slack timestamp
|
||||
- "user" is the user ID, "userName" is their handle, "displayName" is their full name
|
||||
- "attachments" is an array of {"original", "local"} where "local" is the path relative to the working directory
|
||||
- "isBot" is true for bot responses
|
||||
|
||||
### Recent Messages (last 50)
|
||||
Below are the most recent messages. If you need more context, read ${channelDir}/log.jsonl directly.
|
||||
|
||||
${recentMessages}
|
||||
|
||||
### Attachments
|
||||
Files shared in the channel are stored in: ${channelDir}/attachments/
|
||||
The "local" field in attachments points to these files.
|
||||
|
||||
## Scratchpad
|
||||
Your temporary working directory is: ${scratchpadDir}
|
||||
Use this for any file operations. It will be deleted after you complete.
|
||||
|
||||
## Tools
|
||||
You have access to: read, edit, write, bash, attach tools.
|
||||
- read: Read files
|
||||
- edit: Edit files
|
||||
- write: Write new files
|
||||
- bash: Run shell commands
|
||||
- attach: Attach a file to your response (share files with the user)
|
||||
|
||||
Each tool requires a "label" parameter - this is a brief description of what you're doing that will be shown to the user.
|
||||
Keep labels short and informative, e.g., "Reading message history" or "Searching for user's previous questions".
|
||||
|
||||
## Guidelines
|
||||
- Be concise and helpful
|
||||
- If you need more conversation history beyond the recent messages above, read log.jsonl
|
||||
- Use the scratchpad for any temporary work
|
||||
|
||||
## CRITICAL
|
||||
- DO NOT USE EMOJIS. KEEP YOUR RESPONSES AS SHORT AS POSSIBLE.
|
||||
`;
|
||||
}
|
||||
|
||||
function truncate(text: string, maxLen: number): string {
|
||||
if (text.length <= maxLen) return text;
|
||||
return text.substring(0, maxLen - 3) + "...";
|
||||
}
|
||||
|
||||
export function createAgentRunner(): AgentRunner {
|
||||
let agent: Agent | null = null;
|
||||
|
||||
return {
|
||||
async run(ctx: SlackContext, channelDir: string, store: ChannelStore): Promise<void> {
|
||||
// Create scratchpad
|
||||
const scratchpadDir = await mkdtemp(join(tmpdir(), "mom-scratchpad-"));
|
||||
|
||||
try {
|
||||
const recentMessages = getRecentMessages(channelDir, 50);
|
||||
const systemPrompt = buildSystemPrompt(channelDir, scratchpadDir, recentMessages);
|
||||
|
||||
// Set up file upload function for the attach tool
|
||||
setUploadFunction(async (filePath: string, title?: string) => {
|
||||
await ctx.uploadFile(filePath, title);
|
||||
});
|
||||
|
||||
// Create ephemeral agent
|
||||
agent = new Agent({
|
||||
initialState: {
|
||||
systemPrompt,
|
||||
model,
|
||||
thinkingLevel: "off",
|
||||
tools: momTools,
|
||||
},
|
||||
transport: new ProviderTransport({
|
||||
getApiKey: async () => getAnthropicApiKey(),
|
||||
}),
|
||||
});
|
||||
|
||||
// Subscribe to events
|
||||
agent.subscribe(async (event: AgentEvent) => {
|
||||
switch (event.type) {
|
||||
case "tool_execution_start": {
|
||||
const args = event.args as { label?: string };
|
||||
const label = args.label || event.toolName;
|
||||
|
||||
// Log to console
|
||||
console.log(`\n[Tool] ${event.toolName}: ${JSON.stringify(event.args)}`);
|
||||
|
||||
// Log to jsonl
|
||||
await store.logMessage(ctx.message.channel, {
|
||||
ts: Date.now().toString(),
|
||||
user: "bot",
|
||||
text: `[Tool] ${event.toolName}: ${JSON.stringify(event.args)}`,
|
||||
attachments: [],
|
||||
isBot: true,
|
||||
});
|
||||
|
||||
// Show only label to user (italic)
|
||||
await ctx.respond(`_${label}_`);
|
||||
break;
|
||||
}
|
||||
|
||||
case "tool_execution_end": {
|
||||
const resultStr = typeof event.result === "string" ? event.result : JSON.stringify(event.result);
|
||||
|
||||
// Log to console
|
||||
console.log(`[Tool Result] ${event.isError ? "ERROR: " : ""}${truncate(resultStr, 1000)}\n`);
|
||||
|
||||
// Log to jsonl
|
||||
await store.logMessage(ctx.message.channel, {
|
||||
ts: Date.now().toString(),
|
||||
user: "bot",
|
||||
text: `[Tool Result] ${event.toolName}: ${event.isError ? "ERROR: " : ""}${truncate(resultStr, 1000)}`,
|
||||
attachments: [],
|
||||
isBot: true,
|
||||
});
|
||||
|
||||
// Show brief status to user (only on error)
|
||||
if (event.isError) {
|
||||
await ctx.respond(`_Error: ${truncate(resultStr, 200)}_`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "message_update": {
|
||||
const ev = event.assistantMessageEvent;
|
||||
// Stream deltas to console
|
||||
if (ev.type === "text_delta") {
|
||||
process.stdout.write(ev.delta);
|
||||
} else if (ev.type === "thinking_delta") {
|
||||
process.stdout.write(ev.delta);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "message_start":
|
||||
if (event.message.role === "assistant") {
|
||||
process.stdout.write("\n");
|
||||
}
|
||||
break;
|
||||
|
||||
case "message_end":
|
||||
if (event.message.role === "assistant") {
|
||||
process.stdout.write("\n");
|
||||
// Extract text from assistant message
|
||||
const content = event.message.content;
|
||||
let text = "";
|
||||
for (const part of content) {
|
||||
if (part.type === "text") {
|
||||
text += part.text;
|
||||
}
|
||||
}
|
||||
if (text.trim()) {
|
||||
await ctx.respond(text);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Run the agent with user's message
|
||||
await agent.prompt(ctx.message.text || "(attached files)");
|
||||
} finally {
|
||||
agent = null;
|
||||
// Cleanup scratchpad
|
||||
try {
|
||||
rmSync(scratchpadDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
abort(): void {
|
||||
agent?.abort();
|
||||
},
|
||||
};
|
||||
}
|
||||
98
packages/mom/src/main.ts
Normal file
98
packages/mom/src/main.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { join, resolve } from "path";
|
||||
import { type AgentRunner, createAgentRunner } from "./agent.js";
|
||||
import { MomBot, type SlackContext } from "./slack.js";
|
||||
|
||||
console.log("Starting mom bot...");
|
||||
|
||||
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
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length !== 1) {
|
||||
console.error("Usage: mom <working-directory>");
|
||||
console.error("Example: mom ./mom-data");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const workingDir = resolve(args[0]);
|
||||
|
||||
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");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Track active agent runs per channel
|
||||
const activeRuns = new Map<string, AgentRunner>();
|
||||
|
||||
async function handleMessage(ctx: SlackContext, source: "channel" | "dm"): Promise<void> {
|
||||
const channelId = ctx.message.channel;
|
||||
const messageText = ctx.message.text.toLowerCase().trim();
|
||||
|
||||
// Check for stop command
|
||||
if (messageText === "stop") {
|
||||
const runner = activeRuns.get(channelId);
|
||||
if (runner) {
|
||||
console.log(`Stop requested for channel ${channelId}`);
|
||||
runner.abort();
|
||||
await ctx.respond("_Stopping..._");
|
||||
} 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;
|
||||
}
|
||||
|
||||
console.log(`${source === "channel" ? "Channel mention" : "DM"} from <@${ctx.message.user}>: ${ctx.message.text}`);
|
||||
const channelDir = join(workingDir, channelId);
|
||||
|
||||
const runner = createAgentRunner();
|
||||
activeRuns.set(channelId, runner);
|
||||
|
||||
await ctx.setTyping(true);
|
||||
try {
|
||||
await runner.run(ctx, channelDir, ctx.store);
|
||||
} catch (error) {
|
||||
// Don't report abort errors
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
if (msg.includes("aborted") || msg.includes("Aborted")) {
|
||||
// Already said "Stopping..." - nothing more to say
|
||||
} else {
|
||||
console.error("Agent error:", error);
|
||||
await ctx.respond(`❌ Error: ${msg}`);
|
||||
}
|
||||
} finally {
|
||||
activeRuns.delete(channelId);
|
||||
}
|
||||
}
|
||||
|
||||
const bot = new MomBot(
|
||||
{
|
||||
async onChannelMention(ctx) {
|
||||
await handleMessage(ctx, "channel");
|
||||
},
|
||||
|
||||
async onDirectMessage(ctx) {
|
||||
await handleMessage(ctx, "dm");
|
||||
},
|
||||
},
|
||||
{
|
||||
appToken: MOM_SLACK_APP_TOKEN,
|
||||
botToken: MOM_SLACK_BOT_TOKEN,
|
||||
workingDir,
|
||||
},
|
||||
);
|
||||
|
||||
bot.start();
|
||||
259
packages/mom/src/slack.ts
Normal file
259
packages/mom/src/slack.ts
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
import { SocketModeClient } from "@slack/socket-mode";
|
||||
import { WebClient } from "@slack/web-api";
|
||||
import { readFileSync } from "fs";
|
||||
import { basename } from "path";
|
||||
import { type Attachment, ChannelStore } from "./store.js";
|
||||
|
||||
export interface SlackMessage {
|
||||
text: string; // message content (mentions stripped)
|
||||
rawText: string; // original text with mentions
|
||||
user: string; // user ID
|
||||
channel: string; // channel ID
|
||||
ts: string; // timestamp (for threading)
|
||||
attachments: Attachment[]; // file attachments
|
||||
}
|
||||
|
||||
export interface SlackContext {
|
||||
message: SlackMessage;
|
||||
store: ChannelStore;
|
||||
/** Send a new message */
|
||||
respond(text: string): Promise<void>;
|
||||
/** Show/hide typing indicator. If text is provided to respond() after setTyping(true), it updates the typing message instead of posting new. */
|
||||
setTyping(isTyping: boolean): Promise<void>;
|
||||
/** Upload a file to the channel */
|
||||
uploadFile(filePath: string, title?: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface MomHandler {
|
||||
onChannelMention(ctx: SlackContext): Promise<void>;
|
||||
onDirectMessage(ctx: SlackContext): Promise<void>;
|
||||
}
|
||||
|
||||
export interface MomBotConfig {
|
||||
appToken: string;
|
||||
botToken: string;
|
||||
workingDir: string; // directory for channel data and attachments
|
||||
}
|
||||
|
||||
export class MomBot {
|
||||
private socketClient: SocketModeClient;
|
||||
private webClient: WebClient;
|
||||
private handler: MomHandler;
|
||||
private botUserId: string | null = null;
|
||||
public readonly store: ChannelStore;
|
||||
private userCache: Map<string, { userName: string; displayName: string }> = new Map();
|
||||
|
||||
constructor(handler: MomHandler, config: MomBotConfig) {
|
||||
this.handler = handler;
|
||||
this.socketClient = new SocketModeClient({ appToken: config.appToken });
|
||||
this.webClient = new WebClient(config.botToken);
|
||||
this.store = new ChannelStore({
|
||||
workingDir: config.workingDir,
|
||||
botToken: config.botToken,
|
||||
});
|
||||
|
||||
this.setupEventHandlers();
|
||||
}
|
||||
|
||||
private async getUserInfo(userId: string): Promise<{ userName: string; displayName: string }> {
|
||||
if (this.userCache.has(userId)) {
|
||||
return this.userCache.get(userId)!;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.webClient.users.info({ user: userId });
|
||||
const user = result.user as { name?: string; real_name?: string };
|
||||
const info = {
|
||||
userName: user?.name || userId,
|
||||
displayName: user?.real_name || user?.name || userId,
|
||||
};
|
||||
this.userCache.set(userId, info);
|
||||
return info;
|
||||
} catch {
|
||||
return { userName: userId, displayName: userId };
|
||||
}
|
||||
}
|
||||
|
||||
private setupEventHandlers(): void {
|
||||
// Handle @mentions in channels
|
||||
this.socketClient.on("app_mention", async ({ event, ack }) => {
|
||||
await ack();
|
||||
|
||||
const slackEvent = event as {
|
||||
text: string;
|
||||
channel: string;
|
||||
user: string;
|
||||
ts: string;
|
||||
files?: Array<{ name: string; url_private_download?: string; url_private?: string }>;
|
||||
};
|
||||
|
||||
// Log the mention (message event may not fire for app_mention)
|
||||
await this.logMessage(slackEvent);
|
||||
|
||||
const ctx = this.createContext(slackEvent);
|
||||
await this.handler.onChannelMention(ctx);
|
||||
});
|
||||
|
||||
// Handle all messages (for logging) and DMs (for triggering handler)
|
||||
this.socketClient.on("message", async ({ event, ack }) => {
|
||||
await ack();
|
||||
|
||||
const slackEvent = event as {
|
||||
text?: string;
|
||||
channel: string;
|
||||
user?: string;
|
||||
ts: string;
|
||||
channel_type?: string;
|
||||
subtype?: string;
|
||||
bot_id?: string;
|
||||
files?: Array<{ name: string; url_private_download?: string; url_private?: string }>;
|
||||
};
|
||||
|
||||
// Ignore bot messages
|
||||
if (slackEvent.bot_id) return;
|
||||
// Ignore message edits, etc. (but allow file_share)
|
||||
if (slackEvent.subtype !== undefined && slackEvent.subtype !== "file_share") return;
|
||||
// Ignore if no user
|
||||
if (!slackEvent.user) return;
|
||||
// Ignore messages from the bot itself
|
||||
if (slackEvent.user === this.botUserId) return;
|
||||
// Ignore if no text AND no files
|
||||
if (!slackEvent.text && (!slackEvent.files || slackEvent.files.length === 0)) return;
|
||||
|
||||
// Log ALL messages (channel and DM)
|
||||
await this.logMessage({
|
||||
text: slackEvent.text || "",
|
||||
channel: slackEvent.channel,
|
||||
user: slackEvent.user,
|
||||
ts: slackEvent.ts,
|
||||
files: slackEvent.files,
|
||||
});
|
||||
|
||||
// Only trigger handler for DMs (channel mentions are handled by app_mention event)
|
||||
if (slackEvent.channel_type === "im") {
|
||||
const ctx = this.createContext({
|
||||
text: slackEvent.text || "",
|
||||
channel: slackEvent.channel,
|
||||
user: slackEvent.user,
|
||||
ts: slackEvent.ts,
|
||||
files: slackEvent.files,
|
||||
});
|
||||
await this.handler.onDirectMessage(ctx);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async logMessage(event: {
|
||||
text: string;
|
||||
channel: string;
|
||||
user: string;
|
||||
ts: string;
|
||||
files?: Array<{ name: string; url_private_download?: string; url_private?: string }>;
|
||||
}): Promise<void> {
|
||||
const attachments = event.files ? this.store.processAttachments(event.channel, event.files, event.ts) : [];
|
||||
const { userName, displayName } = await this.getUserInfo(event.user);
|
||||
|
||||
await this.store.logMessage(event.channel, {
|
||||
ts: event.ts,
|
||||
user: event.user,
|
||||
userName,
|
||||
displayName,
|
||||
text: event.text,
|
||||
attachments,
|
||||
isBot: false,
|
||||
});
|
||||
}
|
||||
|
||||
private createContext(event: {
|
||||
text: string;
|
||||
channel: string;
|
||||
user: string;
|
||||
ts: string;
|
||||
files?: Array<{ name: string; url_private_download?: string; url_private?: string }>;
|
||||
}): SlackContext {
|
||||
const rawText = event.text;
|
||||
const text = rawText.replace(/<@[A-Z0-9]+>/gi, "").trim();
|
||||
|
||||
// Process attachments (for context, already logged by message handler)
|
||||
const attachments = event.files ? this.store.processAttachments(event.channel, event.files, event.ts) : [];
|
||||
|
||||
let typingMessageTs: string | null = null;
|
||||
|
||||
return {
|
||||
message: {
|
||||
text,
|
||||
rawText,
|
||||
user: event.user,
|
||||
channel: event.channel,
|
||||
ts: event.ts,
|
||||
attachments,
|
||||
},
|
||||
store: this.store,
|
||||
respond: async (responseText: string) => {
|
||||
let responseTs: string;
|
||||
|
||||
if (typingMessageTs) {
|
||||
// Update the typing message with the response
|
||||
await this.webClient.chat.update({
|
||||
channel: event.channel,
|
||||
ts: typingMessageTs,
|
||||
text: responseText,
|
||||
});
|
||||
responseTs = typingMessageTs;
|
||||
typingMessageTs = null;
|
||||
} else {
|
||||
// Post a new message
|
||||
const result = await this.webClient.chat.postMessage({
|
||||
channel: event.channel,
|
||||
text: responseText,
|
||||
});
|
||||
responseTs = result.ts as string;
|
||||
}
|
||||
|
||||
// Log the bot response
|
||||
await this.store.logBotResponse(event.channel, responseText, responseTs);
|
||||
},
|
||||
setTyping: async (isTyping: boolean) => {
|
||||
if (isTyping && !typingMessageTs) {
|
||||
// Post a "thinking" message (italic)
|
||||
const result = await this.webClient.chat.postMessage({
|
||||
channel: event.channel,
|
||||
text: "_Thinking..._",
|
||||
});
|
||||
typingMessageTs = result.ts as string;
|
||||
} else if (!isTyping && typingMessageTs) {
|
||||
// Clear typing state (message will be updated by respond())
|
||||
// If respond() wasn't called, delete the typing message
|
||||
await this.webClient.chat.delete({
|
||||
channel: event.channel,
|
||||
ts: typingMessageTs,
|
||||
});
|
||||
typingMessageTs = null;
|
||||
}
|
||||
},
|
||||
uploadFile: async (filePath: string, title?: string) => {
|
||||
const fileName = title || basename(filePath);
|
||||
const fileContent = readFileSync(filePath);
|
||||
|
||||
await this.webClient.files.uploadV2({
|
||||
channel_id: event.channel,
|
||||
file: fileContent,
|
||||
filename: fileName,
|
||||
title: fileName,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
const auth = await this.webClient.auth.test();
|
||||
this.botUserId = auth.user_id as string;
|
||||
await this.socketClient.start();
|
||||
console.log("⚡️ Mom bot connected and listening!");
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
await this.socketClient.disconnect();
|
||||
console.log("Mom bot disconnected.");
|
||||
}
|
||||
}
|
||||
173
packages/mom/src/store.ts
Normal file
173
packages/mom/src/store.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import { existsSync, mkdirSync } from "fs";
|
||||
import { appendFile, writeFile } from "fs/promises";
|
||||
import { join } from "path";
|
||||
|
||||
export interface Attachment {
|
||||
original: string; // original filename from uploader
|
||||
local: string; // path relative to working dir (e.g., "C12345/attachments/1732531234567_file.png")
|
||||
}
|
||||
|
||||
export interface LoggedMessage {
|
||||
ts: string; // slack timestamp
|
||||
user: string; // user ID (or "bot" for bot responses)
|
||||
userName?: string; // handle (e.g., "mario")
|
||||
displayName?: string; // display name (e.g., "Mario Zechner")
|
||||
text: string;
|
||||
attachments: Attachment[];
|
||||
isBot: boolean;
|
||||
}
|
||||
|
||||
export interface ChannelStoreConfig {
|
||||
workingDir: string;
|
||||
botToken: string; // needed for authenticated file downloads
|
||||
}
|
||||
|
||||
interface PendingDownload {
|
||||
channelId: string;
|
||||
localPath: string; // relative path
|
||||
url: string;
|
||||
}
|
||||
|
||||
export class ChannelStore {
|
||||
private workingDir: string;
|
||||
private botToken: string;
|
||||
private pendingDownloads: PendingDownload[] = [];
|
||||
private isDownloading = false;
|
||||
|
||||
constructor(config: ChannelStoreConfig) {
|
||||
this.workingDir = config.workingDir;
|
||||
this.botToken = config.botToken;
|
||||
|
||||
// Ensure working directory exists
|
||||
if (!existsSync(this.workingDir)) {
|
||||
mkdirSync(this.workingDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create the directory for a channel/DM
|
||||
*/
|
||||
getChannelDir(channelId: string): string {
|
||||
const dir = join(this.workingDir, channelId);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique local filename for an attachment
|
||||
*/
|
||||
generateLocalFilename(originalName: string, timestamp: string): string {
|
||||
// Convert slack timestamp (1234567890.123456) to milliseconds
|
||||
const ts = Math.floor(parseFloat(timestamp) * 1000);
|
||||
// Sanitize original name (remove problematic characters)
|
||||
const sanitized = originalName.replace(/[^a-zA-Z0-9._-]/g, "_");
|
||||
return `${ts}_${sanitized}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process attachments from a Slack message event
|
||||
* Returns attachment metadata and queues downloads
|
||||
*/
|
||||
processAttachments(
|
||||
channelId: string,
|
||||
files: Array<{ name: string; url_private_download?: string; url_private?: string }>,
|
||||
timestamp: string,
|
||||
): Attachment[] {
|
||||
const attachments: Attachment[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const url = file.url_private_download || file.url_private;
|
||||
if (!url) continue;
|
||||
|
||||
const filename = this.generateLocalFilename(file.name, timestamp);
|
||||
const localPath = `${channelId}/attachments/${filename}`;
|
||||
|
||||
attachments.push({
|
||||
original: file.name,
|
||||
local: localPath,
|
||||
});
|
||||
|
||||
// Queue for background download
|
||||
this.pendingDownloads.push({ channelId, localPath, url });
|
||||
}
|
||||
|
||||
// Trigger background download
|
||||
this.processDownloadQueue();
|
||||
|
||||
return attachments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a message to the channel's log.jsonl
|
||||
*/
|
||||
async logMessage(channelId: string, message: LoggedMessage): Promise<void> {
|
||||
const logPath = join(this.getChannelDir(channelId), "log.jsonl");
|
||||
const line = JSON.stringify(message) + "\n";
|
||||
await appendFile(logPath, line, "utf-8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a bot response
|
||||
*/
|
||||
async logBotResponse(channelId: string, text: string, ts: string): Promise<void> {
|
||||
await this.logMessage(channelId, {
|
||||
ts,
|
||||
user: "bot",
|
||||
text,
|
||||
attachments: [],
|
||||
isBot: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the download queue in the background
|
||||
*/
|
||||
private async processDownloadQueue(): Promise<void> {
|
||||
if (this.isDownloading || this.pendingDownloads.length === 0) return;
|
||||
|
||||
this.isDownloading = true;
|
||||
|
||||
while (this.pendingDownloads.length > 0) {
|
||||
const item = this.pendingDownloads.shift();
|
||||
if (!item) break;
|
||||
|
||||
try {
|
||||
await this.downloadAttachment(item.localPath, item.url);
|
||||
console.log(`Downloaded: ${item.localPath}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to download ${item.localPath}:`, error);
|
||||
// Could re-queue for retry here
|
||||
}
|
||||
}
|
||||
|
||||
this.isDownloading = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a single attachment
|
||||
*/
|
||||
private async downloadAttachment(localPath: string, url: string): Promise<void> {
|
||||
const filePath = join(this.workingDir, localPath);
|
||||
|
||||
// Ensure directory exists
|
||||
const dir = join(this.workingDir, localPath.substring(0, localPath.lastIndexOf("/")));
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.botToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const buffer = await response.arrayBuffer();
|
||||
await writeFile(filePath, Buffer.from(buffer));
|
||||
}
|
||||
}
|
||||
46
packages/mom/src/tools/attach.ts
Normal file
46
packages/mom/src/tools/attach.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import type { AgentTool } from "@mariozechner/pi-ai";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { basename, resolve as resolvePath } from "path";
|
||||
|
||||
// This will be set by the agent before running
|
||||
let uploadFn: ((filePath: string, title?: string) => Promise<void>) | null = null;
|
||||
|
||||
export function setUploadFunction(fn: (filePath: string, title?: string) => Promise<void>): void {
|
||||
uploadFn = fn;
|
||||
}
|
||||
|
||||
const attachSchema = Type.Object({
|
||||
label: Type.String({ description: "Brief description of what you're sharing (shown to user)" }),
|
||||
path: Type.String({ description: "Path to the file to attach" }),
|
||||
title: Type.Optional(Type.String({ description: "Title for the file (defaults to filename)" })),
|
||||
});
|
||||
|
||||
export const attachTool: AgentTool<typeof attachSchema> = {
|
||||
name: "attach",
|
||||
label: "attach",
|
||||
description: "Attach a file to your response. Use this to share files, images, or documents with the user.",
|
||||
parameters: attachSchema,
|
||||
execute: async (
|
||||
_toolCallId: string,
|
||||
{ path, title }: { label: string; path: string; title?: string },
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
if (!uploadFn) {
|
||||
throw new Error("Upload function not configured");
|
||||
}
|
||||
|
||||
if (signal?.aborted) {
|
||||
throw new Error("Operation aborted");
|
||||
}
|
||||
|
||||
const absolutePath = resolvePath(path);
|
||||
const fileName = title || basename(absolutePath);
|
||||
|
||||
await uploadFn(absolutePath, fileName);
|
||||
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Attached file: ${fileName}` }],
|
||||
details: undefined,
|
||||
};
|
||||
},
|
||||
};
|
||||
189
packages/mom/src/tools/bash.ts
Normal file
189
packages/mom/src/tools/bash.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import type { AgentTool } from "@mariozechner/pi-ai";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { spawn } from "child_process";
|
||||
import { existsSync } from "fs";
|
||||
|
||||
/**
|
||||
* Get shell configuration based on platform
|
||||
*/
|
||||
function getShellConfig(): { shell: string; args: string[] } {
|
||||
if (process.platform === "win32") {
|
||||
const paths: string[] = [];
|
||||
const programFiles = process.env.ProgramFiles;
|
||||
if (programFiles) {
|
||||
paths.push(`${programFiles}\\Git\\bin\\bash.exe`);
|
||||
}
|
||||
const programFilesX86 = process.env["ProgramFiles(x86)"];
|
||||
if (programFilesX86) {
|
||||
paths.push(`${programFilesX86}\\Git\\bin\\bash.exe`);
|
||||
}
|
||||
|
||||
for (const path of paths) {
|
||||
if (existsSync(path)) {
|
||||
return { shell: path, args: ["-c"] };
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Git Bash not found. Please install Git for Windows from https://git-scm.com/download/win\n` +
|
||||
`Searched in:\n${paths.map((p) => ` ${p}`).join("\n")}`,
|
||||
);
|
||||
}
|
||||
return { shell: "sh", args: ["-c"] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill a process and all its children
|
||||
*/
|
||||
function killProcessTree(pid: number): void {
|
||||
if (process.platform === "win32") {
|
||||
// Use taskkill on Windows to kill process tree
|
||||
try {
|
||||
spawn("taskkill", ["/F", "/T", "/PID", String(pid)], {
|
||||
stdio: "ignore",
|
||||
detached: true,
|
||||
});
|
||||
} catch {
|
||||
// Ignore errors if taskkill fails
|
||||
}
|
||||
} else {
|
||||
// Use SIGKILL on Unix/Linux/Mac
|
||||
try {
|
||||
process.kill(-pid, "SIGKILL");
|
||||
} catch {
|
||||
// Fallback to killing just the child if process group kill fails
|
||||
try {
|
||||
process.kill(pid, "SIGKILL");
|
||||
} catch {
|
||||
// Process already dead
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bashSchema = Type.Object({
|
||||
label: Type.String({ description: "Brief description of what this command does (shown to user)" }),
|
||||
command: Type.String({ description: "Bash command to execute" }),
|
||||
timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional, no default timeout)" })),
|
||||
});
|
||||
|
||||
export const bashTool: AgentTool<typeof bashSchema> = {
|
||||
name: "bash",
|
||||
label: "bash",
|
||||
description:
|
||||
"Execute a bash command in the current working directory. Returns stdout and stderr. Optionally provide a timeout in seconds.",
|
||||
parameters: bashSchema,
|
||||
execute: async (
|
||||
_toolCallId: string,
|
||||
{ command, timeout }: { label: string; command: string; timeout?: number },
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { shell, args } = getShellConfig();
|
||||
const child = spawn(shell, [...args, command], {
|
||||
detached: true,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let timedOut = false;
|
||||
|
||||
// Set timeout if provided
|
||||
let timeoutHandle: NodeJS.Timeout | undefined;
|
||||
if (timeout !== undefined && timeout > 0) {
|
||||
timeoutHandle = setTimeout(() => {
|
||||
timedOut = true;
|
||||
onAbort();
|
||||
}, timeout * 1000);
|
||||
}
|
||||
|
||||
// Collect stdout
|
||||
if (child.stdout) {
|
||||
child.stdout.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
// Limit buffer size
|
||||
if (stdout.length > 10 * 1024 * 1024) {
|
||||
stdout = stdout.slice(0, 10 * 1024 * 1024);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Collect stderr
|
||||
if (child.stderr) {
|
||||
child.stderr.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
// Limit buffer size
|
||||
if (stderr.length > 10 * 1024 * 1024) {
|
||||
stderr = stderr.slice(0, 10 * 1024 * 1024);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Handle process exit
|
||||
child.on("close", (code) => {
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
if (signal) {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
|
||||
if (signal?.aborted) {
|
||||
let output = "";
|
||||
if (stdout) output += stdout;
|
||||
if (stderr) {
|
||||
if (output) output += "\n";
|
||||
output += stderr;
|
||||
}
|
||||
if (output) output += "\n\n";
|
||||
output += "Command aborted";
|
||||
reject(new Error(output));
|
||||
return;
|
||||
}
|
||||
|
||||
if (timedOut) {
|
||||
let output = "";
|
||||
if (stdout) output += stdout;
|
||||
if (stderr) {
|
||||
if (output) output += "\n";
|
||||
output += stderr;
|
||||
}
|
||||
if (output) output += "\n\n";
|
||||
output += `Command timed out after ${timeout} seconds`;
|
||||
reject(new Error(output));
|
||||
return;
|
||||
}
|
||||
|
||||
let output = "";
|
||||
if (stdout) output += stdout;
|
||||
if (stderr) {
|
||||
if (output) output += "\n";
|
||||
output += stderr;
|
||||
}
|
||||
|
||||
if (code !== 0 && code !== null) {
|
||||
if (output) output += "\n\n";
|
||||
reject(new Error(`${output}Command exited with code ${code}`));
|
||||
} else {
|
||||
resolve({ content: [{ type: "text", text: output || "(no output)" }], details: undefined });
|
||||
}
|
||||
});
|
||||
|
||||
// Handle abort signal - kill entire process tree
|
||||
const onAbort = () => {
|
||||
if (child.pid) {
|
||||
killProcessTree(child.pid);
|
||||
}
|
||||
};
|
||||
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
onAbort();
|
||||
} else {
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
269
packages/mom/src/tools/edit.ts
Normal file
269
packages/mom/src/tools/edit.ts
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
import * as os from "node:os";
|
||||
import type { AgentTool } from "@mariozechner/pi-ai";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import * as Diff from "diff";
|
||||
import { constants } from "fs";
|
||||
import { access, readFile, writeFile } from "fs/promises";
|
||||
import { resolve as resolvePath } from "path";
|
||||
|
||||
/**
|
||||
* Expand ~ to home directory
|
||||
*/
|
||||
function expandPath(filePath: string): string {
|
||||
if (filePath === "~") {
|
||||
return os.homedir();
|
||||
}
|
||||
if (filePath.startsWith("~/")) {
|
||||
return os.homedir() + filePath.slice(1);
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unified diff string with line numbers and context
|
||||
*/
|
||||
function generateDiffString(oldContent: string, newContent: string, contextLines = 4): string {
|
||||
const parts = Diff.diffLines(oldContent, newContent);
|
||||
const output: string[] = [];
|
||||
|
||||
const oldLines = oldContent.split("\n");
|
||||
const newLines = newContent.split("\n");
|
||||
const maxLineNum = Math.max(oldLines.length, newLines.length);
|
||||
const lineNumWidth = String(maxLineNum).length;
|
||||
|
||||
let oldLineNum = 1;
|
||||
let newLineNum = 1;
|
||||
let lastWasChange = false;
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
const raw = part.value.split("\n");
|
||||
if (raw[raw.length - 1] === "") {
|
||||
raw.pop();
|
||||
}
|
||||
|
||||
if (part.added || part.removed) {
|
||||
// Show the change
|
||||
for (const line of raw) {
|
||||
if (part.added) {
|
||||
const lineNum = String(newLineNum).padStart(lineNumWidth, " ");
|
||||
output.push(`+${lineNum} ${line}`);
|
||||
newLineNum++;
|
||||
} else {
|
||||
// removed
|
||||
const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
|
||||
output.push(`-${lineNum} ${line}`);
|
||||
oldLineNum++;
|
||||
}
|
||||
}
|
||||
lastWasChange = true;
|
||||
} else {
|
||||
// Context lines - only show a few before/after changes
|
||||
const nextPartIsChange = i < parts.length - 1 && (parts[i + 1].added || parts[i + 1].removed);
|
||||
|
||||
if (lastWasChange || nextPartIsChange) {
|
||||
// Show context
|
||||
let linesToShow = raw;
|
||||
let skipStart = 0;
|
||||
let skipEnd = 0;
|
||||
|
||||
if (!lastWasChange) {
|
||||
// Show only last N lines as leading context
|
||||
skipStart = Math.max(0, raw.length - contextLines);
|
||||
linesToShow = raw.slice(skipStart);
|
||||
}
|
||||
|
||||
if (!nextPartIsChange && linesToShow.length > contextLines) {
|
||||
// Show only first N lines as trailing context
|
||||
skipEnd = linesToShow.length - contextLines;
|
||||
linesToShow = linesToShow.slice(0, contextLines);
|
||||
}
|
||||
|
||||
// Add ellipsis if we skipped lines at start
|
||||
if (skipStart > 0) {
|
||||
output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
|
||||
}
|
||||
|
||||
for (const line of linesToShow) {
|
||||
const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
|
||||
output.push(` ${lineNum} ${line}`);
|
||||
oldLineNum++;
|
||||
newLineNum++;
|
||||
}
|
||||
|
||||
// Add ellipsis if we skipped lines at end
|
||||
if (skipEnd > 0) {
|
||||
output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
|
||||
}
|
||||
|
||||
// Update line numbers for skipped lines
|
||||
oldLineNum += skipStart + skipEnd;
|
||||
newLineNum += skipStart + skipEnd;
|
||||
} else {
|
||||
// Skip these context lines entirely
|
||||
oldLineNum += raw.length;
|
||||
newLineNum += raw.length;
|
||||
}
|
||||
|
||||
lastWasChange = false;
|
||||
}
|
||||
}
|
||||
|
||||
return output.join("\n");
|
||||
}
|
||||
|
||||
const editSchema = Type.Object({
|
||||
label: Type.String({ description: "Brief description of the edit you're making (shown to user)" }),
|
||||
path: Type.String({ description: "Path to the file to edit (relative or absolute)" }),
|
||||
oldText: Type.String({ description: "Exact text to find and replace (must match exactly)" }),
|
||||
newText: Type.String({ description: "New text to replace the old text with" }),
|
||||
});
|
||||
|
||||
export const editTool: AgentTool<typeof editSchema> = {
|
||||
name: "edit",
|
||||
label: "edit",
|
||||
description:
|
||||
"Edit a file by replacing exact text. The oldText must match exactly (including whitespace). Use this for precise, surgical edits.",
|
||||
parameters: editSchema,
|
||||
execute: async (
|
||||
_toolCallId: string,
|
||||
{ path, oldText, newText }: { label: string; path: string; oldText: string; newText: string },
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
const absolutePath = resolvePath(expandPath(path));
|
||||
|
||||
return new Promise<{
|
||||
content: Array<{ type: "text"; text: string }>;
|
||||
details: { diff: string } | undefined;
|
||||
}>((resolve, reject) => {
|
||||
// Check if already aborted
|
||||
if (signal?.aborted) {
|
||||
reject(new Error("Operation aborted"));
|
||||
return;
|
||||
}
|
||||
|
||||
let aborted = false;
|
||||
|
||||
// Set up abort handler
|
||||
const onAbort = () => {
|
||||
aborted = true;
|
||||
reject(new Error("Operation aborted"));
|
||||
};
|
||||
|
||||
if (signal) {
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
|
||||
// Perform the edit operation
|
||||
(async () => {
|
||||
try {
|
||||
// Check if file exists
|
||||
try {
|
||||
await access(absolutePath, constants.R_OK | constants.W_OK);
|
||||
} catch {
|
||||
if (signal) {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
reject(new Error(`File not found: ${path}`));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if aborted before reading
|
||||
if (aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Read the file
|
||||
const content = await readFile(absolutePath, "utf-8");
|
||||
|
||||
// Check if aborted after reading
|
||||
if (aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if old text exists
|
||||
if (!content.includes(oldText)) {
|
||||
if (signal) {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
reject(
|
||||
new Error(
|
||||
`Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Count occurrences
|
||||
const occurrences = content.split(oldText).length - 1;
|
||||
|
||||
if (occurrences > 1) {
|
||||
if (signal) {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
reject(
|
||||
new Error(
|
||||
`Found ${occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if aborted before writing
|
||||
if (aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Perform replacement using indexOf + substring (raw string replace, no special character interpretation)
|
||||
// String.replace() interprets $ in the replacement string, so we do manual replacement
|
||||
const index = content.indexOf(oldText);
|
||||
const newContent = content.substring(0, index) + newText + content.substring(index + oldText.length);
|
||||
|
||||
// Verify the replacement actually changed something
|
||||
if (content === newContent) {
|
||||
if (signal) {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
reject(
|
||||
new Error(
|
||||
`No changes made to ${path}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await writeFile(absolutePath, newContent, "utf-8");
|
||||
|
||||
// Check if aborted after writing
|
||||
if (aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up abort handler
|
||||
if (signal) {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
|
||||
resolve({
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Successfully replaced text in ${path}. Changed ${oldText.length} characters to ${newText.length} characters.`,
|
||||
},
|
||||
],
|
||||
details: { diff: generateDiffString(content, newContent) },
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
// Clean up abort handler
|
||||
if (signal) {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
|
||||
if (!aborted) {
|
||||
reject(error);
|
||||
}
|
||||
}
|
||||
})();
|
||||
});
|
||||
},
|
||||
};
|
||||
13
packages/mom/src/tools/index.ts
Normal file
13
packages/mom/src/tools/index.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
export { attachTool, setUploadFunction } from "./attach.js";
|
||||
export { bashTool } from "./bash.js";
|
||||
export { editTool } from "./edit.js";
|
||||
export { readTool } from "./read.js";
|
||||
export { writeTool } from "./write.js";
|
||||
|
||||
import { attachTool } from "./attach.js";
|
||||
import { bashTool } from "./bash.js";
|
||||
import { editTool } from "./edit.js";
|
||||
import { readTool } from "./read.js";
|
||||
import { writeTool } from "./write.js";
|
||||
|
||||
export const momTools = [readTool, bashTool, editTool, writeTool, attachTool];
|
||||
179
packages/mom/src/tools/read.ts
Normal file
179
packages/mom/src/tools/read.ts
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
import * as os from "node:os";
|
||||
import type { AgentTool, ImageContent, TextContent } from "@mariozechner/pi-ai";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { constants } from "fs";
|
||||
import { access, readFile } from "fs/promises";
|
||||
import { extname, resolve as resolvePath } from "path";
|
||||
|
||||
/**
|
||||
* Expand ~ to home directory
|
||||
*/
|
||||
function expandPath(filePath: string): string {
|
||||
if (filePath === "~") {
|
||||
return os.homedir();
|
||||
}
|
||||
if (filePath.startsWith("~/")) {
|
||||
return os.homedir() + filePath.slice(1);
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map of file extensions to MIME types for common image formats
|
||||
*/
|
||||
const IMAGE_MIME_TYPES: Record<string, string> = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a file is an image based on its extension
|
||||
*/
|
||||
function isImageFile(filePath: string): string | null {
|
||||
const ext = extname(filePath).toLowerCase();
|
||||
return IMAGE_MIME_TYPES[ext] || null;
|
||||
}
|
||||
|
||||
const readSchema = Type.Object({
|
||||
label: Type.String({ description: "Brief description of what you're reading and why (shown to user)" }),
|
||||
path: Type.String({ description: "Path to the file to read (relative or absolute)" }),
|
||||
offset: Type.Optional(Type.Number({ description: "Line number to start reading from (1-indexed)" })),
|
||||
limit: Type.Optional(Type.Number({ description: "Maximum number of lines to read" })),
|
||||
});
|
||||
|
||||
const MAX_LINES = 2000;
|
||||
const MAX_LINE_LENGTH = 2000;
|
||||
|
||||
export const readTool: AgentTool<typeof readSchema> = {
|
||||
name: "read",
|
||||
label: "read",
|
||||
description:
|
||||
"Read the contents of a file. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, defaults to first 2000 lines. Use offset/limit for large files.",
|
||||
parameters: readSchema,
|
||||
execute: async (
|
||||
_toolCallId: string,
|
||||
{ path, offset, limit }: { label: string; path: string; offset?: number; limit?: number },
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
const absolutePath = resolvePath(expandPath(path));
|
||||
const mimeType = isImageFile(absolutePath);
|
||||
|
||||
return new Promise<{ content: (TextContent | ImageContent)[]; details: undefined }>((resolve, reject) => {
|
||||
// Check if already aborted
|
||||
if (signal?.aborted) {
|
||||
reject(new Error("Operation aborted"));
|
||||
return;
|
||||
}
|
||||
|
||||
let aborted = false;
|
||||
|
||||
// Set up abort handler
|
||||
const onAbort = () => {
|
||||
aborted = true;
|
||||
reject(new Error("Operation aborted"));
|
||||
};
|
||||
|
||||
if (signal) {
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
|
||||
// Perform the read operation
|
||||
(async () => {
|
||||
try {
|
||||
// Check if file exists
|
||||
await access(absolutePath, constants.R_OK);
|
||||
|
||||
// Check if aborted before reading
|
||||
if (aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Read the file based on type
|
||||
let content: (TextContent | ImageContent)[];
|
||||
|
||||
if (mimeType) {
|
||||
// Read as image (binary)
|
||||
const buffer = await readFile(absolutePath);
|
||||
const base64 = buffer.toString("base64");
|
||||
|
||||
content = [
|
||||
{ type: "text", text: `Read image file [${mimeType}]` },
|
||||
{ type: "image", data: base64, mimeType },
|
||||
];
|
||||
} else {
|
||||
// Read as text
|
||||
const textContent = await readFile(absolutePath, "utf-8");
|
||||
const lines = textContent.split("\n");
|
||||
|
||||
// Apply offset and limit (matching Claude Code Read tool behavior)
|
||||
const startLine = offset ? Math.max(0, offset - 1) : 0; // 1-indexed to 0-indexed
|
||||
const maxLines = limit || MAX_LINES;
|
||||
const endLine = Math.min(startLine + maxLines, lines.length);
|
||||
|
||||
// Check if offset is out of bounds
|
||||
if (startLine >= lines.length) {
|
||||
throw new Error(`Offset ${offset} is beyond end of file (${lines.length} lines total)`);
|
||||
}
|
||||
|
||||
// Get the relevant lines
|
||||
const selectedLines = lines.slice(startLine, endLine);
|
||||
|
||||
// Truncate long lines and track which were truncated
|
||||
let hadTruncatedLines = false;
|
||||
const formattedLines = selectedLines.map((line) => {
|
||||
if (line.length > MAX_LINE_LENGTH) {
|
||||
hadTruncatedLines = true;
|
||||
return line.slice(0, MAX_LINE_LENGTH);
|
||||
}
|
||||
return line;
|
||||
});
|
||||
|
||||
let outputText = formattedLines.join("\n");
|
||||
|
||||
// Add notices
|
||||
const notices: string[] = [];
|
||||
|
||||
if (hadTruncatedLines) {
|
||||
notices.push(`Some lines were truncated to ${MAX_LINE_LENGTH} characters for display`);
|
||||
}
|
||||
|
||||
if (endLine < lines.length) {
|
||||
const remaining = lines.length - endLine;
|
||||
notices.push(`${remaining} more lines not shown. Use offset=${endLine + 1} to continue reading`);
|
||||
}
|
||||
|
||||
if (notices.length > 0) {
|
||||
outputText += `\n\n... (${notices.join(". ")})`;
|
||||
}
|
||||
|
||||
content = [{ type: "text", text: outputText }];
|
||||
}
|
||||
|
||||
// Check if aborted after reading
|
||||
if (aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up abort handler
|
||||
if (signal) {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
|
||||
resolve({ content, details: undefined });
|
||||
} catch (error: unknown) {
|
||||
// Clean up abort handler
|
||||
if (signal) {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
|
||||
if (!aborted) {
|
||||
reject(error);
|
||||
}
|
||||
}
|
||||
})();
|
||||
});
|
||||
},
|
||||
};
|
||||
100
packages/mom/src/tools/write.ts
Normal file
100
packages/mom/src/tools/write.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import * as os from "node:os";
|
||||
import type { AgentTool } from "@mariozechner/pi-ai";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { mkdir, writeFile } from "fs/promises";
|
||||
import { dirname, resolve as resolvePath } from "path";
|
||||
|
||||
/**
|
||||
* Expand ~ to home directory
|
||||
*/
|
||||
function expandPath(filePath: string): string {
|
||||
if (filePath === "~") {
|
||||
return os.homedir();
|
||||
}
|
||||
if (filePath.startsWith("~/")) {
|
||||
return os.homedir() + filePath.slice(1);
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
const writeSchema = Type.Object({
|
||||
label: Type.String({ description: "Brief description of what you're writing (shown to user)" }),
|
||||
path: Type.String({ description: "Path to the file to write (relative or absolute)" }),
|
||||
content: Type.String({ description: "Content to write to the file" }),
|
||||
});
|
||||
|
||||
export const writeTool: AgentTool<typeof writeSchema> = {
|
||||
name: "write",
|
||||
label: "write",
|
||||
description:
|
||||
"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.",
|
||||
parameters: writeSchema,
|
||||
execute: async (
|
||||
_toolCallId: string,
|
||||
{ path, content }: { label: string; path: string; content: string },
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
const absolutePath = resolvePath(expandPath(path));
|
||||
const dir = dirname(absolutePath);
|
||||
|
||||
return new Promise<{ content: Array<{ type: "text"; text: string }>; details: undefined }>((resolve, reject) => {
|
||||
// Check if already aborted
|
||||
if (signal?.aborted) {
|
||||
reject(new Error("Operation aborted"));
|
||||
return;
|
||||
}
|
||||
|
||||
let aborted = false;
|
||||
|
||||
// Set up abort handler
|
||||
const onAbort = () => {
|
||||
aborted = true;
|
||||
reject(new Error("Operation aborted"));
|
||||
};
|
||||
|
||||
if (signal) {
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
|
||||
// Perform the write operation
|
||||
(async () => {
|
||||
try {
|
||||
// Create parent directories if needed
|
||||
await mkdir(dir, { recursive: true });
|
||||
|
||||
// Check if aborted before writing
|
||||
if (aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Write the file
|
||||
await writeFile(absolutePath, content, "utf-8");
|
||||
|
||||
// Check if aborted after writing
|
||||
if (aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up abort handler
|
||||
if (signal) {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
|
||||
resolve({
|
||||
content: [{ type: "text", text: `Successfully wrote ${content.length} bytes to ${path}` }],
|
||||
details: undefined,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
// Clean up abort handler
|
||||
if (signal) {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
|
||||
if (!aborted) {
|
||||
reject(error);
|
||||
}
|
||||
}
|
||||
})();
|
||||
});
|
||||
},
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue