Files
clawdbot/src/agents/clawdis-tools.ts
2026-01-02 01:11:04 +01:00

1520 lines
48 KiB
TypeScript

import crypto from "node:crypto";
import fs from "node:fs/promises";
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-ai";
import { Type } from "@sinclair/typebox";
import {
browserCloseTab,
browserFocusTab,
browserOpenTab,
browserSnapshot,
browserStart,
browserStatus,
browserStop,
browserTabs,
} from "../browser/client.js";
import {
browserAct,
browserArmDialog,
browserArmFileChooser,
browserConsoleMessages,
browserNavigate,
browserPdfSave,
browserScreenshotAction,
} from "../browser/client-actions.js";
import { resolveBrowserConfig } from "../browser/config.js";
import {
type CameraFacing,
cameraTempPath,
parseCameraClipPayload,
parseCameraSnapPayload,
writeBase64ToFile,
} from "../cli/nodes-camera.js";
import {
canvasSnapshotTempPath,
parseCanvasSnapshotPayload,
} from "../cli/nodes-canvas.js";
import {
parseScreenRecordPayload,
screenRecordTempPath,
writeScreenRecordToFile,
} from "../cli/nodes-screen.js";
import { parseDurationMs } from "../cli/parse-duration.js";
import { loadConfig } from "../config/config.js";
import { reactMessageDiscord } from "../discord/send.js";
import { callGateway } from "../gateway/call.js";
import { detectMime } from "../media/mime.js";
import { sanitizeToolResultImages } from "./tool-images.js";
// biome-ignore lint/suspicious/noExplicitAny: TypeBox schema type from pi-ai uses a different module instance.
type AnyAgentTool = AgentTool<any, unknown>;
const DEFAULT_GATEWAY_URL = "ws://127.0.0.1:18789";
type GatewayCallOptions = {
gatewayUrl?: string;
gatewayToken?: string;
timeoutMs?: number;
};
function resolveGatewayOptions(opts?: GatewayCallOptions) {
const url =
typeof opts?.gatewayUrl === "string" && opts.gatewayUrl.trim()
? opts.gatewayUrl.trim()
: DEFAULT_GATEWAY_URL;
const token =
typeof opts?.gatewayToken === "string" && opts.gatewayToken.trim()
? opts.gatewayToken.trim()
: undefined;
const timeoutMs =
typeof opts?.timeoutMs === "number" && Number.isFinite(opts.timeoutMs)
? Math.max(1, Math.floor(opts.timeoutMs))
: 10_000;
return { url, token, timeoutMs };
}
type StringParamOptions = {
required?: boolean;
trim?: boolean;
label?: string;
};
function readStringParam(
params: Record<string, unknown>,
key: string,
options: StringParamOptions & { required: true },
): string;
function readStringParam(
params: Record<string, unknown>,
key: string,
options?: StringParamOptions,
): string | undefined;
function readStringParam(
params: Record<string, unknown>,
key: string,
options: StringParamOptions = {},
) {
const { required = false, trim = true, label = key } = options;
const raw = params[key];
if (typeof raw !== "string") {
if (required) throw new Error(`${label} required`);
return undefined;
}
const value = trim ? raw.trim() : raw;
if (!value) {
if (required) throw new Error(`${label} required`);
return undefined;
}
return value;
}
async function callGatewayTool<T = unknown>(
method: string,
opts: GatewayCallOptions,
params?: unknown,
extra?: { expectFinal?: boolean },
) {
const gateway = resolveGatewayOptions(opts);
return await callGateway<T>({
url: gateway.url,
token: gateway.token,
method,
params,
timeoutMs: gateway.timeoutMs,
expectFinal: extra?.expectFinal,
clientName: "agent",
mode: "agent",
});
}
function jsonResult(payload: unknown): AgentToolResult<unknown> {
return {
content: [
{
type: "text",
text: JSON.stringify(payload, null, 2),
},
],
details: payload,
};
}
async function imageResult(params: {
label: string;
path: string;
base64: string;
mimeType: string;
extraText?: string;
details?: Record<string, unknown>;
}): Promise<AgentToolResult<unknown>> {
const content: AgentToolResult<unknown>["content"] = [
{
type: "text",
text: params.extraText ?? `MEDIA:${params.path}`,
},
{
type: "image",
data: params.base64,
mimeType: params.mimeType,
},
];
const result: AgentToolResult<unknown> = {
content,
details: { path: params.path, ...params.details },
};
return await sanitizeToolResultImages(result, params.label);
}
async function imageResultFromFile(params: {
label: string;
path: string;
extraText?: string;
details?: Record<string, unknown>;
}): Promise<AgentToolResult<unknown>> {
const buf = await fs.readFile(params.path);
const mimeType =
(await detectMime({ buffer: buf.slice(0, 256) })) ?? "image/png";
return await imageResult({
label: params.label,
path: params.path,
base64: buf.toString("base64"),
mimeType,
extraText: params.extraText,
details: params.details,
});
}
function resolveBrowserBaseUrl(controlUrl?: string) {
const cfg = loadConfig();
const resolved = resolveBrowserConfig(cfg.browser);
if (!resolved.enabled) {
throw new Error(
"Browser control is disabled. Set browser.enabled=true in ~/.clawdis/clawdis.json.",
);
}
const url = controlUrl?.trim() ? controlUrl.trim() : resolved.controlUrl;
return url.replace(/\/$/, "");
}
type NodeListNode = {
nodeId: string;
displayName?: string;
platform?: string;
remoteIp?: string;
deviceFamily?: string;
modelIdentifier?: string;
caps?: string[];
commands?: string[];
permissions?: Record<string, boolean>;
paired?: boolean;
connected?: boolean;
};
type PendingRequest = {
requestId: string;
nodeId: string;
displayName?: string;
platform?: string;
version?: string;
remoteIp?: string;
isRepair?: boolean;
ts: number;
};
type PairedNode = {
nodeId: string;
token?: string;
displayName?: string;
platform?: string;
version?: string;
remoteIp?: string;
permissions?: Record<string, boolean>;
createdAtMs?: number;
approvedAtMs?: number;
};
type PairingList = {
pending: PendingRequest[];
paired: PairedNode[];
};
function parseNodeList(value: unknown): NodeListNode[] {
const obj =
typeof value === "object" && value !== null
? (value as Record<string, unknown>)
: {};
return Array.isArray(obj.nodes) ? (obj.nodes as NodeListNode[]) : [];
}
function parsePairingList(value: unknown): PairingList {
const obj =
typeof value === "object" && value !== null
? (value as Record<string, unknown>)
: {};
const pending = Array.isArray(obj.pending)
? (obj.pending as PendingRequest[])
: [];
const paired = Array.isArray(obj.paired) ? (obj.paired as PairedNode[]) : [];
return { pending, paired };
}
function normalizeNodeKey(value: string) {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+/, "")
.replace(/-+$/, "");
}
async function loadNodes(opts: GatewayCallOptions): Promise<NodeListNode[]> {
try {
const res = (await callGatewayTool("node.list", opts, {})) as unknown;
return parseNodeList(res);
} catch {
const res = (await callGatewayTool("node.pair.list", opts, {})) as unknown;
const { paired } = parsePairingList(res);
return paired.map((n) => ({
nodeId: n.nodeId,
displayName: n.displayName,
platform: n.platform,
remoteIp: n.remoteIp,
}));
}
}
function pickDefaultNode(nodes: NodeListNode[]): NodeListNode | null {
const withCanvas = nodes.filter((n) =>
Array.isArray(n.caps) ? n.caps.includes("canvas") : true,
);
if (withCanvas.length === 0) return null;
const connected = withCanvas.filter((n) => n.connected);
const candidates = connected.length > 0 ? connected : withCanvas;
if (candidates.length === 1) return candidates[0];
const local = candidates.filter(
(n) =>
n.platform?.toLowerCase().startsWith("mac") &&
typeof n.nodeId === "string" &&
n.nodeId.startsWith("mac-"),
);
if (local.length === 1) return local[0];
return null;
}
async function resolveNodeId(
opts: GatewayCallOptions,
query?: string,
allowDefault = false,
) {
const nodes = await loadNodes(opts);
const q = String(query ?? "").trim();
if (!q) {
if (allowDefault) {
const picked = pickDefaultNode(nodes);
if (picked) return picked.nodeId;
}
throw new Error("node required");
}
const qNorm = normalizeNodeKey(q);
const matches = nodes.filter((n) => {
if (n.nodeId === q) return true;
if (typeof n.remoteIp === "string" && n.remoteIp === q) return true;
const name = typeof n.displayName === "string" ? n.displayName : "";
if (name && normalizeNodeKey(name) === qNorm) return true;
if (q.length >= 6 && n.nodeId.startsWith(q)) return true;
return false;
});
if (matches.length === 1) return matches[0].nodeId;
if (matches.length === 0) {
const known = nodes
.map((n) => n.displayName || n.remoteIp || n.nodeId)
.filter(Boolean)
.join(", ");
throw new Error(`unknown node: ${q}${known ? ` (known: ${known})` : ""}`);
}
throw new Error(
`ambiguous node: ${q} (matches: ${matches
.map((n) => n.displayName || n.remoteIp || n.nodeId)
.join(", ")})`,
);
}
const BrowserActSchema = Type.Union([
Type.Object({
kind: Type.Literal("click"),
ref: Type.String(),
targetId: Type.Optional(Type.String()),
doubleClick: Type.Optional(Type.Boolean()),
button: Type.Optional(Type.String()),
modifiers: Type.Optional(Type.Array(Type.String())),
}),
Type.Object({
kind: Type.Literal("type"),
ref: Type.String(),
text: Type.String(),
targetId: Type.Optional(Type.String()),
submit: Type.Optional(Type.Boolean()),
slowly: Type.Optional(Type.Boolean()),
}),
Type.Object({
kind: Type.Literal("press"),
key: Type.String(),
targetId: Type.Optional(Type.String()),
}),
Type.Object({
kind: Type.Literal("hover"),
ref: Type.String(),
targetId: Type.Optional(Type.String()),
}),
Type.Object({
kind: Type.Literal("drag"),
startRef: Type.String(),
endRef: Type.String(),
targetId: Type.Optional(Type.String()),
}),
Type.Object({
kind: Type.Literal("select"),
ref: Type.String(),
values: Type.Array(Type.String()),
targetId: Type.Optional(Type.String()),
}),
Type.Object({
kind: Type.Literal("fill"),
fields: Type.Array(Type.Record(Type.String(), Type.Unknown())),
targetId: Type.Optional(Type.String()),
}),
Type.Object({
kind: Type.Literal("resize"),
width: Type.Number(),
height: Type.Number(),
targetId: Type.Optional(Type.String()),
}),
Type.Object({
kind: Type.Literal("wait"),
timeMs: Type.Optional(Type.Number()),
text: Type.Optional(Type.String()),
textGone: Type.Optional(Type.String()),
targetId: Type.Optional(Type.String()),
}),
Type.Object({
kind: Type.Literal("evaluate"),
fn: Type.String(),
ref: Type.Optional(Type.String()),
targetId: Type.Optional(Type.String()),
}),
Type.Object({
kind: Type.Literal("close"),
targetId: Type.Optional(Type.String()),
}),
]);
const BrowserToolSchema = Type.Union([
Type.Object({
action: Type.Literal("status"),
controlUrl: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("start"),
controlUrl: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("stop"),
controlUrl: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("tabs"),
controlUrl: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("open"),
controlUrl: Type.Optional(Type.String()),
targetUrl: Type.String(),
}),
Type.Object({
action: Type.Literal("focus"),
controlUrl: Type.Optional(Type.String()),
targetId: Type.String(),
}),
Type.Object({
action: Type.Literal("close"),
controlUrl: Type.Optional(Type.String()),
targetId: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("snapshot"),
controlUrl: Type.Optional(Type.String()),
format: Type.Optional(
Type.Union([Type.Literal("aria"), Type.Literal("ai")]),
),
targetId: Type.Optional(Type.String()),
limit: Type.Optional(Type.Number()),
}),
Type.Object({
action: Type.Literal("screenshot"),
controlUrl: Type.Optional(Type.String()),
targetId: Type.Optional(Type.String()),
fullPage: Type.Optional(Type.Boolean()),
ref: Type.Optional(Type.String()),
element: Type.Optional(Type.String()),
type: Type.Optional(
Type.Union([Type.Literal("png"), Type.Literal("jpeg")]),
),
}),
Type.Object({
action: Type.Literal("navigate"),
controlUrl: Type.Optional(Type.String()),
targetUrl: Type.String(),
targetId: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("console"),
controlUrl: Type.Optional(Type.String()),
level: Type.Optional(Type.String()),
targetId: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("pdf"),
controlUrl: Type.Optional(Type.String()),
targetId: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("upload"),
controlUrl: Type.Optional(Type.String()),
paths: Type.Array(Type.String()),
ref: Type.Optional(Type.String()),
inputRef: Type.Optional(Type.String()),
element: Type.Optional(Type.String()),
targetId: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
}),
Type.Object({
action: Type.Literal("dialog"),
controlUrl: Type.Optional(Type.String()),
accept: Type.Boolean(),
promptText: Type.Optional(Type.String()),
targetId: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
}),
Type.Object({
action: Type.Literal("act"),
controlUrl: Type.Optional(Type.String()),
request: BrowserActSchema,
}),
]);
function createBrowserTool(): AnyAgentTool {
return {
label: "Clawdis Browser",
name: "clawdis_browser",
description:
"Control clawd's dedicated browser (status/start/stop/tabs/open/snapshot/screenshot/actions). Use snapshot+act for UI automation. Avoid act:wait by default; use only in exceptional cases when no reliable UI state exists.",
parameters: BrowserToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const action = readStringParam(params, "action", { required: true });
const controlUrl = readStringParam(params, "controlUrl");
const baseUrl = resolveBrowserBaseUrl(controlUrl);
switch (action) {
case "status":
return jsonResult(await browserStatus(baseUrl));
case "start":
await browserStart(baseUrl);
return jsonResult(await browserStatus(baseUrl));
case "stop":
await browserStop(baseUrl);
return jsonResult(await browserStatus(baseUrl));
case "tabs":
return jsonResult({ tabs: await browserTabs(baseUrl) });
case "open": {
const targetUrl = readStringParam(params, "targetUrl", {
required: true,
});
return jsonResult(await browserOpenTab(baseUrl, targetUrl));
}
case "focus": {
const targetId = readStringParam(params, "targetId", {
required: true,
});
await browserFocusTab(baseUrl, targetId);
return jsonResult({ ok: true });
}
case "close": {
const targetId = readStringParam(params, "targetId");
if (targetId) await browserCloseTab(baseUrl, targetId);
else await browserAct(baseUrl, { kind: "close" });
return jsonResult({ ok: true });
}
case "snapshot": {
const format =
params.format === "ai" || params.format === "aria"
? (params.format as "ai" | "aria")
: "ai";
const targetId =
typeof params.targetId === "string"
? params.targetId.trim()
: undefined;
const limit =
typeof params.limit === "number" && Number.isFinite(params.limit)
? params.limit
: undefined;
const snapshot = await browserSnapshot(baseUrl, {
format,
targetId,
limit,
});
if (snapshot.format === "ai") {
return {
content: [{ type: "text", text: snapshot.snapshot }],
details: snapshot,
};
}
return jsonResult(snapshot);
}
case "screenshot": {
const targetId = readStringParam(params, "targetId");
const fullPage = Boolean(params.fullPage);
const ref = readStringParam(params, "ref");
const element = readStringParam(params, "element");
const type = params.type === "jpeg" ? "jpeg" : "png";
const result = await browserScreenshotAction(baseUrl, {
targetId,
fullPage,
ref,
element,
type,
});
return await imageResultFromFile({
label: "browser:screenshot",
path: result.path,
details: result,
});
}
case "navigate": {
const targetUrl = readStringParam(params, "targetUrl", {
required: true,
});
const targetId = readStringParam(params, "targetId");
return jsonResult(
await browserNavigate(baseUrl, { url: targetUrl, targetId }),
);
}
case "console": {
const level =
typeof params.level === "string" ? params.level.trim() : undefined;
const targetId =
typeof params.targetId === "string"
? params.targetId.trim()
: undefined;
return jsonResult(
await browserConsoleMessages(baseUrl, { level, targetId }),
);
}
case "pdf": {
const targetId =
typeof params.targetId === "string"
? params.targetId.trim()
: undefined;
const result = await browserPdfSave(baseUrl, { targetId });
return {
content: [{ type: "text", text: `FILE:${result.path}` }],
details: result,
};
}
case "upload": {
const paths = Array.isArray(params.paths)
? params.paths.map((p) => String(p))
: [];
if (paths.length === 0) throw new Error("paths required");
const ref = readStringParam(params, "ref");
const inputRef = readStringParam(params, "inputRef");
const element = readStringParam(params, "element");
const targetId =
typeof params.targetId === "string"
? params.targetId.trim()
: undefined;
const timeoutMs =
typeof params.timeoutMs === "number" &&
Number.isFinite(params.timeoutMs)
? params.timeoutMs
: undefined;
return jsonResult(
await browserArmFileChooser(baseUrl, {
paths,
ref,
inputRef,
element,
targetId,
timeoutMs,
}),
);
}
case "dialog": {
const accept = Boolean(params.accept);
const promptText =
typeof params.promptText === "string"
? params.promptText
: undefined;
const targetId =
typeof params.targetId === "string"
? params.targetId.trim()
: undefined;
const timeoutMs =
typeof params.timeoutMs === "number" &&
Number.isFinite(params.timeoutMs)
? params.timeoutMs
: undefined;
return jsonResult(
await browserArmDialog(baseUrl, {
accept,
promptText,
targetId,
timeoutMs,
}),
);
}
case "act": {
const request = params.request as Record<string, unknown> | undefined;
if (!request || typeof request !== "object") {
throw new Error("request required");
}
const result = await browserAct(
baseUrl,
request as Parameters<typeof browserAct>[1],
);
return jsonResult(result);
}
default:
throw new Error(`Unknown action: ${action}`);
}
},
};
}
const CanvasToolSchema = Type.Union([
Type.Object({
action: Type.Literal("present"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.Optional(Type.String()),
target: Type.Optional(Type.String()),
x: Type.Optional(Type.Number()),
y: Type.Optional(Type.Number()),
width: Type.Optional(Type.Number()),
height: Type.Optional(Type.Number()),
}),
Type.Object({
action: Type.Literal("hide"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("navigate"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.Optional(Type.String()),
url: Type.String(),
}),
Type.Object({
action: Type.Literal("eval"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.Optional(Type.String()),
javaScript: Type.String(),
}),
Type.Object({
action: Type.Literal("snapshot"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.Optional(Type.String()),
format: Type.Optional(
Type.Union([
Type.Literal("png"),
Type.Literal("jpg"),
Type.Literal("jpeg"),
]),
),
maxWidth: Type.Optional(Type.Number()),
quality: Type.Optional(Type.Number()),
}),
Type.Object({
action: Type.Literal("a2ui_push"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.Optional(Type.String()),
jsonl: Type.Optional(Type.String()),
jsonlPath: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("a2ui_reset"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.Optional(Type.String()),
}),
]);
function createCanvasTool(): AnyAgentTool {
return {
label: "Clawdis Canvas",
name: "clawdis_canvas",
description:
"Control node canvases (present/hide/navigate/eval/snapshot/A2UI). Use snapshot to capture the rendered UI.",
parameters: CanvasToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const action = readStringParam(params, "action", { required: true });
const gatewayOpts: GatewayCallOptions = {
gatewayUrl: readStringParam(params, "gatewayUrl", { trim: false }),
gatewayToken: readStringParam(params, "gatewayToken", { trim: false }),
timeoutMs:
typeof params.timeoutMs === "number" ? params.timeoutMs : undefined,
};
const nodeId = await resolveNodeId(
gatewayOpts,
readStringParam(params, "node", { trim: true }),
true,
);
const invoke = async (
command: string,
invokeParams?: Record<string, unknown>,
) =>
await callGatewayTool("node.invoke", gatewayOpts, {
nodeId,
command,
params: invokeParams,
idempotencyKey: crypto.randomUUID(),
});
switch (action) {
case "present": {
const placement = {
x: typeof params.x === "number" ? params.x : undefined,
y: typeof params.y === "number" ? params.y : undefined,
width: typeof params.width === "number" ? params.width : undefined,
height:
typeof params.height === "number" ? params.height : undefined,
};
const invokeParams: Record<string, unknown> = {};
if (typeof params.target === "string" && params.target.trim()) {
invokeParams.url = params.target.trim();
}
if (
Number.isFinite(placement.x) ||
Number.isFinite(placement.y) ||
Number.isFinite(placement.width) ||
Number.isFinite(placement.height)
) {
invokeParams.placement = placement;
}
await invoke("canvas.present", invokeParams);
return jsonResult({ ok: true });
}
case "hide":
await invoke("canvas.hide", undefined);
return jsonResult({ ok: true });
case "navigate": {
const url = readStringParam(params, "url", { required: true });
await invoke("canvas.navigate", { url });
return jsonResult({ ok: true });
}
case "eval": {
const javaScript = readStringParam(params, "javaScript", {
required: true,
});
const raw = (await invoke("canvas.eval", { javaScript })) as {
payload?: { result?: string };
};
const result = raw?.payload?.result;
if (result) {
return {
content: [{ type: "text", text: result }],
details: { result },
};
}
return jsonResult({ ok: true });
}
case "snapshot": {
const formatRaw =
typeof params.format === "string"
? params.format.toLowerCase()
: "png";
const format =
formatRaw === "jpg" || formatRaw === "jpeg" ? "jpeg" : "png";
const maxWidth =
typeof params.maxWidth === "number" &&
Number.isFinite(params.maxWidth)
? params.maxWidth
: undefined;
const quality =
typeof params.quality === "number" &&
Number.isFinite(params.quality)
? params.quality
: undefined;
const raw = (await invoke("canvas.snapshot", {
format,
maxWidth,
quality,
})) as { payload?: unknown };
const payload = parseCanvasSnapshotPayload(raw?.payload);
const filePath = canvasSnapshotTempPath({
ext: payload.format === "jpeg" ? "jpg" : payload.format,
});
await writeBase64ToFile(filePath, payload.base64);
const mimeType =
payload.format === "jpeg" ? "image/jpeg" : "image/png";
return await imageResult({
label: "canvas:snapshot",
path: filePath,
base64: payload.base64,
mimeType,
details: { format: payload.format },
});
}
case "a2ui_push": {
const jsonl =
typeof params.jsonl === "string" && params.jsonl.trim()
? params.jsonl
: typeof params.jsonlPath === "string" && params.jsonlPath.trim()
? await fs.readFile(params.jsonlPath.trim(), "utf8")
: "";
if (!jsonl.trim()) throw new Error("jsonl or jsonlPath required");
await invoke("canvas.a2ui.pushJSONL", { jsonl });
return jsonResult({ ok: true });
}
case "a2ui_reset":
await invoke("canvas.a2ui.reset", undefined);
return jsonResult({ ok: true });
default:
throw new Error(`Unknown action: ${action}`);
}
},
};
}
const NodesToolSchema = Type.Union([
Type.Object({
action: Type.Literal("status"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
}),
Type.Object({
action: Type.Literal("describe"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.String(),
}),
Type.Object({
action: Type.Literal("pending"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
}),
Type.Object({
action: Type.Literal("approve"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
requestId: Type.String(),
}),
Type.Object({
action: Type.Literal("reject"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
requestId: Type.String(),
}),
Type.Object({
action: Type.Literal("notify"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.String(),
title: Type.Optional(Type.String()),
body: Type.Optional(Type.String()),
sound: Type.Optional(Type.String()),
priority: Type.Optional(
Type.Union([
Type.Literal("passive"),
Type.Literal("active"),
Type.Literal("timeSensitive"),
]),
),
delivery: Type.Optional(
Type.Union([
Type.Literal("system"),
Type.Literal("overlay"),
Type.Literal("auto"),
]),
),
}),
Type.Object({
action: Type.Literal("camera_snap"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.String(),
facing: Type.Optional(
Type.Union([
Type.Literal("front"),
Type.Literal("back"),
Type.Literal("both"),
]),
),
maxWidth: Type.Optional(Type.Number()),
quality: Type.Optional(Type.Number()),
}),
Type.Object({
action: Type.Literal("camera_clip"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.String(),
facing: Type.Optional(
Type.Union([Type.Literal("front"), Type.Literal("back")]),
),
duration: Type.Optional(Type.String()),
durationMs: Type.Optional(Type.Number()),
includeAudio: Type.Optional(Type.Boolean()),
}),
Type.Object({
action: Type.Literal("screen_record"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.String(),
duration: Type.Optional(Type.String()),
durationMs: Type.Optional(Type.Number()),
fps: Type.Optional(Type.Number()),
screenIndex: Type.Optional(Type.Number()),
includeAudio: Type.Optional(Type.Boolean()),
outPath: Type.Optional(Type.String()),
}),
]);
function createNodesTool(): AnyAgentTool {
return {
label: "Clawdis Nodes",
name: "clawdis_nodes",
description:
"Discover and control paired nodes (status/describe/pairing/notify/camera/screen).",
parameters: NodesToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const action = readStringParam(params, "action", { required: true });
const gatewayOpts: GatewayCallOptions = {
gatewayUrl: readStringParam(params, "gatewayUrl", { trim: false }),
gatewayToken: readStringParam(params, "gatewayToken", { trim: false }),
timeoutMs:
typeof params.timeoutMs === "number" ? params.timeoutMs : undefined,
};
switch (action) {
case "status":
return jsonResult(
await callGatewayTool("node.list", gatewayOpts, {}),
);
case "describe": {
const node = readStringParam(params, "node", { required: true });
const nodeId = await resolveNodeId(gatewayOpts, node);
return jsonResult(
await callGatewayTool("node.describe", gatewayOpts, { nodeId }),
);
}
case "pending":
return jsonResult(
await callGatewayTool("node.pair.list", gatewayOpts, {}),
);
case "approve": {
const requestId = readStringParam(params, "requestId", {
required: true,
});
return jsonResult(
await callGatewayTool("node.pair.approve", gatewayOpts, {
requestId,
}),
);
}
case "reject": {
const requestId = readStringParam(params, "requestId", {
required: true,
});
return jsonResult(
await callGatewayTool("node.pair.reject", gatewayOpts, {
requestId,
}),
);
}
case "notify": {
const node = readStringParam(params, "node", { required: true });
const title = typeof params.title === "string" ? params.title : "";
const body = typeof params.body === "string" ? params.body : "";
if (!title.trim() && !body.trim()) {
throw new Error("title or body required");
}
const nodeId = await resolveNodeId(gatewayOpts, node);
await callGatewayTool("node.invoke", gatewayOpts, {
nodeId,
command: "system.notify",
params: {
title: title.trim() || undefined,
body: body.trim() || undefined,
sound:
typeof params.sound === "string" ? params.sound : undefined,
priority:
typeof params.priority === "string"
? params.priority
: undefined,
delivery:
typeof params.delivery === "string"
? params.delivery
: undefined,
},
idempotencyKey: crypto.randomUUID(),
});
return jsonResult({ ok: true });
}
case "camera_snap": {
const node = readStringParam(params, "node", { required: true });
const nodeId = await resolveNodeId(gatewayOpts, node);
const facingRaw =
typeof params.facing === "string"
? params.facing.toLowerCase()
: "both";
const facings: CameraFacing[] =
facingRaw === "both"
? ["front", "back"]
: facingRaw === "front" || facingRaw === "back"
? [facingRaw]
: (() => {
throw new Error("invalid facing (front|back|both)");
})();
const maxWidth =
typeof params.maxWidth === "number" &&
Number.isFinite(params.maxWidth)
? params.maxWidth
: undefined;
const quality =
typeof params.quality === "number" &&
Number.isFinite(params.quality)
? params.quality
: undefined;
const content: AgentToolResult<unknown>["content"] = [];
const details: Array<Record<string, unknown>> = [];
for (const facing of facings) {
const raw = (await callGatewayTool("node.invoke", gatewayOpts, {
nodeId,
command: "camera.snap",
params: {
facing,
maxWidth,
quality,
format: "jpg",
},
idempotencyKey: crypto.randomUUID(),
})) as { payload?: unknown };
const payload = parseCameraSnapPayload(raw?.payload);
const filePath = cameraTempPath({
kind: "snap",
facing,
ext: payload.format === "jpeg" ? "jpg" : payload.format,
});
await writeBase64ToFile(filePath, payload.base64);
content.push({ type: "text", text: `MEDIA:${filePath}` });
content.push({
type: "image",
data: payload.base64,
mimeType: payload.format === "jpeg" ? "image/jpeg" : "image/png",
});
details.push({
facing,
path: filePath,
width: payload.width,
height: payload.height,
});
}
const result: AgentToolResult<unknown> = { content, details };
return await sanitizeToolResultImages(result, "nodes:camera_snap");
}
case "camera_clip": {
const node = readStringParam(params, "node", { required: true });
const nodeId = await resolveNodeId(gatewayOpts, node);
const facing =
typeof params.facing === "string"
? params.facing.toLowerCase()
: "front";
if (facing !== "front" && facing !== "back") {
throw new Error("invalid facing (front|back)");
}
const durationMs =
typeof params.durationMs === "number" &&
Number.isFinite(params.durationMs)
? params.durationMs
: typeof params.duration === "string"
? parseDurationMs(params.duration)
: 3000;
const includeAudio =
typeof params.includeAudio === "boolean"
? params.includeAudio
: true;
const raw = (await callGatewayTool("node.invoke", gatewayOpts, {
nodeId,
command: "camera.clip",
params: {
facing,
durationMs,
includeAudio,
format: "mp4",
},
idempotencyKey: crypto.randomUUID(),
})) as { payload?: unknown };
const payload = parseCameraClipPayload(raw?.payload);
const filePath = cameraTempPath({
kind: "clip",
facing,
ext: payload.format,
});
await writeBase64ToFile(filePath, payload.base64);
return {
content: [{ type: "text", text: `FILE:${filePath}` }],
details: {
facing,
path: filePath,
durationMs: payload.durationMs,
hasAudio: payload.hasAudio,
},
};
}
case "screen_record": {
const node = readStringParam(params, "node", { required: true });
const nodeId = await resolveNodeId(gatewayOpts, node);
const durationMs =
typeof params.durationMs === "number" &&
Number.isFinite(params.durationMs)
? params.durationMs
: typeof params.duration === "string"
? parseDurationMs(params.duration)
: 10_000;
const fps =
typeof params.fps === "number" && Number.isFinite(params.fps)
? params.fps
: 10;
const screenIndex =
typeof params.screenIndex === "number" &&
Number.isFinite(params.screenIndex)
? params.screenIndex
: 0;
const includeAudio =
typeof params.includeAudio === "boolean"
? params.includeAudio
: true;
const raw = (await callGatewayTool("node.invoke", gatewayOpts, {
nodeId,
command: "screen.record",
params: {
durationMs,
screenIndex,
fps,
format: "mp4",
includeAudio,
},
idempotencyKey: crypto.randomUUID(),
})) as { payload?: unknown };
const payload = parseScreenRecordPayload(raw?.payload);
const filePath =
typeof params.outPath === "string" && params.outPath.trim()
? params.outPath.trim()
: screenRecordTempPath({ ext: payload.format || "mp4" });
const written = await writeScreenRecordToFile(
filePath,
payload.base64,
);
return {
content: [{ type: "text", text: `FILE:${written.path}` }],
details: {
path: written.path,
durationMs: payload.durationMs,
fps: payload.fps,
screenIndex: payload.screenIndex,
hasAudio: payload.hasAudio,
},
};
}
default:
throw new Error(`Unknown action: ${action}`);
}
},
};
}
const CronToolSchema = Type.Union([
Type.Object({
action: Type.Literal("status"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
}),
Type.Object({
action: Type.Literal("list"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
includeDisabled: Type.Optional(Type.Boolean()),
}),
Type.Object({
action: Type.Literal("add"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
job: Type.Object({}, { additionalProperties: true }),
}),
Type.Object({
action: Type.Literal("update"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
jobId: Type.String(),
patch: Type.Object({}, { additionalProperties: true }),
}),
Type.Object({
action: Type.Literal("remove"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
jobId: Type.String(),
}),
Type.Object({
action: Type.Literal("run"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
jobId: Type.String(),
}),
Type.Object({
action: Type.Literal("runs"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
jobId: Type.String(),
}),
Type.Object({
action: Type.Literal("wake"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
text: Type.String(),
mode: Type.Optional(
Type.Union([Type.Literal("now"), Type.Literal("next-heartbeat")]),
),
}),
]);
function createCronTool(): AnyAgentTool {
return {
label: "Clawdis Cron",
name: "clawdis_cron",
description:
"Manage Gateway cron jobs (status/list/add/update/remove/run/runs) and send wake events.",
parameters: CronToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const action = readStringParam(params, "action", { required: true });
const gatewayOpts: GatewayCallOptions = {
gatewayUrl: readStringParam(params, "gatewayUrl", { trim: false }),
gatewayToken: readStringParam(params, "gatewayToken", { trim: false }),
timeoutMs:
typeof params.timeoutMs === "number" ? params.timeoutMs : undefined,
};
switch (action) {
case "status":
return jsonResult(
await callGatewayTool("cron.status", gatewayOpts, {}),
);
case "list":
return jsonResult(
await callGatewayTool("cron.list", gatewayOpts, {
includeDisabled: Boolean(params.includeDisabled),
}),
);
case "add": {
if (!params.job || typeof params.job !== "object") {
throw new Error("job required");
}
return jsonResult(
await callGatewayTool("cron.add", gatewayOpts, params.job),
);
}
case "update": {
const jobId = readStringParam(params, "jobId", { required: true });
if (!params.patch || typeof params.patch !== "object") {
throw new Error("patch required");
}
return jsonResult(
await callGatewayTool("cron.update", gatewayOpts, {
jobId,
patch: params.patch,
}),
);
}
case "remove": {
const jobId = readStringParam(params, "jobId", { required: true });
return jsonResult(
await callGatewayTool("cron.remove", gatewayOpts, { jobId }),
);
}
case "run": {
const jobId = readStringParam(params, "jobId", { required: true });
return jsonResult(
await callGatewayTool("cron.run", gatewayOpts, { jobId }),
);
}
case "runs": {
const jobId = readStringParam(params, "jobId", { required: true });
return jsonResult(
await callGatewayTool("cron.runs", gatewayOpts, { jobId }),
);
}
case "wake": {
const text = readStringParam(params, "text", { required: true });
const mode =
params.mode === "now" || params.mode === "next-heartbeat"
? params.mode
: "next-heartbeat";
return jsonResult(
await callGatewayTool(
"wake",
gatewayOpts,
{ mode, text },
{ expectFinal: false },
),
);
}
default:
throw new Error(`Unknown action: ${action}`);
}
},
};
}
const GatewayToolSchema = Type.Union([
Type.Object({
action: Type.Literal("restart"),
delayMs: Type.Optional(Type.Number()),
reason: Type.Optional(Type.String()),
}),
]);
const DiscordToolSchema = Type.Union([
Type.Object({
action: Type.Literal("react"),
channelId: Type.String(),
messageId: Type.String(),
emoji: Type.String(),
}),
]);
function createDiscordTool(): AnyAgentTool {
return {
label: "Clawdis Discord",
name: "clawdis_discord",
description:
"React to Discord messages. Controlled by discord.enableReactions (default: true).",
parameters: DiscordToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const action = readStringParam(params, "action", { required: true });
if (action !== "react") throw new Error(`Unknown action: ${action}`);
const cfg = loadConfig();
if (cfg.discord?.enableReactions === false) {
throw new Error(
"Discord reactions are disabled (set discord.enableReactions=true).",
);
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
const messageId = readStringParam(params, "messageId", {
required: true,
});
const emoji = readStringParam(params, "emoji", { required: true });
await reactMessageDiscord(channelId, messageId, emoji);
return jsonResult({ ok: true });
},
};
}
function createGatewayTool(): AnyAgentTool {
return {
label: "Clawdis Gateway",
name: "clawdis_gateway",
description:
"Restart the running gateway process in-place (SIGUSR1) without needing an external supervisor. Use delayMs to avoid interrupting an in-flight reply.",
parameters: GatewayToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const action = readStringParam(params, "action", { required: true });
if (action !== "restart") throw new Error(`Unknown action: ${action}`);
const delayMsRaw =
typeof params.delayMs === "number" && Number.isFinite(params.delayMs)
? Math.floor(params.delayMs)
: 2000;
const delayMs = Math.min(Math.max(delayMsRaw, 0), 60_000);
const reason =
typeof params.reason === "string" && params.reason.trim()
? params.reason.trim().slice(0, 200)
: undefined;
const pid = process.pid;
setTimeout(() => {
try {
process.kill(pid, "SIGUSR1");
} catch {
/* ignore */
}
}, delayMs);
return jsonResult({
ok: true,
pid,
signal: "SIGUSR1",
delayMs,
reason: reason ?? null,
});
},
};
}
export function createClawdisTools(): AnyAgentTool[] {
return [
createBrowserTool(),
createCanvasTool(),
createNodesTool(),
createCronTool(),
createDiscordTool(),
createGatewayTool(),
];
}