UNPKG

naisys

Version:

NAISYS - Autonomous AI agent runner with built-in context management and cost tracking

311 lines 12.6 kB
import { formatFileSize } from "@naisys/common"; import { HubEvents } from "@naisys/hub-protocol"; import stringArgv from "string-argv"; import { chatCmd } from "../command/commandDefs.js"; /** Format inline attachment suffix, e.g. " [file.txt 2.1KB]" */ function formatAttachmentSuffix(attachments) { if (!attachments?.length) return ""; return attachments .map((a) => ` [${a.filename} ${formatFileSize(a.fileSize)}]`) .join(""); } /** Build download commands for all attachments in a batch of messages */ function formatDownloadFooter(hubUrl, messages) { const allAttachments = messages.flatMap((m) => m.attachments ?? []); if (!allAttachments.length) return ""; return ("\nDownload:\n" + allAttachments .map((a) => ` curl -H "Authorization: Bearer $NAISYS_API_KEY" "${hubUrl}/attachments/${a.id}" -o ${a.filename}`) .join("\n")); } export function createChatService(hubClient, userService, localUserId, promptNotification, attachmentService, shellWrapper, runService) { async function handleCommand(args) { const argv = stringArgv(args); const subs = chatCmd.subcommands; const usageError = (sub) => `Invalid parameters. Usage: ${chatCmd.name} ${subs[sub].usage}`; if (!argv[0]) { argv[0] = "help"; } switch (argv[0]) { case "help": { const lines = [`${chatCmd.name} <command>`]; lines.push(` ${subs.send.usage.padEnd(45)}${subs.send.description}`); if (hubClient) { lines.push(` ${subs.recent.usage.padEnd(45)}${subs.recent.description}`); } return lines.join("\n"); } case "send": { // Expected: ns-chat send "user1,user2" "message" [file1 file2 ...] if (!argv[1] || !argv[2]) { throw usageError("send"); } const recipients = userService.resolveUsernames(argv[1]); // Upload any file attachments (argv[3+]) let attachmentIds; let resolvedPaths; const filePaths = argv.slice(3); if (filePaths.length > 0) { resolvedPaths = await shellWrapper.resolvePaths(filePaths); if (hubClient) { attachmentIds = await attachmentService.uploadAll(resolvedPaths, "mail"); } } return sendMessage(recipients, argv[2], attachmentIds, resolvedPaths); } case "recent": { if (!hubClient) { throw "Not available in local mode."; } const take = argv[1] ? parseInt(argv[1]) : undefined; const skip = argv[2] ? parseInt(argv[2]) : undefined; if (argv[1] && isNaN(take)) { throw `Invalid take parameter. Must be a number. ${usageError("recent")}`; } if (argv[2] && isNaN(skip)) { throw `Invalid skip parameter. Must be a number. ${usageError("recent")}`; } const withUserIds = argv[3] ? userService.resolveUsernames(argv[3]).map((r) => r.userId) : undefined; return recentMessages(withUserIds, skip, take); } default: { const helpResponse = await handleCommand("help"); return `Unknown ${chatCmd.name} subcommand '${argv[0]}'. See valid commands below:\n${helpResponse}`; } } } async function sendMessage(recipients, message, attachmentIds, resolvedPaths) { if (recipients.length === 0) { throw "No recipients"; } message = message.replace(/\\n/g, "\n"); // Ephemerals always go local; the hub doesn't know about them. const localRecipients = hubClient ? recipients.filter((r) => r.isEphemeral) : recipients; const hubRecipients = hubClient ? recipients.filter((r) => !r.isEphemeral) : []; if (hubRecipients.length > 0) { const response = await hubClient.sendRequest(HubEvents.MAIL_SEND, { fromUserId: localUserId, fromRunId: runService.getRunId(), toUserIds: hubRecipients.map((r) => r.userId), subject: "", body: message, kind: "chat", attachmentIds, }); if (!response.success) { throw response.error || "Failed to send chat message"; } } if (localRecipients.length > 0) { const localUser = userService.getUserById(localUserId); const localUsername = localUser?.username || "unknown"; let text = `Chat from ${localUsername}: ${message}`; if (resolvedPaths?.length) { text += "\n Attachments:"; for (const fp of resolvedPaths) { text += `\n ${fp}`; } } for (const recipient of localRecipients) { promptNotification.notify({ userId: recipient.userId, wake: "yes", contextOutput: [text], }); } } return "Chat sent"; } async function recentMessages(withUserIds, skip, take) { if (!hubClient) throw "Not available in local mode."; const response = await hubClient.sendRequest(HubEvents.MAIL_LIST, { userId: localUserId, kind: "chat", skip, take: take ?? 10, ...(withUserIds ? { withUserIds } : {}), }); if (!response.success) { throw response.error || "Failed to list chat messages"; } const messages = response.messages; if (!messages || messages.length === 0) { return "No chat messages."; } // Reverse to show chronological order (oldest first) const conversation = !!withUserIds; const chronological = [...messages].reverse(); let output = chronological .map((m) => formatChatLine(m, conversation ? "conversation" : "overview")) .join("\n"); if (hubClient) { output += formatDownloadFooter(hubClient.getHubUrl(), chronological); } return output; } /** * Format a chat message for display. * Modes control how much participant context is shown: * conversation - viewing a specific chat (participants known), just show sender * overview - viewing all chats (mixed conversations), show who's involved * 1:1: "alice → bob", group: "[alice,bob,carol] alice" * notify - incoming notification, show group context but keep 1:1 simple * 1:1: "alice", group: "[alice,bob,carol] alice" */ function formatChatLine(m, mode) { const prefix = m.isUnread ? "* " : " "; const date = new Date(m.createdAt); const MM = String(date.getMonth() + 1).padStart(2, "0"); const DD = String(date.getDate()).padStart(2, "0"); const HH = String(date.getHours()).padStart(2, "0"); const mm = String(date.getMinutes()).padStart(2, "0"); const dateStr = `${MM}/${DD} ${HH}:${mm}`; const isGroup = m.recipientUsernames.length > 1; const fromName = `${m.fromUsername} (${m.fromTitle})`; let sender; if (mode === "conversation") { // Participants already known from the command context sender = fromName; } else if (mode === "notify") { if (isGroup) { // Group context needed so agent knows which conversation const participants = [m.fromUsername, ...m.recipientUsernames] .sort() .join(","); sender = `[${participants}] ${fromName}`; } else { // 1:1 is obvious from sender alone sender = fromName; } } else { // overview: messages from different conversations are mixed if (isGroup) { const participants = [m.fromUsername, ...m.recipientUsernames] .sort() .join(","); sender = `[${participants}] ${fromName}`; } else { // Show direction so agent can distinguish different 1:1 conversations sender = `${fromName}${m.recipientUsernames[0]}`; } } return `${prefix}${dateStr}: ${sender}: ${m.body ?? ""}${formatAttachmentSuffix(m.attachments)}`; } /** Format a MailMessageData (from MAIL_UNREAD) as a chat line for notifications */ function formatUnreadChatLine(m) { const date = new Date(m.createdAt); const MM = String(date.getMonth() + 1).padStart(2, "0"); const DD = String(date.getDate()).padStart(2, "0"); const HH = String(date.getHours()).padStart(2, "0"); const mm = String(date.getMinutes()).padStart(2, "0"); const dateStr = `${MM}/${DD} ${HH}:${mm}`; const isGroup = m.recipientUsernames.length > 1; let sender; if (isGroup) { const participants = [m.fromUsername, ...m.recipientUsernames] .sort() .join(","); sender = `[${participants}] ${m.fromUsername} (${m.fromTitle})`; } else { sender = `${m.fromUsername} (${m.fromTitle})`; } return `* ${dateStr}: ${sender}: ${m.body}${formatAttachmentSuffix(m.attachments)}`; } /** Highest message ID we've seen from MAIL_UNREAD, used as cursor */ let lastUnreadId = 0; async function markMessagesRead(messageIds) { if (!hubClient || !messageIds.length) return; await hubClient.sendRequest(HubEvents.MAIL_MARK_READ, { userId: localUserId, runId: runService.getRunId(), messageIds, kind: "chat", }); } async function checkAndNotify() { if (!hubClient) return; const response = await hubClient.sendRequest(HubEvents.MAIL_UNREAD, { userId: localUserId, kind: "chat", afterId: lastUnreadId, }); if (!response.success || !response.messages?.length) return; const messages = response.messages; // Advance cursor to max returned ID for (const m of messages) { if (m.id > lastUnreadId) { lastUnreadId = m.id; } } const messageIds = messages.map((m) => m.id); const lines = messages.map((m) => formatUnreadChatLine(m)); const downloadFooter = formatDownloadFooter(hubClient.getHubUrl(), messages); const contextOutput = [ "New chat messages:", ...lines, ...(downloadFooter ? [downloadFooter] : []), ]; promptNotification.notify({ userId: localUserId, wake: "yes", contextOutput, processed: () => markMessagesRead(messageIds), }); } // Set up notification listeners based on mode let cleanupFn; if (hubClient) { const chatReceivedHandler = (data) => { if (data.kind !== "chat") return; if (data.recipientUserIds.includes(localUserId)) { void checkAndNotify(); } }; hubClient.registerEvent(HubEvents.MAIL_RECEIVED, chatReceivedHandler); cleanupFn = () => { hubClient.unregisterEvent(HubEvents.MAIL_RECEIVED, chatReceivedHandler); }; } else { cleanupFn = () => { }; } function cleanup() { cleanupFn(); } const registrableCommand = { command: chatCmd, handleCommand, }; async function sendToUser(userId, message) { const user = userService.getUserById(userId); if (!user) { throw `User with ID ${userId} not found`; } return sendMessage([user], message); } return { ...registrableCommand, checkAndNotify, sendToUser, cleanup, }; } //# sourceMappingURL=chat.js.map