server/activity.ts
Rama main · réplica de lectura del repositorio original.
import { join } from "node:path";
import { AGENTS_ACTION_TOOLS, agentsCallMember } from "../shared/agents-tool-names.ts";
import { bridgedCallName } from "./agents-bridge-names.ts";
import { readDecisions, type DecisionKind, type DecisionRow } from "./decision-log.ts";
import { isRuntimeEvent, readRecentLines } from "./thread-events.ts";
import type { ActivityOutcome, ActivityRow } from "../shared/activity.ts";
export type { ActivityOutcome, ActivityRow };
/** Composio toolkit slugs whose display name is not just a capitalised word. */
const TOOLKIT_NAMES: Record<string, string> = {
GMAIL: "Gmail",
GITHUB: "GitHub",
GOOGLESHEETS: "Google Sheets",
GOOGLECALENDAR: "Google Calendar",
GOOGLEDRIVE: "Google Drive",
GOOGLEDOCS: "Google Docs",
HUBSPOT: "HubSpot",
LINKEDIN: "LinkedIn",
YOUTUBE: "YouTube",
TWITTER: "X",
WHATSAPP: "WhatsApp",
ONEDRIVE: "OneDrive",
};
const BUILTIN_LABELS: Record<string, readonly [key: string, label: string]> = {
Bash: ["activity.tool.ranCommand", "Ran a command"],
Read: ["activity.tool.readFile", "Read a file"],
Edit: ["activity.tool.editedFile", "Edited a file"],
MultiEdit: ["activity.tool.editedFiles", "Edited files"],
Write: ["activity.tool.wroteFile", "Wrote a file"],
Glob: ["activity.tool.searchedFiles", "Searched files"],
Grep: ["activity.tool.searchedFiles", "Searched files"],
LS: ["activity.tool.listedFolder", "Listed a folder"],
WebSearch: ["activity.tool.searchedWeb", "Searched the web"],
WebFetch: ["activity.tool.fetchedPage", "Fetched a page"],
Skill: ["activity.tool.usedSkill", "Used a skill"],
Task: ["activity.tool.ranSubagent", "Ran a subagent"],
Agent: ["activity.tool.ranSubagent", "Ran a subagent"],
AskUserQuestion: ["activity.tool.askedQuestion", "Asked you a question"],
TodoWrite: ["activity.tool.updatedTasks", "Updated its task list"],
};
function humanize(raw: string): string {
const spaced = raw
.replace(/_+/g, " ")
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.replace(/\s+/g, " ")
.trim()
.toLowerCase();
return spaced ? spaced[0].toUpperCase() + spaced.slice(1) : raw;
}
export function toolkitName(slug: string): string {
return TOOLKIT_NAMES[slug] ?? slug[0] + slug.slice(1).toLowerCase();
}
/** Composio names its tools TOOLKIT_ACTION_WORDS, all upper case. */
const COMPOSIO_TOOL = /^([A-Z][A-Z0-9]+)_([A-Z0-9_]+)$/;
type Described = { app: string | null; label: string; labelKey?: string; appKey?: string };
const APP_KEYS: Record<string, string> = { Team: "activity.app.team", Computer: "activity.app.computer", Research: "activity.app.research" };
/** The agents server's X and research tools read as their own app. */
const RESEARCH_TOOL_LABELS: Record<string, Described> = {
x_search: { app: "X", label: "Searched X", labelKey: "activity.tool.searchedX" },
x_user_posts: { app: "X", label: "Read X posts", labelKey: "activity.tool.readXPosts" },
x_post: { app: "X", label: "Read an X post", labelKey: "activity.tool.readXPost" },
x_profile: { app: "X", label: "Looked up an X profile", labelKey: "activity.tool.lookedUpXProfile" },
research_search: { app: "Research", appKey: "activity.app.research", label: "Searched research tools", labelKey: "activity.tool.searchedResearch" },
research_endpoint: { app: "Research", appKey: "activity.app.research", label: "Read a research tool", labelKey: "activity.tool.readResearchTool" },
};
const TEAM_ACTION_KEYS: Record<string, string> = {
cancel_wait: "activity.tool.team.cancelWait",
delete_trigger: "activity.tool.team.deleteTrigger",
list_waits: "activity.tool.team.listWaits",
wait_until: "activity.tool.team.waitUntil",
add_mcp_server: "activity.tool.team.addMcpServer",
ask_bot: "activity.tool.team.askBot",
assignment_status: "activity.tool.team.assignmentStatus",
attach_file: "activity.tool.team.attachFile",
close_thread: "activity.tool.team.closeThread",
coordinate_bots: "activity.tool.team.coordinateBots",
create_bot: "activity.tool.team.createBot",
create_room: "activity.tool.team.createRoom",
create_trigger: "activity.tool.team.createTrigger",
decide: "activity.tool.team.decide",
delegate_bot: "activity.tool.team.delegateBot",
extract: "activity.tool.team.extract",
fill_login: "activity.tool.team.fillLogin",
find_tool: "activity.tool.team.findTool",
generate_image: "activity.tool.team.generateImage",
list_bots: "activity.tool.team.listBots",
list_room_targets: "activity.tool.team.listRoomTargets",
list_rooms: "activity.tool.team.listRooms",
list_shared_computers: "activity.tool.team.listSharedComputers",
list_team_library: "activity.tool.team.listTeamLibrary",
list_models: "activity.tool.team.listModels",
list_team_setup: "activity.tool.team.listTeamSetup",
list_threads: "activity.tool.team.listThreads",
list_trigger_events: "activity.tool.team.listTriggerEvents",
list_triggers: "activity.tool.team.listTriggers",
manage_room: "activity.tool.team.manageRoom",
manage_trigger: "activity.tool.team.manageTrigger",
mcp_server: "activity.tool.team.mcpServer",
memory_log: "activity.tool.team.memoryLog",
memory_update: "activity.tool.team.memoryUpdate",
message_person: "activity.tool.team.messagePerson",
post_to_room: "activity.tool.team.postToRoom",
propose_bot_deletion: "activity.tool.team.proposeBotDeletion",
propose_model: "activity.tool.team.proposeModel",
propose_profile: "activity.tool.team.proposeProfile",
propose_team_memory: "activity.tool.team.proposeTeamMemory",
propose_team_setup: "activity.tool.team.proposeTeamSetup",
publish_page: "activity.tool.team.publishPage",
raise_escalation: "activity.tool.team.raiseEscalation",
report_bug: "activity.tool.team.reportBug",
request_credential: "activity.tool.team.requestCredential",
request_sign_in: "activity.tool.team.requestSignIn",
retry_thread: "activity.tool.team.retryThread",
select_computer: "activity.tool.team.selectComputer",
send: "activity.tool.team.send",
send_voice_note: "activity.tool.team.sendVoiceNote",
session_read: "activity.tool.team.sessionRead",
session_search: "activity.tool.team.sessionSearch",
shared_computer: "activity.tool.team.sharedComputer",
show_html: "activity.tool.team.showHtml",
show_widget: "activity.tool.team.showWidget",
skill_manage: "activity.tool.team.skillManage",
skills_list: "activity.tool.team.skillsList",
start_thread: "activity.tool.team.startThread",
burst: "activity.tool.team.burst",
tool_result_read: "activity.tool.team.toolResultRead",
unpublish_page: "activity.tool.team.unpublishPage",
update_html: "activity.tool.team.updateHtml",
update_widget: "activity.tool.team.updateWidget",
usage_limits: "activity.tool.team.usageLimits",
use_tool: "activity.tool.team.useTool",
vm_exec: "activity.tool.team.vmExec",
};
const COMPUTER_ACTION_KEYS: Record<string, string> = {
click: "activity.tool.computer.click",
computer_exec: "activity.tool.computer.computerExec",
drag: "activity.tool.computer.drag",
exec: "activity.tool.computer.exec",
get_screen_size: "activity.tool.computer.getScreenSize",
key_press: "activity.tool.computer.keyPress",
move: "activity.tool.computer.move",
open_url: "activity.tool.computer.openUrl",
screenshot: "activity.tool.computer.screenshot",
scroll: "activity.tool.computer.scroll",
type_text: "activity.tool.computer.typeText",
wait_for: "activity.tool.computer.waitFor",
};
/** treg's catalog ids name their platform and what they return (`apollo.people.search`): a research call reads as the
* kind of thing it looked up, never the vendor. */
const RESEARCH_CATEGORIES: ReadonlyArray<[RegExp, string, string]> = [
[/^(?:x|twitter|tweets?)$/, "Searched X", "activity.tool.searchedX"],
[/^(?:people|persons?|contacts?|emails?|employees?|leads?|prospects?)$/, "Looked up people", "activity.tool.lookedUpPeople"],
[/^(?:compan(?:y|ies)|organi[sz]ations?|orgs?|firmographics?|business(?:es)?)$/, "Looked up companies", "activity.tool.lookedUpCompanies"],
[/^(?:funding|investors?|investments?|rounds?)$/, "Looked up funding", "activity.tool.lookedUpFunding"],
[/^news$/, "Read the news", "activity.tool.readNews"],
[/^(?:linkedin|instagram|tiktok|youtube|reddit|facebook|threads|social)$/, "Looked up social data", "activity.tool.lookedUpSocial"],
[/^trends?$/, "Looked up trends", "activity.tool.lookedUpTrends"],
[/^(?:serp|google|bing|web)$/, "Searched the web", "activity.tool.searchedWeb"],
[/^(?:seo|backlinks?|keywords?|domains?|rankings?)$/, "Looked up SEO data", "activity.tool.lookedUpSeo"],
[/^(?:images?|video|voice|tts|speech|generate|generation)$/, "Generated media", "activity.tool.generatedMedia"],
];
/** A call's own arguments from its recorded input, which may be a JSON preview. */
function callArguments(input: unknown): unknown {
let args = input;
if (typeof input === "string") {
try { args = JSON.parse(input); } catch { args = undefined; }
}
// A bridged call (use_tool) carries the tool's own arguments under `arguments`.
return args && typeof args === "object" && "arguments" in args ? (args as { arguments?: unknown }).arguments : args;
}
function researchCallLabel(input: unknown): { label: string; labelKey: string } {
const own = callArguments(input);
const id = own && typeof own === "object" && typeof (own as { id?: unknown }).id === "string" ? (own as { id: string }).id : "";
const words = id.toLowerCase().split(/[._@-]+/).slice(1);
for (const [pattern, label, labelKey] of RESEARCH_CATEGORIES) if (words.some((word) => pattern.test(word))) return { label, labelKey };
return { label: "Ran a research call", labelKey: "activity.tool.ranResearchCall" };
}
const ownApp = (app: string): Pick<Described, "app" | "appKey"> => ({ app, ...(Object.hasOwn(APP_KEYS, app) ? { appKey: APP_KEYS[app] } : {}) });
const ownAction = (app: string, action: string, keys: Record<string, string>): Described =>
({ ...ownApp(app), label: humanize(action), ...(Object.hasOwn(keys, action) ? { labelKey: keys[action] } : {}) });
export function describeTool(name: string, input?: unknown): { app: string | null; label: string } {
const { app, label } = describedTool(name, input);
return { app, label };
}
export function describedTool(name: string, input?: unknown): Described {
if (Object.hasOwn(BUILTIN_LABELS, name)) return { app: null, label: BUILTIN_LABELS[name]![1], labelKey: BUILTIN_LABELS[name]![0] };
if (name.startsWith("mcp__")) {
const [, server = "", ...rest] = name.split("__");
const action = rest.join("__");
if (server === "composio") return describeTool(action);
if (server === "computer") return ownAction("Computer", action, COMPUTER_ACTION_KEYS);
if (server === "agents") {
if (action === "research_call") {
const call = researchCallLabel(input);
return { ...ownApp(call.label === "Searched X" ? "X" : "Research"), ...call };
}
const member = (AGENTS_ACTION_TOOLS[action]?.members.length ?? 0) > 1 ? agentsCallMember(action, callArguments(input)) : action;
return Object.hasOwn(RESEARCH_TOOL_LABELS, member) ? RESEARCH_TOOL_LABELS[member]! : ownAction("Team", member, TEAM_ACTION_KEYS);
}
if (server === "email") return { app: "Email", appKey: "activity.app.email", label: humanize(action.replace(/^EMAIL_/, "")) };
if (server === "device") return { app: "Phone", label: humanize(action.replace(/^PHONE_/, "")) };
if (server.startsWith("claude_ai_")) {
return { app: server.slice("claude_ai_".length).replace(/_/g, " "), label: humanize(action) };
}
return { app: humanize(server), label: humanize(action) };
}
const composio = COMPOSIO_TOOL.exec(name);
if (composio) return { app: toolkitName(composio[1]), label: humanize(composio[2]) };
return { app: null, label: humanize(name) };
}
const DECISION_SCAN = 20_000;
const EVENTS_PER_THREAD = 2_000;
/** A verdict is written as the request opens and the run follows, occasionally a clock skew's worth earlier. */
const MATCH_BEFORE_MS = 5_000;
const MATCH_AFTER_MS = 30_000;
const OUTCOME_OF_DECISION: Record<DecisionKind, ActivityOutcome> = {
"auto-approved": "allowed",
"auto-denied": "denied",
"user-approved": "allowed",
"review-would-approve": "allowed",
"user-denied": "denied",
// A person reverted a change that had applied on its own.
"user-undone": "denied",
"review-would-deny": "denied",
"card-shown": "waiting",
};
function describeRow(base: Omit<ActivityRow, "app" | "label">, input?: unknown): ActivityRow {
const { app, label, labelKey, appKey } = describedTool(base.tool, input ?? base.summary);
return { ...base, app, label, ...(labelKey ? { labelKey } : {}), ...(appKey ? { appKey } : {}) };
}
/** The decision log writes a row when a card is shown and another when it is answered; the receipt wants the answer. */
function foldDecisions(rows: DecisionRow[]): ActivityRow[] {
const byRequest = new Map<string, DecisionRow[]>();
rows.forEach((row, index) => {
const key = row.requestId ?? `row:${index}`;
const group = byRequest.get(key);
if (group) group.push(row);
else byRequest.set(key, [row]);
});
const folded: ActivityRow[] = [];
for (const group of byRequest.values()) {
const opened = group[0];
const latest = group[group.length - 1];
const summary = group.find((row) => row.summary);
folded.push(
describeRow({
at: opened.at,
threadId: opened.threadId,
requestId: opened.requestId,
tool: latest.tool ?? opened.tool ?? "unknown",
...(summary ? { summary: summary.summary, summaryCode: summary.summaryCode, summaryParams: summary.summaryParams } : {}),
outcome: OUTCOME_OF_DECISION[latest.decision],
}),
);
}
return folded;
}
function readToolRuns(eventsDir: string, threadId: string): ActivityRow[] {
const { lines } = readRecentLines(join(eventsDir, `${threadId}.ndjson`), EVENTS_PER_THREAD, isRuntimeEvent);
const runs = new Map<string, ActivityRow>();
for (const event of lines) {
if (event.type === "item.started" && event.itemType === "tool") {
const key = JSON.stringify([event.turnId ?? "", event.itemId ?? event.eventId]);
runs.set(
key,
describeRow({
at: event.createdAt,
threadId,
...(event.turnId ? { turnId: event.turnId } : {}),
// A bridged call reads as the tool it ran, only where the title itself names the agents server.
tool: bridgedCallName(event.title ?? "unknown", event.input, { serverNamed: true }),
...(event.step ? { summary: event.step.detail ?? event.step.target } : {}),
outcome: "running",
}, event.input),
);
} else if (event.type === "item.completed" && event.itemType === "tool" && event.itemId) {
const run = runs.get(JSON.stringify([event.turnId ?? "", event.itemId]));
if (run) run.outcome = event.ok ? "ran" : "failed";
}
}
return [...runs.values()];
}
export function readBotActivity(input: {
dataDir: string;
eventsDir: string;
botId: string;
threadIds: string[];
limit: number;
}): ActivityRow[] {
const { dataDir, eventsDir, botId, threadIds, limit } = input;
const runs = threadIds.flatMap((threadId) => readToolRuns(eventsDir, threadId));
const decisions = foldDecisions(readDecisions(dataDir, DECISION_SCAN).filter((row) => row.botId === botId));
const matched = new Set<ActivityRow>();
const rows: ActivityRow[] = [...runs];
for (const decision of decisions) {
if (decision.outcome !== "allowed") {
rows.push(decision);
continue;
}
const askedAt = Date.parse(decision.at);
let best: ActivityRow | null = null;
for (const run of runs) {
if (matched.has(run) || run.threadId !== decision.threadId || run.tool !== decision.tool) continue;
const delta = Date.parse(run.at) - askedAt;
if (delta < -MATCH_BEFORE_MS || delta > MATCH_AFTER_MS) continue;
if (!best || Math.abs(delta) < Math.abs(Date.parse(best.at) - askedAt)) best = run;
}
if (best) {
matched.add(best);
if (decision.summary) Object.assign(best, { summary: decision.summary, summaryCode: decision.summaryCode, summaryParams: decision.summaryParams });
if (decision.requestId) best.requestId = decision.requestId;
} else {
rows.push(decision);
}
}
rows.sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0));
return rows.slice(0, Math.max(1, limit));
}