Files
AgentCompany/src/main/services/chat-slash-router.ts
T
ZacharyZhang-NY 07d0b4587d Promote AgentCompany-New to the repository root and retire the old app
The legacy application - its src, tests, landing site, promo assets, docs,
release tooling, CI workflows, and root configs - leaves the tree; it stays
reachable in history. Everything under AgentCompany-New moves up unchanged:
same package name, so the production userData/database path is untouched,
and e2e/build scripts already resolved paths relative to the project. The
developer cred.txt stays gitignored at its new location. Full verify:ci
passes from the new root.
2026-07-07 00:04:13 -04:00

197 lines
7.4 KiB
TypeScript

import type { DatabaseSync } from "node:sqlite";
import type { SendMessageInput } from "../../shared/commands";
import { APPROVAL_STATE, RISK_LEVEL } from "../../shared/domain";
import { createId } from "../../shared/ids";
import { ApprovalService } from "./approval-service";
import type { AuditService } from "./audit-service";
import { BoardInterventionService } from "./board-intervention-service";
import type { ChatAgentWakeService } from "./chat-agent-wake-service";
import {
approvalRequester,
firstToken,
parseApprovalDecisionTarget,
spaceName,
type PrincipalRef,
type SlashInvocation,
type SpaceRow,
} from "./chat-service-internal";
import type { ResolvedMention } from "./chat-mentions";
import type { FileHandoffAttachment } from "./file-handoff-service";
/**
* Routes the slash commands a chat message can carry — /board, /approve,
* /deny, /agent, /skill, /file, /runbook, /handoff, /escalate — to the right
* domain action and records the routing. Lifted out of ChatService so message
* delivery and command dispatch each stay a readable, single-purpose unit.
*/
export class ChatSlashRouter {
private readonly approvals: ApprovalService;
private readonly boardInterventions: BoardInterventionService;
constructor(
private readonly db: DatabaseSync,
private readonly audit: AuditService,
private readonly agentWake: ChatAgentWakeService,
) {
this.approvals = new ApprovalService(db);
this.boardInterventions = new BoardInterventionService(db);
}
execute(
input: SendMessageInput,
space: SpaceRow,
sender: PrincipalRef | null,
mentions: ResolvedMention[],
attachments: FileHandoffAttachment[],
slash: SlashInvocation,
messageId: string,
threadId: string,
now: string,
): string | null {
if (slash.command === "board") {
return this.boardInterventions.record({
messageInput: input,
sender,
mentions,
args: slash.args,
messageId,
threadId,
spaceId: space.id,
now,
});
}
if (slash.command === "approve" || slash.command === "deny") {
return this.decideApprovalFromSlash(input, sender, slash);
}
if (slash.command === "agent") return this.routeAgentSlash(input, mentions, slash, messageId, threadId, now);
if (slash.command === "skill") return this.routeSkillSlash(input, slash, messageId);
if (slash.command === "file") return this.routeFileSlash(input, attachments, slash, messageId);
if (slash.command === "runbook") return this.routeRunbookSlash(input, attachments, slash, messageId);
if (slash.command === "handoff") return this.routeHandoffSlash(input, attachments, messageId);
if (slash.command !== "escalate") return null;
const requester = approvalRequester(input.senderType, sender);
return this.approvals.create({
orgId: input.orgId,
kind: "chat_escalation",
title: `Escalate ${spaceName(this.db, space.id)}`,
details: slash.args.trim(),
risk: RISK_LEVEL.HIGH,
requestedByType: requester.type,
requestedById: requester.id,
}, { sourceMessageId: messageId, sourceThreadId: threadId, sourceEntityType: "chat_message", sourceEntityId: messageId });
}
recordInvocation(messageId: string, slash: SlashInvocation, resultRef: string | null, now: string): void {
this.db.prepare(`
insert into chat_slash_invocations(id, message_id, command, args_json, result_ref, created_at)
values (?, ?, ?, ?, ?, ?)
`).run(createId("slash"), messageId, slash.command, JSON.stringify({ raw: slash.args }), resultRef, now);
}
private routeAgentSlash(
input: SendMessageInput,
mentions: ResolvedMention[],
slash: SlashInvocation,
messageId: string,
threadId: string,
now: string,
): string {
const wake = this.agentWake.wakeFromSlash(input, mentions, slash, messageId, threadId, now);
this.recordSlashRoute(input, messageId, slash.command, wake.targetId, {
targetType: "agent",
targetId: wake.targetId,
taskId: wake.taskId,
runId: wake.runId,
});
return wake.targetId;
}
private routeSkillSlash(input: SendMessageInput, slash: SlashInvocation, messageId: string): string {
const target = firstToken(slash.args);
if (!target) throw new Error("Slash command /skill requires a skill slug.");
const row = this.db.prepare(`
select id from skill_definitions
where org_id = ?
and (lower(slug) = lower(?) or lower(name) = lower(?))
limit 1
`).get(input.orgId, target, target.replace(/-/g, " ")) as { id: string } | undefined;
if (!row) throw new Error("Slash command /skill target was not found.");
this.recordSlashRoute(input, messageId, slash.command, row.id, { skillId: row.id, target });
return row.id;
}
private routeFileSlash(
input: SendMessageInput,
attachments: FileHandoffAttachment[],
slash: SlashInvocation,
messageId: string,
): string {
const fileId = attachments[0]?.fileId ?? this.resolveFileSlashTarget(input.orgId, slash);
this.recordSlashRoute(input, messageId, slash.command, fileId, { fileId });
return fileId;
}
private routeRunbookSlash(
input: SendMessageInput,
attachments: FileHandoffAttachment[],
slash: SlashInvocation,
messageId: string,
): string {
if (!slash.args.trim() && attachments.length === 0) throw new Error("Slash command /runbook requires details or a file attachment.");
const resultRef = attachments[0]?.fileId ?? messageId;
this.recordSlashRoute(input, messageId, slash.command, resultRef, { resultRef, attachmentCount: attachments.length });
return resultRef;
}
private routeHandoffSlash(input: SendMessageInput, attachments: FileHandoffAttachment[], messageId: string): string {
const fileId = attachments[0]?.fileId;
if (!fileId) throw new Error("Slash command /handoff requires a file attachment.");
this.recordSlashRoute(input, messageId, "handoff", fileId, { fileId, attachmentCount: attachments.length });
return fileId;
}
private resolveFileSlashTarget(orgId: string, slash: SlashInvocation): string {
const target = firstToken(slash.args);
if (!target) throw new Error("Slash command /file requires a file attachment or file reference.");
const row = this.db.prepare(`
select id from file_objects
where org_id = ?
and (id = ? or name = ?)
order by created_at desc
limit 1
`).get(orgId, target, target) as { id: string } | undefined;
if (!row) throw new Error("Slash command /file target was not found.");
return row.id;
}
private recordSlashRoute(
input: SendMessageInput,
messageId: string,
command: string,
resultRef: string,
route: Record<string, unknown>,
): void {
this.audit.record({
orgId: input.orgId,
entityType: "chat_message",
entityId: messageId,
actorType: input.senderType,
actorId: input.senderId,
action: "slash_routed",
payload: { command, resultRef, route },
});
}
private decideApprovalFromSlash(input: SendMessageInput, sender: PrincipalRef | null, slash: SlashInvocation): string {
if (!sender || sender.type !== "employee") throw new Error("Approval decision requires employee sender.");
const approvalId = parseApprovalDecisionTarget(slash);
this.approvals.decide({
orgId: input.orgId,
approvalId,
state: slash.command === "approve" ? APPROVAL_STATE.APPROVED : APPROVAL_STATE.DECLINED,
decidedByEmployeeId: sender.id,
});
return approvalId;
}
}