agenthub/src/core/services/messageService.ts

130 lines
4.0 KiB
TypeScript

import { join } from 'path';
import { getEntityDir } from '../paths.js';
import { getNextId } from '../counter.js';
import { readEntity, writeEntity } from '../files.js';
import { MessageSchema, type Message } from '../schema.js';
import { Index } from '../index.js';
function indexEntryFor(record: Message, filePath: string) {
return {
id: record.id,
type: 'message',
title: `${record.from}${record.to}`,
content: record.text,
filePath,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
status: record.status,
fromAgent: record.from,
toAgent: record.to,
taskId: record.taskId,
};
}
/** Send a direct message from one agent to another. */
export function createMessage(cwd: string, options: Partial<Message> = {}): Message {
if (!options.from) throw new Error('Message requires a "from" agent');
if (!options.to) throw new Error('Message requires a "to" agent');
if (!options.text) throw new Error('Message requires text');
const now = new Date().toISOString();
const record: Message = MessageSchema.parse({
id: getNextId(cwd, 'message'),
from: options.from,
to: options.to,
text: options.text,
taskId: options.taskId,
status: 'unread',
createdAt: now,
updatedAt: now,
});
const filePath = join(getEntityDir(cwd, 'messages'), `${record.id}.md`);
writeEntity(filePath, record, `# ${record.from}${record.to}\n\n${record.text}`);
const index = new Index(cwd);
index.upsert(indexEntryFor(record, filePath));
index.close();
return record;
}
/** Agent aliases that should see the same inbox. Keep deliberately small. */
export function messageRecipientAliases(agent: string): Set<string> {
const key = agent.toLowerCase();
const aliases = new Set([agent, key]);
if (key === 'architect' || key === 'claude') {
aliases.add('architect');
aliases.add('claude');
}
return aliases;
}
export interface InboxMessage {
id: string;
from: string;
to: string;
text: string;
taskId?: string;
status: string;
createdAt: string;
}
/** Messages addressed to `agent`, newest first. */
export function listInbox(cwd: string, agent: string, opts: { unreadOnly?: boolean } = {}): InboxMessage[] {
const index = new Index(cwd);
const all = index.list('message');
index.close();
const recipients = messageRecipientAliases(agent);
return all
.filter((m) => m.toAgent && recipients.has(String(m.toAgent).toLowerCase()))
.filter((m) => !opts.unreadOnly || m.status === 'unread')
.map((m) => ({
id: m.id,
from: m.fromAgent ?? '',
to: m.toAgent ?? '',
text: m.content,
taskId: m.taskId,
status: m.status ?? 'unread',
createdAt: m.createdAt,
}));
}
/** All messages (architect-visible view), newest first. */
export function listMessages(cwd: string): InboxMessage[] {
const index = new Index(cwd);
const all = index.list('message');
index.close();
return all.map((m) => ({
id: m.id,
from: m.fromAgent ?? '',
to: m.toAgent ?? '',
text: m.content,
taskId: m.taskId,
status: m.status ?? 'unread',
createdAt: m.createdAt,
}));
}
export function getMessage(cwd: string, id: string): { message: Message; body: string; filePath: string } {
if (!id) throw new Error('Message ID is required');
const filePath = join(getEntityDir(cwd, 'messages'), `${id}.md`);
const { frontmatter, body } = readEntity(filePath);
return { message: MessageSchema.parse(frontmatter), body, filePath };
}
/** Mark a message as read. */
export function markMessageRead(cwd: string, id: string): Message {
const filePath = join(getEntityDir(cwd, 'messages'), `${id}.md`);
const { frontmatter, body } = readEntity(filePath);
const message = MessageSchema.parse(frontmatter);
const updated: Message = { ...message, status: 'read', updatedAt: new Date().toISOString() };
writeEntity(filePath, updated, body);
const index = new Index(cwd);
index.upsert(indexEntryFor(updated, filePath));
index.close();
return updated;
}