naisys
Version:
NAISYS - Autonomous AI agent runner with built-in context management and cost tracking
311 lines • 12.6 kB
JavaScript
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