diff --git a/config/index.ts b/config/index.ts index eeffee7..948d6ff 100644 --- a/config/index.ts +++ b/config/index.ts @@ -7,12 +7,12 @@ export const pathInDataDir = (filename: string) => path.join(dataDir, filename); interface PlatformIdentity { platform: - | "discord" - | "whatsapp" - | "email" - | "events" - | "linear_key" - | "linear_email"; + | "discord" + | "whatsapp" + | "email" + | "events" + | "linear_key" + | "linear_email"; id: string; // Platform-specific user ID } @@ -61,13 +61,28 @@ const ConfigSchema = z.object({ rolePermissions: z.record(z.string(), z.array(z.string())), }); -// Load user configuration data from file -const userConfigPath = pathInDataDir("user-config.json"); -const rawData = fs.readFileSync(userConfigPath, "utf-8"); -const parsedData = JSON.parse(rawData); +// Mutable exports that will be updated +export let userConfigs: UserConfig[] = []; +export let rolePermissions: Record = {}; -// Validate the parsed JSON using the Zod schema -const configData = ConfigSchema.parse(parsedData); +// Function to load config +function loadConfig() { + try { + const userConfigPath = pathInDataDir("user-config.json"); + const rawData = fs.readFileSync(userConfigPath, "utf-8"); + const parsedData = JSON.parse(rawData); + const configData = ConfigSchema.parse(parsedData); -// Export the validated data -export const { users: userConfigs, rolePermissions } = configData; + // Update the exported variables + userConfigs = configData.users; + rolePermissions = configData.rolePermissions; + } catch (error) { + console.error("Error loading config:", error); + } +} + +// Initial load +loadConfig(); + +// Setup auto-reload every minute +setInterval(loadConfig, 60 * 1000); diff --git a/core/message-processor.ts b/core/message-processor.ts index fa91152..ebf34fd 100644 --- a/core/message-processor.ts +++ b/core/message-processor.ts @@ -32,16 +32,17 @@ export class MessageProcessor { }); } + private checkpointMessageString = "🔄 Chat context has been reset."; + public async processMessage(message: Message): Promise { const userId = message.author.id; const channelId = message.channelId || userId; // Use message.id if channelId is not available // Check if the message is a stop message if (["stop", "reset"].includes(message.content.toLowerCase())) { - message.platform !== "whatsapp" && - (await message.send({ - content: "---setting this point as the start---", - })); + (await message.send({ + content: this.checkpointMessageString, + })); // Clear maps const hashes = this.channelIdHashMap.get(channelId) ?? []; hashes.forEach((hash) => { @@ -98,8 +99,7 @@ export class MessageProcessor { let stopIndex = -1; for (let i = 0; i < history.length; i++) { if ( - history[i].content === "---setting this point as the start---" || - history[i].content.replaceAll("!", "").trim() === "stop" + history[i].content === this.checkpointMessageString ) { stopIndex = i; break; @@ -179,6 +179,10 @@ export class MessageProcessor { .map((e) => JSON.stringify(e)) .join("\n"); + console.log("Embeds", embeds?.length); + console.log("Files", files?.length); + console.log("Attachments", msg?.attachments?.length); + // Transcribe voice messages const voiceMessagesPromises = (msg.attachments || []) .filter( @@ -197,6 +201,11 @@ export class MessageProcessor { const voiceMessages = await Promise.all(voiceMessagesPromises); + console.log("Voice Messages", voiceMessages); + + const images = (msg.attachments || []) + .filter((a) => a.mediaType?.includes("image")) + // Process context message if any let contextMessage = null; if (msg.threadId) { @@ -219,12 +228,12 @@ export class MessageProcessor { created_at: format(msg.timestamp, "yyyy-MM-dd HH:mm:ss") + " IST", context_message: contextMessage ? { - author: contextMessage.author.username, - created_at: - format(contextMessage.timestamp, "yyyy-MM-dd HH:mm:ss") + - " IST", - content: contextMessage.content, - } + author: contextMessage.author.username, + created_at: + format(contextMessage.timestamp, "yyyy-MM-dd HH:mm:ss") + + " IST", + content: contextMessage.content, + } : undefined, context_files: contextMessage?.attachments?.map((a) => a.url) || undefined, @@ -238,7 +247,20 @@ export class MessageProcessor { const aiMessage: OpenAI.Chat.ChatCompletionMessageParam = { role, - content: contextAsJson, + content: (images.length ? [ + ...images.map(img => { + return { + type: "image_url", + image_url: { + url: img.base64 || img.url, + }, + } + }), + { + type: "text", + text: contextAsJson, + } + ] : contextAsJson) as string, name: user?.name || msg.author.username.replace(/\s+/g, "_").substring(0, 64), @@ -260,7 +282,7 @@ export class MessageProcessor { // Collect hashes history.forEach((msg) => { - const hash = this.generateHash(msg.content); + const hash = this.generateHash(typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content)); channelHashes.push(hash); }); @@ -376,9 +398,13 @@ ${summaryContent} queueEntry.runningTools = true; } // Indicate running tools - if (this.sentMessage) { - await this.sentMessage.edit({ content: `Running ${fnc.name}...` }); - } else await message.send({ content: `Running ${fnc.name}...` }); + if (this.sentMessage?.platformAdapter.config.indicators.processing) { + if (this.sentMessage) { + await this.sentMessage.edit({ content: `Running ${fnc.name}...` }); + } else { + this.sentMessage = await message.send({ content: `Running ${fnc.name}...` }) + } + } }) .on("message", (m) => { if ( @@ -434,7 +460,7 @@ ${summaryContent} private generateHash(input: string): string { const hash = createHash("sha256"); - hash.update(input); + hash.update(typeof input === "string" ? input : JSON.stringify(input)); return hash.digest("hex"); } } diff --git a/interfaces/discord.ts b/interfaces/discord.ts index 2595a9e..b1a3d60 100644 --- a/interfaces/discord.ts +++ b/interfaces/discord.ts @@ -18,6 +18,7 @@ import { DMChannel, } from "discord.js"; import { UserConfig, userConfigs } from "../config"; +import { get_transcription } from "../tools/ask"; // Add this import export class DiscordAdapter implements PlatformAdapter { private client: Client; @@ -254,6 +255,33 @@ export class DiscordAdapter implements PlatformAdapter { // Expose getMessageInterface method public getMessageInterface = this.createMessageInterface; + public async handleMediaAttachment(attachment: Attachment) { + if (!attachment.url) return { mediaType: 'other' as const }; + + const response = await fetch(attachment.url); + const buffer = await response.arrayBuffer(); + + if (attachment.contentType?.includes('image')) { + const base64 = `data:${attachment.contentType};base64,${Buffer.from(buffer).toString('base64')}`; + return { + base64, + mediaType: 'image' as const + }; + } + + if (attachment.contentType?.includes('audio')) { + // Create temporary file for transcription + const tempFile = new File([buffer], 'audio', { type: attachment.contentType }); + const transcription = await get_transcription(tempFile); + return { + transcription, + mediaType: 'audio' as const + }; + } + + return { mediaType: 'other' as const }; + } + private async convertMessage( discordMessage: DiscordMessage ): Promise { @@ -263,10 +291,19 @@ export class DiscordAdapter implements PlatformAdapter { config: this.getUserById(discordMessage.author.id), }; - const attachments: Attachment[] = discordMessage.attachments.map( - (attachment) => ({ - url: attachment.url, - contentType: attachment.contentType || undefined, + const attachments: Attachment[] = await Promise.all( + discordMessage.attachments.map(async (attachment) => { + const stdAttachment: Attachment = { + url: attachment.url, + contentType: attachment.contentType || undefined, + }; + + const processedMedia = await this.handleMediaAttachment(stdAttachment); + if (processedMedia.base64) stdAttachment.base64 = processedMedia.base64; + if (processedMedia.transcription) stdAttachment.transcription = processedMedia.transcription; + stdAttachment.mediaType = processedMedia.mediaType; + + return stdAttachment; }) ); @@ -402,7 +439,7 @@ export class DiscordAdapter implements PlatformAdapter { // Helper method to safely send messages with length checks private async safeSend( target: TextChannel | DiscordUser, - messageData: string | { content?: string; [key: string]: any } + messageData: string | { content?: string;[key: string]: any } ): Promise { let content: string | undefined; if (typeof messageData === "string") { @@ -432,7 +469,7 @@ export class DiscordAdapter implements PlatformAdapter { // Helper method to safely reply with length checks private async safeReply( message: DiscordMessage, - messageData: string | { content?: string; [key: string]: any } + messageData: string | { content?: string;[key: string]: any } ): Promise { let content: string | undefined; if (typeof messageData === "string") { @@ -456,7 +493,7 @@ export class DiscordAdapter implements PlatformAdapter { // Helper method to safely edit messages with length checks private async safeEdit( message: DiscordMessage, - data: string | { content?: string; [key: string]: any } + data: string | { content?: string;[key: string]: any } ): Promise { let content: string | undefined; if (typeof data === "string") { diff --git a/interfaces/index.ts b/interfaces/index.ts index a88c1b7..649e87e 100644 --- a/interfaces/index.ts +++ b/interfaces/index.ts @@ -4,6 +4,18 @@ import { startEventsServer } from "./events"; import { Message } from "./message"; import { WhatsAppAdapter } from "./whatsapp"; +// Add debounce utility function +function debounce any>( + func: T, + wait: number +): (...args: Parameters) => void { + let timeout: NodeJS.Timeout; + return (...args: Parameters) => { + clearTimeout(timeout); + timeout = setTimeout(() => func(...args), wait) as any; + }; +} + // Initialize Discord Adapter and Processor export const discordAdapter = new DiscordAdapter(); @@ -17,9 +29,13 @@ export function startInterfaces() { discordAdapter.onMessage(async (message) => { await discordProcessor.processMessage(message); }); - whatsappAdapter.onMessage(async (message) => { + + // Debounce WhatsApp messages with 500ms delay + const debouncedWhatsAppProcessor = debounce(async (message) => { await whatsappProcessor.processMessage(message); - }); + }, 1000); + + whatsappAdapter.onMessage(debouncedWhatsAppProcessor); startEventsServer(); } diff --git a/interfaces/message.ts b/interfaces/message.ts index 9875a96..e00d81b 100644 --- a/interfaces/message.ts +++ b/interfaces/message.ts @@ -19,6 +19,9 @@ export interface Attachment { contentType?: string; data?: Buffer | string; type?: string; + mediaType?: 'image' | 'audio' | 'other'; + base64?: string; + transcription?: string; } export interface Embed { @@ -31,10 +34,10 @@ export interface MessageData { options?: any; flags?: any; file?: - | { - url: string; - } - | { path: string }; + | { + url: string; + } + | { path: string }; } export interface Message { diff --git a/interfaces/platform-adapter.ts b/interfaces/platform-adapter.ts index a109ecf..0c80bed 100644 --- a/interfaces/platform-adapter.ts +++ b/interfaces/platform-adapter.ts @@ -1,5 +1,5 @@ import { UserConfig } from "../config"; -import { Message, User } from "./message"; +import { Attachment, Message, User } from "./message"; export interface FetchOptions { limit?: number; @@ -25,4 +25,9 @@ export interface PlatformAdapter { processing: boolean; }; }; + handleMediaAttachment?(attachment: Attachment): Promise<{ + base64?: string; + transcription?: string; + mediaType: 'image' | 'audio' | 'other'; + }>; } diff --git a/interfaces/whatsapp.ts b/interfaces/whatsapp.ts index c0b0a9e..20fe89f 100644 --- a/interfaces/whatsapp.ts +++ b/interfaces/whatsapp.ts @@ -12,20 +12,17 @@ import { MessageMedia, } from "whatsapp-web.js"; import { UserConfig, userConfigs } from "../config"; -import { eventManager } from "./events"; +// import { eventManager } from "./events"; import { return_current_listeners } from "../tools/events"; import Fuse from "fuse.js"; - -// const allowedUsers = ["pooja", "raj"]; -const allowedUsers: string[] = []; +import { get_transcription } from "../tools/ask"; // Add this import export class WhatsAppAdapter implements PlatformAdapter { private client: WAClient; - private botUserId: string = "918884016724@c.us"; public config = { indicators: { - typing: false, + typing: true, processing: false, }, }; @@ -39,6 +36,11 @@ export class WhatsAppAdapter implements PlatformAdapter { console.log("WhatsApp Client is ready!"); }); + this.client.on("qr", (qr) => { + console.log("QR Code received. Please scan with WhatsApp:"); + console.log(qr); + }); + this.client.initialize(); } catch (error) { console.log(`Failed to initialize WhatsApp client: `, error); @@ -62,6 +64,8 @@ export class WhatsAppAdapter implements PlatformAdapter { public onMessage(callback: (message: StdMessage) => void): void { this.client.on("message_create", async (waMessage: WAMessage) => { + + // emit internal event only if text message and there is an active listener const listeners = return_current_listeners(); if ( @@ -69,16 +73,16 @@ export class WhatsAppAdapter implements PlatformAdapter { !waMessage.fromMe && listeners.find((l) => l.eventId.includes("whatsapp")) ) { - const contact = await this.client.getContactById(waMessage.from); - eventManager.emit("got_whatsapp_message", { - sender_id: waMessage.from, - sender_contact_name: - contact.name || contact.shortName || contact.pushname || "NA", - timestamp: waMessage.timestamp, - content: waMessage.body, - profile_image_url: await contact.getProfilePicUrl(), - is_group_message: contact.isGroup.toString(), - }); + // const contact = await this.client.getContactById(waMessage.from); + // eventManager.emit("got_whatsapp_message", { + // sender_id: waMessage.from, + // sender_contact_name: + // contact.name || contact.shortName || contact.pushname || "NA", + // timestamp: waMessage.timestamp, + // content: waMessage.body, + // profile_image_url: await contact.getProfilePicUrl(), + // is_group_message: contact.isGroup.toString(), + // }); } // user must exist in userConfigs @@ -88,14 +92,15 @@ export class WhatsAppAdapter implements PlatformAdapter { return; } - // user must be in allowedUsers - if (!allowedUsers.includes(usr.name)) { - // console.log(`User not allowed: ${usr.name}`); - return; - } + // // user must be in allowedUsers + // if (!allowedUsers.includes(usr.name)) { + // console.log(`User not allowed: ${usr.name}`, allowedUsers); + // return; + // } // Ignore messages sent by the bot if (waMessage.fromMe) return; + const message = await this.convertMessage(waMessage); callback(message); @@ -125,7 +130,7 @@ export class WhatsAppAdapter implements PlatformAdapter { } public getBotId(): string { - return this.botUserId; + return this.client.info.wid.user; } public async createMessageInterface(userId: string): Promise { @@ -280,6 +285,32 @@ export class WhatsAppAdapter implements PlatformAdapter { // Expose this method so it can be accessed elsewhere public getMessageInterface = this.createMessageInterface; + public async handleMediaAttachment(attachment: Attachment) { + if (!attachment.data) return { mediaType: 'other' as const }; + + const buffer = Buffer.from(attachment.data as string, 'base64'); + + if (attachment.type?.includes('image')) { + const base64 = `data:${attachment.contentType};base64,${buffer.toString('base64')}`; + return { + base64, + mediaType: 'image' as const + }; + } + + if (attachment.type?.includes('audio')) { + // Create temporary file for transcription + const tempFile = new File([buffer], 'audio', { type: attachment.contentType }); + const transcription = await get_transcription(tempFile); + return { + transcription, + mediaType: 'audio' as const + }; + } + + return { mediaType: 'other' as const }; + } + private async convertMessage(waMessage: WAMessage): Promise { const contact = await waMessage.getContact(); @@ -292,14 +323,25 @@ export class WhatsAppAdapter implements PlatformAdapter { // Convert attachments let attachments: Attachment[] = []; if (waMessage.hasMedia) { + console.log("Downloading media..."); const media = await waMessage.downloadMedia(); - attachments.push({ - url: "", // WhatsApp does not provide a direct URL to the media + const attachment: Attachment = { + url: "", data: media.data, contentType: media.mimetype, - type: waMessage.type, - }); + type: waMessage.type + }; + + console.log("Processing media attachment..."); + + const processedMedia = await this.handleMediaAttachment(attachment); + console.log("Processed media attachment:", processedMedia.transcription); + if (processedMedia.base64) attachment.base64 = processedMedia.base64; + if (processedMedia.transcription) attachment.transcription = processedMedia.transcription; + attachment.mediaType = processedMedia.mediaType; + + attachments.push(attachment); } const stdMessage: StdMessage = { @@ -358,7 +400,7 @@ export class WhatsAppAdapter implements PlatformAdapter { user.identities.some( (identity) => identity.platform === "whatsapp" && - identity.id === contact.id._serialized + identity.id === contact.id.user ) ); return userConfig ? userConfig.roles : ["user"]; @@ -400,7 +442,11 @@ export class WhatsAppAdapter implements PlatformAdapter { sendTyping: async () => { // WhatsApp Web API does not support sending typing indicators directly // You may leave this as a no-op + const chat = await this.client.getChatById(waMessage.from) + await chat.sendStateTyping() + }, + attachments, }; return stdMessage; @@ -540,6 +586,8 @@ export class WhatsAppAdapter implements PlatformAdapter { sendTyping: async () => { // WhatsApp Web API does not support sending typing indicators directly // You may leave this as a no-op + const chat = await this.client.getChatById(sentWAMessage.from) + await chat.sendStateTyping() }, }; } diff --git a/tools/actions.ts b/tools/actions.ts index a09dfc3..9adbe6c 100644 --- a/tools/actions.ts +++ b/tools/actions.ts @@ -19,6 +19,9 @@ import { memory_manager_guide, memory_manager_init } from "./memory-manager"; // Paths to the JSON files const ACTIONS_FILE_PATH = pathInDataDir("actions.json"); +// Add this constant at the top with other constants +const MIN_SCHEDULE_INTERVAL_SECONDS = 600; // 10 minutes in seconds + // Define schema for creating an action export const CreateActionParams = z.object({ actionId: z @@ -347,6 +350,13 @@ export async function create_action( let { actionId, description, schedule, instruction, tool_names } = parsed.data; + // Validate schedule frequency + if (!validateScheduleFrequency(schedule)) { + return { + error: "❌ Schedule frequency cannot be less than 10 minutes. Please adjust the schedule." + }; + } + // Get the userId from contextMessage const userId: string = contextMessage.author.id; @@ -484,6 +494,13 @@ export async function update_action( notify, } = parsed.data; + // Validate schedule frequency + if (!validateScheduleFrequency(schedule)) { + return { + error: "❌ Schedule frequency cannot be less than 10 minutes. Please adjust the schedule." + }; + } + // Get the userId from contextMessage const userId: string = contextMessage.author.id; @@ -534,16 +551,25 @@ const action_tools: ( schema: CreateActionParams, description: `Creates a new action. +**IMPORTANT SCHEDULING LIMITATION:** +Actions CANNOT be scheduled more frequently than once every 10 minutes. This is a hard system limitation that cannot be overridden. +- For delays: Minimum delay is 600 seconds (10 minutes) +- For cron: Must have at least 10 minutes between executions + **Example:** -- **User:** "Send a summary email in 10 minutes" +- **User:** "Send a summary email every 10 minutes" - **Action ID:** "send_summary_email" - - **Description:** "Sends a summary email after a delay." - - **Schedule:** { type: "delay", time: 600 } + - **Description:** "Sends a summary email periodically" + - **Schedule:** { type: "cron", time: "*/10 * * * *" } - **Instruction:** "Compose and send a summary email to the user." - **Required Tools:** ["email_service"] -**Notes:** -- Supported scheduling types: 'delay' (in seconds), 'cron' (cron expressions). +**Invalid Examples:** +❌ Every 5 minutes: "*/5 * * * *" +❌ Delay of 300 seconds +❌ Multiple times within 10 minutes + +The system will automatically reject any schedule that attempts to run more frequently than every 10 minutes. `, }), // zodFunction({ @@ -710,5 +736,41 @@ Use the data provided above to fulfill the user's request. } } +// Replace the existing validateCronFrequency function with this more comprehensive one +function validateScheduleFrequency(schedule: { type: "delay" | "cron"; time: number | string }): boolean { + try { + if (schedule.type === "delay") { + const delaySeconds = schedule.time as number; + return delaySeconds >= MIN_SCHEDULE_INTERVAL_SECONDS; + } else if (schedule.type === "cron") { + const cronExpression = schedule.time as string; + const intervals = cronExpression.split(' '); + + // Check minutes field (first position) + const minutesPart = intervals[0]; + if (minutesPart === '*') return false; + if (minutesPart.includes('/')) { + const step = parseInt(minutesPart.split('/')[1]); + if (step < 10) return false; + } + // Convert specific minute values to ensure they're at least 10 minutes apart + if (!minutesPart.includes('/')) { + const minutes = minutesPart.split(',').map(Number); + if (minutes.length > 1) { + minutes.sort((a, b) => a - b); + for (let i = 1; i < minutes.length; i++) { + if (minutes[i] - minutes[i - 1] < 10) return false; + } + if ((60 - minutes[minutes.length - 1] + minutes[0]) < 10) return false; + } + } + return true; + } + return false; + } catch { + return false; + } +} + // Initialize by loading actions from file when the module is loaded loadActionsFromFile(); diff --git a/tools/ask.ts b/tools/ask.ts index 28f19af..e36bfea 100644 --- a/tools/ask.ts +++ b/tools/ask.ts @@ -133,12 +133,12 @@ export async function ask({ tools, seed, json, - image_url, + image_urls, // Changed from image_url to image_urls array }: { model?: string; prompt: string; message?: string; - image_url?: string; + image_urls?: string[]; // Changed to array of strings name?: string; tools?: RunnableToolFunctionWithParse[]; seed?: string; @@ -166,6 +166,22 @@ export async function ask({ // Retrieve existing message history const history = getMessageHistory(seed); + let messageContent: any = message; + if (image_urls && image_urls.length > 0) { + messageContent = [ + { + type: "text", + text: message, + }, + ...image_urls.map((url) => ({ + type: "image_url", + image_url: { + url: url, + }, + })), + ]; + } + // Combine system prompt with message history and new user message messages = [ { @@ -175,24 +191,11 @@ export async function ask({ ...history, { role: "user", - content: image_url - ? [ - { - type: "text", - text: message, - }, - { - type: "image_url", - image_url: { - url: image_url, - }, - }, - ] - : message, + content: messageContent, name, }, ]; - image_url && console.log("got image:", image_url?.slice(0, 20)); + image_urls && console.log("got images:", image_urls.length); } else if (seed && !message) { // If seed is provided but no new message, just retrieve history const history = getMessageHistory(seed); @@ -205,22 +208,25 @@ export async function ask({ ]; } else if (!seed && message) { // If no seed but message is provided, send system prompt and user message without history + let messageContent: any = message; + if (image_urls && image_urls.length > 0) { + messageContent = [ + { + type: "text", + text: message, + }, + ...image_urls.map((url) => ({ + type: "image_url", + image_url: { + url: url, + }, + })), + ]; + } + messages.push({ role: "user", - content: image_url - ? [ - { - type: "text", - text: message, - }, - { - type: "image_url", - image_url: { - url: image_url, - }, - }, - ] - : message, + content: messageContent, name, }); } diff --git a/tools/communication.ts b/tools/communication.ts index cc7ebab..78299a2 100644 --- a/tools/communication.ts +++ b/tools/communication.ts @@ -17,13 +17,13 @@ const CommunicationManagerSchema = z.object({ .describe( "The platform you prefer to use, you can leave this empty to default to the current user's platform." ), - prefered_recipient_details: z - .object({ - name: z.string().optional(), - user_id: z.string().optional(), - }) - .optional() - .describe("Give these details only if you have them."), + // prefered_recipient_details: z + // .object({ + // name: z.string().optional(), + // user_id: z.string().optional(), + // }) + // .optional() + // .describe("Give these details only if you have them."), }); export type CommunicationManager = z.infer; @@ -70,7 +70,7 @@ export async function communication_manager( { request, prefered_platform, - prefered_recipient_details, + // prefered_recipient_details, }: CommunicationManager, context_message: Message ) { @@ -78,27 +78,61 @@ export async function communication_manager( memory_manager_init(context_message, "communications_manager") ); - const prompt = `You are a Communication Manager Tool. + const prompt = `You are a Communication Manager Tool responsible for routing messages to the correct recipients. -Your task is to route messages to the correct recipient. +CONTEXT INFORMATION: +1. Current User (Sender): ${context_message.author.config?.name} +2. Current Platform: ${context_message.platform} +3. WhatsApp Access: ${context_message.getUserRoles().includes("creator")} +4. Available Platforms: discord, whatsapp, email ---- +STEP-BY-STEP PROCESS: +1. First, identify the recipient(s) from the request +2. Then, check if recipient exists in this list of known users: +${JSON.stringify(userConfigs, null, 2)} + +3. If recipient not found in above list: + - Use search_user tool to find them + - Wait for search results before proceeding + +4. Platform Selection: + - If prefered_platform is specified, use that + - If not specified, use current platform: ${context_message.platform} + - For WhatsApp, verify you have creator access first + +TOOLS AVAILABLE: +- search_user: Find user details by name +- send_message_to: Send message on discord/whatsapp +- send_email: Send emails (requires verified email address) +- memory_manager: Store user preferences and contact names ${memory_manager_guide("communications_manager", context_message.author.id)} -You can use the \`memory_manager\` tool to remember user preferences, such as what the user calls certain contacts, to help you route messages better. +MESSAGE DELIVERY GUIDELINES: +Act as a professional assistant delivering messages between people. Consider: ---- +1. Relationship Context: + - Professional for workplace communications + - Casual for friends and family + - Respectful for all contexts -**Default Platform (if not mentioned):** ${context_message.platform} +2. Message Delivery Style: + - Frame the message naturally as an assistant would when passing along information + - Maintain the original intent and tone of the sender + - Add appropriate context without changing the core message -**Configuration of All Users:** ${JSON.stringify(userConfigs)} +3. Natural Communication: + - Deliver messages as if you're the assistant of the user: ${context_message.author.config?.name}. + - Adapt your tone based on the message urgency and importance + - Include relevant context when delivering reminders or requests + - Keep the human element in the communication -**Can Access 'WhatsApp':** ${context_message.getUserRoles().includes("creator")} +Remember: You're not just forwarding messages, you're acting as a professional assistant helping facilitate communication between people. Make your delivery natural and appropriate for each situation. -**Guidelines:** - -- If the user does not mention a platform, use the same platform as the current user. +ERROR PREVENTION: +- Don't halucinate or invent contact details +- Always verify platform availability before sending +- If unsure about recipient, ask for clarification `; const response = await ask({ @@ -106,9 +140,7 @@ You can use the \`memory_manager\` tool to remember user preferences, such as wh model: "gpt-4o-mini", message: `request: ${request} - prefered_platform: ${prefered_platform} - - prefered_recipient_details: ${JSON.stringify(prefered_recipient_details)}`, + prefered_platform: ${prefered_platform}`, tools, }); @@ -128,13 +160,11 @@ export const communication_manager_tool = (context_message: Message) => function: (args) => communication_manager(args, context_message), name: "communication_manager", schema: CommunicationManagerSchema, - description: `Communications Manager. + description: `Sends messages to one or more recipients across different platforms (discord, whatsapp, email). -This tool routes messages to the specified user on the appropriate platform. +Input format: +request: "send [message] to [recipient(s)]" +prefered_platform: (optional) platform name -Use it to send messages to users on various platforms. - -Provide detailed information to ensure the message reaches the correct recipient. - -Include in your request the full message content and its context along with the recipient's details.`, +The tool handles recipient lookup, message composition, and delivery automatically.`, }); diff --git a/tools/event-prompt-augmentations.ts b/tools/event-prompt-augmentations.ts index 6631200..b1b5c1d 100644 --- a/tools/event-prompt-augmentations.ts +++ b/tools/event-prompt-augmentations.ts @@ -13,10 +13,24 @@ export interface PromptAugmentationResult { updatedSystemPrompt?: string; message?: string; updatedTools?: RunnableToolFunctionWithParse[]; - attachedImageBase64?: string; + attachedImagesBase64?: string[]; // Changed to string array model: string; } +/** + * Helper function to handle transcription of a single file or array of files + */ +async function handleTranscription( + input: File | File[] +): Promise { + if (Array.isArray(input)) { + return Promise.all( + input.map((file) => get_transcription(file as globalThis.File)) + ); + } + return get_transcription(input as globalThis.File); +} + /** * 1) Voice Event Augmentation * - Possibly do transcription if an audio File is present. @@ -28,28 +42,104 @@ async function voiceEventAugmentation( baseTools: RunnableToolFunctionWithParse[] | undefined, contextMessage: Message ): Promise { - let attachedImageBase64: string | undefined; + let attachedImagesBase64: string[] = []; // Changed to array - // Transcribe if there's an audio file - if (payload?.transcription && payload.transcription instanceof File) { - console.log("Transcribing audio for voice event listener."); - const file = payload.transcription; - payload.transcription = await get_transcription(file as globalThis.File); + // Handle transcription - single file or array + if (payload?.transcription) { + if ( + payload.transcription instanceof File || + Array.isArray(payload.transcription) + ) { + console.log("Transcribing audio(s) for voice event listener."); + payload.transcription = await handleTranscription(payload.transcription); + } } - // Check for an attached image + // Handle other_reference_data - single file or array const otherContextData = payload?.other_reference_data; - if ( - otherContextData instanceof File && - otherContextData.type.includes("image") - ) { - console.log("Got image in voice event payload; converting to base64..."); - const buffer = await otherContextData.arrayBuffer(); - attachedImageBase64 = `data:${otherContextData.type};base64,${Buffer.from( - buffer - ).toString("base64")}`; + if (otherContextData) { + if (Array.isArray(otherContextData)) { + const results = await Promise.all( + otherContextData.map(async (item) => { + if (item instanceof File) { + if (item.type.includes("audio")) { + return await get_transcription(item as globalThis.File); + } else if (item.type.includes("image")) { + const buffer = await item.arrayBuffer(); + const imageBase64 = `data:${item.type};base64,${Buffer.from( + buffer + ).toString("base64")}`; + attachedImagesBase64.push(imageBase64); // Add to array + return imageBase64; + } + } + return item; + }) + ); + payload.other_reference_data = results; + } else if (otherContextData instanceof File) { + if (otherContextData.type.includes("audio")) { + payload.other_reference_data = await get_transcription( + otherContextData as globalThis.File + ); + } else if (otherContextData.type.includes("image")) { + const buffer = await otherContextData.arrayBuffer(); + const imageBase64 = `data:${otherContextData.type};base64,${Buffer.from( + buffer + ).toString("base64")}`; + attachedImagesBase64.push(imageBase64); // Add to array + } + } } + const files = payload?.files; + if (files) { + if (Array.isArray(files)) { + const results = await Promise.all( + files.map(async (file) => { + if (file instanceof File) { + if (file.type.includes("audio")) { + const res = await get_transcription(file as globalThis.File); + return `transcript of file: ${file.name}: + + ${res} + ` + } else if (file.type.includes("image")) { + const buffer = await file.arrayBuffer(); + const imageBase64 = `data:${file.type};base64,${Buffer.from( + buffer + ).toString("base64")}`; + attachedImagesBase64.push(imageBase64); // Add to array + return `image file: ${file.name}`; + } + } + return file; + }) + ); + payload.files = results; + } else if (files instanceof File) { + if (files.type.includes("audio")) { + // payload.files = await get_transcription(files as globalThis.File); + const res = await get_transcription(files as globalThis.File); + payload.files = `transcript of file: ${files.name}: + + ${res} + `; + + } else if (files.type.includes("image")) { + const buffer = await files.arrayBuffer(); + const imageBase64 = `data:${files.type};base64,${Buffer.from( + buffer + ).toString("base64")}`; + attachedImagesBase64.push(imageBase64); // Add to array + payload.files = `image file: ${files.name}`; + } + } + } + + // console.log("Payload: ", payload); + // console.log("IMages: ", attachedImagesBase64); + let message = ` You are in voice trigger mode. @@ -72,7 +162,7 @@ Your response must be in plain text without extra formatting or Markdown. updatedSystemPrompt: prompt, message, updatedTools: tools, - attachedImageBase64, + attachedImagesBase64, // Now returning array model: "gpt-4o", }; } @@ -147,7 +237,7 @@ async function defaultAugmentation( ): Promise { return { updatedTools: baseTools, - attachedImageBase64: undefined, + attachedImagesBase64: [], // Changed to empty array model: "gpt-4o-mini", }; } @@ -187,7 +277,7 @@ export async function buildPromptAndToolsForEvent( finalPrompt: string; message?: string; finalTools: RunnableToolFunctionWithParse[] | undefined; - attachedImage?: string; + attachedImages?: string[]; model?: string; }> { console.log(`Building prompt for event: ${eventId}`); @@ -227,11 +317,10 @@ You are called when an event triggers. Your task is to execute the user's instru - **Event ID:** ${eventId} - **Description:** ${description} - **Payload:** ${JSON.stringify(payload, null, 2)} -- **Will Auto Notify Creator of Listener:** ${ - notify +- **Will Auto Notify Creator of Listener:** ${notify ? "Yes, no need to send it yourself" : "No, you need to notify the user manually" - } + } - **Instruction:** ${instruction} **Action Required:** @@ -257,7 +346,7 @@ You are called when an event triggers. Your task is to execute the user's instru const { additionalSystemPrompt, updatedTools, - attachedImageBase64, + attachedImagesBase64, updatedSystemPrompt, model, message, @@ -271,7 +360,7 @@ You are called when an event triggers. Your task is to execute the user's instru return { finalPrompt: updatedSystemPrompt || finalPrompt, finalTools: updatedTools, - attachedImage: attachedImageBase64, + attachedImages: attachedImagesBase64, model, message, }; diff --git a/tools/events.ts b/tools/events.ts index 07e8577..ee9ed7e 100644 --- a/tools/events.ts +++ b/tools/events.ts @@ -434,7 +434,7 @@ function registerListener(listener: EventListener) { console.time("buildPromptAndToolsForEvent"); // Now call the helper from the new file - const { finalPrompt, finalTools, attachedImage, model, message } = + const { finalPrompt, finalTools, attachedImages, model, message } = await buildPromptAndToolsForEvent( eventId, description, @@ -457,7 +457,7 @@ function registerListener(listener: EventListener) { model: model, message, prompt: finalPrompt, - image_url: attachedImage, // If there's an attached image base64 + image_urls: attachedImages, // If there's an attached image base64 seed: `${eventId}-${listener.id}`, tools: finalTools, }); diff --git a/tools/index.ts b/tools/index.ts index 799a9c7..300952b 100644 --- a/tools/index.ts +++ b/tools/index.ts @@ -69,7 +69,7 @@ import { linear_manager_tool } from "./linear-manager"; // get time function const GetTimeParams = z.object({}); type GetTimeParams = z.infer; -async function get_date_time({}: GetTimeParams) { +async function get_date_time({ }: GetTimeParams) { return { response: new Date().toLocaleString() }; } @@ -105,7 +105,7 @@ async function run_bash_command({ command }: RunBashCommandParams) { // exit process const ExitProcessParams = z.object({}); type ExitProcessParams = z.infer; -async function restart_self({}: ExitProcessParams, context_message: Message) { +async function restart_self({ }: ExitProcessParams, context_message: Message) { await Promise.all([ send_sys_log("Restarting myself"), context_message.send({ @@ -148,6 +148,8 @@ export function getTools( ) { const userRoles = context_message.getUserRoles(); + console.log("User roles: ", userRoles); + // Aggregate permissions from all roles const userPermissions = new Set(); userRoles.forEach((role) => { @@ -170,254 +172,254 @@ export function getTools( name: string; tool: RunnableToolFunction | RunnableToolFunction[]; }[] = [ - { - name: "calculator", - tool: zodFunction({ - function: calculator, - schema: CalculatorParams, - description: "Evaluate math expression", - }), - }, - { - name: "getTime", - tool: zodFunction({ - function: get_date_time, - schema: GetTimeParams, - description: "Get current date and time", - }), - }, - { - name: "search_user_ids", - tool: zodFunction({ - function: (args) => search_user(args, context_message), + { + name: "calculator", + tool: zodFunction({ + function: calculator, + schema: CalculatorParams, + description: "Evaluate math expression", + }), + }, + { + name: "getTime", + tool: zodFunction({ + function: get_date_time, + schema: GetTimeParams, + description: "Get current date and time", + }), + }, + { name: "search_user_ids", - schema: SearchUserParams, - description: `Search and get user's details. Use this only when required.`, - }), - }, + tool: zodFunction({ + function: (args) => search_user(args, context_message), + name: "search_user_ids", + schema: SearchUserParams, + description: `Search and get user's details. Use this only when required.`, + }), + }, - // Computer nerd tools + // Computer nerd tools - // { - // name: "search_whatsapp_contacts", - // tool: zodFunction({ - // function: search_whatsapp_contacts, - // schema: SearchContactsParams, - // description: `Search for contacts in user's whatsapp account. Use this to get whatsapp user_id of any user. - // Note: Confirm from the user before sending any messages to the contacts found using this search. - // `, - // }), - // }, - /* { - name: "scrapeWeb", - tool: zodFunction({ - function: scrape_and_convert_to_markdown, - schema: ScrapeAndConvertToMarkdownParams, - name: "scrape_web", - description: `Get data from a webpage.`, - }), - }, - { - name: "uploadFile", - tool: zodFunction({ - function: upload_file, - schema: UploadFileParams, - description: `Upload a LOCAL file to a MinIO bucket and return its public URL. + // { + // name: "search_whatsapp_contacts", + // tool: zodFunction({ + // function: search_whatsapp_contacts, + // schema: SearchContactsParams, + // description: `Search for contacts in user's whatsapp account. Use this to get whatsapp user_id of any user. + // Note: Confirm from the user before sending any messages to the contacts found using this search. + // `, + // }), + // }, + /* { + name: "scrapeWeb", + tool: zodFunction({ + function: scrape_and_convert_to_markdown, + schema: ScrapeAndConvertToMarkdownParams, + name: "scrape_web", + description: `Get data from a webpage.`, + }), + }, + { + name: "uploadFile", + tool: zodFunction({ + function: upload_file, + schema: UploadFileParams, + description: `Upload a LOCAL file to a MinIO bucket and return its public URL. + + Note: + - The filePath should be a local file path in the /tmp directory. + - If you want to re-upload a file from the internet, you can download it using run_shell_command to a /tmp directory and then upload it. + + Use cases: + - You can use this to share files from inside the code interpreter using the /tmp file path. + - You can use this to share files that only you have access to, like temporary files or discord files. + - You can use this when the user explicitly asks for a file to be shared with them or wants to download a file.`, + }), + }, + { + name: "getFileList", + tool: zodFunction({ + function: get_file_list, + schema: GetFileListParams, + description: `Get the list of public URLs for all files in the MinIO bucket`, + }), + }, + { + name: "getYouTubeVideoData", + tool: zodFunction({ + function: get_youtube_video_data, + schema: YoutubeTranscriptParams as any, + description: + "Get YouTube video data. Use this only when sent a YouTube URL. Do not use this for YouTube search.", + }), + }, + { + name: "getDownloadLink", + tool: zodFunction({ + function: get_download_link as any, + schema: YoutubeDownloaderParams, + description: `Get download link for YouTube links. + Also, always hide the length of links that are too long by formatting them with markdown. + For any site other than YouTube, use code interpreter to scrape the download link. + + If the user wants the file and not just the link: + You can use the direct link you get from this to download the media inside code interpreter and then share the downloaded files using the send message tool. + Make sure that the file size is within discord limits.`, + }), + }, + { + name: "codeInterpreter", + tool: zodFunction({ + function: (args) => code_interpreter(args, context_message), + name: "code_interpreter", + schema: PythonCodeParams, + description: `Primary Function: Run Python code in an isolated environment. + Key Libraries: pandas for data analysis, matplotlib for visualization. + Use Cases: Data analysis, plotting, image/video processing using ffmpeg for video, complex calculations, and attachment analysis. + You can also use this to try to scrape and get download links from non-YouTube sites. + + File sharing: + To share a file with a user from inside code interpreter, you can save the file to the /tmp/ directory and then use the send message tool to send the file to the user by using the full path of the file, including the /tmp part in the path. + + Notes: + Import necessary libraries; retry if issues arise. + For web scraping, process data to stay within a 10,000 token limit. + Use run_shell_command to check or install dependencies. + Try to fix any errors that are returned at least once before sending to the user, especially syntax/type errors.`, + }), + }, + { + name: "runShellCommand", + tool: zodFunction({ + function: (args) => + run_command_in_code_interpreter_env(args, context_message), + name: "run_shell_command", + schema: RunPythonCommandParams, + description: `Run bash command. Use this to install any needed dependencies.`, + }), + }, */ -Note: -- The filePath should be a local file path in the /tmp directory. -- If you want to re-upload a file from the internet, you can download it using run_shell_command to a /tmp directory and then upload it. + // { + // name: "generateChart", + // tool: zodFunction({ + // function: chart, + // name: "generate_chart", + // schema: ChartParams, + // description: `Generate chart PNG image URL using quickchart.io`, + // }), + // }, + // { + // name: "memeOrCatMaker", + // tool: zodFunction({ + // function: meme_maker, + // name: "meme_or_cat_maker", + // schema: MemeMakerParams, + // description: `Generate meme image URL using memegen.link OR generate cat image URL using cataas.com -Use cases: -- You can use this to share files from inside the code interpreter using the /tmp file path. -- You can use this to share files that only you have access to, like temporary files or discord files. -- You can use this when the user explicitly asks for a file to be shared with them or wants to download a file.`, - }), - }, - { - name: "getFileList", - tool: zodFunction({ - function: get_file_list, - schema: GetFileListParams, - description: `Get the list of public URLs for all files in the MinIO bucket`, - }), - }, - { - name: "getYouTubeVideoData", - tool: zodFunction({ - function: get_youtube_video_data, - schema: YoutubeTranscriptParams as any, - description: - "Get YouTube video data. Use this only when sent a YouTube URL. Do not use this for YouTube search.", - }), - }, - { - name: "getDownloadLink", - tool: zodFunction({ - function: get_download_link as any, - schema: YoutubeDownloaderParams, - description: `Get download link for YouTube links. -Also, always hide the length of links that are too long by formatting them with markdown. -For any site other than YouTube, use code interpreter to scrape the download link. + // Just provide the info in the query, and it will generate the URL for you. + // This can include any memegen.link or cataas.com specific parameters. + // Make sure to give as many details as you can about what the user wants. + // Also, make sure to send the images and memes as files to the user using the send message tool unless explicitly asked to send the URL.`, + // }), + // }, + // { + // name: "sendMessageToChannel", + // tool: zodFunction({ + // function: (args) => send_general_message(args, context_message), + // name: "send_message_to_channel", + // schema: SendGeneralMessageParams, + // description: `Send message to the current Discord channel. + // You can also use this for reminders or other scheduled messages by calculating the delay from the current time. + // If the user does not specify a time for a reminder, think of one based on the task. + // If no channel ID is provided, the message will be sent to the user you are currently chatting with.`, + // }), + // }, + // { + // name: "searchChat", + // tool: zodFunction({ + // function: (args) => search_chat(args, context_message), + // name: "search_chat", + // schema: SearchChatParams, + // description: `Search for messages in the current channel based on query parameters. + // This will search the last 100 (configurable by setting the limit parameter) messages in the channel. + // Set user_only parameter to true if you want to search only the user's messages.`, + // }), + // }, + { + name: "serviceChecker", + tool: zodFunction({ + function: service_checker, + name: "service_checker", + schema: ServiceCheckerParams, + description: `Check the status of a service by querying the status page of the service. Use this when the user asks if something is up or down in the context of a service.`, + }), + }, + // { + // name: "getTotalTokens", + // tool: zodFunction({ + // function: get_total_tokens, + // name: "get_total_tokens", + // schema: GetTotalTokensParams, + // description: `Get total tokens used by a model in a date range -If the user wants the file and not just the link: -You can use the direct link you get from this to download the media inside code interpreter and then share the downloaded files using the send message tool. -Make sure that the file size is within discord limits.`, - }), - }, - { - name: "codeInterpreter", - tool: zodFunction({ - function: (args) => code_interpreter(args, context_message), - name: "code_interpreter", - schema: PythonCodeParams, - description: `Primary Function: Run Python code in an isolated environment. -Key Libraries: pandas for data analysis, matplotlib for visualization. -Use Cases: Data analysis, plotting, image/video processing using ffmpeg for video, complex calculations, and attachment analysis. -You can also use this to try to scrape and get download links from non-YouTube sites. + // The pricing as of 2024 is: + // gpt-4o: + // $5.00 / 1M prompt tokens + // $15.00 / 1M completion tokens -File sharing: -To share a file with a user from inside code interpreter, you can save the file to the /tmp/ directory and then use the send message tool to send the file to the user by using the full path of the file, including the /tmp part in the path. + // gpt-4o-mini: + // $0.150 / 1M prompt tokens + // $0.600 / 1M completion tokens -Notes: -Import necessary libraries; retry if issues arise. -For web scraping, process data to stay within a 10,000 token limit. -Use run_shell_command to check or install dependencies. -Try to fix any errors that are returned at least once before sending to the user, especially syntax/type errors.`, - }), - }, - { - name: "runShellCommand", - tool: zodFunction({ - function: (args) => - run_command_in_code_interpreter_env(args, context_message), - name: "run_shell_command", - schema: RunPythonCommandParams, - description: `Run bash command. Use this to install any needed dependencies.`, - }), - }, */ - - // { - // name: "generateChart", - // tool: zodFunction({ - // function: chart, - // name: "generate_chart", - // schema: ChartParams, - // description: `Generate chart PNG image URL using quickchart.io`, - // }), - // }, - // { - // name: "memeOrCatMaker", - // tool: zodFunction({ - // function: meme_maker, - // name: "meme_or_cat_maker", - // schema: MemeMakerParams, - // description: `Generate meme image URL using memegen.link OR generate cat image URL using cataas.com - - // Just provide the info in the query, and it will generate the URL for you. - // This can include any memegen.link or cataas.com specific parameters. - // Make sure to give as many details as you can about what the user wants. - // Also, make sure to send the images and memes as files to the user using the send message tool unless explicitly asked to send the URL.`, - // }), - // }, - // { - // name: "sendMessageToChannel", - // tool: zodFunction({ - // function: (args) => send_general_message(args, context_message), - // name: "send_message_to_channel", - // schema: SendGeneralMessageParams, - // description: `Send message to the current Discord channel. - // You can also use this for reminders or other scheduled messages by calculating the delay from the current time. - // If the user does not specify a time for a reminder, think of one based on the task. - // If no channel ID is provided, the message will be sent to the user you are currently chatting with.`, - // }), - // }, - // { - // name: "searchChat", - // tool: zodFunction({ - // function: (args) => search_chat(args, context_message), - // name: "search_chat", - // schema: SearchChatParams, - // description: `Search for messages in the current channel based on query parameters. - // This will search the last 100 (configurable by setting the limit parameter) messages in the channel. - // Set user_only parameter to true if you want to search only the user's messages.`, - // }), - // }, - { - name: "serviceChecker", - tool: zodFunction({ - function: service_checker, - name: "service_checker", - schema: ServiceCheckerParams, - description: `Check the status of a service by querying the status page of the service. Use this when the user asks if something is up or down in the context of a service.`, - }), - }, - // { - // name: "getTotalTokens", - // tool: zodFunction({ - // function: get_total_tokens, - // name: "get_total_tokens", - // schema: GetTotalTokensParams, - // description: `Get total tokens used by a model in a date range - - // The pricing as of 2024 is: - // gpt-4o: - // $5.00 / 1M prompt tokens - // $15.00 / 1M completion tokens - - // gpt-4o-mini: - // $0.150 / 1M prompt tokens - // $0.600 / 1M completion tokens - - // Use calculator to make the math calculations.`, - // }), - // }, - { - name: "communicationsManagerTool", - tool: communication_manager_tool(context_message), - }, - { - name: "calendarManagerTool", - tool: zodFunction({ - function: (args) => calendarManager(args, context_message), - name: "calendar_manager", - schema: CalendarManagerParams, - description: `Manage calendar events using user's Calendar. + // Use calculator to make the math calculations.`, + // }), + // }, + { + name: "communicationsManagerTool", + tool: communication_manager_tool(context_message), + }, + { + name: "calendarManagerTool", + tool: zodFunction({ + function: (args) => calendarManager(args, context_message), + name: "calendar_manager", + schema: CalendarManagerParams, + description: `Manage calendar events using user's Calendar. You can just forward the user's request to this tool and it will handle the rest.`, - }), - }, - { - name: "remindersManagerTools", - tool: zodFunction({ - function: (args) => remindersManager(args, context_message), - name: "reminders_manager", - schema: RemindersManagerParams, - description: `Manage reminders using user's reminders. + }), + }, + { + name: "remindersManagerTools", + tool: zodFunction({ + function: (args) => remindersManager(args, context_message), + name: "reminders_manager", + schema: RemindersManagerParams, + description: `Manage reminders using user's reminders. You can just forward the user's request to this tool and it will handle the rest. More detailed todos that dont need user notification will be managed by the notes manager tool instead. `, - }), - }, - { - name: "homeAssistantManagerTool", - tool: zodFunction({ - function: (args) => homeManager(args, context_message), - name: "home_assistant_manager", - schema: HomeManagerParams, - description: `Manage home assistant devices and services in natural language. + }), + }, + { + name: "homeAssistantManagerTool", + tool: zodFunction({ + function: (args) => homeManager(args, context_message), + name: "home_assistant_manager", + schema: HomeManagerParams, + description: `Manage home assistant devices and services in natural language. Give as much details as possible to get the best results. Especially what devices that the user named and what action they want to perform on them. `, - }), - }, - { - name: "notesManagerTool", - tool: zodFunction({ - function: (args) => notesManager(args, context_message), - name: "notes_manager", - schema: NotesManagerParams, - description: `Manage notes using user's notes. + }), + }, + { + name: "notesManagerTool", + tool: zodFunction({ + function: (args) => notesManager(args, context_message), + name: "notes_manager", + schema: NotesManagerParams, + description: `Manage notes using user's notes. You can just forward the user's request verbatim (or by adding more clarity) to this tool and it will handle the rest. @@ -425,57 +427,57 @@ Try to fix any errors that are returned at least once before sending to the user if user talks about any notes, lists, journal, gym entry, standup, personal journal, etc. You can also use this for advanced todos that are more planning related. (these are not reminders, and will not notify the user) `, - }), - }, - { - name: "linkManagerTool", - tool: zodFunction({ - function: (args) => linkManager(args, context_message), - name: "link_manager", - schema: LinkManagerParams, - description: `Manage links using LinkWarden. + }), + }, + { + name: "linkManagerTool", + tool: zodFunction({ + function: (args) => linkManager(args, context_message), + name: "link_manager", + schema: LinkManagerParams, + description: `Manage links using LinkWarden. You can just forward the user's request to this tool and it will handle the rest. `, - }), - }, - { - name: "ProjectManager", - tool: linear_manager_tool(context_message), - }, - { - name: "actionsManagerTool", - tool: zodFunction({ - function: (args) => actionManager(args, context_message), - name: "actions_manager", - schema: ActionManagerParamsSchema, - description: `Manage scheduled actions using the Actions Manager. + }), + }, + { + name: "ProjectManager", + tool: linear_manager_tool(context_message), + }, + { + name: "actionsManagerTool", + tool: zodFunction({ + function: (args) => actionManager(args, context_message), + name: "actions_manager", + schema: ActionManagerParamsSchema, + description: `Manage scheduled actions using the Actions Manager. Forward user requests to create, update, retrieve, or remove actions. You can use this for when a user wants you to do something at a specific time or after a specific time.`, - }), - }, - { - name: "eventsManagerTool", - tool: zodFunction({ - function: (args) => event_manager(args, context_message), - name: "events_manager", - schema: EventManagerSchema, - description: `Manage events using the Events Manager. + }), + }, + { + name: "eventsManagerTool", + tool: zodFunction({ + function: (args) => event_manager(args, context_message), + name: "events_manager", + schema: EventManagerSchema, + description: `Manage events using the Events Manager. Forward user requests to create, update, retrieve, or remove events. When to use: if user wants to create some automation based on some event.`, - }), - }, - { - name: "softwareEngineerManagerTool", - tool: zodFunction({ - function: (args) => dockerToolManager(args, context_message), - name: "software_engineer_manager", - schema: DockerToolManagerSchema, - description: `Software Engineer Manager Tool. + }), + }, + { + name: "softwareEngineerManagerTool", + tool: zodFunction({ + function: (args) => dockerToolManager(args, context_message), + name: "software_engineer_manager", + schema: DockerToolManagerSchema, + description: `Software Engineer Manager Tool. His name is Cody. He is a software engineer, and someone who loves technology. He specializes in linux and devops. @@ -485,28 +487,28 @@ This manager is like a whole other user that you are talking to. When talking to this manager, you can inform the user that you asked cody for this query etc. `, - }), - }, - { - name: "restart", - tool: zodFunction({ - function: (args) => restart_self(args, context_message), - name: "restart_self", - schema: ExitProcessParams, - description: - "Restart yourself. do this only when the user explicitly asks you to restart yourself.", - }), - }, - // { - // name: "eventTools", - // tool: event_tools(context_message), - // }, - // Period tools - { - name: "periodTools", - tool: getPeriodTools(context_message), - }, - ]; + }), + }, + { + name: "restart", + tool: zodFunction({ + function: (args) => restart_self(args, context_message), + name: "restart_self", + schema: ExitProcessParams, + description: + "Restart yourself. do this only when the user explicitly asks you to restart yourself.", + }), + }, + // { + // name: "eventTools", + // tool: event_tools(context_message), + // }, + // Period tools + { + name: "periodTools", + tool: getPeriodTools(context_message), + }, + ]; const manager_tools = manager_id ? [memory_manager_init(context_message, manager_id)] diff --git a/tools/linear-manager.ts b/tools/linear-manager.ts index 4285f3d..52c3f09 100644 --- a/tools/linear-manager.ts +++ b/tools/linear-manager.ts @@ -13,80 +13,59 @@ export const IssueParams = z.object({ title: z.string(), description: z.string().optional(), assigneeId: z.string().optional(), + projectId: z.string().optional(), priority: z.number().optional(), labelIds: z.array(z.string()).optional(), }); export const UpdateIssueParams = z.object({ issueId: z.string().describe("The ID of the issue to update"), + // Basic fields title: z.string().optional().describe("The issue title"), - description: z - .string() - .optional() - .describe("The issue description in markdown format"), - stateId: z.string().optional().describe("The team state/status of the issue"), - assigneeId: z - .string() - .optional() - .describe("The identifier of the user to assign the issue to"), - priority: z - .number() - .min(0) - .max(4) - .optional() - .describe( - "The priority of the issue. 0 = No priority, 1 = Urgent, 2 = High, 3 = Normal, 4 = Low" - ), - addedLabelIds: z - .array(z.string()) - .optional() + description: z.string().optional().describe("The issue description in markdown format"), + descriptionData: z.any().optional().describe("The issue description as a Prosemirror document"), + priority: z.number().min(0).max(4).optional() + .describe("The priority of the issue. 0 = No priority, 1 = Urgent, 2 = High, 3 = Normal, 4 = Low"), + + // Assignee and subscribers + assigneeId: z.string().optional().describe("The identifier of the user to assign the issue to"), + subscriberIds: z.array(z.string()).optional().describe("The identifiers of the users subscribing to this ticket"), + + // Labels + labelIds: z.array(z.string()).optional() + .describe("The complete set of label IDs to set on the issue (replaces existing labels)"), + addedLabelIds: z.array(z.string()).optional() .describe("The identifiers of the issue labels to be added to this issue"), - removedLabelIds: z - .array(z.string()) - .optional() - .describe( - "The identifiers of the issue labels to be removed from this issue" - ), - labelIds: z - .array(z.string()) - .optional() - .describe( - "The complete set of label IDs to set on the issue (replaces existing labels)" - ), - autoClosedByParentClosing: z - .boolean() - .optional() - .describe( - "Whether the issue was automatically closed because its parent issue was closed" - ), - boardOrder: z - .number() - .optional() - .describe("The position of the issue in its column on the board view"), - dueDate: z - .string() - .optional() - .describe("The date at which the issue is due (TimelessDate format)"), - parentId: z - .string() - .optional() - .describe("The identifier of the parent issue"), - projectId: z - .string() - .optional() - .describe("The project associated with the issue"), - sortOrder: z - .number() - .optional() - .describe("The position of the issue related to other issues"), - subIssueSortOrder: z - .number() - .optional() - .describe("The position of the issue in parent's sub-issue list"), - teamId: z - .string() - .optional() - .describe("The identifier of the team associated with the issue"), + removedLabelIds: z.array(z.string()).optional() + .describe("The identifiers of the issue labels to be removed from this issue"), + + // Status and workflow + stateId: z.string().optional().describe("The team state of the issue"), + estimate: z.number().optional().describe("The estimated complexity of the issue"), + + // Dates and scheduling + dueDate: z.string().optional().describe("The date at which the issue is due (YYYY-MM-DD format)"), + snoozedById: z.string().optional().describe("The identifier of the user who snoozed the issue"), + snoozedUntilAt: z.string().optional().describe("The time until an issue will be snoozed in Triage view"), + + // Relationships + parentId: z.string().optional().describe("The identifier of the parent issue"), + projectId: z.string().optional().describe("The project associated with the issue"), + projectMilestoneId: z.string().optional().describe("The project milestone associated with the issue"), + teamId: z.string().optional().describe("The identifier of the team associated with the issue"), + cycleId: z.string().optional().describe("The cycle associated with the issue"), + + // Sorting and positioning + sortOrder: z.number().optional().describe("The position of the issue related to other issues"), + boardOrder: z.number().optional().describe("The position of the issue in its column on the board view"), + subIssueSortOrder: z.number().optional().describe("The position of the issue in parent's sub-issue list"), + prioritySortOrder: z.number().optional().describe("[ALPHA] The position of the issue when ordered by priority"), + + // Templates and automation + lastAppliedTemplateId: z.string().optional().describe("The ID of the last template applied to the issue"), + autoClosedByParentClosing: z.boolean().optional() + .describe("Whether the issue was automatically closed because its parent issue was closed"), + trashed: z.boolean().optional().describe("Whether the issue has been trashed"), }); export const GetIssueParams = z.object({ @@ -103,31 +82,134 @@ export const ListTeamsParams = z.object({ limit: z.number().max(20).describe("Number of teams to return (default 3)"), }); -export const AdvancedSearchIssuesParams = z.object({ - query: z.string().optional(), - teamId: z.string().optional(), - assigneeId: z.string().optional(), - status: z - .enum(["backlog", "todo", "in_progress", "done", "canceled"]) - .optional(), - priority: z.number().min(0).max(4).optional(), - orderBy: z - .enum(["createdAt", "updatedAt"]) - .optional() - .describe("Order by, defaults to updatedAt"), - limit: z - .number() - .max(10) - .describe("Number of results to return (default: 5)"), +// Add these type definitions before the parameter schemas +export const StringComparator = z.object({ + eq: z.string().optional(), + neq: z.string().optional(), + in: z.array(z.string()).optional(), + nin: z.array(z.string()).optional(), + contains: z.string().optional(), + notContains: z.string().optional(), + startsWith: z.string().optional(), + notStartsWith: z.string().optional(), + endsWith: z.string().optional(), + notEndsWith: z.string().optional(), + containsIgnoreCase: z.string().optional(), + notContainsIgnoreCase: z.string().optional(), + startsWithIgnoreCase: z.string().optional(), + notStartsWithIgnoreCase: z.string().optional(), + endsWithIgnoreCase: z.string().optional(), + notEndsWithIgnoreCase: z.string().optional(), }); +export const DateComparator = z.object({ + eq: z.string().optional(), + neq: z.string().optional(), + gt: z.string().optional(), + gte: z.string().optional(), + lt: z.string().optional(), + lte: z.string().optional(), + in: z.array(z.string()).optional(), + nin: z.array(z.string()).optional(), +}); + +export const NumberComparator = z.object({ + eq: z.number().optional(), + neq: z.number().optional(), + gt: z.number().optional(), + gte: z.number().optional(), + lt: z.number().optional(), + lte: z.number().optional(), + in: z.array(z.number()).optional(), + nin: z.array(z.number()).optional(), +}); + +export const IdComparator = z.object({ + eq: z.string().optional(), + neq: z.string().optional(), + in: z.array(z.string()).optional(), + nin: z.array(z.string()).optional(), +}); + +export const WorkflowStateFilter = z.object({ + createdAt: DateComparator.optional(), + description: StringComparator.optional(), + id: IdComparator.optional(), + name: StringComparator.optional(), + position: NumberComparator.optional(), + type: StringComparator.optional(), + updatedAt: DateComparator.optional(), +}); + +export const AdvancedSearchIssuesParams = z.object({ + // Text search + query: z.string().optional().describe("Search in title and description"), + title: z.string().optional().describe("Filter by exact or partial title match"), + description: z.string().optional().describe("Filter by description content"), + + // Basic filters + teamId: z.string().optional().describe("Filter by team ID"), + assigneeId: z.string().optional().describe("Filter by assignee user ID"), + creatorId: z.string().optional().describe("Filter by creator user ID"), + priority: z.number().min(0).max(4).optional() + .describe("0 = No priority, 1 = Urgent, 2 = High, 3 = Normal, 4 = Low"), + + // Status and state + stateId: z.string().optional().describe("Filter by specific workflow state ID (simplified)"), + + // Dates + createdAfter: z.string().optional().describe("Issues created after this ISO datetime"), + createdBefore: z.string().optional().describe("Issues created before this ISO datetime"), + updatedAfter: z.string().optional().describe("Issues updated after this ISO datetime"), + updatedBefore: z.string().optional().describe("Issues updated before this ISO datetime"), + completedAfter: z.string().optional().describe("Issues completed after this ISO datetime"), + completedBefore: z.string().optional().describe("Issues completed before this ISO datetime"), + dueDate: z.string().optional().describe("Filter by due date (YYYY-MM-DD format)"), + dueDateAfter: z.string().optional().describe("Due date after (YYYY-MM-DD format)"), + dueDateBefore: z.string().optional().describe("Due date before (YYYY-MM-DD format)"), + startedAfter: z.string().optional().describe("Issues started after this ISO datetime"), + startedBefore: z.string().optional().describe("Issues started before this ISO datetime"), + + // Relationships + projectId: z.string().optional().describe("Filter by project ID"), + parentId: z.string().optional().describe("Filter by parent issue ID"), + subscriberId: z.string().optional().describe("Filter by subscriber user ID"), + hasBlockedBy: z.boolean().optional().describe("Issues that are blocked by others"), + hasBlocking: z.boolean().optional().describe("Issues that are blocking others"), + hasDuplicates: z.boolean().optional().describe("Issues that have duplicates"), + + // Labels and estimates + labelIds: z.array(z.string()).optional().describe("Filter by one or more label IDs"), + estimate: z.number().optional().describe("Filter by issue estimate points"), + + // Other filters + number: z.number().optional().describe("Filter by issue number"), + snoozedById: z.string().optional().describe("Filter by user who snoozed the issue"), + snoozedUntilAfter: z.string().optional().describe("Issues snoozed until after this ISO datetime"), + snoozedUntilBefore: z.string().optional().describe("Issues snoozed until before this ISO datetime"), + + // Result options + orderBy: z.enum(["createdAt", "updatedAt", "priority", "dueDate"]) + .optional() + .describe("Sort order for results"), + limit: z.number().max(10) + .describe("Number of results to return (default: 2, max: 10)"), +}); + +// Modify SearchUsersParams schema to allow more specific search parameters export const SearchUsersParams = z.object({ - query: z.string().describe("Search query for user names"), + email: z.string().optional().describe("Search by exact email address"), + displayName: z.string().optional().describe("Search by display name"), limit: z .number() .max(10) .describe("Number of results to return (default: 5)"), -}); +}).refine( + data => (data.email && !data.displayName) || (!data.email && data.displayName), + { + message: "Provide either email OR displayName, not both" + } +); // Add new Project Parameter Schemas export const ProjectParams = z.object({ @@ -207,9 +289,45 @@ export const GetProjectParams = z.object({ }); export const SearchProjectsParams = z.object({ - query: z.string().describe("Search query string"), - teamId: z.string().optional(), - limit: z.number().max(5).describe("Number of results to return (default: 1)"), + // Text search + query: z.string().optional().describe("Search in project name and content"), + name: z.string().optional().describe("Filter by exact or partial project name"), + + // Basic filters + teamId: z.string().optional().describe("Filter by team ID"), + creatorId: z.string().optional().describe("Filter by creator user ID"), + leadId: z.string().optional().describe("Filter by lead user ID"), + priority: z.number().min(0).max(4).optional() + .describe("0 = No priority, 1 = Urgent, 2 = High, 3 = Normal, 4 = Low"), + + // Status and state + health: z.string().optional().describe("Filter by project health status"), + state: z.string().optional().describe("[DEPRECATED] Filter by project state"), + status: z.string().optional().describe("Filter by project status ID"), + + // Dates + startDate: z.string().optional().describe("Filter by start date"), + targetDate: z.string().optional().describe("Filter by target date"), + createdAfter: z.string().optional().describe("Projects created after this ISO datetime"), + createdBefore: z.string().optional().describe("Projects created before this ISO datetime"), + updatedAfter: z.string().optional().describe("Projects updated after this ISO datetime"), + updatedBefore: z.string().optional().describe("Projects updated before this ISO datetime"), + completedAfter: z.string().optional().describe("Projects completed after this ISO datetime"), + completedBefore: z.string().optional().describe("Projects completed before this ISO datetime"), + canceledAfter: z.string().optional().describe("Projects canceled after this ISO datetime"), + canceledBefore: z.string().optional().describe("Projects canceled before this ISO datetime"), + + // Relationships + hasBlockedBy: z.boolean().optional().describe("Projects that are blocked by others"), + hasBlocking: z.boolean().optional().describe("Projects that are blocking others"), + hasRelated: z.boolean().optional().describe("Projects that have related items"), + hasViolatedDependencies: z.boolean().optional().describe("Projects with violated dependencies"), + + // Result options + orderBy: z.enum(["createdAt", "updatedAt", "priority", "targetDate"]) + .optional() + .describe("Sort order for results"), + limit: z.number().max(10).describe("Number of results to return (default: 1)"), }); // Add new ListProjectsParams schema after other params @@ -225,10 +343,49 @@ export const ListProjectsParams = z.object({ .describe("Filter projects by state"), }); +// Add after other parameter schemas +export const CreateCommentParams = z.object({ + issueId: z.string().describe("The ID of the issue to comment on"), + body: z.string().describe("The comment text in markdown format"), +}); + +export const ListCommentsParams = z.object({ + issueId: z.string().describe("The ID of the issue to get comments from"), + limit: z.number().max(20).describe("Number of comments to return (default: 10)"), +}); + +// Add new document parameter schemas +export const CreateDocumentParams = z.object({ + title: z.string().describe("The title of the document"), + content: z.string().describe("The content of the document in markdown format"), + icon: z.string().optional().describe("The icon of the document"), + organizationId: z.string().optional().describe("The organization ID"), + projectId: z.string().optional().describe("The project ID to link the document to"), +}); + +export const UpdateDocumentParams = z.object({ + documentId: z.string().describe("The ID of the document to update"), + title: z.string().optional().describe("The new title of the document"), + content: z.string().optional().describe("The new content in markdown format"), + icon: z.string().optional().describe("The new icon of the document"), +}); + +export const GetDocumentParams = z.object({ + documentId: z.string().describe("The ID of the document to retrieve"), +}); + +export const SearchDocumentsParams = z.object({ + query: z.string().describe("Search query string"), + projectId: z.string().optional().describe("Filter by project ID"), + limit: z.number().max(10).describe("Number of results to return (default: 5)"), +}); + interface SimpleIssue { - id: string; + id: string; // The internal UUID of the issue (e.g., "123e4567-e89b-12d3-a456-426614174000") + identifier: string; // The human-readable identifier (e.g., "XCE-205") title: string; status: string; + statusId: string; priority: number; assignee?: string; dueDate?: string; @@ -265,15 +422,42 @@ interface SimpleProject { statusId?: string; } +// Add new document interface +interface SimpleDocument { + id: string; + title: string; + content: string; + icon?: string; + url: string; + createdAt: string; + updatedAt: string; +} + +// Add after other interfaces +interface SimpleComment { + id: string; + body: string; + user?: { + id: string; + name: string; + }; + createdAt: string; +} + function formatIssue(issue: any): SimpleIssue { return { id: issue.id, + identifier: issue.identifier, title: issue.title, status: issue.state?.name || "Unknown", + statusId: issue.state?.id, priority: issue.priority, assignee: issue.assignee?.name, dueDate: issue.dueDate, - labels: issue.labels?.nodes?.map((l: any) => l.name) || [], + labels: issue.labels?.nodes?.map((l: any) => ({ + name: l.name, + id: l.id + })) || [], }; } @@ -296,6 +480,32 @@ function formatProject(project: any): SimpleProject { }; } +// Add new document formatting function +function formatDocument(doc: any): SimpleDocument { + return { + id: doc.id, + title: doc.title, + content: doc.content, + icon: doc.icon, + url: doc.url, + createdAt: doc.createdAt, + updatedAt: doc.updatedAt, + }; +} + +// Add after other formatting functions +function formatComment(comment: any): SimpleComment { + return { + id: comment.id, + body: comment.body, + user: comment.user ? { + id: comment.user.id, + name: comment.user.name, + } : undefined, + createdAt: comment.createdAt, + }; +} + // API Functions async function createIssue( client: LinearClient, @@ -314,7 +524,29 @@ async function updateIssue( ) { try { const { issueId, ...updateData } = params; - return await client.updateIssue(issueId, updateData); + + // Create a new object for the properly typed data + const formattedData: any = { ...updateData }; + + // Convert date strings to proper format if provided + if (formattedData.dueDate) { + // Due date should be YYYY-MM-DD format + formattedData.dueDate = formattedData.dueDate.split('T')[0]; + } + + if (formattedData.snoozedUntilAt) { + // Convert to Date object for the API + formattedData.snoozedUntilAt = new Date(formattedData.snoozedUntilAt); + } + + // Remove any undefined values to avoid API errors + Object.keys(formattedData).forEach(key => { + if (formattedData[key] === undefined) { + delete formattedData[key]; + } + }); + + return await client.updateIssue(issueId, formattedData); } catch (error) { return `Error: ${error}`; } @@ -380,21 +612,67 @@ async function advancedSearchIssues( ) { try { const filter: any = {}; - if (params.teamId) filter.team = { id: { eq: params.teamId } }; - if (params.assigneeId) filter.assignee = { id: { eq: params.assigneeId } }; - if (params.status) filter.state = { type: { eq: params.status } }; - if (params.priority) filter.priority = { eq: params.priority }; + + // Text search filters if (params.query) { filter.or = [ { title: { containsIgnoreCase: params.query } }, { description: { containsIgnoreCase: params.query } }, ]; } + if (params.title) filter.title = { containsIgnoreCase: params.title }; + if (params.description) filter.description = { containsIgnoreCase: params.description }; + + // Basic filters + if (params.teamId) filter.team = { id: { eq: params.teamId } }; + if (params.assigneeId) filter.assignee = { id: { eq: params.assigneeId } }; + if (params.creatorId) filter.creator = { id: { eq: params.creatorId } }; + if (params.priority !== undefined) filter.priority = { eq: params.priority }; + + // Status and state + if (params.stateId) { + filter.state = { id: { eq: params.stateId } }; + } + + // Date filters + if (params.createdAfter) filter.createdAt = { gt: params.createdAfter }; + if (params.createdBefore) filter.createdAt = { lt: params.createdBefore }; + if (params.updatedAfter) filter.updatedAt = { gt: params.updatedAfter }; + if (params.updatedBefore) filter.updatedAt = { lt: params.updatedBefore }; + if (params.completedAfter) filter.completedAt = { gt: params.completedAfter }; + if (params.completedBefore) filter.completedAt = { lt: params.completedBefore }; + if (params.startedAfter) filter.startedAt = { gt: params.startedAfter }; + if (params.startedBefore) filter.startedAt = { lt: params.startedBefore }; + + // Due date filters + if (params.dueDate) filter.dueDate = { eq: params.dueDate }; + if (params.dueDateAfter) filter.dueDate = { gt: params.dueDateAfter }; + if (params.dueDateBefore) filter.dueDate = { lt: params.dueDateBefore }; + + // Relationship filters + if (params.projectId) filter.project = { id: { eq: params.projectId } }; + if (params.parentId) filter.parent = { id: { eq: params.parentId } }; + if (params.subscriberId) filter.subscribers = { some: { id: { eq: params.subscriberId } } }; + if (params.hasBlockedBy) filter.hasBlockedByRelations = { eq: true }; + if (params.hasBlocking) filter.hasBlockingRelations = { eq: true }; + if (params.hasDuplicates) filter.hasDuplicateRelations = { eq: true }; + + // Labels + if (params.labelIds?.length) { + filter.labels = { some: { id: { in: params.labelIds } } }; + } + + // Other filters + if (params.estimate !== undefined) filter.estimate = { eq: params.estimate }; + if (params.number !== undefined) filter.number = { eq: params.number }; + if (params.snoozedById) filter.snoozedBy = { id: { eq: params.snoozedById } }; + if (params.snoozedUntilAfter) filter.snoozedUntilAt = { gt: params.snoozedUntilAfter }; + if (params.snoozedUntilBefore) filter.snoozedUntilAt = { lt: params.snoozedUntilBefore }; const issues = await client.issues({ first: params.limit, filter, - orderBy: params.orderBy || ("updatedAt" as any), + orderBy: params.orderBy || "updatedAt" as any, }); return issues.nodes.map(formatIssue); @@ -403,20 +681,23 @@ async function advancedSearchIssues( } } +// Modify searchUsers function to allow more specific search parameters async function searchUsers( client: LinearClient, - { query, limit }: z.infer + params: z.infer ) { try { + let filter: any = {}; + + if (params.email) { + filter = { email: { eq: params.email } }; + } else if (params.displayName) { + filter = { displayName: { containsIgnoreCase: params.displayName } }; + } + const users = await client.users({ - filter: { - or: [ - { name: { containsIgnoreCase: query } }, - { displayName: { containsIgnoreCase: query } }, - { email: { containsIgnoreCase: query } }, - ], - }, - first: limit, + filter, + first: params.limit, }); return users.nodes.map( @@ -471,25 +752,99 @@ async function getProject( // Modify searchProjects function to handle empty queries async function searchProjects( client: LinearClient, - { query, teamId, limit }: z.infer + params: z.infer ) { try { - const searchParams: any = { first: limit }; const filter: any = {}; - if (teamId) { - filter.team = { id: { eq: teamId } }; + // Text search filters + if (params.query) { + filter.or = [ + { name: { containsIgnoreCase: params.query } }, + { searchableContent: { contains: params.query } } + ]; + } + if (params.name) { + filter.name = { containsIgnoreCase: params.name }; } - if (query) { - filter.or = [{ name: { containsIgnoreCase: query } }]; + // Basic filters + if (params.teamId) { + filter.accessibleTeams = { some: { id: { eq: params.teamId } } }; + } + if (params.creatorId) { + filter.creator = { id: { eq: params.creatorId } }; + } + if (params.leadId) { + filter.lead = { id: { eq: params.leadId } }; + } + if (params.priority !== undefined) { + filter.priority = { eq: params.priority }; } - if (Object.keys(filter).length > 0) { - searchParams.filter = filter; + // Status and state filters + if (params.health) { + filter.health = { eq: params.health }; + } + if (params.state) { + filter.state = { eq: params.state }; + } + if (params.status) { + filter.status = { id: { eq: params.status } }; } - const projects = await client.projects(searchParams); + // Date filters + if (params.startDate) { + filter.startDate = { eq: params.startDate }; + } + if (params.targetDate) { + filter.targetDate = { eq: params.targetDate }; + } + if (params.createdAfter || params.createdBefore) { + filter.createdAt = { + ...(params.createdAfter && { gt: params.createdAfter }), + ...(params.createdBefore && { lt: params.createdBefore }) + }; + } + if (params.updatedAfter || params.updatedBefore) { + filter.updatedAt = { + ...(params.updatedAfter && { gt: params.updatedAfter }), + ...(params.updatedBefore && { lt: params.updatedBefore }) + }; + } + if (params.completedAfter || params.completedBefore) { + filter.completedAt = { + ...(params.completedAfter && { gt: params.completedAfter }), + ...(params.completedBefore && { lt: params.completedBefore }) + }; + } + if (params.canceledAfter || params.canceledBefore) { + filter.canceledAt = { + ...(params.canceledAfter && { gt: params.canceledAfter }), + ...(params.canceledBefore && { lt: params.canceledBefore }) + }; + } + + // Relationship filters + if (params.hasBlockedBy) { + filter.hasBlockedByRelations = { eq: true }; + } + if (params.hasBlocking) { + filter.hasBlockingRelations = { eq: true }; + } + if (params.hasRelated) { + filter.hasRelatedRelations = { eq: true }; + } + if (params.hasViolatedDependencies) { + filter.hasViolatedRelations = { eq: true }; + } + + const projects = await client.projects({ + first: params.limit, + filter, + orderBy: params.orderBy || "updatedAt" as any, + }); + return projects.nodes.map(formatProject); } catch (error) { return `Error: ${error}`; @@ -524,6 +879,106 @@ async function listProjects( } } +// Add new comment API functions before the main manager function +async function createComment( + client: LinearClient, + params: z.infer +) { + try { + const { issueId, body } = params; + const comment = await client.createComment({ + issueId, + body, + }); + return formatComment(comment); + } catch (error) { + return `Error: ${error}`; + } +} + +async function listComments( + client: LinearClient, + params: z.infer +) { + try { + const { issueId, limit } = params; + const issue = await client.issue(issueId); + const comments = await issue.comments({ + first: limit, + orderBy: "createdAt" as any, + }); + return comments.nodes.map(formatComment); + } catch (error) { + return `Error: ${error}`; + } +} + +// Add new document API functions before the main manager function +async function createDocument( + client: LinearClient, + params: z.infer +) { + try { + const document = await client.createDocument(params); + return formatDocument(document); + } catch (error) { + return `Error: ${error}`; + } +} + +async function updateDocument( + client: LinearClient, + params: z.infer +) { + try { + const { documentId, ...updateData } = params; + const document = await client.updateDocument(documentId, updateData); + return formatDocument(document); + } catch (error) { + return `Error: ${error}`; + } +} + +async function getDocument( + client: LinearClient, + { documentId }: z.infer +) { + try { + const document = await client.document(documentId); + return formatDocument(document); + } catch (error) { + return `Error: ${error}`; + } +} + +async function searchDocuments( + client: LinearClient, + params: z.infer +) { + try { + const filter: any = { + or: [ + { title: { containsIgnoreCase: params.query } }, + { content: { containsIgnoreCase: params.query } }, + ], + }; + + if (params.projectId) { + filter.project = { id: { eq: params.projectId } }; + } + + const documents = await client.documents({ + first: params.limit, + filter, + orderBy: "updatedAt" as any, + }); + + return documents.nodes.map(formatDocument); + } catch (error) { + return `Error: ${error}`; + } +} + // Main manager function export const LinearManagerParams = z.object({ request: z @@ -536,11 +991,7 @@ export async function linearManager( { request }: LinearManagerParams, context_message: Message ) { - console.log( - "Context message", - context_message.author, - context_message.getUserRoles() - ); + const userConfig = context_message.author.config; // console.log("User config", userConfig); @@ -563,6 +1014,7 @@ export async function linearManager( const client = new LinearClient({ apiKey: linearApiKey }); + const linear_tools: RunnableToolFunction[] = [ zodFunction({ function: (params) => createIssue(client, params), @@ -606,8 +1058,16 @@ export async function linearManager( function: (params) => advancedSearchIssues(client, params), name: "linearAdvancedSearchIssues", schema: AdvancedSearchIssuesParams, - description: - "Search for issues with advanced filters including status, assignee, and priority", + description: `Search for issues with advanced filters including: +- Status (backlog, todo, in_progress, done, canceled) +- Assignee +- Priority +- Date ranges for: + * Updated time + * Created time + * Completed time +Use ISO datetime format (e.g., "2024-01-18T00:00:00Z") for date filters. +Can find issues updated, created, or completed within specific time periods.`, }), zodFunction({ function: (params) => createProject(client, params), @@ -641,8 +1101,48 @@ export async function linearManager( description: "List projects in Linear, optionally filtered by team and state. Returns most recently updated projects first.", }), + zodFunction({ + function: (params) => createComment(client, params), + name: "linearCreateComment", + schema: CreateCommentParams, + description: "Create a new comment on a Linear issue", + }), + zodFunction({ + function: (params) => listComments(client, params), + name: "linearListComments", + schema: ListCommentsParams, + description: "List comments on a Linear issue", + }), + zodFunction({ + function: (params) => createDocument(client, params), + name: "linearCreateDocument", + schema: CreateDocumentParams, + description: "Create a new document in Linear", + }), + zodFunction({ + function: (params) => updateDocument(client, params), + name: "linearUpdateDocument", + schema: UpdateDocumentParams, + description: "Update an existing document in Linear", + }), + zodFunction({ + function: (params) => getDocument(client, params), + name: "linearGetDocument", + schema: GetDocumentParams, + description: "Get details of a specific document", + }), + zodFunction({ + function: (params) => searchDocuments(client, params), + name: "linearSearchDocuments", + schema: SearchDocumentsParams, + description: "Search for documents in Linear using a query string", + }), ]; + + const organization = await client.organization + const workspace = organization?.name + // fetch all labels available in each team const teams = await client.teams({ first: 10 }); const teamLabels = await client.issueLabels(); @@ -654,54 +1154,65 @@ export async function linearManager( name: state.name, })); + const organizationContext = `Organization: +Name: ${workspace} +Id: ${organization?.id} +`; + // Only include teams and labels in the context if they exist const teamsContext = teams.nodes.length > 0 - ? `Teams:\n${teams.nodes.map((team) => ` - ${team.name}`).join("\n")}` + ? `Teams:\n${teams.nodes.map((team) => ` - ${team.name} id: ${team.id}`).join("\n")}` : ""; const labelsContext = teamLabels.nodes.length > 0 ? `All Labels:\n${teamLabels.nodes - .map((label) => ` - ${label.name} (${label.color})`) - .join("\n")}` + .map((label) => ` - ${label.name} (${label.color}) id: ${label.id}`) + .join("\n")}` : ""; const issueStateContext = state_values.length > 0 ? `All Issue States:\n${state_values - .map((state) => ` - ${state.name}`) - .join("\n")}` + .map((state) => ` - ${state.name} id: ${state.id}`) + .join("\n")}` : ""; - const workspaceContext = [teamsContext, labelsContext, issueStateContext] + const workspaceContext = [organizationContext, teamsContext, labelsContext, issueStateContext] .filter(Boolean) .join("\n\n"); + const userDetails = await client.users({ filter: { email: { eq: linearEmail } } }); + const response = await ask({ - model: "gpt-4o-mini", + model: "gpt-4o", prompt: `You are a Linear project manager. Your job is to understand the user's request and manage issues, teams, and projects using the available tools. +Important note about Linear issue identification: +- issueId: A UUID that uniquely identifies an issue internally (e.g., "123e4567-e89b-12d3-a456-426614174000") +- identifier: A human-readable issue reference (e.g., "XCE-205", "ENG-123") +When referring to issues in responses, always use the identifier format for better readability. + ---- ${memory_manager_guide("linear_manager", context_message.author.id)} ---- -${ - workspaceContext - ? `Here is some more context on current linear workspace:\n${workspaceContext}` - : "" -} +${workspaceContext + ? `Here is some more context on current linear workspace:\n${workspaceContext}` + : "" + } -The user you are currently assisting has the following details: +The user you are currently assisting has the following details (No need to search if the user is asking for their own related issues/projects): - Name: ${userConfig?.name} - Linear Email: ${linearEmail} +- Linear User ID: ${userDetails.nodes[0]?.id} When responding make sure to link the issues when returning the value. linear issue links look like: \`https://linear.app/xcelerator/issue/XCE-205\` -Where \`XCE-205\` is the issue ID and \`xcelerator\` is the team name. - +Where \`XCE-205\` is the identifier (not the issueId) and \`xcelerator\` is the team name. `, message: request, seed: `linear-${context_message.channelId}`, diff --git a/tools/linkwarden.ts b/tools/linkwarden.ts index cc2cad9..a89d9ff 100644 --- a/tools/linkwarden.ts +++ b/tools/linkwarden.ts @@ -416,7 +416,7 @@ ${memory_manager_guide("links_manager", context_message.author.id)} ---- `, message: request, - seed: "link-${context_message.channelId}", + seed: `link-${context_message.channelId}`, tools: link_tools.concat( memory_manager_init(context_message, "links_manager") ) as any,