First Release of Claw3D (#11)

Co-authored-by: iamlukethedev <iamlukethedev@users.noreply.github.com>
This commit is contained in:
Luke The Dev
2026-03-19 23:14:04 -05:00
committed by GitHub
parent 5ea96b2650
commit 4fa4f13558
431 changed files with 105438 additions and 14 deletions
+112
View File
@@ -0,0 +1,112 @@
import fs from "node:fs";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { resolveStateDir } from "@/lib/clawdbot/paths";
export type GatewayAgentStateMove = { from: string; to: string };
export type TrashAgentStateResult = {
trashDir: string;
moved: GatewayAgentStateMove[];
};
export type RestoreAgentStateResult = {
restored: GatewayAgentStateMove[];
};
const isSafeAgentId = (value: string) => /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/.test(value);
const utcStamp = (now: Date = new Date()) => {
const iso = now.toISOString(); // 2026-02-11T00:24:00.123Z
return iso.replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z"); // 20260211T002400Z
};
const moveIfExists = (src: string, dest: string, moves: GatewayAgentStateMove[]) => {
if (!fs.existsSync(src)) return;
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.renameSync(src, dest);
moves.push({ from: src, to: dest });
};
export const trashAgentStateLocally = (params: { agentId: string }): TrashAgentStateResult => {
const agentId = params.agentId.trim();
if (!agentId) {
throw new Error("agentId is required.");
}
if (!isSafeAgentId(agentId)) {
throw new Error(`Invalid agentId: ${agentId}`);
}
const base = resolveStateDir();
const trashRoot = path.join(base, "trash", "studio-delete-agent");
const stamp = utcStamp();
const trashDir = path.join(trashRoot, `${stamp}-${agentId}-${randomUUID()}`);
fs.mkdirSync(path.join(trashDir, "agents"), { recursive: true });
fs.mkdirSync(path.join(trashDir, "workspaces"), { recursive: true });
const moves: GatewayAgentStateMove[] = [];
moveIfExists(
path.join(base, `workspace-${agentId}`),
path.join(trashDir, "workspaces", `workspace-${agentId}`),
moves
);
moveIfExists(path.join(base, "agents", agentId), path.join(trashDir, "agents", agentId), moves);
return { trashDir, moved: moves };
};
const ensureUnderBase = (base: string, candidate: string) => {
const resolvedBase = fs.existsSync(base) ? fs.realpathSync(base) : path.resolve(base);
const resolvedCandidate = fs.realpathSync(candidate);
const prefix = resolvedBase.endsWith(path.sep) ? resolvedBase : `${resolvedBase}${path.sep}`;
if (resolvedCandidate !== resolvedBase && !resolvedCandidate.startsWith(prefix)) {
throw new Error(`trashDir is not under ${base}: ${candidate}`);
}
return { resolvedBase, resolvedCandidate };
};
export const restoreAgentStateLocally = (params: {
agentId: string;
trashDir: string;
}): RestoreAgentStateResult => {
const agentId = params.agentId.trim();
const trashDirRaw = params.trashDir.trim();
if (!agentId) {
throw new Error("agentId is required.");
}
if (!isSafeAgentId(agentId)) {
throw new Error(`Invalid agentId: ${agentId}`);
}
if (!trashDirRaw) {
throw new Error("trashDir is required.");
}
const base = resolveStateDir();
if (!fs.existsSync(trashDirRaw)) {
throw new Error(`trashDir does not exist: ${trashDirRaw}`);
}
const { resolvedCandidate: resolvedTrashDir } = ensureUnderBase(base, trashDirRaw);
const moves: GatewayAgentStateMove[] = [];
const restoreIfExists = (src: string, dest: string) => {
if (!fs.existsSync(src)) return;
if (fs.existsSync(dest)) {
throw new Error(`Refusing to restore over existing path: ${dest}`);
}
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.renameSync(src, dest);
moves.push({ from: src, to: dest });
};
restoreIfExists(
path.join(resolvedTrashDir, "workspaces", `workspace-${agentId}`),
path.join(base, `workspace-${agentId}`)
);
restoreIfExists(
path.join(resolvedTrashDir, "agents", agentId),
path.join(base, "agents", agentId)
);
return { restored: moves };
};
+76
View File
@@ -0,0 +1,76 @@
export const AGENT_FILE_NAMES = [
"AGENTS.md",
"SOUL.md",
"IDENTITY.md",
"USER.md",
"TOOLS.md",
"HEARTBEAT.md",
"MEMORY.md",
] as const;
export type AgentFileName = (typeof AGENT_FILE_NAMES)[number];
export const PERSONALITY_FILE_NAMES = [
"SOUL.md",
"AGENTS.md",
"USER.md",
"IDENTITY.md",
] as const satisfies readonly AgentFileName[];
export type PersonalityFileName = (typeof PERSONALITY_FILE_NAMES)[number];
export const PERSONALITY_FILE_LABELS: Record<PersonalityFileName, string> = {
"SOUL.md": "Persona",
"AGENTS.md": "Directives",
"USER.md": "Context",
"IDENTITY.md": "Identity",
};
export const isAgentFileName = (value: string): value is AgentFileName =>
AGENT_FILE_NAMES.includes(value as AgentFileName);
export const AGENT_FILE_META: Record<AgentFileName, { title: string; hint: string }> = {
"AGENTS.md": {
title: "AGENTS.md",
hint: "Operating instructions, priorities, and rules.",
},
"SOUL.md": {
title: "SOUL.md",
hint: "Persona, tone, and boundaries.",
},
"IDENTITY.md": {
title: "IDENTITY.md",
hint: "Name, vibe, and emoji.",
},
"USER.md": {
title: "USER.md",
hint: "User profile and preferences.",
},
"TOOLS.md": {
title: "TOOLS.md",
hint: "Local tool notes and conventions.",
},
"HEARTBEAT.md": {
title: "HEARTBEAT.md",
hint: "Small checklist for heartbeat runs.",
},
"MEMORY.md": {
title: "MEMORY.md",
hint: "Durable memory for this agent.",
},
};
export const AGENT_FILE_PLACEHOLDERS: Record<AgentFileName, string> = {
"AGENTS.md": "How should this agent work? Priorities, rules, and habits.",
"SOUL.md": "Tone, personality, boundaries, and how it should sound.",
"IDENTITY.md": "Name, vibe, emoji, and a one-line identity.",
"USER.md": "How should it address you? Preferences and context.",
"TOOLS.md": "Local tool notes, conventions, and shortcuts.",
"HEARTBEAT.md": "A tiny checklist for periodic runs.",
"MEMORY.md": "Durable facts, decisions, and preferences to remember.",
};
export const createAgentFilesState = () =>
Object.fromEntries(
AGENT_FILE_NAMES.map((name) => [name, { content: "", exists: false }])
) as Record<AgentFileName, { content: string; exists: boolean }>;
+307
View File
@@ -0,0 +1,307 @@
import type { AgentFileName } from "@/lib/agents/agentFiles";
export type PersonalityBuilderDraft = {
identity: {
name: string;
creature: string;
vibe: string;
emoji: string;
avatar: string;
};
user: {
name: string;
callThem: string;
pronouns: string;
timezone: string;
notes: string;
context: string;
};
soul: {
coreTruths: string;
boundaries: string;
vibe: string;
continuity: string;
};
agents: string;
tools: string;
heartbeat: string;
memory: string;
};
type AgentFilesInput = Record<AgentFileName, { content: string; exists: boolean }>;
const createEmptyDraft = (): PersonalityBuilderDraft => ({
identity: {
name: "",
creature: "",
vibe: "",
emoji: "",
avatar: "",
},
user: {
name: "",
callThem: "",
pronouns: "",
timezone: "",
notes: "",
context: "",
},
soul: {
coreTruths: "",
boundaries: "",
vibe: "",
continuity: "",
},
agents: "",
tools: "",
heartbeat: "",
memory: "",
});
const cleanLabel = (value: string) => value.replace(/[*_]/g, "").trim().toLowerCase();
const cleanValue = (value: string) => {
let next = value.trim();
next = next.replace(/^[*_]+|[*_]+$/g, "").trim();
return next;
};
const normalizeTemplateValue = (value: string) => {
let normalized = value.trim();
normalized = normalized.replace(/^[*_]+|[*_]+$/g, "").trim();
if (normalized.startsWith("(") && normalized.endsWith(")")) {
normalized = normalized.slice(1, -1).trim();
}
normalized = normalized.replace(/[\u2013\u2014]/g, "-");
normalized = normalized.replace(/\s+/g, " ").toLowerCase();
return normalized;
};
const IDENTITY_PLACEHOLDER_VALUES = new Set([
"pick something you like",
"ai? robot? familiar? ghost in the machine? something weirder?",
"how do you come across? sharp? warm? chaotic? calm?",
"your signature - pick one that feels right",
"workspace-relative path, http(s) url, or data uri",
]);
const USER_PLACEHOLDER_VALUES = new Set([
"optional",
]);
const USER_CONTEXT_PLACEHOLDER_VALUES = new Set([
"what do they care about? what projects are they working on? what annoys them? what makes them laugh? build this over time.",
]);
const isIdentityPlaceholder = (value: string) =>
IDENTITY_PLACEHOLDER_VALUES.has(normalizeTemplateValue(value));
const isUserPlaceholder = (value: string) => USER_PLACEHOLDER_VALUES.has(normalizeTemplateValue(value));
const isUserContextPlaceholder = (value: string) =>
USER_CONTEXT_PLACEHOLDER_VALUES.has(normalizeTemplateValue(value));
const parseLabelMap = (content: string): Map<string, string> => {
const map = new Map<string, string>();
const lines = content.split(/\r?\n/);
for (const line of lines) {
const trimmed = line.trim();
if (/^##\s+/.test(trimmed)) {
break;
}
const normalized = trimmed.replace(/^[-*]\s*/, "");
const colonIndex = normalized.indexOf(":");
if (colonIndex < 0) {
continue;
}
const label = cleanLabel(normalized.slice(0, colonIndex));
if (!label) {
continue;
}
if (map.has(label)) {
continue;
}
const value = cleanValue(normalized.slice(colonIndex + 1));
map.set(label, value);
}
return map;
};
const readFirst = (map: Map<string, string>, labels: string[]) => {
for (const label of labels) {
const value = map.get(label);
if (typeof value === "string") {
return value;
}
}
return "";
};
const isSectionHeading = (line: string) => /^##\s+/.test(line.trim());
const parseSection = (content: string, sectionTitle: string): string => {
const lines = content.split(/\r?\n/);
const target = `## ${sectionTitle}`.toLowerCase();
let startIndex = -1;
for (let index = 0; index < lines.length; index += 1) {
if (lines[index].trim().toLowerCase() === target) {
startIndex = index + 1;
break;
}
}
if (startIndex < 0) {
return "";
}
let endIndex = lines.length;
for (let index = startIndex; index < lines.length; index += 1) {
if (isSectionHeading(lines[index])) {
endIndex = index;
break;
}
}
while (startIndex < endIndex && lines[startIndex].trim().length === 0) {
startIndex += 1;
}
while (endIndex > startIndex && lines[endIndex - 1].trim().length === 0) {
endIndex -= 1;
}
if (startIndex >= endIndex) {
return "";
}
return lines.slice(startIndex, endIndex).join("\n");
};
const normalizeText = (value: string) => value.replace(/\r\n/g, "\n").trim();
const normalizeListField = (value: string) => value.replace(/\r\n/g, "\n").trim();
const serializeIdentityMarkdown = (draft: PersonalityBuilderDraft["identity"]) => {
const name = normalizeListField(draft.name);
const creature = normalizeListField(draft.creature);
const vibe = normalizeListField(draft.vibe);
const emoji = normalizeListField(draft.emoji);
const avatar = normalizeListField(draft.avatar);
return [
"# IDENTITY.md - Who Am I?",
"",
`- Name: ${name}`,
`- Creature: ${creature}`,
`- Vibe: ${vibe}`,
`- Emoji: ${emoji}`,
`- Avatar: ${avatar}`,
"",
].join("\n");
};
const serializeUserMarkdown = (draft: PersonalityBuilderDraft["user"]) => {
const name = normalizeListField(draft.name);
const callThem = normalizeListField(draft.callThem);
const pronouns = normalizeListField(draft.pronouns);
const timezone = normalizeListField(draft.timezone);
const notes = normalizeListField(draft.notes);
const context = normalizeText(draft.context);
return [
"# USER.md - About Your Human",
"",
`- Name: ${name}`,
`- What to call them: ${callThem}`,
`- Pronouns: ${pronouns}`,
`- Timezone: ${timezone}`,
`- Notes: ${notes}`,
"",
"## Context",
"",
...(context ? context.split("\n") : []),
"",
].join("\n");
};
const serializeSoulMarkdown = (draft: PersonalityBuilderDraft["soul"]) => {
const coreTruths = normalizeText(draft.coreTruths);
const boundaries = normalizeText(draft.boundaries);
const vibe = normalizeText(draft.vibe);
const continuity = normalizeText(draft.continuity);
return [
"# SOUL.md - Who You Are",
"",
"## Core Truths",
"",
...(coreTruths ? coreTruths.split("\n") : []),
"",
"## Boundaries",
"",
...(boundaries ? boundaries.split("\n") : []),
"",
"## Vibe",
"",
...(vibe ? vibe.split("\n") : []),
"",
"## Continuity",
"",
...(continuity ? continuity.split("\n") : []),
"",
].join("\n");
};
export const parsePersonalityFiles = (files: AgentFilesInput): PersonalityBuilderDraft => {
const draft = createEmptyDraft();
const identity = parseLabelMap(files["IDENTITY.md"].content);
const identityName = readFirst(identity, ["name"]);
const identityCreature = readFirst(identity, ["creature"]);
const identityVibe = readFirst(identity, ["vibe"]);
const identityEmoji = readFirst(identity, ["emoji"]);
const identityAvatar = readFirst(identity, ["avatar"]);
draft.identity.name = isIdentityPlaceholder(identityName) ? "" : identityName;
draft.identity.creature = isIdentityPlaceholder(identityCreature) ? "" : identityCreature;
draft.identity.vibe = isIdentityPlaceholder(identityVibe) ? "" : identityVibe;
draft.identity.emoji = isIdentityPlaceholder(identityEmoji) ? "" : identityEmoji;
draft.identity.avatar = isIdentityPlaceholder(identityAvatar) ? "" : identityAvatar;
const user = parseLabelMap(files["USER.md"].content);
const userName = readFirst(user, ["name"]);
const userCallThem = readFirst(user, ["what to call them", "preferred address", "how to address them"]);
const userPronouns = readFirst(user, ["pronouns"]);
const userTimezone = readFirst(user, ["timezone", "time zone"]);
const userNotes = readFirst(user, ["notes"]);
const userContext = parseSection(files["USER.md"].content, "Context");
draft.user.name = isUserPlaceholder(userName) ? "" : userName;
draft.user.callThem = isUserPlaceholder(userCallThem) ? "" : userCallThem;
draft.user.pronouns = isUserPlaceholder(userPronouns) ? "" : userPronouns;
draft.user.timezone = isUserPlaceholder(userTimezone) ? "" : userTimezone;
draft.user.notes = isUserPlaceholder(userNotes) ? "" : userNotes;
draft.user.context = isUserContextPlaceholder(userContext) ? "" : userContext;
draft.soul.coreTruths = parseSection(files["SOUL.md"].content, "Core Truths");
draft.soul.boundaries = parseSection(files["SOUL.md"].content, "Boundaries");
draft.soul.vibe = parseSection(files["SOUL.md"].content, "Vibe");
draft.soul.continuity = parseSection(files["SOUL.md"].content, "Continuity");
draft.agents = files["AGENTS.md"].content;
draft.tools = files["TOOLS.md"].content;
draft.heartbeat = files["HEARTBEAT.md"].content;
draft.memory = files["MEMORY.md"].content;
return draft;
};
export const serializePersonalityFiles = (
draft: PersonalityBuilderDraft
): Record<AgentFileName, string> => ({
"AGENTS.md": draft.agents,
"SOUL.md": serializeSoulMarkdown(draft.soul),
"IDENTITY.md": serializeIdentityMarkdown(draft.identity),
"USER.md": serializeUserMarkdown(draft.user),
"TOOLS.md": draft.tools,
"HEARTBEAT.md": draft.heartbeat,
"MEMORY.md": draft.memory,
});
+84
View File
@@ -0,0 +1,84 @@
const AVATAR_PALETTE = [
"#1d4ed8",
"#0f766e",
"#7c3aed",
"#c2410c",
"#be123c",
"#4f46e5",
"#0f172a",
"#1f2937",
] as const;
const AVATAR_ACCENTS = [
"#bfdbfe",
"#99f6e4",
"#ddd6fe",
"#fed7aa",
"#fecdd3",
"#c7d2fe",
"#cbd5e1",
"#fde68a",
] as const;
const AVATAR_FOREGROUNDS = [
"#eff6ff",
"#f8fafc",
"#fefce8",
"#fdf4ff",
] as const;
const hashSeed = (seed: string) => {
let hash = 2166136261;
for (let i = 0; i < seed.length; i += 1) {
hash ^= seed.charCodeAt(i);
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
};
const pick = <T,>(values: readonly T[], index: number) => values[index % values.length];
const buildAvatarLabel = (seed: string) => {
const compact = seed
.trim()
.split(/[^a-z0-9]+/i)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? "")
.join("");
return compact || seed.trim().slice(0, 2).toUpperCase() || "?";
};
export const buildAvatarSvg = (seed: string): string => {
const trimmed = seed.trim();
if (!trimmed) {
throw new Error("Avatar seed is required.");
}
const hash = hashSeed(trimmed);
const background = pick(AVATAR_PALETTE, hash);
const accent = pick(AVATAR_ACCENTS, hash >>> 3);
const foreground = pick(AVATAR_FOREGROUNDS, hash >>> 5);
const label = buildAvatarLabel(trimmed);
const offsetA = 16 + (hash % 28);
const offsetB = 68 - ((hash >>> 7) % 24);
const radius = 12 + ((hash >>> 11) % 10);
const tilt = ((hash >>> 13) % 20) - 10;
return [
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80" role="img" aria-label="${label} avatar">`,
`<rect width="80" height="80" rx="18" fill="${background}"/>`,
`<circle cx="${offsetA}" cy="18" r="${radius}" fill="${accent}" opacity="0.65"/>`,
`<circle cx="${offsetB}" cy="66" r="${radius + 4}" fill="${accent}" opacity="0.42"/>`,
`<path d="M10 60 Q40 ${44 + tilt} 70 22 L70 80 L10 80 Z" fill="${foreground}" opacity="0.14"/>`,
`<circle cx="40" cy="32" r="16" fill="${foreground}" opacity="0.92"/>`,
`<path d="M20 72c4-12 14-18 20-18s16 6 20 18" fill="${foreground}" opacity="0.92"/>`,
`<text x="40" y="37" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" font-weight="700" fill="${background}">${label}</text>`,
`</svg>`,
].join("");
};
export const buildAvatarDataUrl = (seed: string): string => {
const svg = buildAvatarSvg(seed);
return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
};
File diff suppressed because one or more lines are too long
+92
View File
@@ -0,0 +1,92 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const LEGACY_STATE_DIRNAMES = [".clawdbot", ".moltbot"] as const;
const NEW_STATE_DIRNAME = ".openclaw";
const CONFIG_FILENAME = "openclaw.json";
const LEGACY_CONFIG_FILENAMES = ["clawdbot.json", "moltbot.json"] as const;
const resolveDefaultHomeDir = (homedir: () => string = os.homedir): string => {
const home = homedir();
if (home) {
try {
if (fs.existsSync(home)) {
return home;
}
} catch {
// ignore
}
}
return os.tmpdir();
};
export const resolveUserPath = (
input: string,
homedir: () => string = os.homedir
): string => {
const trimmed = input.trim();
if (!trimmed) return trimmed;
if (trimmed.startsWith("~")) {
const expanded = trimmed.replace(/^~(?=$|[\\/])/, homedir());
return path.resolve(expanded);
}
return path.resolve(trimmed);
};
export const resolveStateDir = (
env: NodeJS.ProcessEnv = process.env,
homedir: () => string = os.homedir
): string => {
const override =
env.OPENCLAW_STATE_DIR?.trim() ||
env.MOLTBOT_STATE_DIR?.trim() ||
env.CLAWDBOT_STATE_DIR?.trim();
if (override) return resolveUserPath(override, homedir);
const defaultHome = resolveDefaultHomeDir(homedir);
const newDir = path.join(defaultHome, NEW_STATE_DIRNAME);
const legacyDirs = LEGACY_STATE_DIRNAMES.map((dir) => path.join(defaultHome, dir));
const hasNew = fs.existsSync(newDir);
if (hasNew) return newDir;
const existingLegacy = legacyDirs.find((dir) => {
try {
return fs.existsSync(dir);
} catch {
return false;
}
});
return existingLegacy ?? newDir;
};
export const resolveConfigPathCandidates = (
env: NodeJS.ProcessEnv = process.env,
homedir: () => string = os.homedir
): string[] => {
const explicit =
env.OPENCLAW_CONFIG_PATH?.trim() ||
env.MOLTBOT_CONFIG_PATH?.trim() ||
env.CLAWDBOT_CONFIG_PATH?.trim();
if (explicit) return [resolveUserPath(explicit, homedir)];
const defaultHome = resolveDefaultHomeDir(homedir);
const candidates: string[] = [];
const stateDir =
env.OPENCLAW_STATE_DIR?.trim() ||
env.MOLTBOT_STATE_DIR?.trim() ||
env.CLAWDBOT_STATE_DIR?.trim();
if (stateDir) {
const resolved = resolveUserPath(stateDir, homedir);
candidates.push(path.join(resolved, CONFIG_FILENAME));
candidates.push(...LEGACY_CONFIG_FILENAMES.map((name) => path.join(resolved, name)));
}
const defaultDirs = [
path.join(defaultHome, NEW_STATE_DIRNAME),
...LEGACY_STATE_DIRNAMES.map((dir) => path.join(defaultHome, dir)),
];
for (const dir of defaultDirs) {
candidates.push(path.join(dir, CONFIG_FILENAME));
candidates.push(...LEGACY_CONFIG_FILENAMES.map((name) => path.join(dir, name)));
}
return candidates;
};
+314
View File
@@ -0,0 +1,314 @@
import type {
CronJobCreateInput,
CronPayload,
CronSchedule,
CronSessionTarget,
CronWakeMode,
} from "@/lib/cron/types";
export type CronCreateTemplateId =
| "morning-brief"
| "reminder"
| "weekly-review"
| "inbox-triage"
| "custom";
export type CronCreateDraft = {
templateId: CronCreateTemplateId;
name: string;
taskText: string;
scheduleKind: "at" | "every";
scheduleAt?: string;
everyAmount?: number;
everyUnit?: "minutes" | "hours" | "days";
everyAtTime?: string;
everyTimeZone?: string;
deliveryMode?: "announce" | "none";
deliveryChannel?: string;
deliveryTo?: string;
advancedSessionTarget?: CronSessionTarget;
advancedWakeMode?: CronWakeMode;
};
type TimeOfDay = { hour: number; minute: number };
type ZonedParts = {
year: number;
month: number;
day: number;
hour: number;
minute: number;
second: number;
};
const resolveName = (name: string) => {
const trimmed = name.trim();
if (!trimmed) {
throw new Error("Cron job name is required.");
}
return trimmed;
};
const resolveAgentId = (agentId: string) => {
const trimmed = agentId.trim();
if (!trimmed) {
throw new Error("Agent id is required.");
}
return trimmed;
};
const resolveTaskText = (text: string) => {
const trimmed = text.trim();
if (!trimmed) {
throw new Error("Task text is required.");
}
return trimmed;
};
const resolveAtSchedule = (raw: string): CronSchedule => {
const ms = Date.parse(raw);
if (!Number.isFinite(ms)) {
throw new Error("Invalid run time.");
}
return { kind: "at", at: new Date(ms).toISOString() };
};
const resolveTimeZone = (timeZoneRaw: string | undefined): string => {
const trimmed = (timeZoneRaw ?? "").trim();
const fallback = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
const timeZone = trimmed || fallback;
try {
// Validate IANA timezone.
new Intl.DateTimeFormat("en-US", { timeZone }).format(new Date());
} catch {
throw new Error("Invalid timezone.");
}
return timeZone;
};
const resolveTimeOfDay = (raw: string | undefined): TimeOfDay => {
const value = (raw ?? "").trim();
const match = value.match(/^(\d{2}):(\d{2})$/);
if (!match) {
throw new Error("Daily schedule time is required.");
}
const hour = Number.parseInt(match[1], 10);
const minute = Number.parseInt(match[2], 10);
if (!Number.isInteger(hour) || hour < 0 || hour > 23) {
throw new Error("Daily schedule time is required.");
}
if (!Number.isInteger(minute) || minute < 0 || minute > 59) {
throw new Error("Daily schedule time is required.");
}
return { hour, minute };
};
const formatterCache = new Map<string, Intl.DateTimeFormat>();
const getFormatter = (timeZone: string) => {
const cached = formatterCache.get(timeZone);
if (cached) {
return cached;
}
const formatter = new Intl.DateTimeFormat("en-US", {
timeZone,
hour12: false,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
formatterCache.set(timeZone, formatter);
return formatter;
};
const resolveZonedParts = (ms: number, timeZone: string): ZonedParts => {
const parts = getFormatter(timeZone).formatToParts(new Date(ms));
const values: Partial<ZonedParts> = {};
for (const part of parts) {
if (part.type === "year") values.year = Number.parseInt(part.value, 10);
if (part.type === "month") values.month = Number.parseInt(part.value, 10);
if (part.type === "day") values.day = Number.parseInt(part.value, 10);
if (part.type === "hour") values.hour = Number.parseInt(part.value, 10);
if (part.type === "minute") values.minute = Number.parseInt(part.value, 10);
if (part.type === "second") values.second = Number.parseInt(part.value, 10);
}
if (
!values.year ||
!values.month ||
!values.day ||
values.hour === undefined ||
values.minute === undefined ||
values.second === undefined
) {
throw new Error("Invalid timezone.");
}
return values as ZonedParts;
};
const resolveTimeZoneOffsetMs = (utcMs: number, timeZone: string): number => {
const zoned = resolveZonedParts(utcMs, timeZone);
const zonedAsUtcMs = Date.UTC(
zoned.year,
zoned.month - 1,
zoned.day,
zoned.hour,
zoned.minute,
zoned.second,
0
);
return zonedAsUtcMs - Math.floor(utcMs / 1000) * 1000;
};
const resolveZonedDateTimeToUtcMs = (
local: { year: number; month: number; day: number; hour: number; minute: number },
timeZone: string
): number => {
const localAsUtcMs = Date.UTC(local.year, local.month - 1, local.day, local.hour, local.minute, 0, 0);
let guess = localAsUtcMs;
for (let attempt = 0; attempt < 4; attempt += 1) {
const offsetMs = resolveTimeZoneOffsetMs(guess, timeZone);
const nextGuess = localAsUtcMs - offsetMs;
if (nextGuess === guess) {
return nextGuess;
}
guess = nextGuess;
}
return guess;
};
const addDays = (year: number, month: number, day: number, days: number) => {
const date = new Date(Date.UTC(year, month - 1, day, 0, 0, 0, 0));
date.setUTCDate(date.getUTCDate() + days);
return {
year: date.getUTCFullYear(),
month: date.getUTCMonth() + 1,
day: date.getUTCDate(),
};
};
const resolveNextDailyAnchorMs = (params: {
nowMs: number;
timeZone: string;
timeOfDay: TimeOfDay;
}): number => {
const { nowMs, timeZone, timeOfDay } = params;
const nowZoned = resolveZonedParts(nowMs, timeZone);
let candidate = resolveZonedDateTimeToUtcMs(
{
year: nowZoned.year,
month: nowZoned.month,
day: nowZoned.day,
hour: timeOfDay.hour,
minute: timeOfDay.minute,
},
timeZone
);
if (candidate > nowMs) {
return candidate;
}
const tomorrow = addDays(nowZoned.year, nowZoned.month, nowZoned.day, 1);
candidate = resolveZonedDateTimeToUtcMs(
{
year: tomorrow.year,
month: tomorrow.month,
day: tomorrow.day,
hour: timeOfDay.hour,
minute: timeOfDay.minute,
},
timeZone
);
return candidate;
};
const resolveEverySchedule = (
draft: Pick<CronCreateDraft, "everyAmount" | "everyUnit" | "everyAtTime" | "everyTimeZone">,
nowMs: number
): CronSchedule => {
const amount = Number.isFinite(draft.everyAmount) ? Math.floor(draft.everyAmount ?? 0) : 0;
if (amount <= 0) {
throw new Error("Invalid interval amount.");
}
const unit = draft.everyUnit ?? "minutes";
const multiplier =
unit === "minutes" ? 60_000 : unit === "hours" ? 3_600_000 : 86_400_000;
if (unit !== "days") {
return { kind: "every", everyMs: amount * multiplier };
}
const timeZone = resolveTimeZone(draft.everyTimeZone);
const timeOfDay = resolveTimeOfDay(draft.everyAtTime);
const anchorMs = resolveNextDailyAnchorMs({ nowMs, timeZone, timeOfDay });
return {
kind: "every",
everyMs: amount * multiplier,
anchorMs,
};
};
const resolveSchedule = (draft: CronCreateDraft, nowMs: number): CronSchedule => {
if (draft.scheduleKind === "at") {
return resolveAtSchedule(draft.scheduleAt ?? "");
}
return resolveEverySchedule(draft, nowMs);
};
const resolvePayload = (sessionTarget: CronSessionTarget, text: string): CronPayload => {
if (sessionTarget === "main") {
return { kind: "systemEvent", text };
}
return { kind: "agentTurn", message: text };
};
export const buildCronJobCreateInput = (
agentIdRaw: string,
draft: CronCreateDraft,
nowMs = Date.now()
): CronJobCreateInput => {
const agentId = resolveAgentId(agentIdRaw);
const name = resolveName(draft.name);
const taskText = resolveTaskText(draft.taskText);
const sessionTarget = draft.advancedSessionTarget ?? "isolated";
const wakeMode = draft.advancedWakeMode ?? "now";
const schedule = resolveSchedule(draft, nowMs);
const payload = resolvePayload(sessionTarget, taskText);
if (sessionTarget === "main") {
return {
name,
agentId,
enabled: true,
schedule,
sessionTarget,
wakeMode,
payload,
};
}
const deliveryMode = draft.deliveryMode ?? "none";
const deliveryChannel = (draft.deliveryChannel ?? "").trim() || "last";
const deliveryTo = (draft.deliveryTo ?? "").trim();
return {
name,
agentId,
enabled: true,
schedule,
sessionTarget,
wakeMode,
payload,
delivery:
deliveryMode === "none"
? { mode: "none" }
: {
mode: "announce",
channel: deliveryChannel,
to: deliveryTo || undefined,
},
};
};
+293
View File
@@ -0,0 +1,293 @@
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
export type CronSchedule =
| { kind: "at"; at: string }
| { kind: "every"; everyMs: number; anchorMs?: number }
| { kind: "cron"; expr: string; tz?: string };
export type CronSessionTarget = "main" | "isolated";
export type CronWakeMode = "next-heartbeat" | "now";
export type CronDeliveryMode = "none" | "announce";
export type CronDelivery = {
mode: CronDeliveryMode;
channel?: string;
to?: string;
bestEffort?: boolean;
};
export type CronPayload =
| { kind: "systemEvent"; text: string }
| {
kind: "agentTurn";
message: string;
model?: string;
thinking?: string;
timeoutSeconds?: number;
allowUnsafeExternalContent?: boolean;
deliver?: boolean;
channel?: string;
to?: string;
bestEffortDeliver?: boolean;
};
export type CronJobState = {
nextRunAtMs?: number;
runningAtMs?: number;
lastRunAtMs?: number;
lastStatus?: "ok" | "error" | "skipped";
lastError?: string;
lastDurationMs?: number;
};
export type CronJobSummary = {
id: string;
name: string;
agentId?: string;
sessionKey?: string;
description?: string;
enabled: boolean;
deleteAfterRun?: boolean;
updatedAtMs: number;
schedule: CronSchedule;
sessionTarget: CronSessionTarget;
wakeMode: CronWakeMode;
payload: CronPayload;
state: CronJobState;
delivery?: CronDelivery;
};
export type CronJobsResult = {
jobs: CronJobSummary[];
};
export const sortCronJobsByUpdatedAt = (jobs: CronJobSummary[]) =>
[...jobs].sort((a, b) => b.updatedAtMs - a.updatedAtMs);
export type CronJobCreateInput = {
name: string;
agentId: string;
sessionKey?: string;
description?: string;
enabled?: boolean;
deleteAfterRun?: boolean;
schedule: CronSchedule;
sessionTarget: CronSessionTarget;
wakeMode: CronWakeMode;
payload: CronPayload;
delivery?: CronDelivery;
};
export const filterCronJobsForAgent = (jobs: CronJobSummary[], agentId: string): CronJobSummary[] => {
const trimmedAgentId = agentId.trim();
if (!trimmedAgentId) return [];
return jobs.filter((job) => job.agentId?.trim() === trimmedAgentId);
};
export const resolveLatestCronJobForAgent = (
jobs: CronJobSummary[],
agentId: string
): CronJobSummary | null => {
const filtered = filterCronJobsForAgent(jobs, agentId);
if (filtered.length === 0) return null;
return [...filtered].sort((a, b) => b.updatedAtMs - a.updatedAtMs)[0] ?? null;
};
const formatEveryMs = (everyMs: number) => {
if (everyMs % 3600000 === 0) {
return `${everyMs / 3600000}h`;
}
if (everyMs % 60000 === 0) {
return `${everyMs / 60000}m`;
}
if (everyMs % 1000 === 0) {
return `${everyMs / 1000}s`;
}
return `${everyMs}ms`;
};
export const formatCronSchedule = (schedule: CronSchedule) => {
if (schedule.kind === "every") {
return `Every ${formatEveryMs(schedule.everyMs)}`;
}
if (schedule.kind === "cron") {
return schedule.tz ? `Cron: ${schedule.expr} (${schedule.tz})` : `Cron: ${schedule.expr}`;
}
const atDate = new Date(schedule.at);
if (Number.isNaN(atDate.getTime())) return `At: ${schedule.at}`;
return `At: ${atDate.toLocaleString()}`;
};
export const formatCronPayload = (payload: CronPayload) => {
if (payload.kind === "systemEvent") return payload.text;
return payload.message;
};
export const formatCronJobDisplay = (job: CronJobSummary) => {
const lines = [job.name, formatCronSchedule(job.schedule), formatCronPayload(job.payload)].filter(
Boolean
);
return lines.join("\n");
};
export type CronListParams = {
includeDisabled?: boolean;
};
export type CronRunResult =
| { ok: true; ran: true }
| { ok: true; ran: false; reason: "not-due" }
| { ok: false };
export type CronRemoveResult = { ok: true; removed: boolean } | { ok: false; removed: false };
export type CronJobRestoreInput = {
name: string;
agentId: string;
sessionKey?: string;
description?: string;
enabled: boolean;
deleteAfterRun?: boolean;
schedule: CronSchedule;
sessionTarget: CronSessionTarget;
wakeMode: CronWakeMode;
payload: CronPayload;
delivery?: CronDelivery;
};
const resolveJobId = (jobId: string): string => {
const trimmed = jobId.trim();
if (!trimmed) {
throw new Error("Cron job id is required.");
}
return trimmed;
};
const resolveAgentId = (agentId: string): string => {
const trimmed = agentId.trim();
if (!trimmed) {
throw new Error("Agent id is required.");
}
return trimmed;
};
const resolveCronJobName = (name: string): string => {
const trimmed = name.trim();
if (!trimmed) {
throw new Error("Cron job name is required.");
}
return trimmed;
};
export const listCronJobs = async (
client: GatewayClient,
params: CronListParams = {}
): Promise<CronJobsResult> => {
const includeDisabled = params.includeDisabled ?? true;
return client.call<CronJobsResult>("cron.list", {
includeDisabled,
});
};
export const runCronJobNow = async (client: GatewayClient, jobId: string): Promise<CronRunResult> => {
const id = resolveJobId(jobId);
return client.call<CronRunResult>("cron.run", {
id,
mode: "force",
});
};
export const removeCronJob = async (
client: GatewayClient,
jobId: string
): Promise<CronRemoveResult> => {
const id = resolveJobId(jobId);
return client.call<CronRemoveResult>("cron.remove", {
id,
});
};
export const createCronJob = async (
client: GatewayClient,
input: CronJobCreateInput
): Promise<CronJobSummary> => {
const name = resolveCronJobName(input.name);
const agentId = resolveAgentId(input.agentId);
return client.call<CronJobSummary>("cron.add", {
...input,
name,
agentId,
});
};
const toCronJobRestoreInput = (job: CronJobSummary, agentId: string): CronJobRestoreInput => ({
name: job.name,
agentId,
sessionKey: job.sessionKey,
description: job.description,
enabled: job.enabled,
deleteAfterRun: job.deleteAfterRun,
schedule: job.schedule,
sessionTarget: job.sessionTarget,
wakeMode: job.wakeMode,
payload: job.payload,
delivery: job.delivery,
});
const restoreRemovedJobsBestEffort = async (
client: GatewayClient,
removedJobs: CronJobRestoreInput[]
): Promise<void> => {
if (removedJobs.length === 0) return;
try {
await restoreCronJobs(client, removedJobs);
} catch (restoreErr) {
console.error("Failed to restore cron jobs after partial deletion failure.", restoreErr);
}
};
export const restoreCronJobs = async (
client: GatewayClient,
jobs: CronJobRestoreInput[]
): Promise<void> => {
for (const job of jobs) {
try {
await createCronJob(client, job);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`Failed to restore cron job "${job.name}" (${job.agentId}): ${message}`);
}
}
};
export const removeCronJobsForAgentWithBackup = async (
client: GatewayClient,
agentId: string
): Promise<CronJobRestoreInput[]> => {
const id = resolveAgentId(agentId);
const result = await listCronJobs(client, { includeDisabled: true });
const jobs = result.jobs.filter((job) => job.agentId?.trim() === id);
const removedJobs: CronJobRestoreInput[] = [];
for (const job of jobs) {
let removeResult: CronRemoveResult;
try {
removeResult = await removeCronJob(client, job.id);
} catch (err) {
await restoreRemovedJobsBestEffort(client, removedJobs);
throw err;
}
if (!removeResult.ok) {
await restoreRemovedJobsBestEffort(client, removedJobs);
throw new Error(`Failed to delete cron job "${job.name}" (${job.id}).`);
}
if (removeResult.removed) {
removedJobs.push(toCronJobRestoreInput(job, id));
}
}
return removedJobs;
};
export const removeCronJobsForAgent = async (client: GatewayClient, agentId: string): Promise<number> => {
const removedJobs = await removeCronJobsForAgentWithBackup(client, agentId);
return removedJobs.length;
};
+34
View File
@@ -0,0 +1,34 @@
export type RafBatcher = {
schedule: () => void;
cancel: () => void;
};
export const createRafBatcher = (flush: () => void): RafBatcher => {
let rafId: number | null = null;
return {
schedule: () => {
if (rafId !== null) return;
rafId = requestAnimationFrame(() => {
rafId = null;
flush();
});
},
cancel: () => {
if (rafId === null) return;
cancelAnimationFrame(rafId);
rafId = null;
},
};
};
export type ScrollMetrics = {
scrollTop: number;
scrollHeight: number;
clientHeight: number;
};
export const isNearBottom = (metrics: ScrollMetrics, thresholdPx: number = 40): boolean => {
const remaining = metrics.scrollHeight - metrics.clientHeight - metrics.scrollTop;
return remaining <= thresholdPx;
};
+737
View File
@@ -0,0 +1,737 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import {
GatewayBrowserClient,
clearGatewayBrowserSessionStorage,
type GatewayHelloOk,
} from "./openclaw/GatewayBrowserClient";
import type {
StudioGatewaySettings,
StudioSettings,
StudioSettingsPatch,
StudioSettingsPublic,
} from "@/lib/studio/settings";
import type {
StudioSettingsLoadOptions,
StudioSettingsResponse,
} from "@/lib/studio/coordinator";
import { resolveStudioProxyGatewayUrl } from "@/lib/gateway/proxy-url";
import { ensureGatewayReloadModeHotForLocalStudio } from "@/lib/gateway/gatewayReloadMode";
import { GatewayResponseError } from "@/lib/gateway/errors";
export type ReqFrame = {
type: "req";
id: string;
method: string;
params: unknown;
};
export type ResFrame = {
type: "res";
id: string;
ok: boolean;
payload?: unknown;
error?: {
code: string;
message: string;
details?: unknown;
retryable?: boolean;
retryAfterMs?: number;
};
};
export type GatewayStateVersion = {
presence: number;
health: number;
};
export type EventFrame = {
type: "event";
event: string;
payload?: unknown;
seq?: number;
stateVersion?: GatewayStateVersion;
};
export type GatewayFrame = ReqFrame | ResFrame | EventFrame;
export const parseGatewayFrame = (raw: string): GatewayFrame | null => {
try {
return JSON.parse(raw) as GatewayFrame;
} catch {
return null;
}
};
export const buildAgentMainSessionKey = (agentId: string, mainKey: string) => {
const trimmedAgent = agentId.trim();
const trimmedKey = mainKey.trim() || "main";
return `agent:${trimmedAgent}:${trimmedKey}`;
};
export const parseAgentIdFromSessionKey = (sessionKey: string): string | null => {
const match = sessionKey.match(/^agent:([^:]+):/);
return match ? match[1] : null;
};
export const isSameSessionKey = (a: string, b: string) => {
const left = a.trim();
const right = b.trim();
return left.length > 0 && left === right;
};
const CONNECT_FAILED_CLOSE_CODE = 4008;
const GATEWAY_CONNECT_TIMEOUT_MS = 8_000;
const parseConnectFailedCloseReason = (
reason: string
): { code: string; message: string } | null => {
const trimmed = reason.trim();
if (!trimmed.toLowerCase().startsWith("connect failed:")) return null;
const remainder = trimmed.slice("connect failed:".length).trim();
if (!remainder) return null;
const idx = remainder.indexOf(" ");
const code = (idx === -1 ? remainder : remainder.slice(0, idx)).trim();
if (!code) return null;
const message = (idx === -1 ? "" : remainder.slice(idx + 1)).trim();
return { code, message: message || "connect failed" };
};
const DEFAULT_UPSTREAM_GATEWAY_URL =
process.env.NEXT_PUBLIC_GATEWAY_URL || "ws://localhost:18789";
const normalizeLocalGatewayDefaults = (value: unknown): StudioGatewaySettings | null => {
if (!value || typeof value !== "object") return null;
const raw = value as { url?: unknown; token?: unknown };
const url = typeof raw.url === "string" ? raw.url.trim() : "";
const token = typeof raw.token === "string" ? raw.token.trim() : "";
if (!url || !token) return null;
return { url, token };
};
type StatusHandler = (status: GatewayStatus) => void;
type EventHandler = (event: EventFrame) => void;
export type GatewayGapInfo = { expected: number; received: number };
type GapHandler = (info: GatewayGapInfo) => void;
export type GatewayStatus = "disconnected" | "connecting" | "connected";
export type GatewayConnectOptions = {
gatewayUrl: string;
token?: string;
authScopeKey?: string;
clientName?: string;
disableDeviceAuth?: boolean;
};
export { GatewayResponseError } from "@/lib/gateway/errors";
export type { GatewayErrorPayload } from "@/lib/gateway/errors";
export class GatewayClient {
private client: GatewayBrowserClient | null = null;
private statusHandlers = new Set<StatusHandler>();
private eventHandlers = new Set<EventHandler>();
private gapHandlers = new Set<GapHandler>();
private status: GatewayStatus = "disconnected";
private pendingConnect: Promise<void> | null = null;
private resolveConnect: (() => void) | null = null;
private rejectConnect: ((error: Error) => void) | null = null;
private manualDisconnect = false;
private lastHello: GatewayHelloOk | null = null;
onStatus(handler: StatusHandler) {
this.statusHandlers.add(handler);
handler(this.status);
return () => {
this.statusHandlers.delete(handler);
};
}
onEvent(handler: EventHandler) {
this.eventHandlers.add(handler);
return () => {
this.eventHandlers.delete(handler);
};
}
onGap(handler: GapHandler) {
this.gapHandlers.add(handler);
return () => {
this.gapHandlers.delete(handler);
};
}
async connect(options: GatewayConnectOptions) {
if (!options.gatewayUrl.trim()) {
throw new Error("Gateway URL is required.");
}
if (this.client) {
throw new Error("Gateway is already connected or connecting.");
}
this.manualDisconnect = false;
this.updateStatus("connecting");
this.pendingConnect = new Promise<void>((resolve, reject) => {
this.resolveConnect = resolve;
this.rejectConnect = reject;
});
const nextClient = new GatewayBrowserClient({
url: options.gatewayUrl,
token: options.token,
authScopeKey: options.authScopeKey,
clientName: options.clientName,
disableDeviceAuth: options.disableDeviceAuth,
onHello: (hello) => {
if (this.client !== nextClient) return;
this.lastHello = hello;
this.updateStatus("connected");
this.resolveConnect?.();
this.clearConnectPromise();
},
onEvent: (event) => {
if (this.client !== nextClient) return;
this.eventHandlers.forEach((handler) => handler(event));
},
onClose: ({ code, reason }) => {
if (this.client !== nextClient) return;
const connectFailed =
code === CONNECT_FAILED_CLOSE_CODE ? parseConnectFailedCloseReason(reason) : null;
const err = connectFailed
? new GatewayResponseError({
code: connectFailed.code,
message: connectFailed.message,
})
: new Error(`Gateway closed (${code}): ${reason}`);
if (this.rejectConnect) {
this.rejectConnect(err);
this.clearConnectPromise();
}
if (!this.manualDisconnect) {
nextClient.stop();
}
if (this.client === nextClient) {
this.client = null;
}
this.updateStatus("disconnected");
if (this.manualDisconnect) {
console.info("Gateway disconnected.");
}
},
onGap: ({ expected, received }) => {
if (this.client !== nextClient) return;
this.gapHandlers.forEach((handler) => handler({ expected, received }));
},
});
this.client = nextClient;
nextClient.start();
let connectTimeoutId: number | null = null;
try {
await Promise.race([
this.pendingConnect,
new Promise<never>((_, reject) => {
connectTimeoutId = window.setTimeout(() => {
reject(
new Error(
"Timed out connecting to the gateway. Check that it is running, or change the gateway address and try again."
)
);
}, GATEWAY_CONNECT_TIMEOUT_MS);
}),
]);
} catch (err) {
const activeClient = this.client;
this.clearConnectPromise();
activeClient?.stop();
if (this.client === activeClient) {
this.client = null;
}
this.updateStatus("disconnected");
throw err;
} finally {
if (connectTimeoutId !== null) {
window.clearTimeout(connectTimeoutId);
}
}
}
disconnect() {
if (!this.client) {
return;
}
this.manualDisconnect = true;
this.client.stop();
this.client = null;
this.clearConnectPromise();
this.updateStatus("disconnected");
console.info("Gateway disconnected.");
}
async call<T = unknown>(method: string, params: unknown): Promise<T> {
if (!method.trim()) {
throw new Error("Gateway method is required.");
}
if (!this.client || !this.client.connected) {
throw new Error("Gateway is not connected.");
}
const payload = await this.client.request<T>(method, params);
return payload as T;
}
getLastHello() {
return this.lastHello;
}
private updateStatus(status: GatewayStatus) {
this.status = status;
this.statusHandlers.forEach((handler) => handler(status));
}
private clearConnectPromise() {
this.pendingConnect = null;
this.resolveConnect = null;
this.rejectConnect = null;
}
}
export const isGatewayDisconnectLikeError = (err: unknown): boolean => {
if (!(err instanceof Error)) return false;
const msg = err.message.toLowerCase();
if (!msg) return false;
if (
msg.includes("gateway not connected") ||
msg.includes("gateway is not connected") ||
msg.includes("gateway client stopped")
) {
return true;
}
const match = msg.match(/gateway closed \\((\\d+)\\)/);
if (!match) return false;
const code = Number(match[1]);
return Number.isFinite(code) && code === 1012;
};
const WEBCHAT_SESSION_MUTATION_BLOCKED_RE = /webchat clients cannot (patch|delete) sessions/i;
const WEBCHAT_SESSION_MUTATION_HINT_RE = /use chat\.send for session-scoped updates/i;
export const isWebchatSessionMutationBlockedError = (error: unknown): boolean => {
if (!(error instanceof GatewayResponseError)) return false;
if (error.code.trim().toUpperCase() !== "INVALID_REQUEST") return false;
const message = error.message.trim();
if (!message) return false;
return (
WEBCHAT_SESSION_MUTATION_BLOCKED_RE.test(message) &&
WEBCHAT_SESSION_MUTATION_HINT_RE.test(message)
);
};
type SessionSettingsPatchPayload = {
key: string;
model?: string | null;
thinkingLevel?: string | null;
execHost?: "sandbox" | "gateway" | "node" | null;
execSecurity?: "deny" | "allowlist" | "full" | null;
execAsk?: "off" | "on-miss" | "always" | null;
};
export type GatewaySessionsPatchResult = {
ok: true;
key: string;
entry?: {
thinkingLevel?: string;
};
resolved?: {
modelProvider?: string;
model?: string;
};
};
export type SyncGatewaySessionSettingsParams = {
client: GatewayClient;
sessionKey: string;
model?: string | null;
thinkingLevel?: string | null;
execHost?: "sandbox" | "gateway" | "node" | null;
execSecurity?: "deny" | "allowlist" | "full" | null;
execAsk?: "off" | "on-miss" | "always" | null;
};
export const syncGatewaySessionSettings = async ({
client,
sessionKey,
model,
thinkingLevel,
execHost,
execSecurity,
execAsk,
}: SyncGatewaySessionSettingsParams) => {
const key = sessionKey.trim();
if (!key) {
throw new Error("Session key is required.");
}
const includeModel = model !== undefined;
const includeThinkingLevel = thinkingLevel !== undefined;
const includeExecHost = execHost !== undefined;
const includeExecSecurity = execSecurity !== undefined;
const includeExecAsk = execAsk !== undefined;
if (
!includeModel &&
!includeThinkingLevel &&
!includeExecHost &&
!includeExecSecurity &&
!includeExecAsk
) {
throw new Error("At least one session setting must be provided.");
}
const payload: SessionSettingsPatchPayload = { key };
if (includeModel) {
payload.model = model ?? null;
}
if (includeThinkingLevel) {
payload.thinkingLevel = thinkingLevel ?? null;
}
if (includeExecHost) {
payload.execHost = execHost ?? null;
}
if (includeExecSecurity) {
payload.execSecurity = execSecurity ?? null;
}
if (includeExecAsk) {
payload.execAsk = execAsk ?? null;
}
return await client.call<GatewaySessionsPatchResult>("sessions.patch", payload);
};
const doctorFixHint =
"Run `npx openclaw doctor --fix` on the gateway host (or `pnpm openclaw doctor --fix` in a source checkout).";
const formatGatewayError = (error: unknown) => {
if (error instanceof GatewayResponseError) {
if (error.code === "INVALID_REQUEST" && /invalid config/i.test(error.message)) {
return `Gateway error (${error.code}): ${error.message}. ${doctorFixHint}`;
}
return `Gateway error (${error.code}): ${error.message}`;
}
if (error instanceof Error) {
return error.message;
}
return "Unknown gateway error.";
};
export type GatewayConnectionState = {
client: GatewayClient;
status: GatewayStatus;
gatewayUrl: string;
token: string;
localGatewayDefaults: StudioGatewaySettings | null;
error: string | null;
connectPromptReady: boolean;
shouldPromptForConnect: boolean;
connect: () => Promise<void>;
disconnect: () => void;
useLocalGatewayDefaults: () => void;
setGatewayUrl: (value: string) => void;
setToken: (value: string) => void;
clearError: () => void;
};
type StudioSettingsCoordinatorLike = {
loadSettings: (
options?: StudioSettingsLoadOptions,
) => Promise<StudioSettings | StudioSettingsPublic | null>;
loadSettingsEnvelope?: (
options?: StudioSettingsLoadOptions,
) => Promise<StudioSettingsResponse>;
schedulePatch: (patch: StudioSettingsPatch, debounceMs?: number) => void;
flushPending: () => Promise<void>;
};
const isAuthError = (errorMessage: string | null): boolean => {
if (!errorMessage) return false;
const lower = errorMessage.toLowerCase();
return (
lower.includes("auth") ||
lower.includes("unauthorized") ||
lower.includes("forbidden") ||
lower.includes("invalid token") ||
lower.includes("token required") ||
(lower.includes("token") && lower.includes("not configured")) ||
lower.includes("gateway_token_missing")
);
};
const MAX_AUTO_RETRY_ATTEMPTS = 20;
const INITIAL_RETRY_DELAY_MS = 2_000;
const MAX_RETRY_DELAY_MS = 30_000;
const NON_RETRYABLE_CONNECT_ERROR_CODES = new Set([
"studio.gateway_url_missing",
"studio.gateway_token_missing",
"studio.gateway_url_invalid",
"studio.settings_load_failed",
]);
const isNonRetryableConnectErrorCode = (code: string | null): boolean => {
const normalized = code?.trim().toLowerCase() ?? "";
if (!normalized) return false;
return NON_RETRYABLE_CONNECT_ERROR_CODES.has(normalized);
};
export const resolveGatewayAutoRetryDelayMs = (params: {
status: GatewayStatus;
didAutoConnect: boolean;
hasConnectedOnce: boolean;
wasManualDisconnect: boolean;
gatewayUrl: string;
errorMessage: string | null;
connectErrorCode: string | null;
attempt: number;
}): number | null => {
if (params.status !== "disconnected") return null;
if (!params.didAutoConnect) return null;
if (!params.hasConnectedOnce) return null;
if (params.wasManualDisconnect) return null;
if (!params.gatewayUrl.trim()) return null;
if (params.attempt >= MAX_AUTO_RETRY_ATTEMPTS) return null;
if (isNonRetryableConnectErrorCode(params.connectErrorCode)) return null;
if (params.connectErrorCode === null && isAuthError(params.errorMessage)) return null;
return Math.min(
INITIAL_RETRY_DELAY_MS * Math.pow(1.5, params.attempt),
MAX_RETRY_DELAY_MS
);
};
export const useGatewayConnection = (
settingsCoordinator: StudioSettingsCoordinatorLike
): GatewayConnectionState => {
const [client] = useState(() => new GatewayClient());
const didAutoConnect = useRef(false);
const hasConnectedOnceRef = useRef(false);
const loadedGatewaySettings = useRef<{ gatewayUrl: string; token: string } | null>(null);
const retryAttemptRef = useRef(0);
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const wasManualDisconnectRef = useRef(false);
const [gatewayUrl, setGatewayUrl] = useState(DEFAULT_UPSTREAM_GATEWAY_URL);
const [token, setToken] = useState("");
const [localGatewayDefaults, setLocalGatewayDefaults] = useState<StudioGatewaySettings | null>(
null
);
const [status, setStatus] = useState<GatewayStatus>("disconnected");
const [error, setError] = useState<string | null>(null);
const [connectErrorCode, setConnectErrorCode] = useState<string | null>(null);
const [settingsLoaded, setSettingsLoaded] = useState(false);
useEffect(() => {
let cancelled = false;
const loadSettings = async () => {
try {
const envelope =
typeof settingsCoordinator.loadSettingsEnvelope === "function"
? await settingsCoordinator.loadSettingsEnvelope({ force: true })
: {
settings: await settingsCoordinator.loadSettings({ force: true }),
localGatewayDefaults: null,
};
const settings = envelope.settings ?? null;
const gateway = settings?.gateway ?? null;
if (cancelled) return;
setLocalGatewayDefaults(normalizeLocalGatewayDefaults(envelope.localGatewayDefaults));
const nextGatewayUrl = gateway?.url?.trim() ? gateway.url : DEFAULT_UPSTREAM_GATEWAY_URL;
const nextToken =
gateway && "token" in gateway && typeof gateway.token === "string"
? gateway.token
: "";
loadedGatewaySettings.current = {
gatewayUrl: nextGatewayUrl.trim(),
token: nextToken,
};
setGatewayUrl(nextGatewayUrl);
setToken(nextToken);
} catch (err) {
if (!cancelled) {
const message = err instanceof Error ? err.message : "Failed to load gateway settings.";
setError(message);
}
} finally {
if (!cancelled) {
if (!loadedGatewaySettings.current) {
loadedGatewaySettings.current = {
gatewayUrl: DEFAULT_UPSTREAM_GATEWAY_URL.trim(),
token: "",
};
}
setSettingsLoaded(true);
}
}
};
void loadSettings();
return () => {
cancelled = true;
};
}, [settingsCoordinator]);
useEffect(() => {
return client.onStatus((nextStatus) => {
setStatus(nextStatus);
if (nextStatus !== "connecting") {
setError(null);
if (nextStatus === "connected") {
setConnectErrorCode(null);
}
}
});
}, [client]);
useEffect(() => {
return () => {
if (retryTimerRef.current) {
clearTimeout(retryTimerRef.current);
retryTimerRef.current = null;
}
client.disconnect();
};
}, [client]);
const connect = useCallback(async () => {
setError(null);
setConnectErrorCode(null);
wasManualDisconnectRef.current = false;
try {
await settingsCoordinator.flushPending();
await client.connect({
gatewayUrl: resolveStudioProxyGatewayUrl(),
token,
authScopeKey: gatewayUrl,
clientName: "openclaw-control-ui",
});
await ensureGatewayReloadModeHotForLocalStudio({
client,
upstreamGatewayUrl: gatewayUrl,
});
retryAttemptRef.current = 0;
} catch (err) {
setConnectErrorCode(err instanceof GatewayResponseError ? err.code : null);
setError(formatGatewayError(err));
}
}, [client, gatewayUrl, settingsCoordinator, token]);
useEffect(() => {
if (didAutoConnect.current) return;
if (!settingsLoaded) return;
if (!gatewayUrl.trim()) return;
didAutoConnect.current = true;
void connect();
}, [connect, gatewayUrl, settingsLoaded]);
// Auto-retry on disconnect (gateway busy, network blip, etc.)
useEffect(() => {
const attempt = retryAttemptRef.current;
const delay = resolveGatewayAutoRetryDelayMs({
status,
didAutoConnect: didAutoConnect.current,
hasConnectedOnce: hasConnectedOnceRef.current,
wasManualDisconnect: wasManualDisconnectRef.current,
gatewayUrl,
errorMessage: error,
connectErrorCode,
attempt,
});
if (delay === null) return;
retryTimerRef.current = setTimeout(() => {
retryAttemptRef.current = attempt + 1;
void connect();
}, delay);
return () => {
if (retryTimerRef.current) {
clearTimeout(retryTimerRef.current);
retryTimerRef.current = null;
}
};
}, [connect, connectErrorCode, error, gatewayUrl, status]);
// Reset retry count on successful connection
useEffect(() => {
if (status === "connected") {
hasConnectedOnceRef.current = true;
retryAttemptRef.current = 0;
}
}, [status]);
useEffect(() => {
if (!settingsLoaded) return;
const baseline = loadedGatewaySettings.current;
if (!baseline) return;
const nextGatewayUrl = gatewayUrl.trim();
if (nextGatewayUrl === baseline.gatewayUrl && token === baseline.token) {
return;
}
settingsCoordinator.schedulePatch(
{
gateway: {
url: nextGatewayUrl,
token,
},
},
400
);
}, [gatewayUrl, settingsCoordinator, settingsLoaded, token]);
const useLocalGatewayDefaults = useCallback(() => {
if (!localGatewayDefaults) {
return;
}
setGatewayUrl(localGatewayDefaults.url);
setToken(localGatewayDefaults.token);
setError(null);
setConnectErrorCode(null);
}, [localGatewayDefaults]);
const disconnect = useCallback(() => {
setError(null);
setConnectErrorCode(null);
wasManualDisconnectRef.current = true;
client.disconnect();
clearGatewayBrowserSessionStorage();
}, [client]);
const clearError = useCallback(() => {
setError(null);
setConnectErrorCode(null);
}, []);
const connectPromptReady = settingsLoaded;
const shouldPromptForConnect =
settingsLoaded &&
status !== "connected" &&
(!gatewayUrl.trim() || !token.trim() || wasManualDisconnectRef.current || Boolean(error));
return {
client,
status,
gatewayUrl,
token,
localGatewayDefaults,
error,
connectPromptReady,
shouldPromptForConnect,
connect,
disconnect,
useLocalGatewayDefaults,
setGatewayUrl,
setToken,
clearError,
};
};
+761
View File
@@ -0,0 +1,761 @@
import { GatewayResponseError, type GatewayClient } from "@/lib/gateway/GatewayClient";
export type AgentHeartbeatActiveHours = {
start: string;
end: string;
};
export type AgentHeartbeat = {
every: string;
target: string;
includeReasoning: boolean;
ackMaxChars?: number | null;
activeHours?: AgentHeartbeatActiveHours | null;
};
export type AgentHeartbeatResult = {
heartbeat: AgentHeartbeat;
hasOverride: boolean;
};
export type AgentHeartbeatUpdatePayload = {
override: boolean;
heartbeat: AgentHeartbeat;
};
export type AgentHeartbeatSummary = {
id: string;
agentId: string;
source: "override" | "default";
enabled: boolean;
heartbeat: AgentHeartbeat;
};
export type HeartbeatListResult = {
heartbeats: AgentHeartbeatSummary[];
};
export type HeartbeatWakeResult = { ok: true } | { ok: false };
export type GatewayConfigSnapshot = {
config?: Record<string, unknown>;
hash?: string;
exists?: boolean;
path?: string | null;
};
type HeartbeatBlock = Record<string, unknown> | null | undefined;
const DEFAULT_EVERY = "30m";
const DEFAULT_TARGET = "last";
const DEFAULT_ACK_MAX_CHARS = 300;
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === "object" && !Array.isArray(value));
export type ConfigAgentEntry = Record<string, unknown> & { id: string };
export type GatewayAgentSandboxOverrides = {
mode?: "off" | "non-main" | "all";
workspaceAccess?: "none" | "ro" | "rw";
};
export type GatewayAgentToolsOverrides = {
profile?: "minimal" | "coding" | "messaging" | "full";
allow?: string[];
alsoAllow?: string[];
deny?: string[];
sandbox?: {
tools?: {
allow?: string[];
deny?: string[];
};
};
};
export type GatewayAgentOverrides = {
sandbox?: GatewayAgentSandboxOverrides;
tools?: GatewayAgentToolsOverrides;
};
const DEFAULT_AGENT_ID = "main";
export const readConfigAgentList = (
config: Record<string, unknown> | undefined
): ConfigAgentEntry[] => {
if (!config) return [];
const agents = isRecord(config.agents) ? config.agents : null;
const list = Array.isArray(agents?.list) ? agents.list : [];
return list.filter((entry): entry is ConfigAgentEntry => {
if (!isRecord(entry)) return false;
if (typeof entry.id !== "string") return false;
return entry.id.trim().length > 0;
});
};
export const resolveDefaultConfigAgentId = (
config: Record<string, unknown> | undefined
): string => {
const list = readConfigAgentList(config);
if (list.length === 0) {
return DEFAULT_AGENT_ID;
}
const defaults = list.filter((entry) => entry.default === true);
const selected = defaults[0] ?? list[0];
const resolved = selected.id.trim();
return resolved || DEFAULT_AGENT_ID;
};
export const writeConfigAgentList = (
config: Record<string, unknown>,
list: ConfigAgentEntry[]
): Record<string, unknown> => {
const agents = isRecord(config.agents) ? { ...config.agents } : {};
return { ...config, agents: { ...agents, list } };
};
export const upsertConfigAgentEntry = (
list: ConfigAgentEntry[],
agentId: string,
updater: (entry: ConfigAgentEntry) => ConfigAgentEntry
): { list: ConfigAgentEntry[]; entry: ConfigAgentEntry } => {
let updatedEntry: ConfigAgentEntry | null = null;
const nextList = list.map((entry) => {
if (entry.id !== agentId) return entry;
const next = updater({ ...entry, id: agentId });
updatedEntry = next;
return next;
});
if (!updatedEntry) {
updatedEntry = updater({ id: agentId });
nextList.push(updatedEntry);
}
return { list: nextList, entry: updatedEntry };
};
export const slugifyAgentName = (name: string): string => {
const slug = name
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
if (!slug) {
throw new Error("Name produced an empty folder name.");
}
return slug;
};
const coerceString = (value: unknown) => (typeof value === "string" ? value : undefined);
const coerceBoolean = (value: unknown) =>
typeof value === "boolean" ? value : undefined;
const coerceNumber = (value: unknown) =>
typeof value === "number" && Number.isFinite(value) ? value : undefined;
const coerceActiveHours = (value: unknown) => {
if (!isRecord(value)) return undefined;
const start = coerceString(value.start);
const end = coerceString(value.end);
if (!start || !end) return undefined;
return { start, end };
};
const mergeHeartbeat = (defaults: HeartbeatBlock, override: HeartbeatBlock) => {
const merged = {
...(defaults ?? {}),
...(override ?? {}),
} as Record<string, unknown>;
if (override && typeof override === "object" && "activeHours" in override) {
merged.activeHours = (override as Record<string, unknown>).activeHours;
} else if (defaults && typeof defaults === "object" && "activeHours" in defaults) {
merged.activeHours = (defaults as Record<string, unknown>).activeHours;
}
return merged;
};
const normalizeHeartbeat = (
defaults: HeartbeatBlock,
override: HeartbeatBlock
): AgentHeartbeatResult => {
const resolved = mergeHeartbeat(defaults, override);
const every = coerceString(resolved.every) ?? DEFAULT_EVERY;
const target = coerceString(resolved.target) ?? DEFAULT_TARGET;
const includeReasoning = coerceBoolean(resolved.includeReasoning) ?? false;
const ackMaxChars = coerceNumber(resolved.ackMaxChars) ?? DEFAULT_ACK_MAX_CHARS;
const activeHours = coerceActiveHours(resolved.activeHours) ?? null;
return {
heartbeat: {
every,
target,
includeReasoning,
ackMaxChars,
activeHours,
},
hasOverride: Boolean(override && typeof override === "object"),
};
};
const readHeartbeatDefaults = (config: Record<string, unknown>): HeartbeatBlock => {
const agents = isRecord(config.agents) ? config.agents : null;
const defaults = agents && isRecord(agents.defaults) ? agents.defaults : null;
return (defaults?.heartbeat ?? null) as HeartbeatBlock;
};
const buildHeartbeatOverride = (payload: AgentHeartbeat): Record<string, unknown> => {
const nextHeartbeat: Record<string, unknown> = {
every: payload.every,
target: payload.target,
includeReasoning: payload.includeReasoning,
};
if (payload.ackMaxChars !== undefined && payload.ackMaxChars !== null) {
nextHeartbeat.ackMaxChars = payload.ackMaxChars;
}
if (payload.activeHours) {
nextHeartbeat.activeHours = {
start: payload.activeHours.start,
end: payload.activeHours.end,
};
}
return nextHeartbeat;
};
export const resolveHeartbeatSettings = (
config: Record<string, unknown>,
agentId: string
): AgentHeartbeatResult => {
const list = readConfigAgentList(config);
const entry = list.find((item) => item.id === agentId) ?? null;
const defaults = readHeartbeatDefaults(config);
const override =
entry && typeof entry === "object"
? ((entry as Record<string, unknown>).heartbeat as HeartbeatBlock)
: null;
return normalizeHeartbeat(defaults, override);
};
type GatewayStatusHeartbeatAgent = {
agentId?: string;
enabled?: boolean;
every?: string;
everyMs?: number | null;
};
type GatewayStatusSnapshot = {
heartbeat?: {
agents?: GatewayStatusHeartbeatAgent[];
};
};
const resolveHeartbeatAgentId = (agentId: string) => {
const trimmed = agentId.trim();
if (!trimmed) {
throw new Error("Agent id is required.");
}
return trimmed;
};
const resolveStatusHeartbeatAgent = (
status: GatewayStatusSnapshot,
agentId: string
): GatewayStatusHeartbeatAgent | null => {
const list = Array.isArray(status.heartbeat?.agents) ? status.heartbeat?.agents : [];
for (const entry of list) {
if (!entry || typeof entry.agentId !== "string") continue;
if (entry.agentId.trim() !== agentId) continue;
return entry;
}
return null;
};
export const listHeartbeatsForAgent = async (
client: GatewayClient,
agentId: string
): Promise<HeartbeatListResult> => {
const resolvedAgentId = resolveHeartbeatAgentId(agentId);
const [snapshot, status] = await Promise.all([
client.call<GatewayConfigSnapshot>("config.get", {}),
client.call<GatewayStatusSnapshot>("status", {}),
]);
const config = isRecord(snapshot.config) ? snapshot.config : {};
const resolved = resolveHeartbeatSettings(config, resolvedAgentId);
const statusHeartbeat = resolveStatusHeartbeatAgent(status, resolvedAgentId);
const enabled = Boolean(statusHeartbeat?.enabled);
const every = typeof statusHeartbeat?.every === "string" ? statusHeartbeat.every.trim() : "";
const heartbeat = every ? { ...resolved.heartbeat, every } : resolved.heartbeat;
if (!enabled && !resolved.hasOverride) {
return { heartbeats: [] };
}
return {
heartbeats: [
{
id: resolvedAgentId,
agentId: resolvedAgentId,
source: resolved.hasOverride ? "override" : "default",
enabled,
heartbeat,
},
],
};
};
export const triggerHeartbeatNow = async (
client: GatewayClient,
agentId: string
): Promise<HeartbeatWakeResult> => {
const resolvedAgentId = resolveHeartbeatAgentId(agentId);
return client.call<HeartbeatWakeResult>("wake", {
mode: "now",
text: `Claw3D heartbeat trigger (${resolvedAgentId}).`,
});
};
const shouldRetryConfigWrite = (err: unknown) => {
if (!(err instanceof GatewayResponseError)) return false;
return /re-run config\.get|config changed since last load/i.test(err.message);
};
const applyGatewayConfigPatch = async (params: {
client: GatewayClient;
patch: Record<string, unknown>;
baseHash?: string | null;
exists?: boolean;
attempt?: number;
}): Promise<void> => {
const attempt = params.attempt ?? 0;
const requiresBaseHash = params.exists !== false;
const baseHash = requiresBaseHash ? params.baseHash?.trim() : undefined;
if (requiresBaseHash && !baseHash) {
throw new Error("Gateway config hash unavailable; re-run config.get.");
}
const payload: Record<string, unknown> = {
raw: JSON.stringify(params.patch, null, 2),
};
if (baseHash) payload.baseHash = baseHash;
try {
await params.client.call("config.patch", payload);
} catch (err) {
if (attempt < 1 && shouldRetryConfigWrite(err)) {
const snapshot = await params.client.call<GatewayConfigSnapshot>("config.get", {});
return applyGatewayConfigPatch({
...params,
baseHash: snapshot.hash ?? undefined,
exists: snapshot.exists,
attempt: attempt + 1,
});
}
throw err;
}
};
export const renameGatewayAgent = async (params: {
client: GatewayClient;
agentId: string;
name: string;
}) => {
const trimmed = params.name.trim();
if (!trimmed) {
throw new Error("Agent name is required.");
}
await params.client.call("agents.update", { agentId: params.agentId, name: trimmed });
return { id: params.agentId, name: trimmed };
};
const dirnameLike = (value: string): string => {
const lastSlash = value.lastIndexOf("/");
const lastBackslash = value.lastIndexOf("\\");
const idx = Math.max(lastSlash, lastBackslash);
if (idx < 0) return "";
return value.slice(0, idx);
};
const joinPathLike = (dir: string, leaf: string): string => {
const sep = dir.includes("\\") ? "\\" : "/";
const trimmedDir = dir.endsWith("/") || dir.endsWith("\\") ? dir.slice(0, -1) : dir;
return `${trimmedDir}${sep}${leaf}`;
};
export const createGatewayAgent = async (params: {
client: GatewayClient;
name: string;
}): Promise<ConfigAgentEntry> => {
const trimmed = params.name.trim();
if (!trimmed) {
throw new Error("Agent name is required.");
}
const snapshot = await params.client.call<GatewayConfigSnapshot>("config.get", {});
const configPath = typeof snapshot.path === "string" ? snapshot.path.trim() : "";
if (!configPath) {
throw new Error(
'Gateway did not return a config path; cannot compute a default workspace for "agents.create".',
);
}
const stateDir = dirnameLike(configPath);
if (!stateDir) {
throw new Error(
`Gateway config path "${configPath}" is missing a directory; cannot compute workspace.`,
);
}
const idGuess = slugifyAgentName(trimmed);
const workspace = joinPathLike(stateDir, `workspace-${idGuess}`);
const result = (await params.client.call("agents.create", {
name: trimmed,
workspace,
})) as { ok?: boolean; agentId?: string; name?: string; workspace?: string };
const agentId = typeof result?.agentId === "string" ? result.agentId.trim() : "";
if (!agentId) {
throw new Error("Gateway returned an invalid agents.create response (missing agentId).");
}
return { id: agentId, name: trimmed };
};
export const deleteGatewayAgent = async (params: {
client: GatewayClient;
agentId: string;
}) => {
try {
const result = (await params.client.call("agents.delete", {
agentId: params.agentId,
})) as { ok?: boolean; removedBindings?: unknown };
const removedBindings =
typeof result?.removedBindings === "number" && Number.isFinite(result.removedBindings)
? Math.max(0, Math.floor(result.removedBindings))
: 0;
return { removed: true, removedBindings };
} catch (err) {
if (err instanceof GatewayResponseError && /not found/i.test(err.message)) {
return { removed: false, removedBindings: 0 };
}
throw err;
}
};
export const updateGatewayHeartbeat = async (params: {
client: GatewayClient;
agentId: string;
payload: AgentHeartbeatUpdatePayload;
}): Promise<AgentHeartbeatResult> => {
const snapshot = await params.client.call<GatewayConfigSnapshot>("config.get", {});
const baseConfig = isRecord(snapshot.config) ? snapshot.config : {};
const list = readConfigAgentList(baseConfig);
const { list: nextList } = upsertConfigAgentEntry(list, params.agentId, (entry) => {
const next = { ...entry };
if (params.payload.override) {
next.heartbeat = buildHeartbeatOverride(params.payload.heartbeat);
} else if ("heartbeat" in next) {
delete next.heartbeat;
}
return next;
});
const nextConfig = writeConfigAgentList(baseConfig, nextList);
await applyGatewayConfigPatch({
client: params.client,
patch: { agents: { list: nextList } },
baseHash: snapshot.hash ?? undefined,
exists: snapshot.exists,
});
return resolveHeartbeatSettings(nextConfig, params.agentId);
};
export const removeGatewayHeartbeatOverride = async (params: {
client: GatewayClient;
agentId: string;
}): Promise<AgentHeartbeatResult> => {
const snapshot = await params.client.call<GatewayConfigSnapshot>("config.get", {});
const baseConfig = isRecord(snapshot.config) ? snapshot.config : {};
const list = readConfigAgentList(baseConfig);
const nextList = list.map((entry) => {
if (entry.id !== params.agentId) return entry;
if (!("heartbeat" in entry)) return entry;
const next = { ...entry };
delete next.heartbeat;
return next;
});
const changed = nextList.some((entry, index) => entry !== list[index]);
if (!changed) {
return resolveHeartbeatSettings(baseConfig, params.agentId);
}
const nextConfig = writeConfigAgentList(baseConfig, nextList);
await applyGatewayConfigPatch({
client: params.client,
patch: { agents: { list: nextList } },
baseHash: snapshot.hash ?? undefined,
exists: snapshot.exists,
});
return resolveHeartbeatSettings(nextConfig, params.agentId);
};
export type AgentSkillsAccessMode = "all" | "none" | "allowlist";
const resolveRequiredAgentId = (agentId: string): string => {
const trimmed = agentId.trim();
if (!trimmed) {
throw new Error("Agent id is required.");
}
return trimmed;
};
const normalizeSkillAllowlistInput = (values: ReadonlyArray<unknown>): string[] => {
const next = values
.filter((value): value is string => typeof value === "string")
.map((value) => value.trim())
.filter((value) => value.length > 0);
return Array.from(new Set(next)).sort((a, b) => a.localeCompare(b));
};
const normalizeSkillAllowlist = (values: string[]): string[] => {
return normalizeSkillAllowlistInput(values);
};
const areStringArraysEqual = (a: readonly string[], b: readonly string[]): boolean => {
if (a.length !== b.length) return false;
for (let index = 0; index < a.length; index += 1) {
if (a[index] !== b[index]) return false;
}
return true;
};
const buildAgentSkillsConfig = (params: {
baseConfig: Record<string, unknown>;
agentId: string;
mode: AgentSkillsAccessMode;
skillNames?: string[];
}): Record<string, unknown> => {
const list = readConfigAgentList(params.baseConfig);
const currentEntry = list.find((entry) => entry.id === params.agentId);
const hasEntry = Boolean(currentEntry);
const currentRawSkills = currentEntry?.skills;
if (params.mode === "all") {
if (!hasEntry) {
return params.baseConfig;
}
if (!Object.prototype.hasOwnProperty.call(currentEntry, "skills")) {
return params.baseConfig;
}
}
if (params.mode === "none" && Array.isArray(currentRawSkills) && currentRawSkills.length === 0) {
return params.baseConfig;
}
if (params.mode === "allowlist") {
const rawSkills = params.skillNames;
if (!rawSkills) {
throw new Error("Skills allowlist is required when mode is allowlist.");
}
const normalizedNext = normalizeSkillAllowlist(rawSkills);
if (Array.isArray(currentRawSkills)) {
const normalizedCurrent = normalizeSkillAllowlistInput(currentRawSkills);
if (areStringArraysEqual(normalizedCurrent, normalizedNext)) {
return params.baseConfig;
}
}
}
const { list: nextList } = upsertConfigAgentEntry(list, params.agentId, (entry) => {
const next: ConfigAgentEntry = { ...entry, id: params.agentId };
if (params.mode === "all") {
if ("skills" in next) {
delete next.skills;
}
return next;
}
if (params.mode === "none") {
next.skills = [];
return next;
}
const rawSkills = params.skillNames;
if (!rawSkills) {
throw new Error("Skills allowlist is required when mode is allowlist.");
}
next.skills = normalizeSkillAllowlist(rawSkills);
return next;
});
return writeConfigAgentList(params.baseConfig, nextList);
};
export const readGatewayAgentSkillsAllowlist = async (params: {
client: GatewayClient;
agentId: string;
}): Promise<string[] | undefined> => {
const agentId = resolveRequiredAgentId(params.agentId);
const snapshot = await params.client.call<GatewayConfigSnapshot>("config.get", {});
const baseConfig = isRecord(snapshot.config) ? snapshot.config : {};
const list = readConfigAgentList(baseConfig);
const entry = list.find((item) => item.id === agentId);
if (!entry) {
return undefined;
}
const raw = entry.skills;
if (!Array.isArray(raw)) {
return undefined;
}
return normalizeSkillAllowlistInput(raw);
};
export const updateGatewayAgentSkillsAllowlist = async (params: {
client: GatewayClient;
agentId: string;
mode: AgentSkillsAccessMode;
skillNames?: string[];
}): Promise<void> => {
const agentId = resolveRequiredAgentId(params.agentId);
if (params.mode === "allowlist" && !params.skillNames) {
throw new Error("Skills allowlist is required when mode is allowlist.");
}
const attemptWrite = async (attempt: number): Promise<void> => {
const snapshot = await params.client.call<GatewayConfigSnapshot>("config.get", {});
const baseConfig = isRecord(snapshot.config) ? snapshot.config : {};
const nextConfig = buildAgentSkillsConfig({
baseConfig,
agentId,
mode: params.mode,
skillNames: params.skillNames,
});
if (nextConfig === baseConfig) {
return;
}
const payload: Record<string, unknown> = {
raw: JSON.stringify(nextConfig, null, 2),
};
const requiresBaseHash = snapshot.exists !== false;
const baseHash = requiresBaseHash ? snapshot.hash?.trim() : undefined;
if (requiresBaseHash && !baseHash) {
throw new Error("Gateway config hash unavailable; re-run config.get.");
}
if (baseHash) {
payload.baseHash = baseHash;
}
try {
await params.client.call("config.set", payload);
} catch (err) {
if (attempt < 1 && shouldRetryConfigWrite(err)) {
return attemptWrite(attempt + 1);
}
throw err;
}
};
await attemptWrite(0);
};
const normalizeToolList = (values: string[] | undefined): string[] | undefined => {
if (!values) return undefined;
const next = values
.map((value) => value.trim())
.filter((value) => value.length > 0);
return Array.from(new Set(next));
};
export const updateGatewayAgentOverrides = async (params: {
client: GatewayClient;
agentId: string;
overrides: GatewayAgentOverrides;
}): Promise<void> => {
const agentId = params.agentId.trim();
if (!agentId) {
throw new Error("Agent id is required.");
}
if (params.overrides.tools?.allow !== undefined && params.overrides.tools?.alsoAllow !== undefined) {
throw new Error("Agent tools overrides cannot set both allow and alsoAllow.");
}
const hasSandboxOverrides =
Boolean(params.overrides.sandbox?.mode) || Boolean(params.overrides.sandbox?.workspaceAccess);
const hasToolsOverrides =
Boolean(params.overrides.tools?.profile) ||
params.overrides.tools?.allow !== undefined ||
params.overrides.tools?.alsoAllow !== undefined ||
params.overrides.tools?.deny !== undefined ||
params.overrides.tools?.sandbox?.tools?.allow !== undefined ||
params.overrides.tools?.sandbox?.tools?.deny !== undefined;
if (!hasSandboxOverrides && !hasToolsOverrides) {
return;
}
const buildNextConfig = (baseConfig: Record<string, unknown>): Record<string, unknown> => {
const list = readConfigAgentList(baseConfig);
const { list: nextList } = upsertConfigAgentEntry(list, agentId, (entry) => {
const next: ConfigAgentEntry = { ...entry, id: agentId };
if (hasSandboxOverrides) {
const currentSandbox = isRecord(next.sandbox) ? { ...next.sandbox } : {};
if (params.overrides.sandbox?.mode) {
currentSandbox.mode = params.overrides.sandbox.mode;
}
if (params.overrides.sandbox?.workspaceAccess) {
currentSandbox.workspaceAccess = params.overrides.sandbox.workspaceAccess;
}
next.sandbox = currentSandbox;
}
if (hasToolsOverrides) {
const currentTools = isRecord(next.tools) ? { ...next.tools } : {};
if (params.overrides.tools?.profile) {
currentTools.profile = params.overrides.tools.profile;
}
const allow = normalizeToolList(params.overrides.tools?.allow);
if (allow !== undefined) {
currentTools.allow = allow;
delete currentTools.alsoAllow;
}
const alsoAllow = normalizeToolList(params.overrides.tools?.alsoAllow);
if (alsoAllow !== undefined) {
currentTools.alsoAllow = alsoAllow;
delete currentTools.allow;
}
const deny = normalizeToolList(params.overrides.tools?.deny);
if (deny !== undefined) {
currentTools.deny = deny;
}
const sandboxAllow = normalizeToolList(params.overrides.tools?.sandbox?.tools?.allow);
const sandboxDeny = normalizeToolList(params.overrides.tools?.sandbox?.tools?.deny);
if (sandboxAllow !== undefined || sandboxDeny !== undefined) {
const sandboxRaw = (currentTools as Record<string, unknown>).sandbox;
const sandbox = isRecord(sandboxRaw) ? { ...sandboxRaw } : {};
const sandboxToolsRaw = (sandbox as Record<string, unknown>).tools;
const sandboxTools = isRecord(sandboxToolsRaw) ? { ...sandboxToolsRaw } : {};
if (sandboxAllow !== undefined) {
(sandboxTools as Record<string, unknown>).allow = sandboxAllow;
}
if (sandboxDeny !== undefined) {
(sandboxTools as Record<string, unknown>).deny = sandboxDeny;
}
(sandbox as Record<string, unknown>).tools = sandboxTools;
(currentTools as Record<string, unknown>).sandbox = sandbox;
}
next.tools = currentTools;
}
return next;
});
return writeConfigAgentList(baseConfig, nextList);
};
const attemptWrite = async (attempt: number): Promise<void> => {
const snapshot = await params.client.call<GatewayConfigSnapshot>("config.get", {});
const baseConfig = isRecord(snapshot.config) ? snapshot.config : {};
const nextConfig = buildNextConfig(baseConfig);
const payload: Record<string, unknown> = {
raw: JSON.stringify(nextConfig, null, 2),
};
const requiresBaseHash = snapshot.exists !== false;
const baseHash = requiresBaseHash ? snapshot.hash?.trim() : undefined;
if (requiresBaseHash && !baseHash) {
throw new Error("Gateway config hash unavailable; re-run config.get.");
}
if (baseHash) payload.baseHash = baseHash;
try {
await params.client.call("config.set", payload);
} catch (err) {
if (attempt < 1 && shouldRetryConfigWrite(err)) {
return attemptWrite(attempt + 1);
}
throw err;
}
};
await attemptWrite(0);
};
+64
View File
@@ -0,0 +1,64 @@
import type { AgentFileName } from "@/lib/agents/agentFiles";
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
type AgentsFilesGetResponse = {
file?: { missing?: unknown; content?: unknown };
};
const resolveAgentId = (value: string) => {
const trimmed = value.trim();
if (!trimmed) {
throw new Error("agentId is required.");
}
return trimmed;
};
export const readGatewayAgentFile = async (params: {
client: GatewayClient;
agentId: string;
name: AgentFileName;
}): Promise<{ exists: boolean; content: string }> => {
const agentId = resolveAgentId(params.agentId);
const response = await params.client.call<AgentsFilesGetResponse>("agents.files.get", {
agentId,
name: params.name,
});
const file = response?.file;
const fileRecord = file && typeof file === "object" ? (file as Record<string, unknown>) : null;
const missing = fileRecord?.missing === true;
const content =
fileRecord && typeof fileRecord.content === "string" ? fileRecord.content : "";
return { exists: !missing, content };
};
export const writeGatewayAgentFile = async (params: {
client: GatewayClient;
agentId: string;
name: AgentFileName;
content: string;
}): Promise<void> => {
const agentId = resolveAgentId(params.agentId);
await params.client.call("agents.files.set", {
agentId,
name: params.name,
content: params.content,
});
};
export const writeGatewayAgentFiles = async (params: {
client: GatewayClient;
agentId: string;
files: Partial<Record<AgentFileName, string>>;
}): Promise<void> => {
const agentId = resolveAgentId(params.agentId);
const entries = Object.entries(params.files).filter(
(entry): entry is [AgentFileName, string] => typeof entry[1] === "string"
);
for (const [name, content] of entries) {
await params.client.call("agents.files.set", {
agentId,
name,
content,
});
}
};
+24
View File
@@ -0,0 +1,24 @@
export type GatewayErrorPayload = {
code: string;
message: string;
details?: unknown;
retryable?: boolean;
retryAfterMs?: number;
};
export class GatewayResponseError extends Error {
code: string;
details?: unknown;
retryable?: boolean;
retryAfterMs?: number;
constructor(payload: GatewayErrorPayload) {
super(payload.message || "Gateway request failed");
this.name = "GatewayResponseError";
this.code = payload.code;
this.details = payload.details;
this.retryable = payload.retryable;
this.retryAfterMs = payload.retryAfterMs;
}
}
+178
View File
@@ -0,0 +1,178 @@
import { GatewayResponseError, type GatewayClient } from "@/lib/gateway/GatewayClient";
export type GatewayExecApprovalSecurity = "deny" | "allowlist" | "full";
export type GatewayExecApprovalAsk = "off" | "on-miss" | "always";
type ExecAllowlistEntry = {
id?: string;
pattern: string;
lastUsedAt?: number;
lastUsedCommand?: string;
lastResolvedPath?: string;
};
type ExecApprovalsAgent = {
security?: GatewayExecApprovalSecurity;
ask?: GatewayExecApprovalAsk;
askFallback?: string;
autoAllowSkills?: boolean;
allowlist?: ExecAllowlistEntry[];
};
type ExecApprovalsFile = {
version: 1;
socket?: {
path?: string;
token?: string;
};
defaults?: {
security?: string;
ask?: string;
askFallback?: string;
autoAllowSkills?: boolean;
};
agents?: Record<string, ExecApprovalsAgent>;
};
type ExecApprovalsSnapshot = {
path: string;
exists: boolean;
hash: string;
file?: ExecApprovalsFile;
};
const shouldRetrySet = (err: unknown): boolean => {
if (!(err instanceof GatewayResponseError)) return false;
return /re-run exec\.approvals\.get|changed since last load/i.test(err.message);
};
const normalizeAllowlist = (patterns: Array<{ pattern: string }>): Array<{ pattern: string }> => {
const next = patterns
.map((entry) => entry.pattern.trim())
.filter((pattern) => pattern.length > 0);
return Array.from(new Set(next)).map((pattern) => ({ pattern }));
};
const setExecApprovalsWithRetry = async (params: {
client: GatewayClient;
file: ExecApprovalsFile;
baseHash?: string | null;
exists?: boolean;
attempt?: number;
}): Promise<void> => {
const attempt = params.attempt ?? 0;
const requiresBaseHash = params.exists !== false;
const baseHash = requiresBaseHash ? params.baseHash?.trim() : undefined;
if (requiresBaseHash && !baseHash) {
throw new Error("Exec approvals hash unavailable; re-run exec.approvals.get.");
}
const payload: Record<string, unknown> = { file: params.file };
if (baseHash) payload.baseHash = baseHash;
try {
await params.client.call("exec.approvals.set", payload);
} catch (err) {
if (attempt < 1 && shouldRetrySet(err)) {
const snapshot = await params.client.call<ExecApprovalsSnapshot>("exec.approvals.get", {});
return setExecApprovalsWithRetry({
...params,
baseHash: snapshot.hash ?? undefined,
exists: snapshot.exists,
attempt: attempt + 1,
});
}
throw err;
}
};
export async function upsertGatewayAgentExecApprovals(params: {
client: GatewayClient;
agentId: string;
policy: {
security: GatewayExecApprovalSecurity;
ask: GatewayExecApprovalAsk;
allowlist: Array<{ pattern: string }>;
} | null;
}): Promise<void> {
const agentId = params.agentId.trim();
if (!agentId) {
throw new Error("Agent id is required.");
}
const snapshot = await params.client.call<ExecApprovalsSnapshot>("exec.approvals.get", {});
const baseFile: ExecApprovalsFile =
snapshot.file && typeof snapshot.file === "object"
? {
version: 1,
socket: snapshot.file.socket,
defaults: snapshot.file.defaults,
agents: { ...(snapshot.file.agents ?? {}) },
}
: { version: 1, agents: {} };
const nextAgents = { ...(baseFile.agents ?? {}) };
if (!params.policy) {
if (!(agentId in nextAgents)) {
return;
}
delete nextAgents[agentId];
} else {
const existing = nextAgents[agentId] ?? {};
nextAgents[agentId] = {
...existing,
security: params.policy.security,
ask: params.policy.ask,
allowlist: normalizeAllowlist(params.policy.allowlist),
};
}
const nextFile: ExecApprovalsFile = {
...baseFile,
version: 1,
agents: nextAgents,
};
await setExecApprovalsWithRetry({
client: params.client,
file: nextFile,
baseHash: snapshot.hash,
exists: snapshot.exists,
});
}
export async function readGatewayAgentExecApprovals(params: {
client: GatewayClient;
agentId: string;
}): Promise<{
security: GatewayExecApprovalSecurity | null;
ask: GatewayExecApprovalAsk | null;
allowlist: Array<{ pattern: string }>;
} | null> {
const agentId = params.agentId.trim();
if (!agentId) {
throw new Error("Agent id is required.");
}
const snapshot = await params.client.call<ExecApprovalsSnapshot>("exec.approvals.get", {});
const entry = snapshot.file?.agents?.[agentId];
if (!entry) return null;
const security =
entry.security === "deny" || entry.security === "allowlist" || entry.security === "full"
? entry.security
: null;
const ask = entry.ask === "off" || entry.ask === "on-miss" || entry.ask === "always" ? entry.ask : null;
const allowlist = Array.isArray(entry.allowlist)
? entry.allowlist
.map((item) => (item && typeof item === "object" ? (item as ExecAllowlistEntry).pattern : ""))
.filter((pattern): pattern is string => typeof pattern === "string")
.map((pattern) => pattern.trim())
.filter((pattern) => pattern.length > 0)
.map((pattern) => ({ pattern }))
: [];
return {
security,
ask,
allowlist,
};
}
+107
View File
@@ -0,0 +1,107 @@
import { GatewayResponseError, type GatewayClient } from "@/lib/gateway/GatewayClient";
import { isLocalGatewayUrl } from "@/lib/gateway/local-gateway";
type GatewayConfigSnapshot = {
config?: Record<string, unknown>;
hash?: string;
exists?: boolean;
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === "object" && !Array.isArray(value));
const shouldRetryConfigWrite = (err: unknown) => {
if (!(err instanceof GatewayResponseError)) return false;
return /re-run config\.get|config changed since last load/i.test(err.message);
};
const resolveReloadModeFromConfig = (config: unknown): string | null => {
if (!isRecord(config)) return null;
const gateway = isRecord(config.gateway) ? config.gateway : null;
const reload = gateway && isRecord(gateway.reload) ? gateway.reload : null;
if (!reload || typeof reload.mode !== "string") return "hybrid";
const mode = reload.mode.trim().toLowerCase();
return mode.length > 0 ? mode : "hybrid";
};
export const shouldAwaitDisconnectRestartForReloadMode = (mode: string | null): boolean =>
mode !== "hot" && mode !== "off" && mode !== "hybrid";
export async function shouldAwaitDisconnectRestartForRemoteMutation(params: {
client: GatewayClient;
cachedConfigSnapshot: { config?: unknown } | null;
logError?: (message: string, error: unknown) => void;
}): Promise<boolean> {
const cachedMode = resolveReloadModeFromConfig(params.cachedConfigSnapshot?.config);
if (cachedMode) {
return shouldAwaitDisconnectRestartForReloadMode(cachedMode);
}
try {
const snapshot = await params.client.call<GatewayConfigSnapshot>("config.get", {});
const mode = resolveReloadModeFromConfig(snapshot.config);
return shouldAwaitDisconnectRestartForReloadMode(mode);
} catch (err) {
params.logError?.(
"Failed to determine gateway reload mode; defaulting to restart wait.",
err
);
return true;
}
}
export async function ensureGatewayReloadModeHotForLocalStudio(params: {
client: GatewayClient;
upstreamGatewayUrl: string;
}): Promise<void> {
if (!isLocalGatewayUrl(params.upstreamGatewayUrl)) {
return;
}
const attemptWrite = async (attempt: number): Promise<void> => {
const snapshot = await params.client.call<GatewayConfigSnapshot>("config.get", {});
const exists = snapshot.exists !== false;
const baseHash = exists ? snapshot.hash?.trim() : undefined;
if (exists && !baseHash) {
throw new Error("Gateway config hash unavailable; re-run config.get.");
}
const baseConfig = isRecord(snapshot.config) ? snapshot.config : {};
const gateway = isRecord(baseConfig.gateway) ? baseConfig.gateway : {};
const reload = isRecord(gateway.reload) ? gateway.reload : {};
const mode = typeof reload.mode === "string" ? reload.mode.trim() : "";
if (mode === "hot" || mode === "off") {
return;
}
const nextConfig: Record<string, unknown> = {
...baseConfig,
gateway: {
...gateway,
reload: {
...reload,
mode: "hot",
},
},
};
const payload: Record<string, unknown> = {
raw: JSON.stringify(nextConfig, null, 2),
};
if (baseHash) {
payload.baseHash = baseHash;
}
try {
await params.client.call("config.set", payload);
} catch (err) {
if (attempt < 1 && shouldRetryConfigWrite(err)) {
await attemptWrite(attempt + 1);
return;
}
throw err;
}
};
await attemptWrite(0);
}
+22
View File
@@ -0,0 +1,22 @@
const parseHostname = (gatewayUrl: string): string | null => {
const trimmed = gatewayUrl.trim();
if (!trimmed) return null;
try {
return new URL(trimmed).hostname;
} catch {
return null;
}
};
export const isLocalGatewayUrl = (gatewayUrl: string): boolean => {
const hostname = parseHostname(gatewayUrl);
if (!hostname) return false;
const normalized = hostname.trim().toLowerCase();
return (
normalized === "localhost" ||
normalized === "127.0.0.1" ||
normalized === "::1" ||
normalized === "0.0.0.0"
);
};
+92
View File
@@ -0,0 +1,92 @@
export type GatewayModelChoice = {
id: string;
name: string;
provider: string;
contextWindow?: number;
reasoning?: boolean;
};
type GatewayModelAliasEntry = {
alias?: string;
};
type GatewayModelDefaults = {
model?: string | { primary?: string; fallbacks?: string[] };
models?: Record<string, GatewayModelAliasEntry>;
};
export type GatewayModelPolicySnapshot = {
config?: {
agents?: {
defaults?: GatewayModelDefaults;
list?: Array<{
id?: string;
model?: string | { primary?: string; fallbacks?: string[] };
}>;
};
};
};
export const resolveConfiguredModelKey = (
raw: string,
models?: Record<string, GatewayModelAliasEntry>
) => {
const trimmed = raw.trim();
if (!trimmed) return null;
if (trimmed.includes("/")) return trimmed;
if (models) {
const target = Object.entries(models).find(
([, entry]) => entry?.alias?.trim().toLowerCase() === trimmed.toLowerCase()
);
if (target?.[0]) return target[0];
}
return `anthropic/${trimmed}`;
};
export const buildAllowedModelKeys = (snapshot: GatewayModelPolicySnapshot | null) => {
const allowedList: string[] = [];
const allowedSet = new Set<string>();
const defaults = snapshot?.config?.agents?.defaults;
const modelDefaults = defaults?.model;
const modelAliases = defaults?.models;
const pushKey = (raw?: string | null) => {
if (!raw) return;
const resolved = resolveConfiguredModelKey(raw, modelAliases);
if (!resolved) return;
if (allowedSet.has(resolved)) return;
allowedSet.add(resolved);
allowedList.push(resolved);
};
if (typeof modelDefaults === "string") {
pushKey(modelDefaults);
} else if (modelDefaults && typeof modelDefaults === "object") {
pushKey(modelDefaults.primary ?? null);
for (const fallback of modelDefaults.fallbacks ?? []) {
pushKey(fallback);
}
}
if (modelAliases) {
for (const key of Object.keys(modelAliases)) {
pushKey(key);
}
}
return allowedList;
};
export const buildGatewayModelChoices = (
catalog: GatewayModelChoice[],
snapshot: GatewayModelPolicySnapshot | null
) => {
const allowedKeys = buildAllowedModelKeys(snapshot);
if (allowedKeys.length === 0) return catalog;
const filtered = catalog.filter((entry) => allowedKeys.includes(`${entry.provider}/${entry.id}`));
const filteredKeys = new Set(filtered.map((entry) => `${entry.provider}/${entry.id}`));
const extras: GatewayModelChoice[] = [];
for (const key of allowedKeys) {
if (filteredKeys.has(key)) continue;
const [provider, id] = key.split("/");
if (!provider || !id) continue;
extras.push({ provider, id, name: key });
}
return [...filtered, ...extras];
};
@@ -0,0 +1,657 @@
// Adapted from `openclaw/openclaw` `ui/src/ui/gateway.ts`.
// Source license: MIT. Last verified against OpenClaw 2026.2.12
// (`f9e444dd56ccfc2271e8ae1729b7a14a55e1c11e`).
// Update this file via `npm run sync:gateway-client -- /path/to/gateway.ts` and record
// provenance changes in `THIRD_PARTY_CODE.md` whenever the upstream source changes.
import { getPublicKeyAsync, signAsync, utils } from "@noble/ed25519";
import { GatewayResponseError } from "@/lib/gateway/errors";
const GATEWAY_CLIENT_NAMES = {
CONTROL_UI: "openclaw-control-ui",
} as const;
const GATEWAY_CLIENT_MODES = {
WEBCHAT: "webchat",
} as const;
type CryptoLike = {
randomUUID?: (() => string) | undefined;
getRandomValues?: ((array: Uint8Array) => Uint8Array) | undefined;
};
let warnedWeakCrypto = false;
function uuidFromBytes(bytes: Uint8Array): string {
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1
let hex = "";
for (let i = 0; i < bytes.length; i++) {
hex += bytes[i]!.toString(16).padStart(2, "0");
}
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(
16,
20
)}-${hex.slice(20)}`;
}
function weakRandomBytes(): Uint8Array {
const bytes = new Uint8Array(16);
const now = Date.now();
for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);
bytes[0] ^= now & 0xff;
bytes[1] ^= (now >>> 8) & 0xff;
bytes[2] ^= (now >>> 16) & 0xff;
bytes[3] ^= (now >>> 24) & 0xff;
return bytes;
}
function warnWeakCryptoOnce() {
if (warnedWeakCrypto) return;
warnedWeakCrypto = true;
console.warn("[uuid] crypto API missing; falling back to weak randomness");
}
function generateUUID(cryptoLike: CryptoLike | null = globalThis.crypto): string {
if (cryptoLike && typeof cryptoLike.randomUUID === "function") return cryptoLike.randomUUID();
if (cryptoLike && typeof cryptoLike.getRandomValues === "function") {
const bytes = new Uint8Array(16);
cryptoLike.getRandomValues(bytes);
return uuidFromBytes(bytes);
}
warnWeakCryptoOnce();
return uuidFromBytes(weakRandomBytes());
}
type DeviceAuthPayloadParams = {
deviceId: string;
clientId: string;
clientMode: string;
role: string;
scopes: string[];
signedAtMs: number;
token?: string | null;
nonce?: string | null;
version?: "v1" | "v2";
};
function buildDeviceAuthPayload(params: DeviceAuthPayloadParams): string {
const version = params.version ?? (params.nonce ? "v2" : "v1");
const scopes = params.scopes.join(",");
const token = params.token ?? "";
const base = [
version,
params.deviceId,
params.clientId,
params.clientMode,
params.role,
scopes,
String(params.signedAtMs),
token,
];
if (version === "v2") {
base.push(params.nonce ?? "");
}
return base.join("|");
}
type DeviceAuthEntry = {
token: string;
role: string;
scopes: string[];
updatedAtMs: number;
};
type DeviceAuthStore = {
version: 1;
deviceId: string;
tokens: Record<string, DeviceAuthEntry>;
};
const DEVICE_AUTH_STORAGE_KEY = "openclaw.device.auth.v1";
function normalizeAuthScope(scope: string | undefined): string {
const trimmed = scope?.trim();
if (!trimmed) return "default";
return trimmed.toLowerCase();
}
function buildScopedTokenKey(scope: string, role: string): string {
return `${scope}::${role}`;
}
function normalizeRole(role: string): string {
return role.trim();
}
function normalizeScopes(scopes: string[] | undefined): string[] {
if (!Array.isArray(scopes)) return [];
const out = new Set<string>();
for (const scope of scopes) {
const trimmed = scope.trim();
if (trimmed) out.add(trimmed);
}
return [...out].sort();
}
function readDeviceAuthStore(): DeviceAuthStore | null {
try {
const raw = window.localStorage.getItem(DEVICE_AUTH_STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as DeviceAuthStore;
if (!parsed || parsed.version !== 1) return null;
if (!parsed.deviceId || typeof parsed.deviceId !== "string") return null;
if (!parsed.tokens || typeof parsed.tokens !== "object") return null;
return parsed;
} catch {
return null;
}
}
function writeDeviceAuthStore(store: DeviceAuthStore) {
try {
window.localStorage.setItem(DEVICE_AUTH_STORAGE_KEY, JSON.stringify(store));
} catch {
// best-effort
}
}
function loadDeviceAuthToken(params: { deviceId: string; role: string; scope: string }): DeviceAuthEntry | null {
const store = readDeviceAuthStore();
if (!store || store.deviceId !== params.deviceId) return null;
const role = normalizeRole(params.role);
const scope = normalizeAuthScope(params.scope);
const key = buildScopedTokenKey(scope, role);
const entry = store.tokens[key];
if (!entry || typeof entry.token !== "string") return null;
return entry;
}
function storeDeviceAuthToken(params: {
deviceId: string;
role: string;
scope: string;
token: string;
scopes?: string[];
}): DeviceAuthEntry {
const role = normalizeRole(params.role);
const scope = normalizeAuthScope(params.scope);
const key = buildScopedTokenKey(scope, role);
const next: DeviceAuthStore = {
version: 1,
deviceId: params.deviceId,
tokens: {},
};
const existing = readDeviceAuthStore();
if (existing && existing.deviceId === params.deviceId) {
next.tokens = { ...existing.tokens };
}
const entry: DeviceAuthEntry = {
token: params.token,
role,
scopes: normalizeScopes(params.scopes),
updatedAtMs: Date.now(),
};
next.tokens[key] = entry;
writeDeviceAuthStore(next);
return entry;
}
function clearDeviceAuthToken(params: { deviceId: string; role: string; scope: string }) {
const store = readDeviceAuthStore();
if (!store || store.deviceId !== params.deviceId) return;
const role = normalizeRole(params.role);
const scope = normalizeAuthScope(params.scope);
const key = buildScopedTokenKey(scope, role);
const hasScoped = Boolean(store.tokens[key]);
const hasLegacy = Boolean(store.tokens[role]);
if (!hasScoped && !hasLegacy) return;
const next = { ...store, tokens: { ...store.tokens } };
delete next.tokens[key];
delete next.tokens[role];
writeDeviceAuthStore(next);
}
type StoredIdentity = {
version: 1;
deviceId: string;
publicKey: string;
privateKey: string;
createdAtMs: number;
};
type DeviceIdentity = {
deviceId: string;
publicKey: string;
privateKey: string;
};
const DEVICE_IDENTITY_STORAGE_KEY = "openclaw-device-identity-v1";
export function clearGatewayBrowserSessionStorage() {
try {
localStorage.removeItem(DEVICE_AUTH_STORAGE_KEY);
localStorage.removeItem(DEVICE_IDENTITY_STORAGE_KEY);
} catch {
// best-effort
}
}
function base64UrlEncode(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/g, "");
}
function base64UrlDecode(input: string): Uint8Array {
const normalized = input.replaceAll("-", "+").replaceAll("_", "/");
const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
const binary = atob(padded);
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);
return out;
}
function bytesToHex(bytes: Uint8Array): string {
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
async function fingerprintPublicKey(publicKey: Uint8Array): Promise<string> {
const hash = await crypto.subtle.digest("SHA-256", new Uint8Array(publicKey));
return bytesToHex(new Uint8Array(hash));
}
async function generateIdentity(): Promise<DeviceIdentity> {
const privateKey = utils.randomSecretKey();
const publicKey = await getPublicKeyAsync(privateKey);
const deviceId = await fingerprintPublicKey(publicKey);
return {
deviceId,
publicKey: base64UrlEncode(publicKey),
privateKey: base64UrlEncode(privateKey),
};
}
async function loadOrCreateDeviceIdentity(): Promise<DeviceIdentity> {
try {
const raw = localStorage.getItem(DEVICE_IDENTITY_STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw) as StoredIdentity;
if (
parsed?.version === 1 &&
typeof parsed.deviceId === "string" &&
typeof parsed.publicKey === "string" &&
typeof parsed.privateKey === "string"
) {
const derivedId = await fingerprintPublicKey(base64UrlDecode(parsed.publicKey));
if (derivedId !== parsed.deviceId) {
const updated: StoredIdentity = {
...parsed,
deviceId: derivedId,
};
localStorage.setItem(DEVICE_IDENTITY_STORAGE_KEY, JSON.stringify(updated));
return {
deviceId: derivedId,
publicKey: parsed.publicKey,
privateKey: parsed.privateKey,
};
}
return {
deviceId: parsed.deviceId,
publicKey: parsed.publicKey,
privateKey: parsed.privateKey,
};
}
}
} catch {
// fall through to regenerate
}
const identity = await generateIdentity();
const stored: StoredIdentity = {
version: 1,
deviceId: identity.deviceId,
publicKey: identity.publicKey,
privateKey: identity.privateKey,
createdAtMs: Date.now(),
};
localStorage.setItem(DEVICE_IDENTITY_STORAGE_KEY, JSON.stringify(stored));
return identity;
}
async function signDevicePayload(privateKeyBase64Url: string, payload: string) {
const key = base64UrlDecode(privateKeyBase64Url);
const data = new TextEncoder().encode(payload);
const sig = await signAsync(data, key);
return base64UrlEncode(sig);
}
export type GatewayEventFrame = {
type: "event";
event: string;
payload?: unknown;
seq?: number;
stateVersion?: { presence: number; health: number };
};
export type GatewayResponseFrame = {
type: "res";
id: string;
ok: boolean;
payload?: unknown;
error?: { code: string; message: string; details?: unknown };
};
export type GatewayHelloOk = {
type: "hello-ok";
protocol: number;
features?: { methods?: string[]; events?: string[] };
snapshot?: unknown;
auth?: {
deviceToken?: string;
role?: string;
scopes?: string[];
issuedAtMs?: number;
};
policy?: { tickIntervalMs?: number };
};
type Pending = {
resolve: (value: unknown) => void;
reject: (err: unknown) => void;
};
export type GatewayBrowserClientOptions = {
url: string;
token?: string;
password?: string;
authScopeKey?: string;
disableDeviceAuth?: boolean;
clientName?: string;
clientVersion?: string;
platform?: string;
mode?: string;
instanceId?: string;
onHello?: (hello: GatewayHelloOk) => void;
onEvent?: (evt: GatewayEventFrame) => void;
onClose?: (info: { code: number; reason: string }) => void;
onGap?: (info: { expected: number; received: number }) => void;
};
const CONNECT_FAILED_CLOSE_CODE = 4008;
const WS_CLOSE_REASON_MAX_BYTES = 123;
function truncateWsCloseReason(reason: string, maxBytes = WS_CLOSE_REASON_MAX_BYTES): string {
const trimmed = reason.trim();
if (!trimmed) return "connect failed";
const encoder = new TextEncoder();
if (encoder.encode(trimmed).byteLength <= maxBytes) return trimmed;
let out = "";
for (const char of trimmed) {
const next = out + char;
if (encoder.encode(next).byteLength > maxBytes) break;
out = next;
}
return out.trimEnd() || "connect failed";
}
export class GatewayBrowserClient {
private ws: WebSocket | null = null;
private pending = new Map<string, Pending>();
private closed = false;
private lastSeq: number | null = null;
private connectNonce: string | null = null;
private connectSent = false;
private connectTimer: number | null = null;
private backoffMs = 800;
constructor(private opts: GatewayBrowserClientOptions) {}
start() {
this.closed = false;
this.connect();
}
stop() {
this.closed = true;
this.ws?.close();
this.ws = null;
this.flushPending(new Error("gateway client stopped"));
}
get connected() {
return this.ws?.readyState === WebSocket.OPEN;
}
private connect() {
if (this.closed) return;
this.ws = new WebSocket(this.opts.url);
this.ws.onopen = () => this.queueConnect();
this.ws.onmessage = (ev) => this.handleMessage(String(ev.data ?? ""));
this.ws.onclose = (ev) => {
const reason = String(ev.reason ?? "");
this.ws = null;
this.flushPending(new Error(`gateway closed (${ev.code}): ${reason}`));
this.opts.onClose?.({ code: ev.code, reason });
this.scheduleReconnect();
};
this.ws.onerror = () => {
// ignored; close handler will fire
};
}
private scheduleReconnect() {
if (this.closed) return;
const delay = this.backoffMs;
this.backoffMs = Math.min(this.backoffMs * 1.7, 15_000);
window.setTimeout(() => this.connect(), delay);
}
private flushPending(err: Error) {
for (const [, p] of this.pending) p.reject(err);
this.pending.clear();
}
private async sendConnect() {
if (this.connectSent) return;
this.connectSent = true;
if (this.connectTimer !== null) {
window.clearTimeout(this.connectTimer);
this.connectTimer = null;
}
const isSecureContext =
!this.opts.disableDeviceAuth && typeof crypto !== "undefined" && !!crypto.subtle;
const scopes = ["operator.admin", "operator.approvals", "operator.pairing"];
const role = "operator";
const authScopeKey = normalizeAuthScope(this.opts.authScopeKey ?? this.opts.url);
let deviceIdentity: Awaited<ReturnType<typeof loadOrCreateDeviceIdentity>> | null = null;
let canFallbackToShared = false;
let authToken = this.opts.token;
if (isSecureContext) {
deviceIdentity = await loadOrCreateDeviceIdentity();
const storedToken = loadDeviceAuthToken({
deviceId: deviceIdentity.deviceId,
role,
scope: authScopeKey,
})?.token;
authToken = storedToken ?? this.opts.token;
canFallbackToShared = Boolean(storedToken && this.opts.token);
}
const auth =
authToken || this.opts.password
? {
token: authToken,
password: this.opts.password,
}
: undefined;
let device:
| {
id: string;
publicKey: string;
signature: string;
signedAt: number;
nonce: string | undefined;
}
| undefined;
if (isSecureContext && deviceIdentity) {
const signedAtMs = Date.now();
const nonce = this.connectNonce ?? undefined;
const payload = buildDeviceAuthPayload({
deviceId: deviceIdentity.deviceId,
clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.CONTROL_UI,
clientMode: this.opts.mode ?? GATEWAY_CLIENT_MODES.WEBCHAT,
role,
scopes,
signedAtMs,
token: authToken ?? null,
nonce,
});
const signature = await signDevicePayload(deviceIdentity.privateKey, payload);
device = {
id: deviceIdentity.deviceId,
publicKey: deviceIdentity.publicKey,
signature,
signedAt: signedAtMs,
nonce,
};
}
const params = {
minProtocol: 3,
maxProtocol: 3,
client: {
id: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.CONTROL_UI,
version: this.opts.clientVersion ?? "dev",
platform: this.opts.platform ?? navigator.platform ?? "web",
mode: this.opts.mode ?? GATEWAY_CLIENT_MODES.WEBCHAT,
instanceId: this.opts.instanceId,
},
role,
scopes,
device,
caps: [],
auth,
userAgent: navigator.userAgent,
locale: navigator.language,
};
void this.request<GatewayHelloOk>("connect", params)
.then((hello) => {
if (hello?.auth?.deviceToken && deviceIdentity) {
storeDeviceAuthToken({
deviceId: deviceIdentity.deviceId,
role: hello.auth.role ?? role,
scope: authScopeKey,
token: hello.auth.deviceToken,
scopes: hello.auth.scopes ?? [],
});
}
this.backoffMs = 800;
this.opts.onHello?.(hello);
})
.catch((err) => {
if (canFallbackToShared && deviceIdentity) {
clearDeviceAuthToken({ deviceId: deviceIdentity.deviceId, role, scope: authScopeKey });
}
const rawReason =
err instanceof GatewayResponseError
? `connect failed: ${err.code} ${err.message}`
: "connect failed";
const reason = truncateWsCloseReason(rawReason);
if (reason !== rawReason) {
console.warn("[gateway] connect close reason truncated to 123 UTF-8 bytes");
}
this.ws?.close(CONNECT_FAILED_CLOSE_CODE, reason);
});
}
private handleMessage(raw: string) {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return;
}
const frame = parsed as { type?: unknown };
if (frame.type === "event") {
const evt = parsed as GatewayEventFrame;
if (evt.event === "connect.challenge") {
const payload = evt.payload as { nonce?: unknown } | undefined;
const nonce = payload && typeof payload.nonce === "string" ? payload.nonce : null;
if (nonce) {
this.connectNonce = nonce;
void this.sendConnect();
}
return;
}
const seq = typeof evt.seq === "number" ? evt.seq : null;
if (seq !== null) {
if (this.lastSeq !== null && seq > this.lastSeq + 1) {
this.opts.onGap?.({ expected: this.lastSeq + 1, received: seq });
}
this.lastSeq = seq;
}
try {
this.opts.onEvent?.(evt);
} catch (err) {
console.error("[gateway] event handler error:", err);
}
return;
}
if (frame.type === "res") {
const res = parsed as GatewayResponseFrame;
const pending = this.pending.get(res.id);
if (!pending) return;
this.pending.delete(res.id);
if (res.ok) pending.resolve(res.payload);
else {
if (res.error && typeof res.error.code === "string") {
pending.reject(
new GatewayResponseError({
code: res.error.code,
message: res.error.message ?? "request failed",
details: res.error.details,
})
);
return;
}
pending.reject(new Error(res.error?.message ?? "request failed"));
}
return;
}
}
request<T = unknown>(method: string, params?: unknown): Promise<T> {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
return Promise.reject(new Error("gateway not connected"));
}
const id = generateUUID();
const frame = { type: "req", id, method, params };
const p = new Promise<T>((resolve, reject) => {
this.pending.set(id, { resolve: (v) => resolve(v as T), reject });
});
this.ws.send(JSON.stringify(frame));
return p;
}
private queueConnect() {
this.connectNonce = null;
this.connectSent = false;
if (this.connectTimer !== null) window.clearTimeout(this.connectTimer);
this.connectTimer = window.setTimeout(() => {
void this.sendConnect();
}, 750);
}
}
+6
View File
@@ -0,0 +1,6 @@
export const resolveStudioProxyGatewayUrl = (): string => {
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
const host = window.location.host;
return `${protocol}://${host}/api/gateway/ws`;
};
+23
View File
@@ -0,0 +1,23 @@
export const fetchJson = async <T>(
input: RequestInfo | URL,
init?: RequestInit
): Promise<T> => {
const res = await fetch(input, init);
const text = await res.text();
let data: unknown = null;
if (text) {
try {
data = JSON.parse(text);
} catch {
data = null;
}
}
if (!res.ok) {
const errorMessage =
data && typeof data === "object" && "error" in data && typeof data.error === "string"
? data.error
: `Request failed with status ${res.status}.`;
throw new Error(errorMessage);
}
return data as T;
};
+57
View File
@@ -0,0 +1,57 @@
import { isLocalGatewayUrl } from "@/lib/gateway/local-gateway";
const SCREENSHOT_ONLY_HOSTS = [
/(^|\.)x\.com$/i,
/(^|\.)twitter\.com$/i,
/(^|\.)instagram\.com$/i,
/(^|\.)facebook\.com$/i,
/(^|\.)threads\.net$/i,
/(^|\.)linkedin\.com$/i,
/(^|\.)tiktok\.com$/i,
];
const normalizeLoopbackHostname = (hostname: string) => {
const normalized = hostname.trim().toLowerCase();
return normalized === "0.0.0.0" ? "127.0.0.1" : hostname;
};
export const shouldPreferBrowserScreenshot = (value: string | null | undefined): boolean => {
const trimmed = value?.trim();
if (!trimmed) return false;
try {
const hostname = new URL(trimmed).hostname;
return SCREENSHOT_ONLY_HOSTS.some((pattern) => pattern.test(hostname));
} catch {
return false;
}
};
export const resolveBrowserControlBaseUrl = (gatewayUrl: string | null | undefined): string | null => {
const trimmed = gatewayUrl?.trim();
if (!trimmed || !isLocalGatewayUrl(trimmed)) return null;
try {
const parsed = new URL(trimmed);
const protocol = parsed.protocol === "wss:" ? "https:" : "http:";
const port = parsed.port
? Number(parsed.port)
: parsed.protocol === "wss:"
? 443
: 80;
if (!Number.isFinite(port)) return null;
const controlPort = port + 2;
return `${protocol}//${normalizeLoopbackHostname(parsed.hostname)}:${controlPort}`;
} catch {
return null;
}
};
export const normalizeBrowserPreviewUrl = (value: string): string => {
try {
const parsed = new URL(value);
parsed.hash = "";
const normalized = parsed.toString();
return normalized.endsWith("/") ? normalized.slice(0, -1) : normalized;
} catch {
return value.trim();
}
};
+76
View File
@@ -0,0 +1,76 @@
import type { MockPhoneCallScenario } from "@/lib/office/call/types";
const normalizeWhitespace = (value: string | null | undefined): string =>
(value ?? "").replace(/\s+/g, " ").trim();
const titleCase = (value: string): string =>
value.replace(/\b([a-z])([a-z']*)/g, (_, first: string, rest: string) => {
return `${first.toUpperCase()}${rest}`;
});
const isPhoneNumberLike = (value: string): boolean => /[\d+]/.test(value);
const formatCalleeLabel = (callee: string): string => {
const normalized = normalizeWhitespace(callee).toLowerCase();
if (!normalized) return "your contact";
if (normalized === "my wife") return "your wife";
if (normalized === "my husband") return "your husband";
if (normalized === "my mom") return "your mom";
if (normalized === "my dad") return "your dad";
if (isPhoneNumberLike(normalized)) return normalized;
return titleCase(normalized);
};
const buildPromptText = (calleeLabel: string): string =>
`What should I say to ${calleeLabel}?`;
const DEMO_DIAL_NUMBER = "973-619-4672";
const resolveDialNumber = (): string => DEMO_DIAL_NUMBER;
const buildSpokenText = (message: string): string =>
`Hi, this is Luke assistant. He told me to tell you ${message}. Thank you.`;
const buildRecipientReply = (message: string): string => {
const normalized = normalizeWhitespace(message).toLowerCase();
if (normalized.includes("late for dinner")) {
return "Okay, thanks for letting me know.";
}
if (normalized.includes("on my way")) return "Okay, see you soon.";
if (normalized.includes("love you")) return "Love you too. Talk soon.";
if (normalized.includes("be there")) return "Sounds good. I will be ready.";
if (normalized.includes("running late")) return "Thanks for letting me know.";
return "Got it. I will pass that along on this mock line.";
};
export const buildMockPhoneCallScenario = (params: {
callee: string;
message?: string | null;
voiceAvailable: boolean;
}): MockPhoneCallScenario => {
const calleeLabel = formatCalleeLabel(params.callee);
const dialNumber = resolveDialNumber();
const message = normalizeWhitespace(params.message);
if (!message) {
return {
phase: "needs_message",
callee: calleeLabel,
dialNumber,
promptText: buildPromptText(calleeLabel),
spokenText: null,
recipientReply: null,
statusLine: `Waiting for your message to ${calleeLabel}.`,
voiceAvailable: params.voiceAvailable,
};
}
return {
phase: "ready_to_call",
callee: calleeLabel,
dialNumber,
promptText: null,
spokenText: buildSpokenText(message),
recipientReply: buildRecipientReply(message),
statusLine: `Connected to ${calleeLabel}.`,
voiceAvailable: params.voiceAvailable,
};
};
+12
View File
@@ -0,0 +1,12 @@
export type MockPhoneCallPhase = "needs_message" | "ready_to_call";
export type MockPhoneCallScenario = {
phase: MockPhoneCallPhase;
callee: string;
dialNumber: string;
promptText: string | null;
spokenText: string | null;
recipientReply: string | null;
statusLine: string;
voiceAvailable: boolean;
};
+601
View File
@@ -0,0 +1,601 @@
import type { TranscriptEntry } from "@/features/agents/state/transcript";
import { stripUiMetadata } from "@/lib/text/message-extract";
// This module is the single natural-language entry point for office movement and room intents.
// Runtime consumers should prefer the unified snapshot instead of scattering transport-specific
// regex checks across chat, office, or scene code.
export type OfficeDeskDirective = "desk" | "release";
export type OfficeGithubDirective = "github" | "release";
export type OfficeGymDirective = "gym";
export type OfficeQaDirective = "qa_lab" | "release";
export type OfficeStandupDirective = "standup";
export type OfficeCallPhase = "needs_message" | "ready_to_call";
export type OfficeTextPhase = "needs_message" | "ready_to_send";
export type OfficeCallDirective = {
callee: string;
message: string | null;
phase: OfficeCallPhase;
};
export type OfficeTextDirective = {
recipient: string;
message: string | null;
phase: OfficeTextPhase;
};
export type OfficeIntentSnapshot = {
normalized: string;
desk: OfficeDeskDirective | null;
github: OfficeGithubDirective | null;
gym:
| {
directive: OfficeGymDirective;
source: "manual" | "skill";
}
| null;
qa: OfficeQaDirective | null;
art: null;
standup: OfficeStandupDirective | null;
call: OfficeCallDirective | null;
text: OfficeTextDirective | null;
};
type OfficeInteractionDirective =
| { target: "desk"; action: "hold" | "release" }
| { target: "github"; action: "hold" | "release" };
const normalizeDirectiveText = (value: string | null | undefined): string => {
if (!value) return "";
const cleaned = stripUiMetadata(value).trim().replace(/^>\s*/, "");
return cleaned
.toLowerCase()
.replace(/[.!?]+$/g, "")
.replace(/\s+/g, " ")
.trim();
};
const INTENT_SNAPSHOT_CACHE_LIMIT = 250;
const intentSnapshotCache = new Map<string, OfficeIntentSnapshot>();
const getCachedIntentSnapshot = (
normalized: string,
): OfficeIntentSnapshot | undefined => {
const cached = intentSnapshotCache.get(normalized);
if (!cached) return undefined;
intentSnapshotCache.delete(normalized);
intentSnapshotCache.set(normalized, cached);
return cached;
};
const cacheIntentSnapshot = (normalized: string, snapshot: OfficeIntentSnapshot) => {
intentSnapshotCache.set(normalized, snapshot);
if (intentSnapshotCache.size <= INTENT_SNAPSHOT_CACHE_LIMIT) return snapshot;
const oldestKey = intentSnapshotCache.keys().next().value;
if (oldestKey) intentSnapshotCache.delete(oldestKey);
return snapshot;
};
const resolveOfficeInteractionDirectiveFromNormalized = (
normalized: string,
): OfficeInteractionDirective | null => {
const mentionsDesk = /\b(?:your|the)?\s*desk\b/.test(normalized);
const deskCommandPatterns = [
/\bgo\s+to\s+(?:your|the)\s+desk\b/,
/\bhead\s+to\s+(?:your|the)\s+desk\b/,
/\breturn\s+to\s+(?:your|the)\s+desk\b/,
/\bgo\s+back\s+to\s+(?:your|the)\s+desk\b/,
/\bback\s+to\s+(?:your|the)\s+desk\b/,
/\bgo\s+sit\s+(?:at|on)\s+(?:your|the)\s+desk\b/,
/\bsit\s+(?:at|on)\s+(?:your|the)\s+desk\b/,
];
const isDeskCommand = deskCommandPatterns.some((pattern) =>
pattern.test(normalized),
);
if (isDeskCommand) return { target: "desk", action: "hold" };
const isDeskRelease =
mentionsDesk &&
(normalized.includes("leave") ||
normalized.includes("leave your desk") ||
normalized.includes("leave the desk"));
const isWalkRelease =
normalized.includes("walk") &&
(normalized.includes("go on a walk") ||
normalized.includes("go to walk") ||
normalized.includes("go for a walk") ||
normalized.includes("go walk"));
if (isDeskRelease || isWalkRelease)
return { target: "desk", action: "release" };
const mentionsServerRoom =
normalized.includes("server room") ||
normalized.includes("github") ||
normalized.includes("api") ||
normalized.includes("code review") ||
normalized.includes("pull request") ||
normalized.includes("pull requests") ||
normalized.includes(" prs") ||
normalized.startsWith("pr ");
const githubReviewIntentPatterns = [
/\b(?:lets|let's)\s+review\s+(?:some\s+)?(?:prs?|pull requests?)\b/,
/\b(?:lets|let's)\s+review\s+(?:some\s+)?(?:apis?|code)\b/,
/\breview\s+(?:some\s+)?(?:prs?|pull requests?)\b/,
/\breview\s+(?:some\s+)?(?:apis?|code)\b/,
/\b(?:is|are)\s+there\s+any\s+(?:prs?|pull requests?)(?:\s+to\s+review)?\b/,
/\b(?:is|are)\s+there\s+any\s+(?:apis?|code)(?:\s+to\s+review)?\b/,
/\bany\s+(?:prs?|pull requests?)\s+to\s+review\b/,
/\bany\s+(?:apis?|code)\s+to\s+review\b/,
/\bcheck\s+github\b/,
/\bcheck\s+(?:the\s+)?pull requests?\b/,
/\bopen\s+github\b/,
/\bshow\s+github\b/,
/\bgo\s+to\s+(?:the\s+)?server room\b/,
/\bwalk\s+to\s+(?:the\s+)?server room\b/,
/\bhead\s+to\s+(?:the\s+)?server room\b/,
/\bgo\s+review\b/,
];
const matchesGithubReviewIntent = githubReviewIntentPatterns.some((pattern) =>
pattern.test(normalized),
);
const isGithubCommand =
matchesGithubReviewIntent || normalized.includes("review github");
if (isGithubCommand && (mentionsServerRoom || matchesGithubReviewIntent)) {
return { target: "github", action: "hold" };
}
const isGithubRelease =
mentionsServerRoom &&
(normalized.includes("leave") ||
normalized.includes("exit") ||
normalized.includes("close github") ||
normalized.includes("stop reviewing") ||
normalized.includes("done reviewing") ||
normalized.includes("leave the server room"));
if (isGithubRelease) return { target: "github", action: "release" };
return null;
};
const resolveOfficeGymSkillDirectiveFromNormalized = (
normalized: string,
): OfficeGymDirective | null => {
const skillIntentPatterns = [
/\bskills?\b/,
/\bskills?\s+marketplace\b/,
/\bbuild\s+(?:another\s+)?skill\b/,
/\bcreate\s+(?:another\s+)?skill\b/,
/\bdevelop\s+(?:another\s+)?skill\b/,
/\binstall\s+(?:a\s+)?skill\b/,
/\benable\s+(?:a\s+)?skill\b/,
/\bsetup\s+(?:a\s+)?skill\b/,
/\bconfigure\s+(?:a\s+)?skill\b/,
/\bopenclaw\s+skill\b/,
/\bskill\s+for\s+openclaw\b/,
];
return skillIntentPatterns.some((pattern) => pattern.test(normalized))
? "gym"
: null;
};
const resolveOfficeGymCommandDirectiveFromNormalized = (
normalized: string,
): OfficeGymDirective | null => {
const gymCommandPatterns = [
/\b(?:lets|let's)\s+go\s+to\s+the\s+gym\b/,
/\b(?:lets|let's)\s+go\s+to\s+gym\b/,
/\bgo\s+to\s+the\s+gym\b/,
/\bgo\s+to\s+gym\b/,
/\bhead\s+to\s+the\s+gym\b/,
/\bhead\s+to\s+gym\b/,
/\bgo\s+work\s+out\b/,
/\bgo\s+workout\b/,
];
return gymCommandPatterns.some((pattern) => pattern.test(normalized))
? "gym"
: null;
};
const resolveOfficeQaDirectiveFromNormalized = (
normalized: string,
): OfficeQaDirective | null => {
const qaIntentPatterns = [
/\bwrite\s+tests?\b/,
/\brun\s+tests?\b/,
/\b(?:verify|verification)\b/,
/\breproduce\b/,
/\bcheck\s+if\s+this\s+works\b/,
/\btest\s+this\b/,
/\btest\s+this\s+build\b/,
/\bqa\s+(?:room|lab)\b/,
/\bquality\s+assurance\b/,
/\bdebug\s+this\b/,
];
const qaReleasePatterns = [
/\bleave\s+(?:the\s+)?(?:qa\s+(?:room|lab)|testing\s+lab)\b/,
/\bexit\s+(?:the\s+)?(?:qa\s+(?:room|lab)|testing\s+lab)\b/,
/\bclose\s+(?:the\s+)?qa\s+(?:room|lab)\b/,
/\bdone\s+(?:testing|verifying|reproducing)\b/,
/\bstop\s+(?:testing|verifying|reproducing)\b/,
];
if (qaReleasePatterns.some((pattern) => pattern.test(normalized))) {
return "release";
}
return qaIntentPatterns.some((pattern) => pattern.test(normalized))
? "qa_lab"
: null;
};
const resolveOfficeStandupDirectiveFromNormalized = (
normalized: string,
): OfficeStandupDirective | null => {
const hasMeetingKeyword =
normalized.includes("standup") ||
normalized.includes("scrum") ||
normalized.includes("standard meeting");
if (
normalized.includes("meeting time") ||
normalized.includes("standup meeting") ||
normalized.includes("scrum meeting") ||
normalized.includes("standard meeting")
) {
return "standup";
}
if (
hasMeetingKeyword &&
(normalized.includes("let's have") ||
normalized.includes("lets have") ||
normalized.includes("have a") ||
normalized.includes("start the") ||
normalized.includes("start "))
) {
return "standup";
}
const standupIntentPatterns = [
/\b(?:lets|let's)\s+have\s+(?:a\s+)?standup(?:\s+meeting)?\b/,
/\b(?:lets|let's)\s+have\s+(?:a\s+)?scrum(?:\s+meeting)?\b/,
/\b(?:lets|let's)\s+have\s+(?:a\s+)?standard\s+meeting\b/,
/\bstandup\s+meeting\b/,
/\bscrum\s+meeting\b/,
/\bstandard\s+meeting\b/,
/\bmeeting\s+time\b/,
/\bscrum\s+meeting\s+time\b/,
/\bstandup\s+time\b/,
/\bstart\s+(?:the\s+)?standup\b/,
/\bstart\s+(?:the\s+)?scrum\b/,
/\bhave\s+(?:a\s+)?meeting\b/,
];
return standupIntentPatterns.some((pattern) => pattern.test(normalized))
? "standup"
: null;
};
const normalizeOfficeCallCallee = (value: string): string => {
return value
.replace(/^(?:please|can you|could you|would you)\s+/i, "")
.replace(/\s+/g, " ")
.trim();
};
const normalizeOfficeTextRecipient = (value: string): string => {
return value
.replace(/^(?:please|can you|could you|would you)\s+/i, "")
.replace(/^(?:a\s+)?(?:text|message|dm)\s+to\s+/i, "")
.replace(/\s+/g, " ")
.trim();
};
const resolveOfficeCallDirectiveFromNormalized = (
normalized: string,
): OfficeCallDirective | null => {
if (!normalized.includes("call")) return null;
if (
normalized.includes("call it a day") ||
normalized.includes("callback") ||
normalized.includes("call stack")
) {
return null;
}
const match = normalized.match(
/\b(?:make|place|start)?\s*(?:a\s+)?call(?:\s+to)?\s+(.+)$/,
) ?? normalized.match(/\bcall\s+(.+)$/);
const tail = match?.[1]?.trim() ?? "";
if (!tail) return null;
const separators = [
/\s+and\s+tell\s+(?:him|her|them)\s+/,
/\s+and\s+tell\s+/,
/\s+tell\s+(?:him|her|them)\s+/,
/\s+tell\s+/,
/\s+and\s+say\s+/,
/\s+say\s+/,
];
for (const separator of separators) {
const parts = tail.split(separator);
if (parts.length < 2) continue;
const callee = normalizeOfficeCallCallee(parts[0] ?? "");
const message = parts.slice(1).join(" ").trim();
if (!callee || !message) continue;
return {
callee,
message,
phase: "ready_to_call",
};
}
const callee = normalizeOfficeCallCallee(tail);
if (!callee) return null;
return {
callee,
message: null,
phase: "needs_message",
};
};
const resolveOfficeTextDirectiveFromNormalized = (
normalized: string,
): OfficeTextDirective | null => {
if (
!/\b(?:text|message|whatsapp|whats\s+app|slack|dm)\b/.test(normalized)
) {
return null;
}
if (/\b(?:message\s+me|direct\s+message\s+me)\b/.test(normalized)) {
return null;
}
const directTail =
normalized.match(
/\b(?:send\s+)?(?:a\s+)?(?:text(?:\s+message)?|message|whatsapp|whats\s+app|slack(?:\s+dm)?|dm)(?:\s+to)?\s+(.+)$/,
)?.[1]?.trim() ??
"";
const invertedMatch = normalized.match(
/\bsend\s+(.+?)\s+(?:a\s+)?(?:text(?:\s+message)?|message|dm)\b(?:\s+(.+))?$/,
);
const tail = directTail || invertedMatch?.[1]?.trim() || "";
const trailingHint = invertedMatch?.[2]?.trim() ?? "";
if (!tail) return null;
const separators = [
/\s+that\s+/,
/\s+saying\s+/,
/\s+and\s+say\s+/,
/\s+say\s+/,
/\s+with\s+the\s+message\s+/,
];
for (const separator of separators) {
const source = trailingHint
? `${tail} ${trailingHint}`.trim()
: tail;
const parts = source.split(separator);
if (parts.length < 2) continue;
const recipient = normalizeOfficeTextRecipient(parts[0] ?? "");
const message = parts.slice(1).join(" ").trim();
if (!recipient || !message) continue;
return {
recipient,
message,
phase: "ready_to_send",
};
}
const recipient = normalizeOfficeTextRecipient(tail);
if (!recipient) return null;
return {
recipient,
message: null,
phase: "needs_message",
};
};
export const resolveOfficeIntentSnapshot = (
value: string | null | undefined,
): OfficeIntentSnapshot => {
// Normalize once so every downstream intent category is derived from the same text view.
const normalized = normalizeDirectiveText(value);
if (!normalized) {
return {
normalized: "",
desk: null,
github: null,
gym: null,
qa: null,
art: null,
standup: null,
call: null,
text: null,
};
}
const cached = getCachedIntentSnapshot(normalized);
if (cached) return cached;
const interactionDirective = resolveOfficeInteractionDirectiveFromNormalized(
normalized,
);
const gymManualDirective = resolveOfficeGymCommandDirectiveFromNormalized(
normalized,
);
const qaDirective = resolveOfficeQaDirectiveFromNormalized(normalized);
const gymSkillDirective = resolveOfficeGymSkillDirectiveFromNormalized(normalized);
const standupDirective = resolveOfficeStandupDirectiveFromNormalized(normalized);
const callDirective = resolveOfficeCallDirectiveFromNormalized(normalized);
const textDirective = resolveOfficeTextDirectiveFromNormalized(normalized);
const gymDirective = gymManualDirective
? {
directive: gymManualDirective,
source: "manual" as const,
}
: gymSkillDirective
? {
directive: gymSkillDirective,
source: "skill" as const,
}
: null;
return cacheIntentSnapshot(normalized, {
normalized,
desk:
interactionDirective?.target === "desk"
? interactionDirective.action === "hold"
? "desk"
: "release"
: null,
github:
interactionDirective?.target === "github"
? interactionDirective.action === "hold"
? "github"
: "release"
: null,
gym: gymDirective,
qa: qaDirective,
art: null,
standup: standupDirective,
call: callDirective,
text: textDirective,
});
};
export const resolveOfficeDeskDirective = (
value: string | null | undefined,
): OfficeDeskDirective | null => resolveOfficeIntentSnapshot(value).desk;
export const resolveOfficeGithubDirective = (
value: string | null | undefined,
): OfficeGithubDirective | null => resolveOfficeIntentSnapshot(value).github;
export const resolveOfficeGymDirective = (
value: string | null | undefined,
): OfficeGymDirective | null => {
const gymIntent = resolveOfficeIntentSnapshot(value).gym;
return gymIntent?.source === "skill" ? gymIntent.directive : null;
};
export const resolveOfficeGymCommandDirective = (
value: string | null | undefined,
): OfficeGymDirective | null => {
const gymIntent = resolveOfficeIntentSnapshot(value).gym;
return gymIntent?.source === "manual" ? gymIntent.directive : null;
};
export const resolveOfficeQaDirective = (
value: string | null | undefined,
): OfficeQaDirective | null => resolveOfficeIntentSnapshot(value).qa;
export const resolveOfficeStandupDirective = (
value: string | null | undefined,
): OfficeStandupDirective | null => resolveOfficeIntentSnapshot(value).standup;
export const resolveOfficeCallDirective = (
value: string | null | undefined,
): OfficeCallDirective | null => resolveOfficeIntentSnapshot(value).call;
export const resolveOfficeTextDirective = (
value: string | null | undefined,
): OfficeTextDirective | null => resolveOfficeIntentSnapshot(value).text;
const resolveTranscriptDirective = <
TDirective extends
| OfficeDeskDirective
| OfficeGithubDirective
| OfficeGymDirective
| OfficeQaDirective
| OfficeStandupDirective,
>(
entries: TranscriptEntry[] | undefined,
resolver: (value: string | null | undefined) => TDirective | null,
): TDirective | null => {
if (!Array.isArray(entries) || entries.length === 0) return null;
for (let index = entries.length - 1; index >= 0; index -= 1) {
const entry = entries[index];
if (!entry || entry.role !== "user") continue;
const directive = resolver(entry.text);
if (directive) return directive;
}
return null;
};
export const reduceOfficeDeskHoldState = (params: {
currentHeld: boolean;
lastUserMessage: string | null | undefined;
transcriptEntries: TranscriptEntry[] | undefined;
}): boolean => {
// Hold reducers intentionally fall back to transcript history so transport-specific sessions
// can recover the latest durable directive after canonical history refreshes.
const latestMessageDirective = resolveOfficeDeskDirective(
params.lastUserMessage,
);
if (latestMessageDirective === "desk") return true;
if (latestMessageDirective === "release") return false;
const transcriptDirective = resolveTranscriptDirective(
params.transcriptEntries,
resolveOfficeDeskDirective,
);
if (transcriptDirective === "desk") return true;
if (transcriptDirective === "release") return false;
return params.currentHeld;
};
export const reduceOfficeGithubHoldState = (params: {
currentHeld: boolean;
lastUserMessage: string | null | undefined;
transcriptEntries: TranscriptEntry[] | undefined;
}): boolean => {
const latestMessageDirective = resolveOfficeGithubDirective(
params.lastUserMessage,
);
if (latestMessageDirective === "github") return true;
if (latestMessageDirective === "release") return false;
const transcriptDirective = resolveTranscriptDirective(
params.transcriptEntries,
resolveOfficeGithubDirective,
);
if (transcriptDirective === "github") return true;
if (transcriptDirective === "release") return false;
return params.currentHeld;
};
export const reduceOfficeGymHoldState = (params: {
currentHeld: boolean;
isAgentRunning: boolean;
lastUserMessage: string | null | undefined;
transcriptEntries: TranscriptEntry[] | undefined;
}): boolean => {
if (!params.isAgentRunning) return false;
const latestMessageDirective = resolveOfficeGymDirective(
params.lastUserMessage,
);
if (latestMessageDirective === "gym") return true;
const transcriptDirective = resolveTranscriptDirective(
params.transcriptEntries,
resolveOfficeGymDirective,
);
if (transcriptDirective === "gym") return true;
return params.currentHeld;
};
export const reduceOfficeQaHoldState = (params: {
currentHeld: boolean;
lastUserMessage: string | null | undefined;
transcriptEntries: TranscriptEntry[] | undefined;
}): boolean => {
const latestMessageDirective = resolveOfficeQaDirective(
params.lastUserMessage,
);
if (latestMessageDirective === "qa_lab") return true;
if (latestMessageDirective === "release") return false;
const transcriptDirective = resolveTranscriptDirective(
params.transcriptEntries,
resolveOfficeQaDirective,
);
if (transcriptDirective === "qa_lab") return true;
if (transcriptDirective === "release") return false;
return params.currentHeld;
};
+326
View File
@@ -0,0 +1,326 @@
import {
buildAgentChatItems,
type AgentChatItem,
} from "@/features/agents/components/chatItems";
import type { AgentState } from "@/features/agents/state/store";
import { parseToolMarkdown } from "@/lib/text/message-extract";
export type OfficeDeskMonitorMode =
| "coding"
| "browser"
| "waiting"
| "idle"
| "error";
export type OfficeDeskMonitorEntry = {
kind: "user" | "assistant" | "thinking" | "tool";
text: string;
live?: boolean;
};
export type OfficeDeskMonitor = {
agentId: string;
agentName: string;
mode: OfficeDeskMonitorMode;
title: string;
subtitle: string;
browserUrl: string | null;
updatedAt: number | null;
live: boolean;
entries: OfficeDeskMonitorEntry[];
editor: {
fileName: string;
language: string;
lines: string[];
terminalLines: string[];
cursorLine: number;
cursorColumn: number;
} | null;
};
const URL_RE = /\bhttps?:\/\/[^\s<>"'`]+/gi;
const DOMAIN_RE =
/\b(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}(?:\/[^\s<>"'`]*)?\b/gi;
const PATH_RE =
/(?:^|[\s("'`])((?:src|app|components|pages|lib|tests|server|scripts)\/[^\s)"'`]+?\.(?:ts|tsx|js|jsx|html|css|json|md))/g;
const CODE_FENCE_RE = /```([a-z0-9_+-]+)?\n([\s\S]*?)```/i;
const BROWSER_KEYWORD_RE =
/\b(browser|navigate|snapshot|screenshot|tab|click|console|cookies|storage|page|url)\b/i;
const BROWSER_INTENT_RE =
/\b(browse|inspect|visit|navigate|open|go to|website|site|page)\b/i;
const extractUrls = (value: string): string[] => {
const matches = value.match(URL_RE);
return matches ? matches.map((entry) => entry.trim()) : [];
};
const normalizeBrowserUrl = (value: string): string | null => {
const trimmed = value.trim().replace(/[.,;!?]+$/g, "");
if (!trimmed) return null;
if (/^https?:\/\//i.test(trimmed)) return trimmed;
if (!trimmed.includes(".") || /\s/.test(trimmed)) return null;
return `https://${trimmed}`;
};
const extractDomains = (value: string): string[] => {
const matches = value.match(DOMAIN_RE);
return matches ? matches.map((entry) => entry.trim()) : [];
};
const extractPath = (value: string): string | null => {
const match = value.match(PATH_RE);
if (!match || match.length === 0) return null;
const last = match[match.length - 1];
if (!last) return null;
return last.trim().replace(/^[\s("'`]+/, "");
};
const normalizeEntryText = (text: string): string => {
return text.replace(/\s+/g, " ").trim();
};
const flattenMonitorEntry = (item: AgentChatItem): OfficeDeskMonitorEntry | null => {
const text =
item.kind === "tool"
? (() => {
const parsed = parseToolMarkdown(item.text);
const body = parsed.body.trim();
return normalizeEntryText(body ? `${parsed.label}: ${body}` : parsed.label);
})()
: normalizeEntryText(item.text);
if (!text) return null;
return {
kind: item.kind,
text,
...("live" in item && item.live ? { live: true } : {}),
};
};
const toCommentLine = (value: string): string => ` // ${value}`;
const derivePseudoEditor = (task: string): { fileName: string; language: string; lines: string[] } => {
const normalized = task.trim().toLowerCase();
if (normalized.includes("contact form")) {
return {
fileName: "ContactForm.tsx",
language: "tsx",
lines: [
'export default function ContactForm() {',
' return (',
' <main className="mx-auto max-w-xl p-8">',
' <h1 className="text-3xl font-semibold">Contact us</h1>',
' <form className="mt-6 space-y-4 rounded-2xl border p-6 shadow-sm">',
' <input className="w-full rounded-lg border px-4 py-3" placeholder="Name" />',
' <input className="w-full rounded-lg border px-4 py-3" placeholder="Email" />',
' <textarea className="min-h-40 w-full rounded-lg border px-4 py-3" placeholder="Message" />',
' <button className="rounded-lg bg-black px-5 py-3 text-white">Send message</button>',
' </form>',
' </main>',
' );',
'}',
],
};
}
if (normalized.includes("hello world")) {
return {
fileName: "page.tsx",
language: "tsx",
lines: [
'export default function Page() {',
' return (',
' <main className="flex min-h-screen items-center justify-center">',
' <h1 className="text-5xl font-bold">Hello world</h1>',
' </main>',
' );',
'}',
],
};
}
if (normalized.includes("html")) {
return {
fileName: "index.html",
language: "html",
lines: [
'<!DOCTYPE html>',
'<html lang="en">',
'<head>',
' <meta charset="UTF-8" />',
' <meta name="viewport" content="width=device-width, initial-scale=1.0" />',
' <title>Working Draft</title>',
'</head>',
'<body>',
` <!-- ${task.trim()} -->`,
'</body>',
'</html>',
],
};
}
return {
fileName: "workbench.tsx",
language: "tsx",
lines: [
'export function Workbench() {',
toCommentLine(task.trim() || "Working on the requested task."),
' return (',
' <section>',
' <div>Implementing monitor preview...</div>',
' </section>',
' );',
'}',
],
};
};
const deriveEditorDocument = (params: {
agent: AgentState;
entries: OfficeDeskMonitorEntry[];
}): OfficeDeskMonitor["editor"] => {
const codeSource = [...params.entries]
.reverse()
.find((entry) => entry.kind === "assistant" || entry.kind === "tool" || entry.kind === "thinking");
const sourceText = codeSource?.text ?? "";
const codeFence = sourceText.match(CODE_FENCE_RE);
let fileName =
extractPath(sourceText) ??
extractPath(params.agent.lastUserMessage ?? "") ??
null;
let language = codeFence?.[1]?.trim() || "";
let lines: string[] = [];
if (codeFence?.[2]) {
lines = codeFence[2].replace(/\r/g, "").split("\n");
} else {
const task =
params.agent.lastUserMessage ??
[...params.entries].reverse().find((entry) => entry.kind === "user")?.text ??
"Working on the current request.";
const pseudo = derivePseudoEditor(task);
if (!fileName) fileName = pseudo.fileName;
if (!language) language = pseudo.language;
lines = pseudo.lines;
}
const resolvedFileName = fileName?.split("/").pop()?.trim() || "workbench.tsx";
const resolvedLanguage =
language ||
resolvedFileName.split(".").pop()?.trim() ||
"tsx";
const terminalLines = params.entries
.slice(-4)
.map((entry) => `${entry.kind === "tool" ? "$ " : entry.kind === "user" ? "> " : ""}${entry.text}`);
const cursorLine = Math.max(1, lines.length);
const cursorColumn = Math.max(1, (lines[lines.length - 1]?.length ?? 0) + 1);
return {
fileName: resolvedFileName,
language: resolvedLanguage,
lines,
terminalLines,
cursorLine,
cursorColumn,
};
};
const summarizeMode = (params: {
agent: AgentState;
entries: OfficeDeskMonitorEntry[];
browserUrl: string | null;
}): { mode: OfficeDeskMonitorMode; title: string; subtitle: string } => {
const { agent, entries, browserUrl } = params;
if (agent.status === "error") {
return {
mode: "error",
title: "Run error",
subtitle: agent.latestPreview ?? "The agent hit an error.",
};
}
if (browserUrl) {
let hostname = browserUrl;
try {
hostname = new URL(browserUrl).host || browserUrl;
} catch {
// Keep the raw URL when parsing fails.
}
return {
mode: "browser",
title: "Browsing",
subtitle: hostname,
};
}
if (agent.awaitingUserInput) {
return {
mode: "waiting",
title: "Waiting",
subtitle: agent.latestPreview ?? "Waiting for the next instruction.",
};
}
if (
agent.status === "running" ||
agent.streamText ||
agent.thinkingTrace ||
entries.some((entry) => entry.live)
) {
return {
mode: "coding",
title: "Working",
subtitle: agent.latestPreview ?? "Live agent activity.",
};
}
return {
mode: "idle",
title: "Idle",
subtitle: agent.latestPreview ?? "No recent live activity.",
};
};
export const buildOfficeDeskMonitor = (
agent: AgentState,
): OfficeDeskMonitor => {
const chatItems = buildAgentChatItems({
outputLines: agent.outputLines,
streamText: agent.streamText,
liveThinkingTrace: agent.thinkingTrace ?? "",
showThinkingTraces: agent.showThinkingTraces,
toolCallingEnabled: agent.toolCallingEnabled,
});
const flatEntries = chatItems
.map(flattenMonitorEntry)
.filter((entry): entry is OfficeDeskMonitorEntry => Boolean(entry));
const latestEntries = flatEntries.slice(-6);
const browserUrl =
[
agent.lastUserMessage ?? "",
agent.latestPreview ?? "",
...latestEntries.map((entry) => entry.text),
...flatEntries.map((entry) => entry.text),
]
.flatMap((text) => [
...extractUrls(text),
...extractDomains(text)
.filter(() => BROWSER_KEYWORD_RE.test(text) || BROWSER_INTENT_RE.test(text)),
])
.map((value) => normalizeBrowserUrl(value))
.find((value): value is string => Boolean(value)) ??
null;
const modeSummary = summarizeMode({
agent,
entries: latestEntries,
browserUrl,
});
return {
agentId: agent.agentId,
agentName: agent.name,
mode: modeSummary.mode,
title: modeSummary.title,
subtitle: modeSummary.subtitle,
browserUrl,
updatedAt: agent.lastActivityAt ?? agent.lastAssistantMessageAt ?? null,
live:
agent.status === "running" ||
Boolean(agent.streamText) ||
Boolean(agent.thinkingTrace) ||
latestEntries.some((entry) => entry.live),
entries: latestEntries,
editor: modeSummary.mode === "coding" ? deriveEditorDocument({ agent, entries: flatEntries }) : null,
};
};
File diff suppressed because it is too large Load Diff
+642
View File
@@ -0,0 +1,642 @@
import * as childProcess from "node:child_process";
export type GitHubAuthState = "ready" | "missing-gh" | "unauthenticated";
export type GitHubPullRequestSummary = {
number: number;
title: string;
url: string;
repo: string;
author: string;
updatedAt: string | null;
isDraft: boolean;
labels: string[];
reviewDecision: string | null;
headRefName: string | null;
baseRefName: string | null;
statusSummary: string | null;
};
export type GitHubStatusCheck = {
name: string;
status: string | null;
conclusion: string | null;
workflow: string | null;
detailsUrl: string | null;
};
export type GitHubReviewEntry = {
author: string;
state: string | null;
body: string;
submittedAt: string | null;
};
export type GitHubCommentEntry = {
author: string;
body: string;
createdAt: string | null;
url: string | null;
};
export type GitHubCommitEntry = {
oid: string;
messageHeadline: string;
authoredDate: string | null;
};
export type GitHubFileEntry = {
path: string;
additions: number;
deletions: number;
status: string | null;
previousPath: string | null;
patch: string | null;
};
export type GitHubPullRequestDetail = GitHubPullRequestSummary & {
body: string;
state: string | null;
mergeable: string | null;
headRefOid: string | null;
statusChecks: GitHubStatusCheck[];
reviews: GitHubReviewEntry[];
comments: GitHubCommentEntry[];
commits: GitHubCommitEntry[];
files: GitHubFileEntry[];
diff: string;
diffTruncated: boolean;
};
export type GitHubDashboardResponse = {
ready: boolean;
authState: GitHubAuthState;
viewerLogin: string | null;
currentRepoSlug: string | null;
currentRepoPullRequests: GitHubPullRequestSummary[];
reviewRequests: GitHubPullRequestSummary[];
authoredPullRequests: GitHubPullRequestSummary[];
message: string | null;
};
export type GitHubDetailResponse = {
ready: boolean;
authState: GitHubAuthState;
viewerLogin: string | null;
currentRepoSlug: string | null;
pullRequest: GitHubPullRequestDetail | null;
message: string | null;
};
export type GitHubReviewAction = "APPROVE" | "COMMENT" | "REQUEST_CHANGES";
export type GitHubInlineCommentSide = "LEFT" | "RIGHT";
const DEFAULT_MAX_BUFFER = 8 * 1024 * 1024;
const DIFF_PREVIEW_LIMIT = 80_000;
const trimText = (value: unknown): string | null => {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed ? trimmed : null;
};
const toNumber = (value: unknown): number => {
return typeof value === "number" && Number.isFinite(value) ? value : 0;
};
const toRecord = (value: unknown): Record<string, unknown> => {
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
};
const toArray = (value: unknown): unknown[] => {
return Array.isArray(value) ? value : [];
};
const extractCommandMessage = (stderr: string, stdout: string, fallback: string): string => {
const stderrText = stderr.trim();
if (stderrText) return stderrText;
const stdoutText = stdout.trim();
if (stdoutText) return stdoutText;
return fallback;
};
const runCommand = (command: string, args: string[], options?: { input?: string; maxBuffer?: number }) => {
const result = childProcess.spawnSync(command, args, {
cwd: process.cwd(),
encoding: "utf8",
input: options?.input,
maxBuffer: options?.maxBuffer ?? DEFAULT_MAX_BUFFER,
});
if (result.error) {
throw result.error;
}
return {
stdout: result.stdout ?? "",
stderr: result.stderr ?? "",
status: result.status ?? 0,
};
};
const runJsonCommand = <T>(command: string, args: string[], label: string): T => {
const result = runCommand(command, args);
if (result.status !== 0) {
throw new Error(
extractCommandMessage(result.stderr, result.stdout, `Failed to run ${label}.`),
);
}
const trimmed = result.stdout.trim();
if (!trimmed) {
throw new Error(`Empty JSON response from ${label}.`);
}
try {
return JSON.parse(trimmed) as T;
} catch {
throw new Error(`Invalid JSON response from ${label}.`);
}
};
const runTextCommand = (command: string, args: string[], label: string): string => {
const result = runCommand(command, args);
if (result.status !== 0) {
throw new Error(
extractCommandMessage(result.stderr, result.stdout, `Failed to run ${label}.`),
);
}
return result.stdout;
};
const getGitHubAuthState = (): { authState: GitHubAuthState; viewerLogin: string | null; message: string | null } => {
try {
const version = runCommand("gh", ["--version"]);
if (version.status !== 0) {
return {
authState: "missing-gh",
viewerLogin: null,
message: extractCommandMessage(version.stderr, version.stdout, "GitHub CLI is not installed."),
};
}
} catch {
return {
authState: "missing-gh",
viewerLogin: null,
message: "GitHub CLI is not installed.",
};
}
try {
const viewerLogin = runTextCommand("gh", ["api", "user", "--jq", ".login"], "gh api user").trim();
return {
authState: "ready",
viewerLogin: viewerLogin || null,
message: null,
};
} catch (error) {
return {
authState: "unauthenticated",
viewerLogin: null,
message: error instanceof Error ? error.message : "GitHub CLI is not authenticated.",
};
}
};
const parseRemoteUrl = (remoteUrl: string): string | null => {
const trimmed = remoteUrl.trim();
if (!trimmed) return null;
try {
const url = new URL(trimmed);
const pathname = url.pathname.replace(/^\/+/, "").replace(/\.git$/i, "");
return pathname || null;
} catch {
const sshMatch = trimmed.match(/github\.com[:/](.+?)(?:\.git)?$/i);
return sshMatch?.[1]?.trim() || null;
}
};
export const resolveCurrentRepoSlug = (): string | null => {
try {
const remoteUrl = runTextCommand("git", ["remote", "get-url", "origin"], "git remote get-url origin");
return parseRemoteUrl(remoteUrl);
} catch {
return null;
}
};
const summarizeStatusChecks = (value: unknown): string | null => {
const entries = toArray(value);
if (entries.length === 0) return null;
let failed = 0;
let pending = 0;
let passed = 0;
for (const entry of entries) {
const record = toRecord(entry);
const state = trimText(record.state)?.toUpperCase() ?? trimText(record.status)?.toUpperCase() ?? "";
const conclusion = trimText(record.conclusion)?.toUpperCase() ?? "";
if (conclusion === "FAILURE" || conclusion === "TIMED_OUT" || conclusion === "CANCELLED") {
failed += 1;
} else if (state === "PENDING" || state === "IN_PROGRESS" || state === "QUEUED" || state === "EXPECTED") {
pending += 1;
} else {
passed += 1;
}
}
if (failed > 0) return `${failed} failing`;
if (pending > 0) return `${pending} pending`;
if (passed > 0) return `${passed} passing`;
return null;
};
const normalizeLabels = (value: unknown): string[] => {
return toArray(value)
.map((entry) => trimText(toRecord(entry).name) ?? trimText(entry))
.filter((entry): entry is string => Boolean(entry));
};
const normalizeSummary = (value: unknown, fallbackRepo: string | null = null): GitHubPullRequestSummary => {
const record = toRecord(value);
const repository = toRecord(record.repository);
const repo =
trimText(repository.nameWithOwner) ??
trimText(record.repo) ??
fallbackRepo ??
"unknown/unknown";
return {
number: toNumber(record.number),
title: trimText(record.title) ?? "Untitled pull request",
url: trimText(record.url) ?? "",
repo,
author: trimText(toRecord(record.author).login) ?? "unknown",
updatedAt: trimText(record.updatedAt),
isDraft: Boolean(record.isDraft),
labels: normalizeLabels(record.labels),
reviewDecision: trimText(record.reviewDecision),
headRefName: trimText(record.headRefName),
baseRefName: trimText(record.baseRefName),
statusSummary: summarizeStatusChecks(record.statusCheckRollup),
};
};
const normalizeStatusChecks = (value: unknown): GitHubStatusCheck[] => {
return toArray(value).map((entry) => {
const record = toRecord(entry);
return {
name:
trimText(record.name) ??
trimText(record.context) ??
trimText(record.workflowName) ??
"Unnamed check",
status: trimText(record.state) ?? trimText(record.status),
conclusion: trimText(record.conclusion),
workflow: trimText(record.workflowName),
detailsUrl: trimText(record.detailsUrl) ?? trimText(record.targetUrl),
};
});
};
const normalizeReviews = (value: unknown): GitHubReviewEntry[] => {
return toArray(value).map((entry) => {
const record = toRecord(entry);
return {
author: trimText(toRecord(record.author).login) ?? "unknown",
state: trimText(record.state),
body: trimText(record.body) ?? "",
submittedAt: trimText(record.submittedAt),
};
});
};
const normalizeComments = (value: unknown): GitHubCommentEntry[] => {
return toArray(value).map((entry) => {
const record = toRecord(entry);
return {
author: trimText(toRecord(record.author).login) ?? "unknown",
body: trimText(record.body) ?? "",
createdAt: trimText(record.createdAt),
url: trimText(record.url),
};
});
};
const normalizeCommits = (value: unknown): GitHubCommitEntry[] => {
return toArray(value).map((entry) => {
const record = toRecord(entry);
return {
oid: trimText(record.oid) ?? "",
messageHeadline: trimText(record.messageHeadline) ?? "Commit",
authoredDate: trimText(record.authoredDate),
};
});
};
const normalizeFiles = (value: unknown): GitHubFileEntry[] => {
return toArray(value).map((entry) => {
const record = toRecord(entry);
const rawPatch = typeof record.patch === "string" ? record.patch.trimEnd() : "";
return {
path: trimText(record.path) ?? trimText(record.filename) ?? "unknown",
additions: toNumber(record.additions),
deletions: toNumber(record.deletions),
status: trimText(record.status),
previousPath:
trimText(record.previousPath) ?? trimText(record.previous_filename),
patch: rawPatch.trim() ? rawPatch : null,
};
});
};
const loadSearchResults = (args: string[]): GitHubPullRequestSummary[] => {
const payload = runJsonCommand<unknown[]>("gh", args, "gh search prs");
return payload.map((entry) => normalizeSummary(entry));
};
const loadRepoPullRequests = (repo: string): GitHubPullRequestSummary[] => {
const payload = runJsonCommand<unknown[]>(
"gh",
[
"pr",
"list",
"--repo",
repo,
"--state",
"open",
"--limit",
"25",
"--json",
"number,title,url,author,reviewDecision,isDraft,updatedAt,headRefName,baseRefName,statusCheckRollup,labels",
],
"gh pr list",
);
return payload.map((entry) => normalizeSummary(entry, repo));
};
const loadPullRequestDiff = (repo: string, number: number): { diff: string; diffTruncated: boolean } => {
try {
const output = runTextCommand(
"gh",
["pr", "diff", String(number), "--repo", repo],
"gh pr diff",
);
const diff = output.trimEnd();
if (diff.length <= DIFF_PREVIEW_LIMIT) {
return { diff, diffTruncated: false };
}
return {
diff: `${diff.slice(0, DIFF_PREVIEW_LIMIT).trimEnd()}\n\n... diff truncated ...`,
diffTruncated: true,
};
} catch {
return { diff: "", diffTruncated: false };
}
};
const loadPullRequestFiles = (repo: string, number: number): GitHubFileEntry[] => {
try {
const payload = runJsonCommand<unknown[]>(
"gh",
["api", `repos/${repo}/pulls/${number}/files`, "--paginate", "--slurp"],
"gh api pull request files",
);
return normalizeFiles(payload.flatMap((page) => toArray(page)));
} catch {
return [];
}
};
const loadPullRequestDetail = (repo: string, number: number): GitHubPullRequestDetail => {
const payload = runJsonCommand<Record<string, unknown>>(
"gh",
[
"pr",
"view",
String(number),
"--repo",
repo,
"--json",
"number,title,url,author,files,reviews,comments,commits,reviewDecision,statusCheckRollup,headRefName,headRefOid,baseRefName,updatedAt,isDraft,labels,body,state,mergeable",
],
"gh pr view",
);
const diff = loadPullRequestDiff(repo, number);
const files = loadPullRequestFiles(repo, number);
return {
...normalizeSummary(payload, repo),
body: trimText(payload.body) ?? "",
state: trimText(payload.state),
mergeable: trimText(payload.mergeable),
headRefOid: trimText(payload.headRefOid),
statusChecks: normalizeStatusChecks(payload.statusCheckRollup),
reviews: normalizeReviews(payload.reviews),
comments: normalizeComments(payload.comments),
commits: normalizeCommits(payload.commits),
files: files.length > 0 ? files : normalizeFiles(payload.files),
diff: diff.diff,
diffTruncated: diff.diffTruncated,
};
};
export const loadGitHubDashboard = (): GitHubDashboardResponse => {
const auth = getGitHubAuthState();
const currentRepoSlug = resolveCurrentRepoSlug();
if (auth.authState !== "ready") {
return {
ready: false,
authState: auth.authState,
viewerLogin: auth.viewerLogin,
currentRepoSlug,
currentRepoPullRequests: [],
reviewRequests: [],
authoredPullRequests: [],
message: auth.message,
};
}
return {
ready: true,
authState: auth.authState,
viewerLogin: auth.viewerLogin,
currentRepoSlug,
currentRepoPullRequests: currentRepoSlug ? loadRepoPullRequests(currentRepoSlug) : [],
reviewRequests: loadSearchResults([
"search",
"prs",
"--review-requested",
"@me",
"--state",
"open",
"--limit",
"25",
"--json",
"number,title,url,author,repository,updatedAt,isDraft,labels",
]),
authoredPullRequests: loadSearchResults([
"search",
"prs",
"--author",
"@me",
"--state",
"open",
"--limit",
"25",
"--json",
"number,title,url,author,repository,updatedAt,isDraft,labels",
]),
message: null,
};
};
export const loadGitHubPullRequestDetail = (params: {
repo: string;
number: number;
}): GitHubDetailResponse => {
const auth = getGitHubAuthState();
const currentRepoSlug = resolveCurrentRepoSlug();
if (auth.authState !== "ready") {
return {
ready: false,
authState: auth.authState,
viewerLogin: auth.viewerLogin,
currentRepoSlug,
pullRequest: null,
message: auth.message,
};
}
return {
ready: true,
authState: auth.authState,
viewerLogin: auth.viewerLogin,
currentRepoSlug,
pullRequest: loadPullRequestDetail(params.repo, params.number),
message: null,
};
};
export const submitGitHubPullRequestReview = (params: {
repo: string;
number: number;
action: GitHubReviewAction;
body?: string | null;
}) => {
const auth = getGitHubAuthState();
if (auth.authState !== "ready") {
throw new Error(auth.message ?? "GitHub CLI is not ready.");
}
const args = ["pr", "review", String(params.number), "--repo", params.repo];
if (params.action === "APPROVE") {
args.push("--approve");
} else if (params.action === "REQUEST_CHANGES") {
args.push("--request-changes");
} else {
args.push("--comment");
}
const body =
params.body?.trim() ||
(params.action === "COMMENT"
? "Reviewed in Claw3D."
: params.action === "REQUEST_CHANGES"
? "Please address the requested updates from Claw3D."
: "");
if (body) {
args.push("--body", body);
}
const result = runCommand("gh", args, { maxBuffer: DEFAULT_MAX_BUFFER });
if (result.status !== 0) {
throw new Error(
extractCommandMessage(
result.stderr,
result.stdout,
"Failed to submit the GitHub review.",
),
);
}
return {
ok: true,
message:
params.action === "APPROVE"
? "Pull request approved."
: params.action === "REQUEST_CHANGES"
? "Requested changes on pull request."
: "Review comment submitted.",
};
};
const resolvePullRequestHeadOid = (repo: string, number: number): string => {
const oid = runTextCommand(
"gh",
[
"pr",
"view",
String(number),
"--repo",
repo,
"--json",
"headRefOid",
"--jq",
".headRefOid",
],
"gh pr view headRefOid",
).trim();
if (!oid) {
throw new Error("Unable to determine the latest pull request commit.");
}
return oid;
};
export const submitGitHubInlineComment = (params: {
repo: string;
number: number;
path: string;
line: number;
side: GitHubInlineCommentSide;
body: string;
commitId?: string | null;
}) => {
const auth = getGitHubAuthState();
if (auth.authState !== "ready") {
throw new Error(auth.message ?? "GitHub CLI is not ready.");
}
const trimmedBody = params.body.trim();
if (!trimmedBody) {
throw new Error("Comment body is required.");
}
const commitId = params.commitId?.trim() || resolvePullRequestHeadOid(params.repo, params.number);
const result = runCommand(
"gh",
[
"api",
`repos/${params.repo}/pulls/${params.number}/comments`,
"--method",
"POST",
"-f",
`body=${trimmedBody}`,
"-f",
`commit_id=${commitId}`,
"-f",
`path=${params.path}`,
"-F",
`line=${params.line}`,
"-f",
`side=${params.side}`,
],
{ maxBuffer: DEFAULT_MAX_BUFFER },
);
if (result.status !== 0) {
throw new Error(
extractCommandMessage(
result.stderr,
result.stdout,
"Failed to submit the GitHub inline comment.",
),
);
}
return {
ok: true,
message: "Inline comment submitted.",
};
};
+36
View File
@@ -0,0 +1,36 @@
import type { AgentState } from "@/features/agents/state/store";
export type SessionEpochSnapshot = Record<string, number>;
export type OfficeCleaningCue = {
id: string;
agentId: string;
agentName: string;
ts: number;
};
export const buildSessionEpochSnapshot = (
agents: AgentState[],
): SessionEpochSnapshot =>
Object.fromEntries(
agents.map((agent) => [agent.agentId, agent.sessionEpoch ?? 0]),
);
export const resolveResetAgentIds = ({
previous,
agents,
}: {
previous: SessionEpochSnapshot;
agents: AgentState[];
}): string[] => {
const triggered: string[] = [];
for (const agent of agents) {
const prevEpoch = previous[agent.agentId];
if (prevEpoch === undefined) continue;
const nextEpoch = agent.sessionEpoch ?? 0;
if (nextEpoch > prevEpoch) {
triggered.push(agent.agentId);
}
}
return triggered;
};
+73
View File
@@ -0,0 +1,73 @@
import fs from "node:fs";
import path from "node:path";
import { resolveStateDir } from "@/lib/clawdbot/paths";
import { readConfigAgentList } from "@/lib/gateway/agentConfig";
import type { OfficeAgentState } from "@/lib/office/schema";
export type OfficeAgentPresence = {
agentId: string;
name: string;
state: OfficeAgentState;
preferredDeskId?: string;
};
export type OfficePresenceSnapshot = {
workspaceId: string;
timestamp: string;
agents: OfficeAgentPresence[];
};
const OPENCLAW_CONFIG_FILENAME = "openclaw.json";
const stableHash = (input: string): number => {
let hash = 0;
for (let index = 0; index < input.length; index += 1) {
hash = (hash * 31 + input.charCodeAt(index)) >>> 0;
}
return hash;
};
const resolveStateFromSeed = (seed: number): OfficeAgentState => {
const mod = seed % 20;
if (mod <= 9) return "working";
if (mod <= 14) return "idle";
if (mod <= 17) return "meeting";
return "error";
};
export const loadOfficePresenceSnapshot = (workspaceId: string): OfficePresenceSnapshot => {
const configPath = path.join(resolveStateDir(), OPENCLAW_CONFIG_FILENAME);
const timestamp = new Date().toISOString();
if (!fs.existsSync(configPath)) {
return {
workspaceId,
timestamp,
agents: [],
};
}
const raw = fs.readFileSync(configPath, "utf8");
const parsed = JSON.parse(raw) as unknown;
const config =
parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: undefined;
const agentList = readConfigAgentList(config);
const bucket = Math.floor(Date.now() / 2000);
const agents: OfficeAgentPresence[] = agentList.map((entry) => {
const id = entry.id.trim();
const nameRaw = typeof entry.name === "string" ? entry.name : id;
const seed = stableHash(`${id}:${bucket}`);
return {
agentId: id,
name: nameRaw,
state: resolveStateFromSeed(seed),
preferredDeskId: `desk-${id}`,
};
});
return {
workspaceId,
timestamp,
agents,
};
};
+935
View File
@@ -0,0 +1,935 @@
export type OfficeLayerId =
| "floor"
| "walls"
| "furniture"
| "decor"
| "lighting"
| "agents";
export type OfficeZoneType =
| "desk_zone"
| "meeting_room"
| "lounge"
| "game_room"
| "hallway"
| "coffee_area";
export type OfficeAgentState = "idle" | "working" | "meeting" | "error";
export type OfficeLightPreset =
| "ceiling_lamp"
| "desk_monitor"
| "tv_glow"
| "meeting_spotlight"
| "emergency_error";
export type OfficeLightAnimationPreset =
| "steady"
| "soft_flicker"
| "breathing_pulse"
| "error_strobe_subtle";
export type OfficeAmbiencePreset =
| "coffee_steam"
| "window_dust"
| "game_sparkle"
| "plant_pollen";
export type OfficeInteractionKind =
| "couch_sit"
| "arcade_stand"
| "tv_watch"
| "desk_seat"
| "window_stand";
export type OfficeVector = {
x: number;
y: number;
};
export type OfficePolygon = {
points: OfficeVector[];
};
export type OfficeLayer = {
id: OfficeLayerId;
visible: boolean;
locked: boolean;
opacity: number;
parallax: number;
};
export type OfficeMapObject = {
id: string;
assetId: string;
layerId: OfficeLayerId;
x: number;
y: number;
rotation: number;
flipX: boolean;
flipY: boolean;
zIndex: number;
tags: string[];
};
export type OfficeLightBinding = {
agentId?: string;
zoneId?: string;
deskObjectId?: string;
state?: OfficeAgentState;
};
export type OfficeFlickerProfile = {
speed: number;
amplitude: number;
};
export type OfficeLightObject = {
id: string;
preset: OfficeLightPreset;
animationPreset: OfficeLightAnimationPreset;
x: number;
y: number;
radius: number;
baseIntensity: number;
spriteAssetId?: string;
flicker?: OfficeFlickerProfile;
binding?: OfficeLightBinding;
roomId?: string;
enabled: boolean;
};
export type OfficeAmbienceEmitter = {
id: string;
preset: OfficeAmbiencePreset;
zoneId: string;
maxParticles: number;
spawnRate: number;
enabled: boolean;
};
export type OfficeInteractionPoint = {
id: string;
kind: OfficeInteractionKind;
x: number;
y: number;
zoneId?: string;
facingDegrees?: number;
tags: string[];
};
export type OfficeZone = {
id: string;
type: OfficeZoneType;
name: string;
shape: OfficePolygon;
capacity?: number;
interactionPointIds?: string[];
ambienceTags?: string[];
};
export type OfficeCollision = {
id: string;
shape: OfficePolygon;
blocked: boolean;
};
export type OfficeSpawnPoint = {
id: string;
x: number;
y: number;
};
export type OfficeDeskAssignment = {
deskObjectId: string;
seatAnchor: OfficeVector;
facingDegrees: number;
};
export type OfficeTheme = {
mood: "neutral" | "focus" | "cozy" | "night";
enableThoughtBubbles: boolean;
};
export type OfficeLightingOverlay = {
enabled: boolean;
baseDarkness: number;
roomDarkness: Record<string, number>;
};
export type OfficeCanvas = {
width: number;
height: number;
tileSize: number;
backgroundColor: string;
};
export type OfficeMap = {
mapVersion: number;
workspaceId: string;
officeVersionId: string;
canvas: OfficeCanvas;
layers: OfficeLayer[];
objects: OfficeMapObject[];
zones: OfficeZone[];
collisions: OfficeCollision[];
spawnPoints: OfficeSpawnPoint[];
deskAssignments: Record<string, OfficeDeskAssignment>;
lightingOverlay?: OfficeLightingOverlay;
lights?: OfficeLightObject[];
ambienceEmitters?: OfficeAmbienceEmitter[];
interactionPoints?: OfficeInteractionPoint[];
theme?: OfficeTheme;
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === "object" && !Array.isArray(value));
const asString = (value: unknown, fallback = ""): string =>
typeof value === "string" ? value : fallback;
const asNumber = (value: unknown, fallback = 0): number =>
typeof value === "number" && Number.isFinite(value) ? value : fallback;
const asBoolean = (value: unknown, fallback = false): boolean =>
typeof value === "boolean" ? value : fallback;
const normalizeLayerId = (value: unknown): OfficeLayerId => {
const normalized = asString(value);
if (
normalized === "floor" ||
normalized === "walls" ||
normalized === "furniture" ||
normalized === "decor" ||
normalized === "lighting" ||
normalized === "agents"
) {
return normalized;
}
return "decor";
};
const normalizeZoneType = (value: unknown): OfficeZoneType => {
const normalized = asString(value);
if (
normalized === "desk_zone" ||
normalized === "meeting_room" ||
normalized === "lounge" ||
normalized === "game_room" ||
normalized === "hallway" ||
normalized === "coffee_area"
) {
return normalized;
}
return "hallway";
};
const normalizeLightPreset = (value: unknown): OfficeLightPreset => {
const normalized = asString(value);
if (
normalized === "ceiling_lamp" ||
normalized === "desk_monitor" ||
normalized === "tv_glow" ||
normalized === "meeting_spotlight" ||
normalized === "emergency_error"
) {
return normalized;
}
return "ceiling_lamp";
};
const normalizeLightAnimationPreset = (
value: unknown,
): OfficeLightAnimationPreset => {
const normalized = asString(value);
if (
normalized === "steady" ||
normalized === "soft_flicker" ||
normalized === "breathing_pulse" ||
normalized === "error_strobe_subtle"
) {
return normalized;
}
return "steady";
};
const normalizeAmbiencePreset = (value: unknown): OfficeAmbiencePreset => {
const normalized = asString(value);
if (
normalized === "coffee_steam" ||
normalized === "window_dust" ||
normalized === "game_sparkle" ||
normalized === "plant_pollen"
) {
return normalized;
}
return "window_dust";
};
const normalizeInteractionKind = (value: unknown): OfficeInteractionKind => {
const normalized = asString(value);
if (
normalized === "couch_sit" ||
normalized === "arcade_stand" ||
normalized === "tv_watch" ||
normalized === "desk_seat" ||
normalized === "window_stand"
) {
return normalized;
}
return "window_stand";
};
const normalizeVector = (value: unknown): OfficeVector => {
if (!isRecord(value)) {
return { x: 0, y: 0 };
}
return {
x: asNumber(value.x, 0),
y: asNumber(value.y, 0),
};
};
const normalizePolygon = (value: unknown): OfficePolygon => {
if (!isRecord(value)) {
return { points: [] };
}
const points = Array.isArray(value.points)
? value.points.map(normalizeVector)
: [];
return { points };
};
const defaultLayers = (): OfficeLayer[] => [
{ id: "floor", visible: true, locked: false, opacity: 1, parallax: 1 },
{ id: "walls", visible: true, locked: false, opacity: 1, parallax: 1 },
{ id: "furniture", visible: true, locked: false, opacity: 1, parallax: 1 },
{ id: "decor", visible: true, locked: false, opacity: 1, parallax: 1 },
{ id: "lighting", visible: true, locked: false, opacity: 1, parallax: 1 },
{ id: "agents", visible: true, locked: true, opacity: 1, parallax: 1 },
];
export const createEmptyOfficeMap = (params: {
workspaceId: string;
officeVersionId: string;
width: number;
height: number;
}): OfficeMap => ({
mapVersion: 2,
workspaceId: params.workspaceId,
officeVersionId: params.officeVersionId,
canvas: {
width: params.width,
height: params.height,
tileSize: 32,
backgroundColor: "#101820",
},
layers: defaultLayers(),
objects: [],
zones: [],
collisions: [],
spawnPoints: [{ id: "spawn-main", x: 120, y: 120 }],
deskAssignments: {},
lightingOverlay: {
enabled: true,
baseDarkness: 0.2,
roomDarkness: {},
},
lights: [],
ambienceEmitters: [],
interactionPoints: [],
theme: {
mood: "neutral",
enableThoughtBubbles: true,
},
});
export const createStarterOfficeMap = (params: {
workspaceId: string;
officeVersionId: string;
width: number;
height: number;
}): OfficeMap => {
const base = createEmptyOfficeMap(params);
const hallwayZone: OfficeZone = {
id: "zone_hallway",
type: "hallway",
name: "Hallway",
shape: {
points: [
{ x: 80, y: 120 },
{ x: 1500, y: 120 },
{ x: 1500, y: 230 },
{ x: 80, y: 230 },
],
},
};
const deskZone: OfficeZone = {
id: "zone_desks",
type: "desk_zone",
name: "Desk Area",
shape: {
points: [
{ x: 120, y: 270 },
{ x: 980, y: 270 },
{ x: 980, y: 780 },
{ x: 120, y: 780 },
],
},
};
const meetingZone: OfficeZone = {
id: "zone_meeting",
type: "meeting_room",
name: "Meeting Room",
shape: {
points: [
{ x: 1030, y: 270 },
{ x: 1480, y: 270 },
{ x: 1480, y: 540 },
{ x: 1030, y: 540 },
],
},
};
const loungeZone: OfficeZone = {
id: "zone_lounge",
type: "lounge",
name: "Lounge",
shape: {
points: [
{ x: 1030, y: 560 },
{ x: 1480, y: 560 },
{ x: 1480, y: 780 },
{ x: 1030, y: 780 },
],
},
};
const coffeeZone: OfficeZone = {
id: "zone_coffee",
type: "coffee_area",
name: "Coffee",
shape: {
points: [
{ x: 80, y: 20 },
{ x: 330, y: 20 },
{ x: 330, y: 110 },
{ x: 80, y: 110 },
],
},
};
const gameZone: OfficeZone = {
id: "zone_game",
type: "game_room",
name: "Game Room",
shape: {
points: [
{ x: 1340, y: 20 },
{ x: 1540, y: 20 },
{ x: 1540, y: 110 },
{ x: 1340, y: 110 },
],
},
};
return {
...base,
objects: [
{
id: "floor_a",
assetId: "floor_tile",
layerId: "floor",
x: 420,
y: 350,
rotation: 0,
flipX: false,
flipY: false,
zIndex: 10,
tags: [],
},
{
id: "desk_a",
assetId: "desk_modern",
layerId: "furniture",
x: 260,
y: 350,
rotation: 0,
flipX: false,
flipY: false,
zIndex: 200,
tags: ["desk"],
},
{
id: "desk_b",
assetId: "desk_modern",
layerId: "furniture",
x: 480,
y: 350,
rotation: 0,
flipX: false,
flipY: false,
zIndex: 201,
tags: ["desk"],
},
{
id: "desk_c",
assetId: "desk_modern",
layerId: "furniture",
x: 700,
y: 350,
rotation: 0,
flipX: false,
flipY: false,
zIndex: 202,
tags: ["desk"],
},
{
id: "meeting_table",
assetId: "meeting_table",
layerId: "furniture",
x: 1240,
y: 390,
rotation: 0,
flipX: false,
flipY: false,
zIndex: 240,
tags: ["meeting"],
},
{
id: "tv_lounge",
assetId: "tv_wall",
layerId: "decor",
x: 1260,
y: 610,
rotation: 0,
flipX: false,
flipY: false,
zIndex: 260,
tags: ["tv"],
},
{
id: "arcade_a",
assetId: "arcade_machine",
layerId: "decor",
x: 1440,
y: 70,
rotation: 0,
flipX: false,
flipY: false,
zIndex: 261,
tags: ["arcade"],
},
{
id: "coffee_bar",
assetId: "coffee_station",
layerId: "decor",
x: 210,
y: 70,
rotation: 0,
flipX: false,
flipY: false,
zIndex: 262,
tags: ["coffee"],
},
],
zones: [
hallwayZone,
deskZone,
meetingZone,
loungeZone,
coffeeZone,
gameZone,
],
lights: [
{
id: "light_ceiling_desks",
preset: "ceiling_lamp",
animationPreset: "soft_flicker",
x: 520,
y: 220,
radius: 240,
baseIntensity: 0.42,
enabled: true,
},
{
id: "light_meeting",
preset: "meeting_spotlight",
animationPreset: "breathing_pulse",
x: 1240,
y: 320,
radius: 180,
baseIntensity: 0.38,
enabled: true,
roomId: "zone_meeting",
},
{
id: "light_tv",
preset: "tv_glow",
animationPreset: "steady",
x: 1260,
y: 610,
radius: 130,
baseIntensity: 0.3,
enabled: true,
binding: { zoneId: "zone_lounge", state: "idle" },
},
{
id: "light_error_demo",
preset: "emergency_error",
animationPreset: "error_strobe_subtle",
x: 260,
y: 320,
radius: 90,
baseIntensity: 0.22,
enabled: true,
binding: { state: "error" },
},
],
ambienceEmitters: [
{
id: "emit_coffee",
preset: "coffee_steam",
zoneId: "zone_coffee",
maxParticles: 16,
spawnRate: 0.16,
enabled: true,
},
{
id: "emit_window",
preset: "window_dust",
zoneId: "zone_hallway",
maxParticles: 14,
spawnRate: 0.08,
enabled: true,
},
{
id: "emit_game",
preset: "game_sparkle",
zoneId: "zone_game",
maxParticles: 12,
spawnRate: 0.12,
enabled: true,
},
],
interactionPoints: [
{
id: "point_tv_watch",
kind: "tv_watch",
x: 1190,
y: 650,
zoneId: "zone_lounge",
tags: [],
},
{
id: "point_arcade_stand",
kind: "arcade_stand",
x: 1390,
y: 90,
zoneId: "zone_game",
tags: [],
},
{
id: "point_coffee",
kind: "window_stand",
x: 200,
y: 100,
zoneId: "zone_coffee",
tags: [],
},
],
deskAssignments: {
main: {
deskObjectId: "desk_a",
seatAnchor: { x: 260, y: 375 },
facingDegrees: 180,
},
},
};
};
const normalizeDeskAssignments = (
value: unknown,
): Record<string, OfficeDeskAssignment> => {
if (!isRecord(value)) return {};
const next: Record<string, OfficeDeskAssignment> = {};
for (const [agentId, raw] of Object.entries(value)) {
if (!isRecord(raw)) continue;
const deskObjectId = asString(raw.deskObjectId).trim();
if (!deskObjectId) continue;
next[agentId] = {
deskObjectId,
seatAnchor: normalizeVector(raw.seatAnchor),
facingDegrees: asNumber(raw.facingDegrees, 180),
};
}
return next;
};
export const normalizeOfficeMap = (
value: unknown,
fallback: OfficeMap,
): OfficeMap => {
if (!isRecord(value)) return fallback;
const mapVersion = asNumber(value.mapVersion, fallback.mapVersion);
const workspaceId =
asString(value.workspaceId, fallback.workspaceId).trim() ||
fallback.workspaceId;
const officeVersionId =
asString(value.officeVersionId, fallback.officeVersionId).trim() ||
fallback.officeVersionId;
const canvasRecord = isRecord(value.canvas) ? value.canvas : {};
const canvas: OfficeCanvas = {
width: asNumber(canvasRecord.width, fallback.canvas.width),
height: asNumber(canvasRecord.height, fallback.canvas.height),
tileSize: asNumber(canvasRecord.tileSize, fallback.canvas.tileSize),
backgroundColor: asString(
canvasRecord.backgroundColor,
fallback.canvas.backgroundColor,
),
};
const layersRaw = Array.isArray(value.layers) ? value.layers : [];
const layers: OfficeLayer[] =
layersRaw.length === 0
? fallback.layers
: layersRaw.map((entry) => {
const raw = isRecord(entry) ? entry : {};
return {
id: normalizeLayerId(raw.id),
visible: asBoolean(raw.visible, true),
locked: asBoolean(raw.locked, false),
opacity: asNumber(raw.opacity, 1),
parallax: asNumber(raw.parallax, 1),
};
});
const objectsRaw = Array.isArray(value.objects) ? value.objects : [];
const objects: OfficeMapObject[] = objectsRaw
.map((entry): OfficeMapObject | null => {
if (!isRecord(entry)) return null;
const id = asString(entry.id).trim();
const assetId = asString(entry.assetId).trim();
if (!id || !assetId) return null;
return {
id,
assetId,
layerId: normalizeLayerId(entry.layerId),
x: asNumber(entry.x, 0),
y: asNumber(entry.y, 0),
rotation: asNumber(entry.rotation, 0),
flipX: asBoolean(entry.flipX, false),
flipY: asBoolean(entry.flipY, false),
zIndex: asNumber(entry.zIndex, 0),
tags: Array.isArray(entry.tags)
? entry.tags.filter(
(item): item is string => typeof item === "string",
)
: [],
};
})
.filter((entry): entry is OfficeMapObject => Boolean(entry));
const zonesRaw = Array.isArray(value.zones) ? value.zones : [];
const zones: OfficeZone[] = zonesRaw
.map((entry): OfficeZone | null => {
if (!isRecord(entry)) return null;
const id = asString(entry.id).trim();
const name = asString(entry.name).trim();
if (!id || !name) return null;
return {
id,
name,
type: normalizeZoneType(entry.type),
shape: normalizePolygon(entry.shape),
capacity:
typeof entry.capacity === "number" ? entry.capacity : undefined,
interactionPointIds: Array.isArray(entry.interactionPointIds)
? entry.interactionPointIds.filter(
(item): item is string => typeof item === "string",
)
: undefined,
ambienceTags: Array.isArray(entry.ambienceTags)
? entry.ambienceTags.filter(
(item): item is string => typeof item === "string",
)
: undefined,
};
})
.filter((entry): entry is OfficeZone => Boolean(entry));
const collisionsRaw = Array.isArray(value.collisions) ? value.collisions : [];
const collisions: OfficeCollision[] = collisionsRaw
.map((entry): OfficeCollision | null => {
if (!isRecord(entry)) return null;
const id = asString(entry.id).trim();
if (!id) return null;
return {
id,
shape: normalizePolygon(entry.shape),
blocked: asBoolean(entry.blocked, true),
};
})
.filter((entry): entry is OfficeCollision => Boolean(entry));
const spawnRaw = Array.isArray(value.spawnPoints) ? value.spawnPoints : [];
const spawnPoints: OfficeSpawnPoint[] = spawnRaw
.map((entry): OfficeSpawnPoint | null => {
if (!isRecord(entry)) return null;
const id = asString(entry.id).trim();
if (!id) return null;
return {
id,
x: asNumber(entry.x, 0),
y: asNumber(entry.y, 0),
};
})
.filter((entry): entry is OfficeSpawnPoint => Boolean(entry));
const overlayRaw = isRecord(value.lightingOverlay)
? value.lightingOverlay
: {};
const roomDarknessRaw = isRecord(overlayRaw.roomDarkness)
? overlayRaw.roomDarkness
: {};
const roomDarkness: Record<string, number> = {};
for (const [key, raw] of Object.entries(roomDarknessRaw)) {
roomDarkness[key] = asNumber(raw, 0.2);
}
const lightingOverlay: OfficeLightingOverlay = {
enabled: asBoolean(
overlayRaw.enabled,
fallback.lightingOverlay?.enabled ?? true,
),
baseDarkness: asNumber(
overlayRaw.baseDarkness,
fallback.lightingOverlay?.baseDarkness ?? 0.2,
),
roomDarkness,
};
const lightsRaw = Array.isArray(value.lights) ? value.lights : [];
const lights: OfficeLightObject[] = lightsRaw
.map((entry): OfficeLightObject | null => {
if (!isRecord(entry)) return null;
const id = asString(entry.id).trim();
if (!id) return null;
const flickerRaw = isRecord(entry.flicker) ? entry.flicker : null;
const bindingRaw = isRecord(entry.binding) ? entry.binding : null;
return {
id,
preset: normalizeLightPreset(entry.preset),
animationPreset: normalizeLightAnimationPreset(entry.animationPreset),
x: asNumber(entry.x, 0),
y: asNumber(entry.y, 0),
radius: asNumber(entry.radius, 120),
baseIntensity: asNumber(entry.baseIntensity, 0.5),
spriteAssetId: asString(entry.spriteAssetId).trim() || undefined,
flicker: flickerRaw
? {
speed: asNumber(flickerRaw.speed, 0.8),
amplitude: asNumber(flickerRaw.amplitude, 0.12),
}
: undefined,
binding: bindingRaw
? {
agentId: asString(bindingRaw.agentId).trim() || undefined,
zoneId: asString(bindingRaw.zoneId).trim() || undefined,
deskObjectId:
asString(bindingRaw.deskObjectId).trim() || undefined,
state: (() => {
const state = asString(bindingRaw.state).trim();
if (
state === "idle" ||
state === "working" ||
state === "meeting" ||
state === "error"
) {
return state;
}
return undefined;
})(),
}
: undefined,
roomId: asString(entry.roomId).trim() || undefined,
enabled: asBoolean(entry.enabled, true),
};
})
.filter((entry): entry is OfficeLightObject => Boolean(entry));
const ambienceRaw = Array.isArray(value.ambienceEmitters)
? value.ambienceEmitters
: [];
const ambienceEmitters: OfficeAmbienceEmitter[] = ambienceRaw
.map((entry): OfficeAmbienceEmitter | null => {
if (!isRecord(entry)) return null;
const id = asString(entry.id).trim();
const zoneId = asString(entry.zoneId).trim();
if (!id || !zoneId) return null;
return {
id,
preset: normalizeAmbiencePreset(entry.preset),
zoneId,
maxParticles: asNumber(entry.maxParticles, 18),
spawnRate: asNumber(entry.spawnRate, 0.2),
enabled: asBoolean(entry.enabled, true),
};
})
.filter((entry): entry is OfficeAmbienceEmitter => Boolean(entry));
const interactionRaw = Array.isArray(value.interactionPoints)
? value.interactionPoints
: [];
const interactionPoints: OfficeInteractionPoint[] = interactionRaw
.map((entry): OfficeInteractionPoint | null => {
if (!isRecord(entry)) return null;
const id = asString(entry.id).trim();
if (!id) return null;
return {
id,
kind: normalizeInteractionKind(entry.kind),
x: asNumber(entry.x, 0),
y: asNumber(entry.y, 0),
zoneId: asString(entry.zoneId).trim() || undefined,
facingDegrees:
typeof entry.facingDegrees === "number"
? asNumber(entry.facingDegrees, 0)
: undefined,
tags: Array.isArray(entry.tags)
? entry.tags.filter(
(item): item is string => typeof item === "string",
)
: [],
};
})
.filter((entry): entry is OfficeInteractionPoint => Boolean(entry));
const themeRaw = isRecord(value.theme) ? value.theme : {};
const mood = asString(themeRaw.mood, fallback.theme?.mood ?? "neutral");
const theme: OfficeTheme = {
mood:
mood === "focus" || mood === "cozy" || mood === "night"
? mood
: "neutral",
enableThoughtBubbles: asBoolean(
themeRaw.enableThoughtBubbles,
fallback.theme?.enableThoughtBubbles ?? true,
),
};
return {
mapVersion,
workspaceId,
officeVersionId,
canvas,
layers,
objects,
zones,
collisions,
spawnPoints,
deskAssignments: normalizeDeskAssignments(value.deskAssignments),
lightingOverlay,
lights,
ambienceEmitters,
interactionPoints,
theme,
};
};
+503
View File
@@ -0,0 +1,503 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { readConfigAgentList } from "@/lib/gateway/agentConfig";
import {
loadGitHubDashboard,
type GitHubPullRequestSummary,
} from "@/lib/office/github";
import { validateJiraBaseUrl } from "@/lib/security/urlSafety";
import type {
StandupAgentSnapshot,
StandupCommitSummary,
StandupConfig,
StandupMeeting,
StandupSourceState,
StandupSummaryCard,
StandupTicketSummary,
StandupTriggerKind,
} from "@/lib/office/standup/types";
import { resolveStateDir } from "@/lib/clawdbot/paths";
type JiraIssueRecord = StandupTicketSummary & {
assigneeName: string | null;
assigneeEmail: string | null;
};
const OPENCLAW_CONFIG_FILENAME = "openclaw.json";
const coerceText = (value: string | null | undefined): string => {
return (value ?? "").replace(/\s+/g, " ").trim();
};
const splitBlockers = (value: string): string[] => {
return value
.split(/\n|,/)
.map((entry) => entry.trim())
.filter(Boolean)
.slice(0, 3);
};
const buildSourceState = (
kind: StandupSourceState["kind"],
input: Partial<StandupSourceState>
): StandupSourceState => ({
kind,
ready: input.ready ?? false,
stale: input.stale ?? false,
updatedAt: input.updatedAt ?? null,
error: input.error ?? null,
});
const dedupePullRequests = (entries: GitHubPullRequestSummary[]): GitHubPullRequestSummary[] => {
const seen = new Set<string>();
const result: GitHubPullRequestSummary[] = [];
for (const entry of entries) {
const key = `${entry.repo}#${entry.number}`;
if (seen.has(key)) continue;
seen.add(key);
result.push(entry);
}
return result;
};
const loadGitHubCommitSummaries = (): {
commits: StandupCommitSummary[];
hasFailingChecks: boolean;
sourceState: StandupSourceState;
} => {
try {
const dashboard = loadGitHubDashboard();
if (!dashboard.ready) {
return {
commits: [],
hasFailingChecks: false,
sourceState: buildSourceState("github", {
ready: false,
error: dashboard.message ?? "GitHub is not ready.",
}),
};
}
const combined = dedupePullRequests([
...dashboard.currentRepoPullRequests,
...dashboard.reviewRequests,
...dashboard.authoredPullRequests,
]).slice(0, 6);
const commits = combined.map((entry) => ({
id: `${entry.repo}#${entry.number}`,
title: entry.title,
subtitle:
[entry.repo, entry.statusSummary, entry.reviewDecision]
.filter(Boolean)
.join(" · ") || null,
url: entry.url,
}));
const hasFailingChecks = combined.some((entry) =>
(entry.statusSummary ?? "").toLowerCase().includes("failing")
);
return {
commits,
hasFailingChecks,
sourceState: buildSourceState("github", {
ready: true,
updatedAt: new Date().toISOString(),
}),
};
} catch (error) {
return {
commits: [],
hasFailingChecks: false,
sourceState: buildSourceState("github", {
ready: false,
error: error instanceof Error ? error.message : "Failed to load GitHub activity.",
}),
};
}
};
const buildJiraSearchUrl = (config: StandupConfig["jira"]) => {
const jql =
coerceText(config.jql) ||
(config.projectKey
? `project = ${config.projectKey} AND statusCategory != Done ORDER BY updated DESC`
: "");
if (!jql) return null;
const baseUrl = validateJiraBaseUrl(config.baseUrl);
const url = new URL(`${baseUrl}/rest/api/3/search/jql`);
url.searchParams.set("maxResults", "50");
url.searchParams.set("fields", "summary,status,assignee");
url.searchParams.set("jql", jql);
return url;
};
const loadJiraIssues = async (
config: StandupConfig["jira"]
): Promise<{ issues: JiraIssueRecord[]; sourceState: StandupSourceState }> => {
if (!config.enabled) {
return {
issues: [],
sourceState: buildSourceState("jira", {
ready: false,
stale: true,
error: "Jira is disabled.",
}),
};
}
if (!config.baseUrl || !config.email || !config.apiToken) {
return {
issues: [],
sourceState: buildSourceState("jira", {
ready: false,
stale: true,
error: "Jira credentials are incomplete.",
}),
};
}
let jiraBaseUrl: string;
try {
jiraBaseUrl = validateJiraBaseUrl(config.baseUrl);
} catch (error) {
return {
issues: [],
sourceState: buildSourceState("jira", {
ready: false,
stale: true,
error: error instanceof Error ? error.message : "Jira base URL is invalid.",
}),
};
}
const searchUrl = buildJiraSearchUrl(config);
if (!searchUrl) {
return {
issues: [],
sourceState: buildSourceState("jira", {
ready: false,
stale: true,
error: "Add a Jira project key or JQL query.",
}),
};
}
try {
const auth = Buffer.from(`${config.email}:${config.apiToken}`).toString("base64");
const response = await fetch(searchUrl, {
headers: {
Accept: "application/json",
Authorization: `Basic ${auth}`,
},
cache: "no-store",
});
const payload = (await response.json().catch(() => null)) as
| {
issues?: Array<{
id?: string;
key?: string;
self?: string;
fields?: {
summary?: string;
status?: { name?: string | null } | null;
assignee?: {
displayName?: string | null;
emailAddress?: string | null;
} | null;
} | null;
}>;
errorMessages?: string[];
}
| null;
if (!response.ok) {
throw new Error(
payload?.errorMessages?.join(" ") || "Failed to load Jira issues."
);
}
const issues: JiraIssueRecord[] = (payload?.issues ?? []).map((issue) => ({
id: issue.id ?? issue.key ?? randomUUID(),
key: issue.key ?? "JIRA",
title: coerceText(issue.fields?.summary) || "Untitled issue",
status: coerceText(issue.fields?.status?.name) || "Unknown",
url: issue.key ? `${jiraBaseUrl}/browse/${issue.key}` : null,
assigneeName: coerceText(issue.fields?.assignee?.displayName) || null,
assigneeEmail: coerceText(issue.fields?.assignee?.emailAddress) || null,
}));
return {
issues,
sourceState: buildSourceState("jira", {
ready: true,
updatedAt: new Date().toISOString(),
}),
};
} catch (error) {
return {
issues: [],
sourceState: buildSourceState("jira", {
ready: false,
stale: true,
error: error instanceof Error ? error.message : "Failed to load Jira issues.",
}),
};
}
};
const normalizeAgentSnapshots = (agents: StandupAgentSnapshot[]): StandupAgentSnapshot[] => {
const valid = agents
.map((agent) => ({
agentId: coerceText(agent.agentId),
name: coerceText(agent.name) || coerceText(agent.agentId),
latestPreview: coerceText(agent.latestPreview) || null,
lastUserMessage: coerceText(agent.lastUserMessage) || null,
}))
.filter((agent) => agent.agentId);
if (valid.length > 0) return valid;
const configPath = path.join(resolveStateDir(), OPENCLAW_CONFIG_FILENAME);
if (!fs.existsSync(configPath)) return [];
const raw = fs.readFileSync(configPath, "utf8");
const parsed = JSON.parse(raw) as unknown;
const config =
parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: undefined;
return readConfigAgentList(config).map((entry) => ({
agentId: entry.id.trim(),
name:
(typeof entry.name === "string" ? entry.name.trim() : "") || entry.id.trim(),
latestPreview: null,
lastUserMessage: null,
}));
};
const selectAgentIssues = (
agent: StandupAgentSnapshot,
manualAssignee: string | null,
issues: JiraIssueRecord[]
): StandupTicketSummary[] => {
const nameHint = coerceText(manualAssignee) || coerceText(agent.name) || agent.agentId;
const normalizedHint = nameHint.toLowerCase();
const matched = issues.filter((issue) => {
const displayName = issue.assigneeName?.toLowerCase() ?? "";
const email = issue.assigneeEmail?.toLowerCase() ?? "";
return (
displayName.includes(normalizedHint) ||
normalizedHint.includes(displayName) ||
email.includes(normalizedHint)
);
});
return (matched.length > 0 ? matched : issues).slice(0, 3).map((issue) => ({
id: issue.id,
key: issue.key,
title: issue.title,
status: issue.status,
url: issue.url,
}));
};
const buildSpeech = (agentName: string, currentTask: string, blockers: string[]) => {
const headline = `${agentName}: ${currentTask}`.trim();
if (headline.length <= 110 && blockers.length === 0) return headline;
if (blockers.length > 0) {
const blockerText = `Blocked by ${blockers[0]}.`;
const combined = `${headline}. ${blockerText}`.trim();
if (combined.length <= 120) return combined;
}
return `${headline.slice(0, 117).trimEnd()}...`;
};
export const buildStandupMeeting = async (params: {
config: StandupConfig;
agents: StandupAgentSnapshot[];
trigger: StandupTriggerKind;
scheduledFor?: string | null;
}): Promise<StandupMeeting> => {
const agents = normalizeAgentSnapshots(params.agents);
const [jiraResult] = await Promise.all([loadJiraIssues(params.config.jira)]);
const githubResult = loadGitHubCommitSummaries();
const cards: StandupSummaryCard[] = agents.map((agent) => {
const manual = params.config.manualByAgentId[agent.agentId] ?? {
jiraAssignee: null,
currentTask: "",
blockers: "",
note: "",
updatedAt: null,
};
const activeTickets = selectAgentIssues(
agent,
manual.jiraAssignee,
jiraResult.issues
);
const currentTask =
coerceText(manual.currentTask) ||
activeTickets[0]?.title ||
agent.latestPreview ||
agent.lastUserMessage ||
githubResult.commits[0]?.title ||
"Reviewing current work.";
const blockers = splitBlockers(manual.blockers);
if (blockers.length === 0 && githubResult.hasFailingChecks) {
blockers.push("GitHub checks are failing.");
}
const manualNotes = [manual.note].map(coerceText).filter(Boolean);
return {
agentId: agent.agentId,
agentName: agent.name,
speech: buildSpeech(agent.name, currentTask, blockers),
currentTask,
blockers,
recentCommits: githubResult.commits.slice(0, 3),
activeTickets,
manualNotes,
sourceStates: [
githubResult.sourceState,
jiraResult.sourceState,
buildSourceState("manual", {
ready: true,
updatedAt: manual.updatedAt,
}),
],
};
});
const startedAt = new Date().toISOString();
return {
id: randomUUID(),
trigger: params.trigger,
phase: "gathering",
scheduledFor: params.scheduledFor ?? null,
startedAt,
updatedAt: startedAt,
completedAt: null,
currentSpeakerAgentId: null,
speakerStartedAt: null,
speakerDurationMs: params.config.schedule.speakerSeconds * 1000,
participantOrder: cards.map((card) => card.agentId),
arrivedAgentIds: [],
cards,
};
};
export const startStandupSpeaker = (
meeting: StandupMeeting,
speakerAgentId: string | null
): StandupMeeting => {
const now = new Date().toISOString();
return {
...meeting,
phase: speakerAgentId ? "in_progress" : "complete",
currentSpeakerAgentId: speakerAgentId,
speakerStartedAt: speakerAgentId ? now : null,
updatedAt: now,
completedAt: speakerAgentId ? null : now,
};
};
export const advanceStandupMeeting = (meeting: StandupMeeting): StandupMeeting => {
const currentIndex = meeting.currentSpeakerAgentId
? meeting.participantOrder.indexOf(meeting.currentSpeakerAgentId)
: -1;
const nextAgentId = meeting.participantOrder[currentIndex + 1] ?? null;
return startStandupSpeaker(meeting, nextAgentId);
};
export const updateStandupArrivals = (
meeting: StandupMeeting,
arrivedAgentIds: string[]
): StandupMeeting => {
const nextArrivals = Array.from(
new Set(arrivedAgentIds.map((entry) => coerceText(entry)).filter(Boolean))
);
return {
...meeting,
arrivedAgentIds: nextArrivals,
updatedAt: new Date().toISOString(),
};
};
export const meetingHasEveryoneArrived = (meeting: StandupMeeting): boolean => {
return meeting.participantOrder.every((agentId) =>
meeting.arrivedAgentIds.includes(agentId)
);
};
const expandRange = (segment: string): number[] => {
if (segment.includes("-")) {
const [startRaw, endRaw] = segment.split("-");
const start = Number(startRaw);
const end = Number(endRaw);
if (!Number.isFinite(start) || !Number.isFinite(end)) return [];
const values: number[] = [];
for (let value = start; value <= end; value += 1) values.push(value);
return values;
}
const numeric = Number(segment);
return Number.isFinite(numeric) ? [numeric] : [];
};
const matchesCronPart = (part: string, value: number): boolean => {
const trimmed = part.trim();
if (trimmed === "*") return true;
return trimmed.split(",").some((segment) => expandRange(segment).includes(value));
};
const getZonedDateParts = (date: Date, timeZone: string) => {
const formatter = new Intl.DateTimeFormat("en-US", {
timeZone,
minute: "numeric",
hour: "numeric",
day: "numeric",
month: "numeric",
year: "numeric",
weekday: "short",
hour12: false,
});
const parts = formatter.formatToParts(date);
const lookup = (type: Intl.DateTimeFormatPartTypes) =>
parts.find((part) => part.type === type)?.value ?? "";
const weekdayMap: Record<string, number> = {
Sun: 0,
Mon: 1,
Tue: 2,
Wed: 3,
Thu: 4,
Fri: 5,
Sat: 6,
};
return {
minute: Number(lookup("minute")),
hour: Number(lookup("hour")),
day: Number(lookup("day")),
month: Number(lookup("month")),
weekday: weekdayMap[lookup("weekday")] ?? -1,
};
};
export const shouldRunStandupNow = (
config: StandupConfig,
now: Date = new Date()
): boolean => {
if (!config.schedule.enabled) return false;
const parts = config.schedule.cronExpr.trim().split(/\s+/);
if (parts.length !== 5) return false;
const zoned = getZonedDateParts(now, config.schedule.timezone || "UTC");
return (
matchesCronPart(parts[0] ?? "*", zoned.minute) &&
matchesCronPart(parts[1] ?? "*", zoned.hour) &&
matchesCronPart(parts[2] ?? "*", zoned.day) &&
matchesCronPart(parts[3] ?? "*", zoned.month) &&
matchesCronPart(parts[4] ?? "*", zoned.weekday)
);
};
export const isSameScheduleMinute = (
leftIso: string | null,
right: Date,
timeZone: string
): boolean => {
if (!leftIso) return false;
const left = new Date(leftIso);
if (Number.isNaN(left.getTime())) return false;
const leftParts = getZonedDateParts(left, timeZone);
const rightParts = getZonedDateParts(right, timeZone);
return (
leftParts.minute === rightParts.minute &&
leftParts.hour === rightParts.hour &&
leftParts.day === rightParts.day &&
leftParts.month === rightParts.month
);
};
+89
View File
@@ -0,0 +1,89 @@
import fs from "node:fs";
import path from "node:path";
import { resolveStateDir } from "@/lib/clawdbot/paths";
import type { StandupMeeting, StandupMeetingStore } from "@/lib/office/standup/types";
const STORE_DIR = "claw3d";
const STORE_FILE = "standup-store.json";
const ensureDirectory = (dirPath: string) => {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
};
const resolveStorePath = () => {
const dir = path.join(resolveStateDir(), STORE_DIR);
ensureDirectory(dir);
return path.join(dir, STORE_FILE);
};
const defaultStore = (): StandupMeetingStore => ({
activeMeeting: null,
lastMeeting: null,
});
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === "object" && !Array.isArray(value));
const normalizeMeeting = (value: unknown): StandupMeeting | null => {
if (!isRecord(value)) return null;
if (typeof value.id !== "string") return null;
if (!Array.isArray(value.cards) || !Array.isArray(value.participantOrder)) return null;
if (!Array.isArray(value.arrivedAgentIds)) return null;
if (typeof value.startedAt !== "string" || typeof value.updatedAt !== "string") return null;
return value as StandupMeeting;
};
const readStore = (): StandupMeetingStore => {
const storePath = resolveStorePath();
if (!fs.existsSync(storePath)) {
return defaultStore();
}
const raw = fs.readFileSync(storePath, "utf8");
const parsed = JSON.parse(raw) as unknown;
if (!isRecord(parsed)) return defaultStore();
return {
activeMeeting: normalizeMeeting(parsed.activeMeeting),
lastMeeting: normalizeMeeting(parsed.lastMeeting),
};
};
const writeStore = (store: StandupMeetingStore) => {
const storePath = resolveStorePath();
fs.writeFileSync(storePath, JSON.stringify(store, null, 2), "utf8");
};
export const loadStandupMeetingStore = (): StandupMeetingStore => readStore();
export const loadActiveStandupMeeting = (): StandupMeeting | null => readStore().activeMeeting;
export const saveStandupMeeting = (meeting: StandupMeeting | null): StandupMeetingStore => {
const current = readStore();
const next: StandupMeetingStore = {
activeMeeting: meeting,
lastMeeting: meeting ?? current.lastMeeting,
};
if (meeting?.phase === "complete") {
next.lastMeeting = meeting;
}
writeStore(next);
return next;
};
export const updateStandupMeeting = (
updater: (meeting: StandupMeeting | null) => StandupMeeting | null
): StandupMeetingStore => {
const current = readStore();
const nextMeeting = updater(current.activeMeeting);
const nextStore: StandupMeetingStore = {
activeMeeting: nextMeeting,
lastMeeting:
nextMeeting?.phase === "complete"
? nextMeeting
: current.lastMeeting,
};
writeStore(nextStore);
return nextStore;
};
+109
View File
@@ -0,0 +1,109 @@
export type StandupPhase = "scheduled" | "gathering" | "in_progress" | "complete";
export type StandupSourceKind = "github" | "jira" | "manual";
export type StandupTriggerKind = "manual" | "scheduled";
export type StandupAgentSnapshot = {
agentId: string;
name: string;
latestPreview?: string | null;
lastUserMessage?: string | null;
};
export type StandupManualEntry = {
jiraAssignee: string | null;
currentTask: string;
blockers: string;
note: string;
updatedAt: string | null;
};
export type StandupTicketSummary = {
id: string;
key: string;
title: string;
status: string;
url: string | null;
};
export type StandupCommitSummary = {
id: string;
title: string;
subtitle: string | null;
url: string | null;
};
export type StandupSourceState = {
kind: StandupSourceKind;
ready: boolean;
stale: boolean;
updatedAt: string | null;
error: string | null;
};
export type StandupSummaryCard = {
agentId: string;
agentName: string;
speech: string;
currentTask: string;
blockers: string[];
recentCommits: StandupCommitSummary[];
activeTickets: StandupTicketSummary[];
manualNotes: string[];
sourceStates: StandupSourceState[];
};
export type StandupMeeting = {
id: string;
trigger: StandupTriggerKind;
phase: StandupPhase;
scheduledFor: string | null;
startedAt: string;
updatedAt: string;
completedAt: string | null;
currentSpeakerAgentId: string | null;
speakerStartedAt: string | null;
speakerDurationMs: number;
participantOrder: string[];
arrivedAgentIds: string[];
cards: StandupSummaryCard[];
};
export type StandupMeetingStore = {
activeMeeting: StandupMeeting | null;
lastMeeting: StandupMeeting | null;
};
export type StandupJiraConfig = {
enabled: boolean;
baseUrl: string;
email: string;
apiToken: string;
projectKey: string;
jql: string;
};
export type StandupScheduleConfig = {
enabled: boolean;
cronExpr: string;
timezone: string;
speakerSeconds: number;
autoOpenBoard: boolean;
lastAutoRunAt: string | null;
};
export type StandupConfig = {
schedule: StandupScheduleConfig;
jira: StandupJiraConfig;
manualByAgentId: Record<string, StandupManualEntry>;
};
export type StandupConfigPayload = {
gatewayUrl: string;
config: StandupConfig;
};
export type StandupMeetingPayload = {
meeting: StandupMeeting | null;
};
+259
View File
@@ -0,0 +1,259 @@
import fs from "node:fs";
import path from "node:path";
import { resolveStateDir } from "@/lib/clawdbot/paths";
import { createEmptyOfficeMap, normalizeOfficeMap, type OfficeMap } from "@/lib/office/schema";
export type OfficeRecord = {
id: string;
workspaceId: string;
name: string;
createdAt: string;
updatedAt: string;
archivedAt?: string | null;
};
export type OfficeVersionRecord = {
id: string;
officeId: string;
workspaceId: string;
versionNumber: number;
mapJson: OfficeMap;
createdBy: string;
createdAt: string;
notes?: string;
};
export type PublishedOfficeRecord = {
workspaceId: string;
officeId: string;
officeVersionId: string;
publishedAt: string;
publishedBy: string;
};
type OfficeStoreShape = {
schemaVersion: number;
offices: OfficeRecord[];
officeVersions: OfficeVersionRecord[];
published: PublishedOfficeRecord[];
};
const STORE_DIR = "claw3d";
const STORE_FILE = "office-store.json";
const STORE_VERSION = 1;
const ensureDirectory = (dirPath: string) => {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
};
const resolveStorePath = () => {
const stateDir = resolveStateDir();
const dir = path.join(stateDir, STORE_DIR);
ensureDirectory(dir);
return path.join(dir, STORE_FILE);
};
const defaultStore = (): OfficeStoreShape => ({
schemaVersion: STORE_VERSION,
offices: [],
officeVersions: [],
published: [],
});
const normalizeStore = (value: unknown): OfficeStoreShape => {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return defaultStore();
}
const raw = value as Record<string, unknown>;
const offices = Array.isArray(raw.offices) ? raw.offices : [];
const officeVersions = Array.isArray(raw.officeVersions) ? raw.officeVersions : [];
const published = Array.isArray(raw.published) ? raw.published : [];
return {
schemaVersion: STORE_VERSION,
offices: offices.filter((entry): entry is OfficeRecord => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false;
const record = entry as Record<string, unknown>;
return (
typeof record.id === "string" &&
typeof record.workspaceId === "string" &&
typeof record.name === "string" &&
typeof record.createdAt === "string" &&
typeof record.updatedAt === "string"
);
}),
officeVersions: officeVersions.filter((entry): entry is OfficeVersionRecord => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false;
const record = entry as Record<string, unknown>;
return (
typeof record.id === "string" &&
typeof record.officeId === "string" &&
typeof record.workspaceId === "string" &&
typeof record.versionNumber === "number" &&
typeof record.createdBy === "string" &&
typeof record.createdAt === "string" &&
Boolean(record.mapJson)
);
}),
published: published.filter((entry): entry is PublishedOfficeRecord => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false;
const record = entry as Record<string, unknown>;
return (
typeof record.workspaceId === "string" &&
typeof record.officeId === "string" &&
typeof record.officeVersionId === "string" &&
typeof record.publishedAt === "string" &&
typeof record.publishedBy === "string"
);
}),
};
};
const readStore = (): OfficeStoreShape => {
const storePath = resolveStorePath();
if (!fs.existsSync(storePath)) {
return defaultStore();
}
const raw = fs.readFileSync(storePath, "utf8");
return normalizeStore(JSON.parse(raw));
};
const writeStore = (store: OfficeStoreShape) => {
const storePath = resolveStorePath();
fs.writeFileSync(storePath, JSON.stringify(store, null, 2), "utf8");
};
export const listOfficesForWorkspace = (workspaceId: string) => {
const store = readStore();
return store.offices.filter((entry) => entry.workspaceId === workspaceId);
};
export const listOfficeVersions = (workspaceId: string, officeId: string) => {
const store = readStore();
return store.officeVersions
.filter((entry) => entry.workspaceId === workspaceId && entry.officeId === officeId)
.sort((left, right) => right.versionNumber - left.versionNumber);
};
export const getPublishedOffice = (workspaceId: string) => {
const store = readStore();
return store.published.find((entry) => entry.workspaceId === workspaceId) ?? null;
};
export const getPublishedOfficeMap = (workspaceId: string): OfficeMap | null => {
const store = readStore();
const published = store.published.find((entry) => entry.workspaceId === workspaceId);
if (!published) return null;
const version = store.officeVersions.find(
(entry) =>
entry.workspaceId === workspaceId &&
entry.officeId === published.officeId &&
entry.id === published.officeVersionId
);
if (!version) return null;
const fallback = createEmptyOfficeMap({
workspaceId,
officeVersionId: version.id,
width: 1600,
height: 900,
});
return normalizeOfficeMap(version.mapJson, fallback);
};
export const upsertOffice = (params: {
workspaceId: string;
officeId: string;
name: string;
}) => {
const store = readStore();
const now = new Date().toISOString();
const existing = store.offices.find(
(entry) => entry.workspaceId === params.workspaceId && entry.id === params.officeId
);
if (existing) {
existing.name = params.name;
existing.updatedAt = now;
writeStore(store);
return existing;
}
const created: OfficeRecord = {
id: params.officeId,
workspaceId: params.workspaceId,
name: params.name,
createdAt: now,
updatedAt: now,
};
store.offices.push(created);
writeStore(store);
return created;
};
export const saveOfficeVersion = (params: {
workspaceId: string;
officeId: string;
versionId: string;
createdBy: string;
notes?: string;
map: OfficeMap;
}) => {
const store = readStore();
const now = new Date().toISOString();
const matching = store.officeVersions.filter(
(entry) => entry.workspaceId === params.workspaceId && entry.officeId === params.officeId
);
const nextVersionNumber = matching.length === 0 ? 1 : Math.max(...matching.map((entry) => entry.versionNumber)) + 1;
const record: OfficeVersionRecord = {
id: params.versionId,
officeId: params.officeId,
workspaceId: params.workspaceId,
versionNumber: nextVersionNumber,
mapJson: params.map,
createdBy: params.createdBy,
createdAt: now,
notes: params.notes,
};
store.officeVersions.push(record);
writeStore(store);
return record;
};
export const publishOfficeVersion = (params: {
workspaceId: string;
officeId: string;
officeVersionId: string;
publishedBy: string;
}) => {
const store = readStore();
const match = store.officeVersions.find(
(entry) =>
entry.workspaceId === params.workspaceId &&
entry.officeId === params.officeId &&
entry.id === params.officeVersionId
);
if (!match) {
throw new Error("Office version not found.");
}
const now = new Date().toISOString();
const current = store.published.find((entry) => entry.workspaceId === params.workspaceId);
if (current) {
current.officeId = params.officeId;
current.officeVersionId = params.officeVersionId;
current.publishedBy = params.publishedBy;
current.publishedAt = now;
writeStore(store);
return current;
}
const created: PublishedOfficeRecord = {
workspaceId: params.workspaceId,
officeId: params.officeId,
officeVersionId: params.officeVersionId,
publishedBy: params.publishedBy,
publishedAt: now,
};
store.published.push(created);
writeStore(store);
return created;
};
+65
View File
@@ -0,0 +1,65 @@
import type { MockTextMessageScenario } from "@/lib/office/text/types";
const normalizeWhitespace = (value: string | null | undefined): string =>
(value ?? "").replace(/\s+/g, " ").trim();
const titleCase = (value: string): string =>
value.replace(/\b([a-z])([a-z']*)/g, (_, first: string, rest: string) => {
return `${first.toUpperCase()}${rest}`;
});
const formatRecipientLabel = (recipient: string): string => {
const normalized = normalizeWhitespace(recipient).toLowerCase();
if (!normalized) return "your contact";
if (normalized === "my wife") return "your wife";
if (normalized === "my husband") return "your husband";
if (normalized === "my mom") return "your mom";
if (normalized === "my dad") return "your dad";
return titleCase(normalized);
};
const buildPromptText = (recipientLabel: string): string =>
`What should I message ${recipientLabel}?`;
const buildConfirmationText = (message: string): string => {
const normalized = normalizeWhitespace(message).toLowerCase();
if (normalized.includes("late for the soccer game")) {
return "No worries, thanks for the heads up.";
}
if (normalized.includes("running late")) {
return "Thanks for letting me know.";
}
if (normalized.includes("on my way")) {
return "Perfect, see you soon.";
}
if (normalized.includes("be there")) {
return "Sounds good.";
}
return "Delivered.";
};
export const buildMockTextMessageScenario = (params: {
recipient: string;
message?: string | null;
}): MockTextMessageScenario => {
const recipientLabel = formatRecipientLabel(params.recipient);
const message = normalizeWhitespace(params.message);
if (!message) {
return {
phase: "needs_message",
recipient: recipientLabel,
messageText: null,
confirmationText: null,
promptText: buildPromptText(recipientLabel),
statusLine: `Waiting for your message to ${recipientLabel}.`,
};
}
return {
phase: "ready_to_send",
recipient: recipientLabel,
messageText: message,
confirmationText: buildConfirmationText(message),
promptText: null,
statusLine: `Text queued for ${recipientLabel}.`,
};
};
+10
View File
@@ -0,0 +1,10 @@
export type MockTextMessagePhase = "needs_message" | "ready_to_send";
export type MockTextMessageScenario = {
phase: MockTextMessagePhase;
recipient: string;
messageText: string | null;
confirmationText: string | null;
promptText: string | null;
statusLine: string;
};
@@ -0,0 +1,26 @@
"use client";
export const toDateInputValue = (date: Date) => date.toISOString().slice(0, 10);
export const getDefaultUsageAnalyticsRange = () => {
const endDate = toDateInputValue(new Date());
const start = new Date();
start.setDate(start.getDate() - 6);
return {
startDate: toDateInputValue(start),
endDate,
};
};
export const formatCurrency = (value: number | null | undefined) => {
const amount = value ?? 0;
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: amount < 10 ? 2 : 0,
maximumFractionDigits: amount < 10 ? 2 : 0,
}).format(amount);
};
export const formatNumber = (value: number | null | undefined) =>
new Intl.NumberFormat("en-US").format(value ?? 0);
+342
View File
@@ -0,0 +1,342 @@
import fs from "node:fs";
import * as fsp from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { createRequire } from "node:module";
import { pathToFileURL } from "node:url";
const require = createRequire(import.meta.url);
const CONFIGURED_OPENCLAW_PACKAGE_ROOT = process.env.OPENCLAW_PACKAGE_ROOT?.trim() ?? "";
const OPENCLAW_DIST_INDEX_RELATIVE_PATH = path.join("dist", "index.js");
const OPENCLAW_DIST_DIRECTORY_RELATIVE_PATH = "dist";
const AUDIO_KIND = "audio.transcription";
const DEFAULT_VOICE_MIME = "audio/webm";
const DEFAULT_VOICE_BASENAME = "voice-note";
const MIME_EXTENSION_MAP: Record<string, string> = {
"audio/mp4": ".m4a",
"audio/mpeg": ".mp3",
"audio/ogg": ".ogg",
"audio/wav": ".wav",
"audio/webm": ".webm",
"audio/x-m4a": ".m4a",
"audio/x-wav": ".wav",
};
type OpenClawConfig = {
tools?: {
media?: {
audio?: {
enabled?: boolean;
};
};
};
};
type MediaUnderstandingOutput = {
kind?: string;
text?: string;
provider?: string;
model?: string;
};
type MediaUnderstandingDecision = {
outcome?: string;
attachments?: Array<{
attempts?: Array<{
reason?: string;
}>;
}>;
};
type RunCapabilityResult = {
outputs?: MediaUnderstandingOutput[];
decision?: MediaUnderstandingDecision;
};
type OpenClawConfigModule = {
t: () => OpenClawConfig;
};
type OpenClawRunnerModule = {
a: (params: {
capability: "audio";
cfg: OpenClawConfig;
ctx: Record<string, unknown>;
attachments: {
cleanup: () => Promise<void>;
};
media: Array<Record<string, unknown>>;
providerRegistry: unknown;
config: unknown;
}) => Promise<RunCapabilityResult>;
n: (attachments: Array<Record<string, unknown>>) => {
cleanup: () => Promise<void>;
};
r: (ctx: Record<string, unknown>) => Array<Record<string, unknown>>;
t: () => unknown;
};
type OpenClawTranscriptionSdk = {
loadConfig: OpenClawConfigModule["t"];
runCapability: OpenClawRunnerModule["a"];
createMediaAttachmentCache: OpenClawRunnerModule["n"];
normalizeMediaAttachments: OpenClawRunnerModule["r"];
buildProviderRegistry: OpenClawRunnerModule["t"];
};
export type OpenClawVoiceTranscriptionResult = {
transcript: string | null;
provider: string | null;
model: string | null;
decision: MediaUnderstandingDecision | null;
ignored: boolean;
};
let sdkPromise: Promise<OpenClawTranscriptionSdk> | null = null;
const nativeImport = new Function(
"specifier",
"return import(specifier);",
) as (specifier: string) => Promise<unknown>;
const resolveInstalledOpenClawPackageRoot = (): string | null => {
try {
const resolvedEntry = require.resolve("openclaw");
return path.dirname(path.dirname(resolvedEntry));
} catch {
return null;
}
};
export const normalizeVoiceMimeType = (value: string | null | undefined): string => {
const trimmed = value?.trim().toLowerCase() ?? "";
if (!trimmed) return DEFAULT_VOICE_MIME;
const [baseType] = trimmed.split(";", 1);
return MIME_EXTENSION_MAP[baseType] ? baseType : trimmed.startsWith("audio/") ? baseType : DEFAULT_VOICE_MIME;
};
export const inferVoiceFileExtension = (
fileName: string | null | undefined,
mimeType: string | null | undefined,
): string => {
const trimmedName = fileName?.trim() ?? "";
const nameExtension = path.extname(trimmedName).toLowerCase();
if (nameExtension && Object.values(MIME_EXTENSION_MAP).includes(nameExtension)) {
return nameExtension;
}
return MIME_EXTENSION_MAP[normalizeVoiceMimeType(mimeType)] ?? MIME_EXTENSION_MAP[DEFAULT_VOICE_MIME];
};
export const sanitizeVoiceFileName = (
fileName: string | null | undefined,
mimeType: string | null | undefined,
): string => {
const extension = inferVoiceFileExtension(fileName, mimeType);
const rawBase = path.basename(fileName?.trim() || DEFAULT_VOICE_BASENAME, path.extname(fileName?.trim() || ""));
const sanitizedBase =
rawBase.replace(/[^a-z0-9._-]+/gi, "-").replace(/-+/g, "-").replace(/^[-.]+|[-.]+$/g, "") ||
DEFAULT_VOICE_BASENAME;
const normalizedBase = sanitizedBase.toLowerCase();
return normalizedBase.endsWith(extension) ? normalizedBase : `${normalizedBase}${extension}`;
};
export const buildVoiceTranscriptionErrorMessage = (
decision: MediaUnderstandingDecision | null | undefined,
): string => {
if (!decision) return "OpenClaw did not return a transcript.";
const outcome = decision.outcome?.trim() || "unknown";
const reasons = (decision.attachments ?? [])
.flatMap((attachment) => attachment.attempts ?? [])
.map((attempt) => attempt.reason?.trim() ?? "")
.filter(Boolean);
const detail = reasons[0] ? ` ${reasons[0]}` : "";
switch (outcome) {
case "disabled":
return `OpenClaw audio transcription is disabled.${detail}`.trim();
case "no-attachment":
return "OpenClaw did not receive any audio to transcribe.";
case "scope-deny":
return `OpenClaw blocked audio transcription for this request.${detail}`.trim();
case "skipped":
return `OpenClaw skipped audio transcription.${detail}`.trim();
default:
return `OpenClaw did not return a transcript.${detail}`.trim();
}
};
export const shouldIgnoreVoiceTranscription = (params: {
transcript: string | null | undefined;
decision: MediaUnderstandingDecision | null | undefined;
}): boolean => {
const transcript = params.transcript?.trim() ?? "";
if (transcript) return false;
const reasons = (params.decision?.attachments ?? [])
.flatMap((attachment) => attachment.attempts ?? [])
.map((attempt) => attempt.reason?.trim().toLowerCase() ?? "")
.filter(Boolean);
return reasons.some((reason) =>
[
"missing text",
"empty transcript",
"no speech",
"no audio detected",
"no transcript text",
].some((snippet) => reason.includes(snippet)),
);
};
const resolveOpenClawPackageRoot = (): string => {
const configuredCandidate = CONFIGURED_OPENCLAW_PACKAGE_ROOT;
if (configuredCandidate) {
const indexPath = path.join(configuredCandidate, OPENCLAW_DIST_INDEX_RELATIVE_PATH);
if (fs.existsSync(indexPath)) return configuredCandidate;
throw new Error("OPENCLAW_PACKAGE_ROOT does not point to a valid OpenClaw installation.");
}
const installedCandidate = resolveInstalledOpenClawPackageRoot();
if (installedCandidate) {
const indexPath = path.join(installedCandidate, OPENCLAW_DIST_INDEX_RELATIVE_PATH);
if (fs.existsSync(indexPath)) return installedCandidate;
}
throw new Error(
"OpenClaw could not be resolved from the current Node runtime. Install the `openclaw` package or set OPENCLAW_PACKAGE_ROOT.",
);
};
const loadOpenClawSdk = async (): Promise<OpenClawTranscriptionSdk> => {
if (sdkPromise) return sdkPromise;
sdkPromise = (async () => {
const packageRoot = resolveOpenClawPackageRoot();
const distDirectory = path.join(packageRoot, OPENCLAW_DIST_DIRECTORY_RELATIVE_PATH);
const distEntries = (await fsp.readdir(distDirectory)).sort();
const configCandidates = distEntries.filter((entry) => /^config-.*\.js$/.test(entry));
let loadConfig: OpenClawTranscriptionSdk["loadConfig"] | null = null;
for (const candidate of configCandidates) {
const configModule = (await nativeImport(
pathToFileURL(path.join(distDirectory, candidate)).href,
)) as Partial<OpenClawConfigModule>;
if (typeof configModule.t === "function") {
loadConfig = configModule.t;
break;
}
}
if (!loadConfig) {
throw new Error("The installed OpenClaw runtime does not expose a loadConfig() module.");
}
const runnerCandidates = distEntries.filter((entry) => /^runner-.*\.js$/.test(entry));
for (const candidate of runnerCandidates) {
const runnerModule = (await nativeImport(
pathToFileURL(path.join(distDirectory, candidate)).href,
)) as Partial<
OpenClawRunnerModule
>;
if (
typeof runnerModule.a === "function" &&
typeof runnerModule.n === "function" &&
typeof runnerModule.r === "function" &&
typeof runnerModule.t === "function"
) {
return {
loadConfig,
runCapability: runnerModule.a,
createMediaAttachmentCache: runnerModule.n,
normalizeMediaAttachments: runnerModule.r,
buildProviderRegistry: runnerModule.t,
};
}
}
throw new Error("The installed OpenClaw runtime does not expose the audio transcription runner.");
})().catch((error) => {
sdkPromise = null;
throw error;
});
return sdkPromise;
};
export const transcribeVoiceWithOpenClaw = async (params: {
buffer: Buffer;
fileName?: string | null;
mimeType?: string | null;
}): Promise<OpenClawVoiceTranscriptionResult> => {
const sdk = await loadOpenClawSdk();
const cfg = sdk.loadConfig();
if (cfg.tools?.media?.audio?.enabled === false) {
throw new Error("OpenClaw audio transcription is disabled.");
}
const mimeType = normalizeVoiceMimeType(params.mimeType);
const fileName = sanitizeVoiceFileName(params.fileName, mimeType);
const tempDirectory = await fsp.mkdtemp(path.join(os.tmpdir(), "claw3d-voice-"));
const tempPath = path.join(tempDirectory, `${randomUUID()}-${fileName}`);
await fsp.writeFile(tempPath, params.buffer);
const ctx: Record<string, unknown> = {
Body: "",
BodyForAgent: "",
BodyForCommands: "",
RawBody: "",
CommandBody: "",
ChatType: "direct",
MediaPath: tempPath,
MediaType: mimeType,
MediaPaths: [tempPath],
MediaTypes: [mimeType],
};
const media = sdk.normalizeMediaAttachments(ctx);
const cache = sdk.createMediaAttachmentCache(media);
try {
const result = await sdk.runCapability({
capability: "audio",
cfg,
ctx,
attachments: cache,
media,
providerRegistry: sdk.buildProviderRegistry(),
config: cfg.tools?.media?.audio,
});
const audioOutputs = (result.outputs ?? []).filter((output) => output.kind === AUDIO_KIND);
const transcript = audioOutputs
.map((output) => output.text?.trim() ?? "")
.filter(Boolean)
.join("\n\n")
.trim();
if (!transcript) {
if (shouldIgnoreVoiceTranscription({ transcript, decision: result.decision ?? null })) {
return {
transcript: null,
provider: null,
model: null,
decision: result.decision ?? null,
ignored: true,
};
}
throw new Error(buildVoiceTranscriptionErrorMessage(result.decision ?? null));
}
const firstOutput = audioOutputs[0] ?? null;
return {
transcript,
provider: firstOutput?.provider ?? null,
model: firstOutput?.model ?? null,
decision: result.decision ?? null,
ignored: false,
};
} finally {
await cache.cleanup().catch(() => undefined);
await fsp.rm(tempPath, { force: true }).catch(() => undefined);
await fsp.rmdir(tempDirectory).catch(() => undefined);
}
};
+71
View File
@@ -0,0 +1,71 @@
import { isIP } from "node:net";
const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "0.0.0.0"]);
const isPrivateIpv4 = (hostname: string): boolean => {
const parts = hostname.split(".").map((part) => Number(part));
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
return false;
}
if (parts[0] === 10) return true;
if (parts[0] === 127) return true;
if (parts[0] === 169 && parts[1] === 254) return true;
if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true;
if (parts[0] === 192 && parts[1] === 168) return true;
return false;
};
const isPrivateIpv6 = (hostname: string): boolean => {
const normalized = hostname.trim().toLowerCase();
if (!normalized) return false;
if (normalized === "::1") return true;
if (normalized.startsWith("fc") || normalized.startsWith("fd")) return true;
if (normalized.startsWith("fe8") || normalized.startsWith("fe9")) return true;
if (normalized.startsWith("fea") || normalized.startsWith("feb")) return true;
return false;
};
export const isPrivateOrLoopbackHostname = (hostname: string): boolean => {
const normalized = hostname.trim().toLowerCase();
if (!normalized) return true;
if (LOOPBACK_HOSTNAMES.has(normalized)) return true;
const ipVersion = isIP(normalized);
if (ipVersion === 4) return isPrivateIpv4(normalized);
if (ipVersion === 6) return isPrivateIpv6(normalized);
return false;
};
export const validateBrowserPreviewTarget = (value: string): URL => {
const parsed = new URL(value);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error("Browser preview only supports http(s) URLs.");
}
if (parsed.username || parsed.password) {
throw new Error("Browser preview does not allow embedded URL credentials.");
}
if (isPrivateOrLoopbackHostname(parsed.hostname)) {
throw new Error("Browser preview does not allow loopback or private-network targets.");
}
return parsed;
};
export const validateJiraBaseUrl = (value: string): string => {
const parsed = new URL(value);
if (parsed.protocol !== "https:") {
throw new Error("Jira base URL must use https.");
}
if (parsed.username || parsed.password) {
throw new Error("Jira base URL must not include embedded credentials.");
}
const hostname = parsed.hostname.trim().toLowerCase();
if (!hostname.endsWith(".atlassian.net")) {
throw new Error("Jira base URL must be an Atlassian Cloud host.");
}
if (parsed.pathname !== "/" && parsed.pathname !== "") {
throw new Error("Jira base URL must not include a path.");
}
if (parsed.search || parsed.hash) {
throw new Error("Jira base URL must not include a query string or hash.");
}
return parsed.origin;
};
+59
View File
@@ -0,0 +1,59 @@
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
import {
readGatewayAgentSkillsAllowlist,
updateGatewayAgentSkillsAllowlist,
} from "@/lib/gateway/agentConfig";
import { filterOsCompatibleSkills } from "@/lib/skills/presentation";
import type { SkillStatusEntry } from "@/lib/skills/types";
const normalizeSkillName = (value: string): string => value.trim();
export const resolveVisibleAgentSkillNames = (skills: SkillStatusEntry[]): string[] => {
return Array.from(
new Set(
filterOsCompatibleSkills(skills)
.map((entry) => normalizeSkillName(entry.name))
.filter((name) => name.length > 0)
)
);
};
export const setAgentSkillEnabled = async (params: {
client: GatewayClient;
agentId: string;
skillName: string;
enabled: boolean;
visibleSkills: SkillStatusEntry[];
}): Promise<void> => {
const resolvedSkillName = normalizeSkillName(params.skillName);
if (!resolvedSkillName) {
throw new Error("Skill name is required.");
}
const visibleSkillNames = resolveVisibleAgentSkillNames(params.visibleSkills);
if (visibleSkillNames.length === 0) {
throw new Error("Cannot update skill access: no skills available for this agent.");
}
const existingAllowlist = await readGatewayAgentSkillsAllowlist({
client: params.client,
agentId: params.agentId,
});
const baseline = existingAllowlist ?? visibleSkillNames;
const next = new Set(
baseline.map((value) => normalizeSkillName(value)).filter((value) => value.length > 0)
);
if (params.enabled) {
next.add(resolvedSkillName);
} else {
next.delete(resolvedSkillName);
}
await updateGatewayAgentSkillsAllowlist({
client: params.client,
agentId: params.agentId,
mode: "allowlist",
skillNames: [...next],
});
};
+217
View File
@@ -0,0 +1,217 @@
import {
buildSkillMissingDetails,
canRemoveSkill,
deriveSkillReadinessState,
groupSkillsBySource,
hasInstallableMissingBinary,
type SkillReadinessState,
} from "@/lib/skills/presentation";
import type { SkillStatusEntry } from "@/lib/skills/types";
export type SkillMarketplaceCollectionId =
| "featured"
| "installed"
| "setup-required"
| "built-in"
| "workspace"
| "extra"
| "other";
export type SkillMarketplaceMetadata = {
category: string;
tagline: string;
trustLabel: string;
capabilities: string[];
featured?: boolean;
editorBadge?: string;
rating?: number;
installs?: number;
};
export type SkillMarketplaceEntry = {
skill: SkillStatusEntry;
readiness: SkillReadinessState;
metadata: SkillMarketplaceMetadata;
installable: boolean;
removable: boolean;
missingDetails: string[];
};
const SKILL_MARKETPLACE_OVERRIDES: Record<string, Partial<SkillMarketplaceMetadata>> = {
github: {
category: "Engineering",
tagline: "Turns repository operations into a one-step teammate workflow.",
capabilities: ["Pull request support", "Issue context", "Repository operations"],
featured: true,
editorBadge: "Popular",
rating: 4.9,
installs: 18240,
},
figma: {
category: "Design",
tagline: "Connects design files, specs, and implementation context.",
capabilities: ["Design context", "Asset lookup", "Spec handoff"],
featured: true,
editorBadge: "Editor pick",
rating: 4.8,
installs: 9640,
},
slack: {
category: "Communication",
tagline: "Keeps agents plugged into team channels and notifications.",
capabilities: ["Channel updates", "Message drafting", "Notification routing"],
featured: true,
rating: 4.7,
installs: 14110,
},
linear: {
category: "Planning",
tagline: "Brings issue tracking and execution loops directly into agent workflows.",
capabilities: ["Issue lookup", "Status updates", "Planning workflows"],
featured: true,
rating: 4.7,
installs: 11980,
},
};
const hashString = (value: string): number => {
let hash = 0;
for (let index = 0; index < value.length; index += 1) {
hash = value.charCodeAt(index) + ((hash << 5) - hash);
}
return Math.abs(hash);
};
const titleCaseWords = (value: string): string =>
value
.split(/[\s_-]+/)
.filter((part) => part.length > 0)
.map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
.join(" ");
const buildFallbackCapabilities = (skill: SkillStatusEntry): string[] => {
const capabilities: string[] = [];
if (skill.primaryEnv) {
capabilities.push(`Uses ${skill.primaryEnv}.`);
}
if (skill.install.length > 0) {
capabilities.push("Supports guided dependency install.");
}
if (skill.always) {
capabilities.push("Always available by policy.");
}
if (skill.homepage) {
capabilities.push("Has external docs.");
}
if (capabilities.length === 0) {
capabilities.push("Reusable operational workflow.");
}
return capabilities.slice(0, 3);
};
const buildFallbackMetadata = (skill: SkillStatusEntry): SkillMarketplaceMetadata => {
const normalizedKey = skill.skillKey.trim().toLowerCase();
const source = skill.source.trim();
const seed = hashString(`${normalizedKey}:${source}`);
const category =
skill.bundled || source === "openclaw-bundled"
? "Built-in"
: source === "openclaw-managed"
? "Installed"
: source === "openclaw-workspace"
? "Workspace"
: source === "openclaw-extra"
? "Community"
: "Automation";
const trustLabel =
skill.bundled || source === "openclaw-bundled"
? "Verified"
: source === "openclaw-managed"
? "Managed"
: source === "openclaw-workspace"
? "Workspace"
: "Community";
return {
category,
tagline: skill.description.trim() || `${titleCaseWords(skill.name)} capability pack.`,
trustLabel,
capabilities: buildFallbackCapabilities(skill),
featured: skill.bundled || source === "openclaw-managed",
rating: 4.2 + (seed % 7) / 10,
installs: 400 + (seed % 9500),
};
};
export const resolveSkillMarketplaceMetadata = (skill: SkillStatusEntry): SkillMarketplaceMetadata => {
const normalizedKey = skill.skillKey.trim().toLowerCase();
const fallback = buildFallbackMetadata(skill);
const override = SKILL_MARKETPLACE_OVERRIDES[normalizedKey];
if (!override) {
return fallback;
}
return {
...fallback,
...override,
capabilities: override.capabilities ?? fallback.capabilities,
};
};
export const buildSkillMarketplaceEntry = (skill: SkillStatusEntry): SkillMarketplaceEntry => {
return {
skill,
readiness: deriveSkillReadinessState(skill),
metadata: resolveSkillMarketplaceMetadata(skill),
installable: hasInstallableMissingBinary(skill),
removable: canRemoveSkill(skill),
missingDetails: buildSkillMissingDetails(skill),
};
};
export const buildSkillMarketplaceCollections = (
skills: SkillStatusEntry[]
): Array<{
id: SkillMarketplaceCollectionId;
label: string;
entries: SkillMarketplaceEntry[];
}> => {
const entries = skills.map(buildSkillMarketplaceEntry);
const sourceGroups = groupSkillsBySource(skills);
const collections: Array<{
id: SkillMarketplaceCollectionId;
label: string;
entries: SkillMarketplaceEntry[];
}> = [];
const featured = entries.filter((entry) => entry.metadata.featured).slice(0, 6);
if (featured.length > 0) {
collections.push({ id: "featured", label: "Featured", entries: featured });
}
const installed = entries.filter((entry) => entry.readiness === "ready" || entry.skill.disabled);
if (installed.length > 0) {
collections.push({ id: "installed", label: "Installed", entries: installed });
}
const setupRequired = entries.filter((entry) => entry.readiness === "needs-setup");
if (setupRequired.length > 0) {
collections.push({ id: "setup-required", label: "Needs setup", entries: setupRequired });
}
for (const group of sourceGroups) {
const groupEntries = group.skills.map(buildSkillMarketplaceEntry);
const groupId =
group.id === "built-in" ||
group.id === "workspace" ||
group.id === "extra" ||
group.id === "other"
? group.id
: "installed";
collections.push({
id: groupId,
label: group.label,
entries: groupEntries,
});
}
return collections;
};
+280
View File
@@ -0,0 +1,280 @@
import type {
RemovableSkillSource,
SkillInstallOption,
SkillStatusEntry,
} from "@/lib/skills/types";
export type SkillSourceGroupId = "workspace" | "built-in" | "installed" | "extra" | "other";
export type SkillSourceGroup = {
id: SkillSourceGroupId;
label: string;
skills: SkillStatusEntry[];
};
export type SkillReadinessState =
| "ready"
| "needs-setup"
| "unavailable"
| "disabled-globally";
export type AgentSkillDisplayState = "ready" | "setup-required" | "not-supported";
export type AgentSkillsAccessMode = "all" | "none" | "selected";
const GROUP_DEFINITIONS: Array<{ id: Exclude<SkillSourceGroupId, "other">; label: string }> = [
{ id: "workspace", label: "Workspace Skills" },
{ id: "built-in", label: "Built-in Skills" },
{ id: "installed", label: "Installed Skills" },
{ id: "extra", label: "Extra Skills" },
];
const WORKSPACE_SOURCES = new Set(["openclaw-workspace", "agents-skills-personal", "agents-skills-project"]);
const REMOVABLE_SOURCES = new Set<RemovableSkillSource>([
"openclaw-managed",
"openclaw-workspace",
]);
const trimNonEmpty = (value: string): string | null => {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
};
const OS_LABELS: Record<string, string> = {
darwin: "macOS",
linux: "Linux",
win32: "Windows",
windows: "Windows",
};
const toOsLabel = (value: string): string => {
const normalized = value.trim().toLowerCase();
return OS_LABELS[normalized] ?? value.trim();
};
const normalizeStringList = (values: string[] | undefined): string[] => {
if (!Array.isArray(values)) {
return [];
}
const normalized: string[] = [];
for (const value of values) {
const trimmed = trimNonEmpty(value);
if (trimmed) {
normalized.push(trimmed);
}
}
return normalized;
};
export const normalizeAgentSkillsAllowlist = (values: string[] | undefined): string[] => {
const normalized = normalizeStringList(values);
return Array.from(new Set(normalized));
};
export const deriveAgentSkillsAccessMode = (
values: string[] | undefined
): AgentSkillsAccessMode => {
if (!Array.isArray(values)) {
return "all";
}
return normalizeAgentSkillsAllowlist(values).length === 0 ? "none" : "selected";
};
export const buildAgentSkillsAllowlistSet = (values: string[] | undefined): Set<string> =>
new Set(normalizeAgentSkillsAllowlist(values));
const resolveGroupId = (skill: SkillStatusEntry): SkillSourceGroupId => {
const source = trimNonEmpty(skill.source) ?? "";
const bundled = skill.bundled || source === "openclaw-bundled";
if (bundled) return "built-in";
if (WORKSPACE_SOURCES.has(source)) return "workspace";
if (source === "openclaw-managed") return "installed";
if (source === "openclaw-extra") return "extra";
return "other";
};
export const groupSkillsBySource = (skills: SkillStatusEntry[]): SkillSourceGroup[] => {
const grouped = new Map<SkillSourceGroupId, SkillSourceGroup>();
for (const def of GROUP_DEFINITIONS) {
grouped.set(def.id, { id: def.id, label: def.label, skills: [] });
}
grouped.set("other", { id: "other", label: "Other Skills", skills: [] });
for (const skill of skills) {
const groupId = resolveGroupId(skill);
grouped.get(groupId)?.skills.push(skill);
}
const ordered: SkillSourceGroup[] = [];
for (const def of GROUP_DEFINITIONS) {
const group = grouped.get(def.id);
if (group && group.skills.length > 0) {
ordered.push(group);
}
}
const other = grouped.get("other");
if (other && other.skills.length > 0) {
ordered.push(other);
}
return ordered;
};
export const canRemoveSkillSource = (source: string): source is RemovableSkillSource => {
const trimmed = trimNonEmpty(source);
if (!trimmed) {
return false;
}
return REMOVABLE_SOURCES.has(trimmed as RemovableSkillSource);
};
export const canRemoveSkill = (skill: SkillStatusEntry): boolean => {
return canRemoveSkillSource(skill.source);
};
export const buildSkillMissingDetails = (skill: SkillStatusEntry): string[] => {
const details: string[] = [];
const bins = normalizeStringList(skill.missing.bins);
if (bins.length > 0) {
details.push(`Missing tools: ${bins.join(", ")}`);
}
const anyBins = normalizeStringList(skill.missing.anyBins);
if (anyBins.length > 0) {
details.push(`Missing one-of tools (install any): ${anyBins.join(" | ")}`);
}
const env = normalizeStringList(skill.missing.env);
if (env.length > 0) {
details.push(`Missing env vars (set in gateway env): ${env.join(", ")}`);
}
const config = normalizeStringList(skill.missing.config);
if (config.length > 0) {
details.push(`Missing config values (set in openclaw.json): ${config.join(", ")}`);
}
const os = normalizeStringList(skill.missing.os);
if (os.length > 0) {
details.push(`Requires OS: ${os.map((value) => toOsLabel(value)).join(", ")}`);
}
return details;
};
export const buildSkillReasons = (skill: SkillStatusEntry): string[] => {
const reasons: string[] = [];
if (skill.disabled) {
reasons.push("disabled");
}
if (skill.blockedByAllowlist) {
reasons.push("blocked by allowlist");
}
if (normalizeStringList(skill.missing.bins).length > 0) {
reasons.push("missing tools");
}
if (normalizeStringList(skill.missing.anyBins).length > 0) {
reasons.push("missing one-of tools");
}
if (normalizeStringList(skill.missing.env).length > 0) {
reasons.push("missing env vars");
}
if (normalizeStringList(skill.missing.config).length > 0) {
reasons.push("missing config values");
}
if (normalizeStringList(skill.missing.os).length > 0) {
reasons.push("unsupported OS");
}
return reasons;
};
export const isSkillOsIncompatible = (skill: SkillStatusEntry): boolean => {
return normalizeStringList(skill.missing.os).length > 0;
};
export const filterOsCompatibleSkills = (skills: SkillStatusEntry[]): SkillStatusEntry[] => {
return skills.filter((skill) => !isSkillOsIncompatible(skill));
};
export const deriveSkillReadinessState = (skill: SkillStatusEntry): SkillReadinessState => {
if (skill.disabled) {
return "disabled-globally";
}
if (isSkillOsIncompatible(skill) || skill.blockedByAllowlist) {
return "unavailable";
}
if (skill.eligible) {
return "ready";
}
return "needs-setup";
};
export const deriveAgentSkillDisplayState = (
readiness: SkillReadinessState
): AgentSkillDisplayState => {
if (readiness === "ready") {
return "ready";
}
if (readiness === "unavailable") {
return "not-supported";
}
return "setup-required";
};
export const isBundledBlockedSkill = (skill: SkillStatusEntry): boolean => {
const source = trimNonEmpty(skill.source) ?? "";
return (skill.bundled || source === "openclaw-bundled") && !skill.eligible;
};
export const hasInstallableMissingBinary = (skill: SkillStatusEntry): boolean => {
const installOptions = Array.isArray(skill.install) ? skill.install : [];
if (installOptions.length === 0) {
return false;
}
const missingBinarySet = new Set([
...normalizeStringList(skill.missing.bins),
...normalizeStringList(skill.missing.anyBins),
]);
if (missingBinarySet.size === 0) {
return false;
}
for (const option of installOptions) {
const bins = normalizeStringList(option.bins);
if (bins.length === 0) {
return true;
}
for (const bin of bins) {
if (missingBinarySet.has(bin)) {
return true;
}
}
}
return false;
};
export const resolvePreferredInstallOption = (
skill: SkillStatusEntry
): SkillInstallOption | null => {
if (!hasInstallableMissingBinary(skill)) {
return null;
}
const missingBinarySet = new Set([
...normalizeStringList(skill.missing.bins),
...normalizeStringList(skill.missing.anyBins),
]);
for (const option of skill.install) {
const bins = normalizeStringList(option.bins);
if (bins.length === 0) {
return option;
}
for (const bin of bins) {
if (missingBinarySet.has(bin)) {
return option;
}
}
}
return skill.install[0] ?? null;
};
+91
View File
@@ -0,0 +1,91 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { resolveUserPath } from "@/lib/clawdbot/paths";
import type { RemovableSkillSource, SkillRemoveRequest, SkillRemoveResult } from "@/lib/skills/types";
const resolveComparablePath = (input: string): string => {
const resolved = path.resolve(input);
if (!fs.existsSync(resolved)) {
return resolved;
}
try {
return fs.realpathSync(resolved);
} catch {
return resolved;
}
};
const isPathInside = (root: string, candidate: string): boolean => {
const resolvedRoot = resolveComparablePath(root);
const resolvedCandidate = resolveComparablePath(candidate);
if (resolvedCandidate === resolvedRoot) {
return true;
}
const rootPrefix = resolvedRoot.endsWith(path.sep) ? resolvedRoot : `${resolvedRoot}${path.sep}`;
return resolvedCandidate.startsWith(rootPrefix);
};
const normalizeRequiredPath = (value: string, field: string): string => {
const trimmed = value.trim();
if (!trimmed) {
throw new Error(`${field} is required.`);
}
return resolveUserPath(trimmed, os.homedir);
};
const resolveAllowedRoot = (params: {
source: RemovableSkillSource;
workspaceDir: string;
managedSkillsDir: string;
}): string => {
if (params.source === "openclaw-managed") {
return params.managedSkillsDir;
}
return path.join(params.workspaceDir, "skills");
};
export const removeSkillLocally = (params: SkillRemoveRequest): SkillRemoveResult => {
const skillKey = params.skillKey.trim();
if (!skillKey) {
throw new Error("skillKey is required.");
}
const source = params.source;
const baseDir = normalizeRequiredPath(params.baseDir, "baseDir");
const workspaceDir = normalizeRequiredPath(params.workspaceDir, "workspaceDir");
const managedSkillsDir = normalizeRequiredPath(params.managedSkillsDir, "managedSkillsDir");
const allowedRoot = resolveAllowedRoot({
source,
workspaceDir,
managedSkillsDir,
});
if (!isPathInside(allowedRoot, baseDir)) {
throw new Error(`Refusing to remove skill outside allowed root: ${baseDir}`);
}
if (resolveComparablePath(allowedRoot) === resolveComparablePath(baseDir)) {
throw new Error(`Refusing to remove the skills root directory: ${baseDir}`);
}
const exists = fs.existsSync(baseDir);
if (exists) {
const stats = fs.statSync(baseDir);
if (!stats.isDirectory()) {
throw new Error(`Skill path is not a directory: ${baseDir}`);
}
const skillDocPath = path.join(baseDir, "SKILL.md");
if (!fs.existsSync(skillDocPath) || !fs.statSync(skillDocPath).isFile()) {
throw new Error(`Refusing to remove non-skill directory: ${baseDir}`);
}
fs.rmSync(baseDir, { recursive: true, force: false });
}
return {
removed: exists,
removedPath: baseDir,
source,
};
};
+29
View File
@@ -0,0 +1,29 @@
import { fetchJson } from "@/lib/http";
import type { SkillRemoveRequest, SkillRemoveResult } from "@/lib/skills/types";
const normalizeRequired = (value: string, field: string): string => {
const trimmed = value.trim();
if (!trimmed) {
throw new Error(`${field} is required.`);
}
return trimmed;
};
export const removeSkillFromGateway = async (
request: SkillRemoveRequest
): Promise<SkillRemoveResult> => {
const payload: SkillRemoveRequest = {
skillKey: normalizeRequired(request.skillKey, "skillKey"),
source: request.source,
baseDir: normalizeRequired(request.baseDir, "baseDir"),
workspaceDir: normalizeRequired(request.workspaceDir, "workspaceDir"),
managedSkillsDir: normalizeRequired(request.managedSkillsDir, "managedSkillsDir"),
};
const response = await fetchJson<{ result: SkillRemoveResult }>("/api/gateway/skills/remove", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
});
return response.result;
};
+141
View File
@@ -0,0 +1,141 @@
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
export type SkillStatusConfigCheck = {
path: string;
satisfied: boolean;
};
export type SkillRequirementSet = {
bins: string[];
anyBins: string[];
env: string[];
config: string[];
os: string[];
};
export type SkillInstallOption = {
id: string;
kind: "brew" | "node" | "go" | "uv" | "download";
label: string;
bins: string[];
};
export type RemovableSkillSource = "openclaw-managed" | "openclaw-workspace";
export type SkillStatusEntry = {
name: string;
description: string;
source: string;
bundled: boolean;
filePath: string;
baseDir: string;
skillKey: string;
primaryEnv?: string;
emoji?: string;
homepage?: string;
always: boolean;
disabled: boolean;
blockedByAllowlist: boolean;
eligible: boolean;
requirements: SkillRequirementSet;
missing: SkillRequirementSet;
configChecks: SkillStatusConfigCheck[];
install: SkillInstallOption[];
};
export type SkillStatusReport = {
workspaceDir: string;
managedSkillsDir: string;
skills: SkillStatusEntry[];
};
export type SkillInstallRequest = {
name: string;
installId: string;
timeoutMs?: number;
};
export type SkillInstallResult = {
ok: boolean;
message: string;
stdout: string;
stderr: string;
code: number | null;
warnings?: string[];
};
export type SkillUpdateRequest = {
skillKey: string;
enabled?: boolean;
apiKey?: string;
};
export type SkillUpdateResult = {
ok: boolean;
skillKey: string;
config: Record<string, unknown>;
};
export type SkillRemoveRequest = {
skillKey: string;
source: RemovableSkillSource;
baseDir: string;
workspaceDir: string;
managedSkillsDir: string;
};
export type SkillRemoveResult = {
removed: boolean;
removedPath: string;
source: RemovableSkillSource;
};
const resolveAgentId = (agentId: string): string => {
const trimmed = agentId.trim();
if (!trimmed) {
throw new Error("Agent id is required to load skill status.");
}
return trimmed;
};
const resolveRequiredValue = (value: string, message: string): string => {
const trimmed = value.trim();
if (!trimmed) {
throw new Error(message);
}
return trimmed;
};
export const loadAgentSkillStatus = async (
client: GatewayClient,
agentId: string
): Promise<SkillStatusReport> => {
return client.call<SkillStatusReport>("skills.status", {
agentId: resolveAgentId(agentId),
});
};
export const installSkill = async (
client: GatewayClient,
params: SkillInstallRequest
): Promise<SkillInstallResult> => {
return client.call<SkillInstallResult>("skills.install", {
name: resolveRequiredValue(params.name, "Skill name is required to install dependencies."),
installId: resolveRequiredValue(
params.installId,
"Install option id is required to install dependencies."
),
...(typeof params.timeoutMs === "number" ? { timeoutMs: params.timeoutMs } : {}),
});
};
export const updateSkill = async (
client: GatewayClient,
params: SkillUpdateRequest
): Promise<SkillUpdateResult> => {
return client.call<SkillUpdateResult>("skills.update", {
skillKey: resolveRequiredValue(params.skillKey, "Skill key is required to update skill setup."),
...(typeof params.enabled === "boolean" ? { enabled: params.enabled } : {}),
...(typeof params.apiKey === "string" ? { apiKey: params.apiKey } : {}),
});
};
+138
View File
@@ -0,0 +1,138 @@
import { runSshJson } from "@/lib/ssh/gateway-host";
export type GatewayAgentStateMove = { from: string; to: string };
export type TrashAgentStateResult = {
trashDir: string;
moved: GatewayAgentStateMove[];
};
export type RestoreAgentStateResult = {
restored: GatewayAgentStateMove[];
};
const TRASH_SCRIPT = `
set -euo pipefail
python3 - "$1" <<'PY'
import datetime
import json
import os
import pathlib
import re
import shutil
import sys
import uuid
agent_id = sys.argv[1].strip()
if not agent_id:
raise SystemExit("agentId is required.")
if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}", agent_id):
raise SystemExit(f"Invalid agentId: {agent_id}")
base = pathlib.Path.home() / ".openclaw"
trash_root = base / "trash" / "studio-delete-agent"
stamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%SZ")
trash_dir = trash_root / f"{stamp}-{agent_id}-{uuid.uuid4()}"
(trash_dir / "agents").mkdir(parents=True, exist_ok=True)
(trash_dir / "workspaces").mkdir(parents=True, exist_ok=True)
moves = []
def move_if_exists(src: pathlib.Path, dest: pathlib.Path):
if not src.exists():
return
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(src), str(dest))
moves.append({"from": str(src), "to": str(dest)})
move_if_exists(base / f"workspace-{agent_id}", trash_dir / "workspaces" / f"workspace-{agent_id}")
move_if_exists(base / "agents" / agent_id, trash_dir / "agents" / agent_id)
print(json.dumps({"trashDir": str(trash_dir), "moved": moves}))
PY
`;
const RESTORE_SCRIPT = `
set -euo pipefail
python3 - "$1" "$2" <<'PY'
import json
import pathlib
import re
import shutil
import sys
agent_id = sys.argv[1].strip()
trash_dir_raw = sys.argv[2].strip()
if not agent_id:
raise SystemExit("agentId is required.")
if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}", agent_id):
raise SystemExit(f"Invalid agentId: {agent_id}")
if not trash_dir_raw:
raise SystemExit("trashDir is required.")
base = pathlib.Path.home() / ".openclaw"
trash_dir = pathlib.Path(trash_dir_raw).expanduser()
try:
resolved_trash = trash_dir.resolve(strict=True)
except FileNotFoundError:
raise SystemExit(f"trashDir does not exist: {trash_dir_raw}")
resolved_base = base.resolve(strict=False)
if resolved_base not in resolved_trash.parents:
raise SystemExit(f"trashDir is not under {base}: {trash_dir_raw}")
moves = []
def restore_if_exists(src: pathlib.Path, dest: pathlib.Path):
if not src.exists():
return
if dest.exists():
raise SystemExit(f"Refusing to restore over existing path: {dest}")
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(src), str(dest))
moves.append({"from": str(src), "to": str(dest)})
restore_if_exists(
resolved_trash / "workspaces" / f"workspace-{agent_id}",
base / f"workspace-{agent_id}",
)
restore_if_exists(
resolved_trash / "agents" / agent_id,
base / "agents" / agent_id,
)
print(json.dumps({"restored": moves}))
PY
`;
export const trashAgentStateOverSsh = (params: {
sshTarget: string;
agentId: string;
}): TrashAgentStateResult => {
const result = runSshJson({
sshTarget: params.sshTarget,
argv: ["bash", "-s", "--", params.agentId],
input: TRASH_SCRIPT,
label: `trash agent state (${params.agentId})`,
});
return result as TrashAgentStateResult;
};
export const restoreAgentStateOverSsh = (params: {
sshTarget: string;
agentId: string;
trashDir: string;
}): RestoreAgentStateResult => {
const result = runSshJson({
sshTarget: params.sshTarget,
argv: ["bash", "-s", "--", params.agentId, params.trashDir],
input: RESTORE_SCRIPT,
label: `restore agent state (${params.agentId})`,
});
return result as RestoreAgentStateResult;
};
+124
View File
@@ -0,0 +1,124 @@
import { loadStudioSettings } from "@/lib/studio/settings-store";
import * as childProcess from "node:child_process";
const SSH_TARGET_ENV = "OPENCLAW_GATEWAY_SSH_TARGET";
const SSH_USER_ENV = "OPENCLAW_GATEWAY_SSH_USER";
export const resolveConfiguredSshTarget = (env: NodeJS.ProcessEnv = process.env): string | null => {
const configuredTarget = env[SSH_TARGET_ENV]?.trim() ?? "";
const configuredUser = env[SSH_USER_ENV]?.trim() ?? "";
if (configuredTarget) {
if (configuredTarget.includes("@")) return configuredTarget;
if (configuredUser) return `${configuredUser}@${configuredTarget}`;
return configuredTarget;
}
return null;
};
export const resolveGatewaySshTargetFromGatewayUrl = (
gatewayUrl: string,
env: NodeJS.ProcessEnv = process.env
): string => {
const configured = resolveConfiguredSshTarget(env);
if (configured) return configured;
const trimmed = gatewayUrl.trim();
if (!trimmed) {
throw new Error(
`Gateway URL is missing. Set it in Studio settings or set ${SSH_TARGET_ENV}.`
);
}
let hostname: string;
try {
hostname = new URL(trimmed).hostname;
} catch {
throw new Error(`Invalid gateway URL: ${trimmed}`);
}
if (!hostname) {
throw new Error(`Invalid gateway URL: ${trimmed}`);
}
const configuredUser = env[SSH_USER_ENV]?.trim() ?? "";
const user = configuredUser || "ubuntu";
return `${user}@${hostname}`;
};
export const resolveGatewaySshTarget = (env: NodeJS.ProcessEnv = process.env): string => {
const configured = resolveConfiguredSshTarget(env);
if (configured) return configured;
const settings = loadStudioSettings();
return resolveGatewaySshTargetFromGatewayUrl(settings.gateway?.url?.trim() ?? "", env);
};
export const extractJsonErrorMessage = (value: string): string | null => {
const trimmed = value.trim();
if (!trimmed) return null;
try {
const parsed = JSON.parse(trimmed) as unknown;
if (!parsed || typeof parsed !== "object") return null;
const record = parsed as Record<string, unknown>;
const direct = record.error;
if (typeof direct === "string" && direct.trim()) return direct.trim();
if (direct && typeof direct === "object") {
const nested = (direct as Record<string, unknown>).message;
if (typeof nested === "string" && nested.trim()) return nested.trim();
}
return null;
} catch {
return null;
}
};
export const parseJsonOutput = (raw: string, label: string): unknown => {
const trimmed = raw.trim();
if (!trimmed) {
throw new Error(`Command produced empty JSON output (${label}).`);
}
try {
return JSON.parse(trimmed) as unknown;
} catch {
throw new Error(`Command produced invalid JSON output (${label}).`);
}
};
export const runSshJson = (params: {
sshTarget: string;
argv: string[];
label: string;
input?: string;
fallbackMessage?: string;
maxBuffer?: number;
}): unknown => {
const options: childProcess.SpawnSyncOptionsWithStringEncoding = {
encoding: "utf8",
input: params.input,
};
if (params.maxBuffer !== undefined) {
options.maxBuffer = params.maxBuffer;
}
const result = childProcess.spawnSync("ssh", ["-o", "BatchMode=yes", params.sshTarget, ...params.argv], {
...options,
});
if (result.error) {
throw new Error(`Failed to execute ssh: ${result.error.message}`);
}
const stdout = result.stdout ?? "";
const stderr = result.stderr ?? "";
if (result.status !== 0) {
const stderrText = stderr.trim();
const stdoutText = stdout.trim();
const message =
extractJsonErrorMessage(stdout) ??
extractJsonErrorMessage(stderr) ??
(stderrText ||
stdoutText ||
params.fallbackMessage ||
`Command failed (${params.label}).`);
throw new Error(message);
}
return parseJsonOutput(stdout, params.label);
};
+88
View File
@@ -0,0 +1,88 @@
import { runSshJson } from "@/lib/ssh/gateway-host";
import type { SkillRemoveRequest, SkillRemoveResult } from "@/lib/skills/types";
const REMOVE_SKILL_SCRIPT = `
set -euo pipefail
python3 - "$1" "$2" "$3" "$4" "$5" <<'PY'
import json
import pathlib
import shutil
import sys
skill_key = sys.argv[1].strip()
source = sys.argv[2].strip()
base_dir_raw = sys.argv[3].strip()
workspace_dir_raw = sys.argv[4].strip()
managed_skills_dir_raw = sys.argv[5].strip()
if not skill_key:
raise SystemExit("skillKey is required.")
if not source:
raise SystemExit("source is required.")
if not base_dir_raw:
raise SystemExit("baseDir is required.")
if not workspace_dir_raw:
raise SystemExit("workspaceDir is required.")
if not managed_skills_dir_raw:
raise SystemExit("managedSkillsDir is required.")
allowed_sources = {
"openclaw-managed",
"openclaw-workspace",
}
if source not in allowed_sources:
raise SystemExit(f"Unsupported skill source for removal: {source}")
base_dir = pathlib.Path(base_dir_raw).expanduser().resolve(strict=False)
workspace_dir = pathlib.Path(workspace_dir_raw).expanduser().resolve(strict=False)
managed_skills_dir = pathlib.Path(managed_skills_dir_raw).expanduser().resolve(strict=False)
if source == "openclaw-managed":
allowed_root = managed_skills_dir
else:
allowed_root = (workspace_dir / "skills").resolve(strict=False)
try:
base_dir.relative_to(allowed_root)
except ValueError:
raise SystemExit(f"Refusing to remove skill outside allowed root: {base_dir}")
if base_dir == allowed_root:
raise SystemExit(f"Refusing to remove the skills root directory: {base_dir}")
removed = False
if base_dir.exists():
if not base_dir.is_dir():
raise SystemExit(f"Skill path is not a directory: {base_dir}")
skill_doc = base_dir / "SKILL.md"
if not skill_doc.exists() or not skill_doc.is_file():
raise SystemExit(f"Refusing to remove non-skill directory: {base_dir}")
shutil.rmtree(base_dir)
removed = True
print(json.dumps({"removed": removed, "removedPath": str(base_dir), "source": source}))
PY
`;
export const removeSkillOverSsh = (params: {
sshTarget: string;
request: SkillRemoveRequest;
}): SkillRemoveResult => {
const result = runSshJson({
sshTarget: params.sshTarget,
argv: [
"bash",
"-s",
"--",
params.request.skillKey,
params.request.source,
params.request.baseDir,
params.request.workspaceDir,
params.request.managedSkillsDir,
],
input: REMOVE_SKILL_SCRIPT,
label: `remove skill (${params.request.skillKey})`,
});
return result as SkillRemoveResult;
};
+385
View File
@@ -0,0 +1,385 @@
import { fetchJson } from "@/lib/http";
import type {
StudioAnalyticsPreferencePatch,
StudioFocusedPreference,
StudioGatewaySettingsPublic,
StudioSettingsPublic,
StudioSettingsPatch,
StudioStandupPreferencePatch,
StudioVoiceRepliesPreferencePatch,
} from "@/lib/studio/settings";
export type StudioSettingsResponse = {
settings: StudioSettingsPublic;
localGatewayDefaults?: StudioGatewaySettingsPublic | null;
};
export type StudioSettingsLoadOptions = {
force?: boolean;
maxAgeMs?: number;
};
type FocusedPatch = Record<string, Partial<StudioFocusedPreference> | null>;
type AvatarsPatch = Record<string, Record<string, string | null> | null>;
type DeskAssignmentsPatch = Record<string, Record<string, string | null> | null>;
type AnalyticsPatch = Record<string, StudioAnalyticsPreferencePatch | null>;
type VoiceRepliesPatch = Record<string, StudioVoiceRepliesPreferencePatch | null>;
type StandupPatch = Record<string, StudioStandupPreferencePatch | null>;
export type StudioSettingsCoordinatorTransport = {
fetchSettings: () => Promise<StudioSettingsResponse>;
updateSettings: (patch: StudioSettingsPatch) => Promise<StudioSettingsResponse>;
};
const mergeFocusedPatch = (
current: FocusedPatch | undefined,
next: FocusedPatch | undefined
): FocusedPatch | undefined => {
if (!current && !next) return undefined;
return {
...(current ?? {}),
...(next ?? {}),
};
};
const mergeAvatarsPatch = (
current: AvatarsPatch | undefined,
next: AvatarsPatch | undefined
): AvatarsPatch | undefined => {
if (!current && !next) return undefined;
const merged: AvatarsPatch = { ...(current ?? {}) };
for (const [gatewayKey, value] of Object.entries(next ?? {})) {
if (value === null) {
merged[gatewayKey] = null;
continue;
}
const existing = merged[gatewayKey];
if (existing && existing !== null) {
merged[gatewayKey] = { ...existing, ...value };
continue;
}
merged[gatewayKey] = { ...value };
}
return merged;
};
const mergeDeskAssignmentsPatch = (
current: DeskAssignmentsPatch | undefined,
next: DeskAssignmentsPatch | undefined
): DeskAssignmentsPatch | undefined => {
if (!current && !next) return undefined;
const merged: DeskAssignmentsPatch = { ...(current ?? {}) };
for (const [gatewayKey, value] of Object.entries(next ?? {})) {
if (value === null) {
merged[gatewayKey] = null;
continue;
}
const existing = merged[gatewayKey];
if (existing && existing !== null) {
merged[gatewayKey] = { ...existing, ...value };
continue;
}
merged[gatewayKey] = { ...value };
}
return merged;
};
const mergeAnalyticsPatch = (
current: AnalyticsPatch | undefined,
next: AnalyticsPatch | undefined
): AnalyticsPatch | undefined => {
if (!current && !next) return undefined;
const merged: AnalyticsPatch = { ...(current ?? {}) };
for (const [gatewayKey, value] of Object.entries(next ?? {})) {
if (value === null) {
merged[gatewayKey] = null;
continue;
}
const existing = merged[gatewayKey];
if (existing && existing !== null) {
merged[gatewayKey] = {
...existing,
...value,
...(value.budgets || existing.budgets
? {
budgets: {
...(existing.budgets ?? {}),
...(value.budgets ?? {}),
},
}
: {}),
};
continue;
}
merged[gatewayKey] = {
...value,
...(value.budgets ? { budgets: { ...value.budgets } } : {}),
};
}
return merged;
};
const mergeVoiceRepliesPatch = (
current: VoiceRepliesPatch | undefined,
next: VoiceRepliesPatch | undefined
): VoiceRepliesPatch | undefined => {
if (!current && !next) return undefined;
const merged: VoiceRepliesPatch = { ...(current ?? {}) };
for (const [gatewayKey, value] of Object.entries(next ?? {})) {
if (value === null) {
merged[gatewayKey] = null;
continue;
}
const existing = merged[gatewayKey];
if (existing && existing !== null) {
merged[gatewayKey] = {
...existing,
...value,
};
continue;
}
merged[gatewayKey] = { ...value };
}
return merged;
};
const mergeStandupPatch = (
current: StandupPatch | undefined,
next: StandupPatch | undefined
): StandupPatch | undefined => {
if (!current && !next) return undefined;
const merged: StandupPatch = { ...(current ?? {}) };
for (const [gatewayKey, value] of Object.entries(next ?? {})) {
if (value === null) {
merged[gatewayKey] = null;
continue;
}
const existing = merged[gatewayKey];
if (existing && existing !== null) {
merged[gatewayKey] = {
...existing,
...value,
...(value.schedule || existing.schedule
? {
schedule: {
...(existing.schedule ?? {}),
...(value.schedule ?? {}),
},
}
: {}),
...(value.jira || existing.jira
? {
jira: {
...(existing.jira ?? {}),
...(value.jira ?? {}),
},
}
: {}),
...(value.manualByAgentId || existing.manualByAgentId
? {
manualByAgentId: {
...(existing.manualByAgentId ?? {}),
...(value.manualByAgentId ?? {}),
},
}
: {}),
};
continue;
}
merged[gatewayKey] = {
...value,
...(value.schedule ? { schedule: { ...value.schedule } } : {}),
...(value.jira ? { jira: { ...value.jira } } : {}),
...(value.manualByAgentId
? { manualByAgentId: { ...value.manualByAgentId } }
: {}),
};
}
return merged;
};
const mergeStudioPatch = (
current: StudioSettingsPatch | null,
next: StudioSettingsPatch
): StudioSettingsPatch => {
if (!current) {
return {
...(next.gateway !== undefined ? { gateway: next.gateway } : {}),
...(next.focused ? { focused: { ...next.focused } } : {}),
...(next.avatars ? { avatars: { ...next.avatars } } : {}),
...(next.deskAssignments ? { deskAssignments: { ...next.deskAssignments } } : {}),
...(next.analytics ? { analytics: { ...next.analytics } } : {}),
...(next.voiceReplies ? { voiceReplies: { ...next.voiceReplies } } : {}),
...(next.standup ? { standup: { ...next.standup } } : {}),
};
}
const focused = mergeFocusedPatch(current.focused, next.focused);
const avatars = mergeAvatarsPatch(current.avatars, next.avatars);
const deskAssignments = mergeDeskAssignmentsPatch(
current.deskAssignments,
next.deskAssignments
);
const analytics = mergeAnalyticsPatch(current.analytics, next.analytics);
const voiceReplies = mergeVoiceRepliesPatch(current.voiceReplies, next.voiceReplies);
const standup = mergeStandupPatch(current.standup, next.standup);
return {
...(next.gateway !== undefined
? { gateway: next.gateway }
: current.gateway !== undefined
? { gateway: current.gateway }
: {}),
...(focused ? { focused } : {}),
...(avatars ? { avatars } : {}),
...(deskAssignments ? { deskAssignments } : {}),
...(analytics ? { analytics } : {}),
...(voiceReplies ? { voiceReplies } : {}),
...(standup ? { standup } : {}),
};
};
export class StudioSettingsCoordinator {
private pendingPatch: StudioSettingsPatch | null = null;
private timer: ReturnType<typeof setTimeout> | null = null;
private queue: Promise<void> = Promise.resolve();
private disposed = false;
private cachedEnvelope: StudioSettingsResponse | null = null;
private cachedAtMs = 0;
private pendingLoadPromise: Promise<StudioSettingsResponse> | null = null;
constructor(
private readonly transport: StudioSettingsCoordinatorTransport,
private readonly defaultDebounceMs: number = 350,
private readonly defaultCacheTtlMs: number = 5_000
) {}
private primeCache(response: StudioSettingsResponse): StudioSettingsResponse {
this.cachedEnvelope = response;
this.cachedAtMs = Date.now();
return response;
}
private getCachedEnvelope(maxAgeMs: number = this.defaultCacheTtlMs) {
if (!this.cachedEnvelope) return null;
if (maxAgeMs >= 0 && Date.now() - this.cachedAtMs > maxAgeMs) {
return null;
}
return this.cachedEnvelope;
}
async loadSettings(
options?: StudioSettingsLoadOptions,
): Promise<StudioSettingsPublic | null> {
const result = await this.loadSettingsEnvelope(options);
return result.settings ?? null;
}
async loadSettingsEnvelope(
options?: StudioSettingsLoadOptions,
): Promise<StudioSettingsResponse> {
const force = options?.force === true;
const maxAgeMs = options?.maxAgeMs ?? this.defaultCacheTtlMs;
if (!force) {
const cached = this.getCachedEnvelope(maxAgeMs);
if (cached) {
return cached;
}
if (this.pendingLoadPromise) {
return this.pendingLoadPromise;
}
} else if (this.pendingLoadPromise) {
return this.pendingLoadPromise;
}
const loadPromise = this.transport
.fetchSettings()
.then((response) => this.primeCache(response))
.finally(() => {
if (this.pendingLoadPromise === loadPromise) {
this.pendingLoadPromise = null;
}
});
this.pendingLoadPromise = loadPromise;
return loadPromise;
}
schedulePatch(patch: StudioSettingsPatch, debounceMs: number = this.defaultDebounceMs): void {
if (this.disposed) return;
this.pendingPatch = mergeStudioPatch(this.pendingPatch, patch);
if (this.timer) {
clearTimeout(this.timer);
}
this.timer = setTimeout(() => {
this.timer = null;
void this.flushPending().catch((err) => {
console.error("Failed to flush pending studio settings patch.", err);
});
}, debounceMs);
}
async applyPatchNow(patch: StudioSettingsPatch): Promise<void> {
if (this.disposed) return;
this.pendingPatch = mergeStudioPatch(this.pendingPatch, patch);
await this.flushPending();
}
async flushPending(): Promise<void> {
if (this.disposed) {
return this.queue;
}
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
const patch = this.pendingPatch;
this.pendingPatch = null;
if (!patch) {
return this.queue;
}
const write = this.queue.then(async () => {
const response = await this.transport.updateSettings(patch);
this.primeCache(response);
});
this.queue = write.catch((err) => {
console.error("Failed to persist studio settings patch.", err);
});
return write;
}
dispose(): void {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
this.pendingPatch = null;
this.pendingLoadPromise = null;
this.disposed = true;
}
}
export const fetchStudioSettings = async (): Promise<StudioSettingsResponse> => {
return fetchJson<StudioSettingsResponse>("/api/studio", { cache: "no-store" });
};
export const updateStudioSettings = async (
patch: StudioSettingsPatch
): Promise<StudioSettingsResponse> => {
return fetchJson<StudioSettingsResponse>("/api/studio", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
});
};
export const createStudioSettingsCoordinator = (options?: {
debounceMs?: number;
cacheTtlMs?: number;
}): StudioSettingsCoordinator => {
return new StudioSettingsCoordinator(
{
fetchSettings: fetchStudioSettings,
updateSettings: updateStudioSettings,
},
options?.debounceMs,
options?.cacheTtlMs,
);
};
+89
View File
@@ -0,0 +1,89 @@
import fs from "node:fs";
import path from "node:path";
import { resolveStateDir } from "@/lib/clawdbot/paths";
import {
defaultStudioSettings,
mergeStudioSettings,
normalizeStudioSettings,
type StudioSettings,
type StudioSettingsPatch,
} from "@/lib/studio/settings";
// Studio settings are intentionally stored as a local JSON file for a single-user workflow.
// That includes gateway connection details, so treat the state directory as plaintext secret
// storage and document any changes to this threat model in README.md and SECURITY.md.
const SETTINGS_DIRNAME = "claw3d";
const SETTINGS_FILENAME = "settings.json";
const OPENCLAW_CONFIG_FILENAME = "openclaw.json";
export const resolveStudioSettingsPath = () =>
path.join(resolveStateDir(), SETTINGS_DIRNAME, SETTINGS_FILENAME);
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === "object");
const readOpenclawGatewayDefaults = (): { url: string; token: string } | null => {
try {
const configPath = path.join(resolveStateDir(), OPENCLAW_CONFIG_FILENAME);
if (!fs.existsSync(configPath)) return null;
const raw = fs.readFileSync(configPath, "utf8");
const parsed = JSON.parse(raw) as unknown;
if (!isRecord(parsed)) return null;
const gateway = isRecord(parsed.gateway) ? parsed.gateway : null;
if (!gateway) return null;
const auth = isRecord(gateway.auth) ? gateway.auth : null;
const token = typeof auth?.token === "string" ? auth.token.trim() : "";
const port = typeof gateway.port === "number" && Number.isFinite(gateway.port) ? gateway.port : null;
if (!token) return null;
const url = port ? `ws://localhost:${port}` : "";
if (!url) return null;
return { url, token };
} catch {
return null;
}
};
export const loadLocalGatewayDefaults = () => {
return readOpenclawGatewayDefaults();
};
export const loadStudioSettings = (): StudioSettings => {
const settingsPath = resolveStudioSettingsPath();
if (!fs.existsSync(settingsPath)) {
const defaults = defaultStudioSettings();
const gateway = loadLocalGatewayDefaults();
return gateway ? { ...defaults, gateway } : defaults;
}
const raw = fs.readFileSync(settingsPath, "utf8");
const parsed = JSON.parse(raw) as unknown;
const settings = normalizeStudioSettings(parsed);
if (!settings.gateway?.token) {
const gateway = loadLocalGatewayDefaults();
if (gateway) {
return {
...settings,
gateway: settings.gateway?.url?.trim()
? { url: settings.gateway.url.trim(), token: gateway.token }
: gateway,
};
}
}
return settings;
};
export const saveStudioSettings = (next: StudioSettings) => {
const settingsPath = resolveStudioSettingsPath();
const dir = path.dirname(settingsPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(settingsPath, JSON.stringify(next, null, 2), "utf8");
};
export const applyStudioSettingsPatch = (patch: StudioSettingsPatch): StudioSettings => {
const current = loadStudioSettings();
const next = mergeStudioSettings(current, patch);
saveStudioSettings(next);
return next;
};
+831
View File
@@ -0,0 +1,831 @@
import type {
StandupConfig,
StandupJiraConfig,
StandupManualEntry,
StandupScheduleConfig,
} from "@/lib/office/standup/types";
export type StudioGatewaySettings = {
url: string;
token: string;
};
export type StudioGatewaySettingsPublic = {
url: string;
tokenConfigured: boolean;
};
export type StudioGatewaySettingsPatch = {
url?: string | null;
token?: string | null;
};
export type FocusFilter = "all" | "running" | "approvals";
export type StudioViewMode = "focused";
export type StudioFocusedPreference = {
mode: StudioViewMode;
selectedAgentId: string | null;
filter: FocusFilter;
};
export type StudioAnalyticsBudgetSettings = {
dailySpendLimitUsd: number | null;
monthlySpendLimitUsd: number | null;
perAgentSoftLimitUsd: number | null;
alertThresholdPct: number;
};
export type StudioAnalyticsPreference = {
budgets: StudioAnalyticsBudgetSettings;
};
export type StudioAnalyticsPreferencePatch = {
budgets?: Partial<StudioAnalyticsBudgetSettings>;
};
export type StudioVoiceRepliesProvider = "elevenlabs";
export type StudioVoiceRepliesPreference = {
enabled: boolean;
provider: StudioVoiceRepliesProvider;
voiceId: string | null;
speed: number;
};
export type StudioVoiceRepliesPreferencePatch = {
enabled?: boolean;
provider?: StudioVoiceRepliesProvider;
voiceId?: string | null;
speed?: number;
};
export type StudioDeskAssignments = Record<string, string>;
export type StudioStandupPreference = StandupConfig;
export type StudioStandupPreferencePublic = Omit<StudioStandupPreference, "jira"> & {
jira: StandupJiraConfigPublic;
};
export type StudioStandupPreferencePatch = {
schedule?: Partial<StandupScheduleConfig>;
jira?: Partial<StandupJiraConfig>;
manualByAgentId?: Record<string, Partial<StandupManualEntry> | null>;
};
export type StandupJiraConfigPublic = Omit<StandupJiraConfig, "apiToken"> & {
apiToken: string;
apiTokenConfigured: boolean;
};
export type StudioSettings = {
version: 1;
gateway: StudioGatewaySettings | null;
focused: Record<string, StudioFocusedPreference>;
avatars: Record<string, Record<string, string>>;
deskAssignments: Record<string, StudioDeskAssignments>;
analytics: Record<string, StudioAnalyticsPreference>;
voiceReplies: Record<string, StudioVoiceRepliesPreference>;
standup?: Record<string, StudioStandupPreference>;
};
export type StudioSettingsPublic = Omit<StudioSettings, "gateway" | "standup"> & {
gateway: StudioGatewaySettingsPublic | null;
standup?: Record<string, StudioStandupPreferencePublic>;
};
export type StudioSettingsPatch = {
gateway?: StudioGatewaySettingsPatch | null;
focused?: Record<string, Partial<StudioFocusedPreference> | null>;
avatars?: Record<string, Record<string, string | null> | null>;
deskAssignments?: Record<string, Record<string, string | null> | null>;
analytics?: Record<string, StudioAnalyticsPreferencePatch | null>;
voiceReplies?: Record<string, StudioVoiceRepliesPreferencePatch | null>;
standup?: Record<string, StudioStandupPreferencePatch | null>;
};
const SETTINGS_VERSION = 1 as const;
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === "object");
const coerceString = (value: unknown) => (typeof value === "string" ? value.trim() : "");
const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "0.0.0.0"]);
const normalizeGatewayUrl = (value: unknown) => {
const url = coerceString(value);
if (!url) return "";
try {
const parsed = new URL(url);
if (!LOOPBACK_HOSTNAMES.has(parsed.hostname.toLowerCase())) {
return url;
}
const auth =
parsed.username || parsed.password
? `${parsed.username}${parsed.password ? `:${parsed.password}` : ""}@`
: "";
const host = parsed.port ? `localhost:${parsed.port}` : "localhost";
const dropDefaultPath =
parsed.pathname === "/" && !url.endsWith("/") && !parsed.search && !parsed.hash;
const pathname = dropDefaultPath ? "" : parsed.pathname;
return `${parsed.protocol}//${auth}${host}${pathname}${parsed.search}${parsed.hash}`;
} catch {
return url;
}
};
const normalizeGatewayKey = (value: unknown) => {
const key = coerceString(value);
return key ? key : null;
};
const normalizeFocusFilter = (
value: unknown,
fallback: FocusFilter = "all"
): FocusFilter => {
const filter = coerceString(value);
if (filter === "needs-attention") return "all";
if (filter === "idle") return "approvals";
if (
filter === "all" ||
filter === "running" ||
filter === "approvals"
) {
return filter;
}
return fallback;
};
const normalizeViewMode = (
value: unknown,
fallback: StudioViewMode = "focused"
): StudioViewMode => {
const mode = coerceString(value);
if (mode === "focused") {
return mode;
}
return fallback;
};
const normalizeSelectedAgentId = (value: unknown, fallback: string | null = null) => {
if (value === null) return null;
if (typeof value !== "string") return fallback;
const trimmed = value.trim();
return trimmed ? trimmed : null;
};
const normalizeOptionalNumber = (value: unknown, fallback: number | null = null) => {
if (value === null) return null;
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
return value;
};
const normalizeAlertThresholdPct = (value: unknown, fallback: number = 80) => {
const next = normalizeOptionalNumber(value, fallback);
if (next === null) return fallback;
return Math.min(100, Math.max(1, next));
};
const defaultFocusedPreference = (): StudioFocusedPreference => ({
mode: "focused",
selectedAgentId: null,
filter: "all",
});
export const defaultStudioAnalyticsPreference = (): StudioAnalyticsPreference => ({
budgets: {
dailySpendLimitUsd: null,
monthlySpendLimitUsd: null,
perAgentSoftLimitUsd: null,
alertThresholdPct: 80,
},
});
export const defaultStudioVoiceRepliesPreference =
(): StudioVoiceRepliesPreference => ({
enabled: false,
provider: "elevenlabs",
voiceId: null,
speed: 1,
});
export const defaultStudioStandupScheduleConfig = (): StandupScheduleConfig => ({
enabled: false,
cronExpr: "0 9 * * 1-5",
timezone: "UTC",
speakerSeconds: 8,
autoOpenBoard: true,
lastAutoRunAt: null,
});
export const defaultStudioStandupJiraConfig = (): StandupJiraConfig => ({
enabled: false,
baseUrl: "",
email: "",
apiToken: "",
projectKey: "",
jql: "",
});
export const defaultStudioStandupManualEntry = (): StandupManualEntry => ({
jiraAssignee: null,
currentTask: "",
blockers: "",
note: "",
updatedAt: null,
});
export const defaultStudioStandupPreference = (): StudioStandupPreference => ({
schedule: defaultStudioStandupScheduleConfig(),
jira: defaultStudioStandupJiraConfig(),
manualByAgentId: {},
});
const normalizeVoiceReplySpeed = (value: unknown, fallback: number = 1): number => {
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
return Math.min(1.2, Math.max(0.7, value));
};
const normalizeOptionalIsoString = (
value: unknown,
fallback: string | null = null
): string | null => {
if (value === null) return null;
if (typeof value !== "string") return fallback;
const trimmed = value.trim();
return trimmed ? trimmed : null;
};
const normalizeStandupScheduleConfig = (
value: unknown,
fallback: StandupScheduleConfig = defaultStudioStandupScheduleConfig()
): StandupScheduleConfig => {
if (!isRecord(value)) return fallback;
const cronExpr = coerceString(value.cronExpr) || fallback.cronExpr;
const timezone = coerceString(value.timezone) || fallback.timezone;
const speakerSecondsRaw =
typeof value.speakerSeconds === "number" && Number.isFinite(value.speakerSeconds)
? Math.round(value.speakerSeconds)
: fallback.speakerSeconds;
return {
enabled: typeof value.enabled === "boolean" ? value.enabled : fallback.enabled,
cronExpr,
timezone,
speakerSeconds: Math.max(4, Math.min(120, speakerSecondsRaw)),
autoOpenBoard:
typeof value.autoOpenBoard === "boolean"
? value.autoOpenBoard
: fallback.autoOpenBoard,
lastAutoRunAt: normalizeOptionalIsoString(
value.lastAutoRunAt,
fallback.lastAutoRunAt
),
};
};
const normalizeStandupJiraConfig = (
value: unknown,
fallback: StandupJiraConfig = defaultStudioStandupJiraConfig()
): StandupJiraConfig => {
if (!isRecord(value)) return fallback;
const baseUrl = coerceString(value.baseUrl).replace(/\/+$/, "");
return {
enabled: typeof value.enabled === "boolean" ? value.enabled : fallback.enabled,
baseUrl: baseUrl || fallback.baseUrl,
email: coerceString(value.email) || fallback.email,
apiToken: coerceString(value.apiToken) || fallback.apiToken,
projectKey: coerceString(value.projectKey).toUpperCase() || fallback.projectKey,
jql: coerceString(value.jql) || fallback.jql,
};
};
const normalizeStandupManualEntry = (
value: unknown,
fallback: StandupManualEntry = defaultStudioStandupManualEntry()
): StandupManualEntry => {
if (!isRecord(value)) return fallback;
return {
jiraAssignee: normalizeSelectedAgentId(value.jiraAssignee, fallback.jiraAssignee),
currentTask: coerceString(value.currentTask) || fallback.currentTask,
blockers: coerceString(value.blockers) || fallback.blockers,
note: coerceString(value.note) || fallback.note,
updatedAt: normalizeOptionalIsoString(value.updatedAt, fallback.updatedAt),
};
};
const normalizeStandupPreference = (
value: unknown,
fallback: StudioStandupPreference = defaultStudioStandupPreference()
): StudioStandupPreference => {
if (!isRecord(value)) return fallback;
const manualByAgentId: Record<string, StandupManualEntry> = {};
if (isRecord(value.manualByAgentId)) {
for (const [agentIdRaw, entryRaw] of Object.entries(value.manualByAgentId)) {
const agentId = coerceString(agentIdRaw);
if (!agentId) continue;
manualByAgentId[agentId] = normalizeStandupManualEntry(
entryRaw,
fallback.manualByAgentId[agentId] ?? defaultStudioStandupManualEntry()
);
}
}
return {
schedule: normalizeStandupScheduleConfig(value.schedule, fallback.schedule),
jira: normalizeStandupJiraConfig(value.jira, fallback.jira),
manualByAgentId,
};
};
const normalizeStandup = (
value: unknown
): Record<string, StudioStandupPreference> => {
if (!isRecord(value)) return {};
const standup: Record<string, StudioStandupPreference> = {};
for (const [gatewayKeyRaw, standupRaw] of Object.entries(value)) {
const gatewayKey = normalizeGatewayKey(gatewayKeyRaw);
if (!gatewayKey) continue;
standup[gatewayKey] = normalizeStandupPreference(standupRaw);
}
return standup;
};
const normalizeFocusedPreference = (
value: unknown,
fallback: StudioFocusedPreference = defaultFocusedPreference()
): StudioFocusedPreference => {
if (!isRecord(value)) return fallback;
return {
mode: normalizeViewMode(value.mode, fallback.mode),
selectedAgentId: normalizeSelectedAgentId(
value.selectedAgentId,
fallback.selectedAgentId
),
filter: normalizeFocusFilter(value.filter, fallback.filter),
};
};
const normalizeGatewaySettings = (value: unknown): StudioGatewaySettings | null => {
if (!isRecord(value)) return null;
const url = normalizeGatewayUrl(value.url);
if (!url) return null;
const token = coerceString(value.token);
return { url, token };
};
const mergeGatewaySettings = (
current: StudioGatewaySettings | null,
patch: StudioGatewaySettingsPatch | null,
): StudioGatewaySettings | null => {
if (patch === null) return null;
const nextUrl =
patch.url === undefined ? current?.url ?? "" : normalizeGatewayUrl(patch.url);
if (!nextUrl) return null;
const nextToken =
patch.token === undefined ? current?.token ?? "" : coerceString(patch.token);
return {
url: nextUrl,
token: nextToken,
};
};
const normalizeFocused = (value: unknown): Record<string, StudioFocusedPreference> => {
if (!isRecord(value)) return {};
const focused: Record<string, StudioFocusedPreference> = {};
for (const [gatewayKeyRaw, focusedRaw] of Object.entries(value)) {
const gatewayKey = normalizeGatewayKey(gatewayKeyRaw);
if (!gatewayKey) continue;
focused[gatewayKey] = normalizeFocusedPreference(focusedRaw);
}
return focused;
};
const normalizeAvatars = (value: unknown): Record<string, Record<string, string>> => {
if (!isRecord(value)) return {};
const avatars: Record<string, Record<string, string>> = {};
for (const [gatewayKeyRaw, gatewayRaw] of Object.entries(value)) {
const gatewayKey = normalizeGatewayKey(gatewayKeyRaw);
if (!gatewayKey) continue;
if (!isRecord(gatewayRaw)) continue;
const entries: Record<string, string> = {};
for (const [agentIdRaw, seedRaw] of Object.entries(gatewayRaw)) {
const agentId = coerceString(agentIdRaw);
if (!agentId) continue;
const seed = coerceString(seedRaw);
if (!seed) continue;
entries[agentId] = seed;
}
avatars[gatewayKey] = entries;
}
return avatars;
};
const normalizeDeskAssignments = (
value: unknown,
): Record<string, StudioDeskAssignments> => {
if (!isRecord(value)) return {};
const deskAssignments: Record<string, StudioDeskAssignments> = {};
for (const [gatewayKeyRaw, gatewayRaw] of Object.entries(value)) {
const gatewayKey = normalizeGatewayKey(gatewayKeyRaw);
if (!gatewayKey) continue;
if (!isRecord(gatewayRaw)) continue;
const entries: StudioDeskAssignments = {};
for (const [deskUidRaw, agentIdRaw] of Object.entries(gatewayRaw)) {
const deskUid = coerceString(deskUidRaw);
if (!deskUid) continue;
const agentId = coerceString(agentIdRaw);
if (!agentId) continue;
entries[deskUid] = agentId;
}
deskAssignments[gatewayKey] = entries;
}
return deskAssignments;
};
const normalizeAnalyticsBudgetSettings = (
value: unknown,
fallback: StudioAnalyticsBudgetSettings = defaultStudioAnalyticsPreference().budgets
): StudioAnalyticsBudgetSettings => {
if (!isRecord(value)) return fallback;
return {
dailySpendLimitUsd: normalizeOptionalNumber(
value.dailySpendLimitUsd,
fallback.dailySpendLimitUsd
),
monthlySpendLimitUsd: normalizeOptionalNumber(
value.monthlySpendLimitUsd,
fallback.monthlySpendLimitUsd
),
perAgentSoftLimitUsd: normalizeOptionalNumber(
value.perAgentSoftLimitUsd,
fallback.perAgentSoftLimitUsd
),
alertThresholdPct: normalizeAlertThresholdPct(
value.alertThresholdPct,
fallback.alertThresholdPct
),
};
};
const normalizeAnalyticsPreference = (
value: unknown,
fallback: StudioAnalyticsPreference = defaultStudioAnalyticsPreference()
): StudioAnalyticsPreference => {
if (!isRecord(value)) return fallback;
return {
budgets: normalizeAnalyticsBudgetSettings(value.budgets, fallback.budgets),
};
};
const normalizeAnalytics = (value: unknown): Record<string, StudioAnalyticsPreference> => {
if (!isRecord(value)) return {};
const analytics: Record<string, StudioAnalyticsPreference> = {};
for (const [gatewayKeyRaw, analyticsRaw] of Object.entries(value)) {
const gatewayKey = normalizeGatewayKey(gatewayKeyRaw);
if (!gatewayKey) continue;
analytics[gatewayKey] = normalizeAnalyticsPreference(analyticsRaw);
}
return analytics;
};
const normalizeVoiceRepliesProvider = (
value: unknown,
fallback: StudioVoiceRepliesProvider = "elevenlabs"
): StudioVoiceRepliesProvider => {
const provider = coerceString(value);
return provider === "elevenlabs" ? provider : fallback;
};
const normalizeVoiceRepliesPreference = (
value: unknown,
fallback: StudioVoiceRepliesPreference = defaultStudioVoiceRepliesPreference()
): StudioVoiceRepliesPreference => {
if (!isRecord(value)) return fallback;
return {
enabled: typeof value.enabled === "boolean" ? value.enabled : fallback.enabled,
provider: normalizeVoiceRepliesProvider(value.provider, fallback.provider),
voiceId: normalizeSelectedAgentId(value.voiceId, fallback.voiceId),
speed: normalizeVoiceReplySpeed(value.speed, fallback.speed),
};
};
const normalizeVoiceReplies = (
value: unknown
): Record<string, StudioVoiceRepliesPreference> => {
if (!isRecord(value)) return {};
const voiceReplies: Record<string, StudioVoiceRepliesPreference> = {};
for (const [gatewayKeyRaw, voiceRepliesRaw] of Object.entries(value)) {
const gatewayKey = normalizeGatewayKey(gatewayKeyRaw);
if (!gatewayKey) continue;
voiceReplies[gatewayKey] = normalizeVoiceRepliesPreference(voiceRepliesRaw);
}
return voiceReplies;
};
export const defaultStudioSettings = (): StudioSettings => ({
version: SETTINGS_VERSION,
gateway: null,
focused: {},
avatars: {},
deskAssignments: {},
analytics: {},
voiceReplies: {},
standup: {},
});
export const sanitizeStudioGatewaySettings = (
value: StudioGatewaySettings | null,
): StudioGatewaySettingsPublic | null => {
if (!value) return null;
return {
url: value.url,
tokenConfigured: value.token.length > 0,
};
};
export const sanitizeStandupJiraConfig = (
value: StandupJiraConfig,
): StandupJiraConfigPublic => ({
...value,
apiToken: "",
apiTokenConfigured: value.apiToken.length > 0,
});
export const sanitizeStandupPreference = (
value: StudioStandupPreference,
): StudioStandupPreferencePublic => ({
...value,
jira: sanitizeStandupJiraConfig(value.jira),
});
export const sanitizeStudioSettings = (
value: StudioSettings,
): StudioSettingsPublic => ({
...value,
gateway: sanitizeStudioGatewaySettings(value.gateway),
standup: Object.fromEntries(
Object.entries(value.standup ?? {}).map(([gatewayKey, preference]) => [
gatewayKey,
sanitizeStandupPreference(preference),
]),
),
});
export const normalizeStudioSettings = (raw: unknown): StudioSettings => {
if (!isRecord(raw)) return defaultStudioSettings();
const gateway = normalizeGatewaySettings(raw.gateway);
const focused = normalizeFocused(raw.focused);
const avatars = normalizeAvatars(raw.avatars);
const deskAssignments = normalizeDeskAssignments(raw.deskAssignments);
const analytics = normalizeAnalytics(raw.analytics);
const voiceReplies = normalizeVoiceReplies(raw.voiceReplies);
const standup = normalizeStandup(raw.standup);
return {
version: SETTINGS_VERSION,
gateway,
focused,
avatars,
deskAssignments,
analytics,
voiceReplies,
standup,
};
};
export const mergeStudioSettings = (
current: StudioSettings,
patch: StudioSettingsPatch
): StudioSettings => {
const nextGateway =
patch.gateway === undefined ? current.gateway : mergeGatewaySettings(current.gateway, patch.gateway);
const nextFocused = { ...current.focused };
const nextAvatars = { ...current.avatars };
const nextDeskAssignments = { ...current.deskAssignments };
const nextAnalytics = { ...current.analytics };
const nextVoiceReplies = { ...current.voiceReplies };
const nextStandup = { ...(current.standup ?? {}) };
if (patch.focused) {
for (const [keyRaw, value] of Object.entries(patch.focused)) {
const key = normalizeGatewayKey(keyRaw);
if (!key) continue;
if (value === null) {
delete nextFocused[key];
continue;
}
const fallback = nextFocused[key] ?? defaultFocusedPreference();
nextFocused[key] = normalizeFocusedPreference(value, fallback);
}
}
if (patch.avatars) {
for (const [gatewayKeyRaw, gatewayPatch] of Object.entries(patch.avatars)) {
const gatewayKey = normalizeGatewayKey(gatewayKeyRaw);
if (!gatewayKey) continue;
if (gatewayPatch === null) {
delete nextAvatars[gatewayKey];
continue;
}
if (!isRecord(gatewayPatch)) continue;
const existing = nextAvatars[gatewayKey] ? { ...nextAvatars[gatewayKey] } : {};
for (const [agentIdRaw, seedPatchRaw] of Object.entries(gatewayPatch)) {
const agentId = coerceString(agentIdRaw);
if (!agentId) continue;
if (seedPatchRaw === null) {
delete existing[agentId];
continue;
}
const seed = coerceString(seedPatchRaw);
if (!seed) {
delete existing[agentId];
continue;
}
existing[agentId] = seed;
}
nextAvatars[gatewayKey] = existing;
}
}
if (patch.deskAssignments) {
for (const [gatewayKeyRaw, gatewayPatch] of Object.entries(patch.deskAssignments)) {
const gatewayKey = normalizeGatewayKey(gatewayKeyRaw);
if (!gatewayKey) continue;
if (gatewayPatch === null) {
delete nextDeskAssignments[gatewayKey];
continue;
}
if (!isRecord(gatewayPatch)) continue;
const existing = nextDeskAssignments[gatewayKey]
? { ...nextDeskAssignments[gatewayKey] }
: {};
for (const [deskUidRaw, agentIdPatchRaw] of Object.entries(gatewayPatch)) {
const deskUid = coerceString(deskUidRaw);
if (!deskUid) continue;
if (agentIdPatchRaw === null) {
delete existing[deskUid];
continue;
}
const agentId = coerceString(agentIdPatchRaw);
if (!agentId) {
delete existing[deskUid];
continue;
}
existing[deskUid] = agentId;
}
nextDeskAssignments[gatewayKey] = existing;
}
}
if (patch.analytics) {
for (const [gatewayKeyRaw, analyticsPatch] of Object.entries(patch.analytics)) {
const gatewayKey = normalizeGatewayKey(gatewayKeyRaw);
if (!gatewayKey) continue;
if (analyticsPatch === null) {
delete nextAnalytics[gatewayKey];
continue;
}
const fallback = nextAnalytics[gatewayKey] ?? defaultStudioAnalyticsPreference();
nextAnalytics[gatewayKey] = normalizeAnalyticsPreference(
{
...fallback,
...analyticsPatch,
budgets: {
...fallback.budgets,
...(isRecord(analyticsPatch.budgets) ? analyticsPatch.budgets : {}),
},
},
fallback
);
}
}
if (patch.voiceReplies) {
for (const [gatewayKeyRaw, voiceRepliesPatch] of Object.entries(patch.voiceReplies)) {
const gatewayKey = normalizeGatewayKey(gatewayKeyRaw);
if (!gatewayKey) continue;
if (voiceRepliesPatch === null) {
delete nextVoiceReplies[gatewayKey];
continue;
}
const fallback =
nextVoiceReplies[gatewayKey] ?? defaultStudioVoiceRepliesPreference();
nextVoiceReplies[gatewayKey] = normalizeVoiceRepliesPreference(
{
...fallback,
...voiceRepliesPatch,
},
fallback
);
}
}
if (patch.standup) {
for (const [gatewayKeyRaw, standupPatch] of Object.entries(patch.standup)) {
const gatewayKey = normalizeGatewayKey(gatewayKeyRaw);
if (!gatewayKey) continue;
if (standupPatch === null) {
delete nextStandup[gatewayKey];
continue;
}
const fallback =
nextStandup[gatewayKey] ?? defaultStudioStandupPreference();
const nextManualByAgentId = { ...fallback.manualByAgentId };
if (standupPatch.manualByAgentId) {
for (const [agentIdRaw, entryPatch] of Object.entries(standupPatch.manualByAgentId)) {
const agentId = coerceString(agentIdRaw);
if (!agentId) continue;
if (entryPatch === null) {
delete nextManualByAgentId[agentId];
continue;
}
const manualFallback =
nextManualByAgentId[agentId] ?? defaultStudioStandupManualEntry();
nextManualByAgentId[agentId] = normalizeStandupManualEntry(
{
...manualFallback,
...entryPatch,
},
manualFallback
);
}
}
nextStandup[gatewayKey] = normalizeStandupPreference(
{
...fallback,
...standupPatch,
schedule: {
...fallback.schedule,
...(isRecord(standupPatch.schedule) ? standupPatch.schedule : {}),
},
jira: {
...fallback.jira,
...(isRecord(standupPatch.jira) ? standupPatch.jira : {}),
},
manualByAgentId: nextManualByAgentId,
},
fallback
);
}
}
return {
version: SETTINGS_VERSION,
gateway: nextGateway ?? null,
focused: nextFocused,
avatars: nextAvatars,
deskAssignments: nextDeskAssignments,
analytics: nextAnalytics,
voiceReplies: nextVoiceReplies,
standup: nextStandup,
};
};
export const resolveFocusedPreference = (
settings: StudioSettings | StudioSettingsPublic,
gatewayUrl: string
): StudioFocusedPreference | null => {
const key = normalizeGatewayKey(gatewayUrl);
if (!key) return null;
return settings.focused[key] ?? null;
};
export const resolveAgentAvatarSeed = (
settings: StudioSettings | StudioSettingsPublic,
gatewayUrl: string,
agentId: string
): string | null => {
const gatewayKey = normalizeGatewayKey(gatewayUrl);
if (!gatewayKey) return null;
const agentKey = coerceString(agentId);
if (!agentKey) return null;
return settings.avatars[gatewayKey]?.[agentKey] ?? null;
};
export const resolveDeskAssignments = (
settings: StudioSettings | StudioSettingsPublic,
gatewayUrl: string
): StudioDeskAssignments => {
const gatewayKey = normalizeGatewayKey(gatewayUrl);
if (!gatewayKey) return {};
return settings.deskAssignments[gatewayKey] ?? {};
};
export const resolveAnalyticsPreference = (
settings: StudioSettings | StudioSettingsPublic,
gatewayUrl: string
): StudioAnalyticsPreference => {
const gatewayKey = normalizeGatewayKey(gatewayUrl);
if (!gatewayKey) return defaultStudioAnalyticsPreference();
return settings.analytics[gatewayKey] ?? defaultStudioAnalyticsPreference();
};
export const resolveVoiceRepliesPreference = (
settings: StudioSettings | StudioSettingsPublic,
gatewayUrl: string
): StudioVoiceRepliesPreference => {
const gatewayKey = normalizeGatewayKey(gatewayUrl);
if (!gatewayKey) return defaultStudioVoiceRepliesPreference();
return settings.voiceReplies[gatewayKey] ?? defaultStudioVoiceRepliesPreference();
};
export const resolveStandupPreference = (
settings: StudioSettings | StudioSettingsPublic,
gatewayUrl: string
): StudioStandupPreference => {
const gatewayKey = normalizeGatewayKey(gatewayUrl);
if (!gatewayKey) return defaultStudioStandupPreference();
return settings.standup?.[gatewayKey] ?? defaultStudioStandupPreference();
};
+17
View File
@@ -0,0 +1,17 @@
export const normalizeAssistantDisplayText = (value: string): string => {
const lines = value.replace(/\r\n?/g, "\n").split("\n");
const normalized: string[] = [];
let lastWasBlank = false;
for (const rawLine of lines) {
const line = rawLine.replace(/[ \t]+$/g, "");
if (line.trim().length === 0) {
if (lastWasBlank) continue;
normalized.push("");
lastWasBlank = true;
continue;
}
normalized.push(line);
lastWasBlank = false;
}
return normalized.join("\n").trim();
};
+80
View File
@@ -0,0 +1,80 @@
const MEDIA_LINE_RE = /^\s*MEDIA:\s*(.+?)\s*$/;
const MEDIA_ONLY_RE = /^\s*MEDIA:\s*$/;
const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]);
const isImagePath = (value: string): boolean => {
const trimmed = value.trim();
if (!trimmed) return false;
const lower = trimmed.toLowerCase();
for (const ext of IMAGE_EXTENSIONS) {
if (lower.endsWith(ext)) return true;
}
return false;
};
const toMediaUrl = (path: string): string => {
return `/api/gateway/media?path=${encodeURIComponent(path)}`;
};
/**
* Rewrites tool-style media lines like:
* MEDIA: /home/ubuntu/.openclaw/workspace-agent/foo.png
* into markdown image links so the chat UI can render them inline.
*
* - Skips replacements inside fenced code blocks.
*/
export const rewriteMediaLinesToMarkdown = (text: string): string => {
if (!text) return text;
const lines = text.replace(/\r\n?/g, "\n").split("\n");
const out: string[] = [];
let inFence = false;
for (let idx = 0; idx < lines.length; idx += 1) {
const line = lines[idx] ?? "";
const trimmed = line.trimStart();
if (trimmed.startsWith("```")) {
inFence = !inFence;
out.push(line);
continue;
}
if (inFence) {
out.push(line);
continue;
}
let mediaPath: string | null = null;
let consumesNextLine = false;
const match = line.match(MEDIA_LINE_RE);
if (match) {
mediaPath = (match[1] ?? "").trim() || null;
} else if (MEDIA_ONLY_RE.test(line)) {
const next = (lines[idx + 1] ?? "").trim();
if (isImagePath(next)) {
mediaPath = next;
consumesNextLine = true;
}
}
if (!mediaPath) {
out.push(line);
continue;
}
const url = toMediaUrl(mediaPath);
if (isImagePath(mediaPath)) {
out.push(`![](${url})`);
out.push("");
out.push(`MEDIA: ${mediaPath}`);
if (consumesNextLine) {
idx += 1;
}
continue;
}
out.push(line);
}
return out.join("\n");
};
+552
View File
@@ -0,0 +1,552 @@
const ENVELOPE_PREFIX = /^\[([^\]]+)\]\s*/;
const ENVELOPE_CHANNELS = [
"WebChat",
"WhatsApp",
"Telegram",
"Signal",
"Slack",
"Discord",
"iMessage",
"Teams",
"Matrix",
"Zalo",
"Zalo Personal",
"BlueBubbles",
];
const textCache = new WeakMap<object, string | null>();
const thinkingCache = new WeakMap<object, string | null>();
const THINKING_TAG_RE = /<\s*\/?\s*(think(?:ing)?|analysis)\s*>/gi;
const THINKING_OPEN_RE = /<\s*(think(?:ing)?|analysis)\s*>/i;
const THINKING_CLOSE_RE = /<\s*\/\s*(think(?:ing)?|analysis)\s*>/i;
const THINKING_BLOCK_RE =
/<\s*(think(?:ing)?|analysis)\s*>([\s\S]*?)<\s*\/\s*\1\s*>/gi;
const THINKING_STREAM_TAG_RE = /<\s*(\/?)\s*(?:think(?:ing)?|analysis|thought|antthinking)\s*>/gi;
const TRACE_MARKDOWN_PREFIX = "[[trace]]";
const TOOL_CALL_PREFIX = "[[tool]]";
const TOOL_RESULT_PREFIX = "[[tool-result]]";
const META_PREFIX = "[[meta]]";
export type AgentInstructionParams = {
message: string;
};
const EXEC_APPROVAL_WAIT_POLICY = [
"Execution approval policy:",
"- If any tool result says approval is required or pending, stop immediately.",
"- Do not call additional tools and do not switch to alternate approaches.",
'If approved command output is unavailable, reply exactly: "Waiting for approved command result."',
].join("\n");
const stripAppendedExecApprovalPolicy = (text: string): string => {
const suffix = `\n\n${EXEC_APPROVAL_WAIT_POLICY}`;
if (!text.endsWith(suffix)) return text;
return text.slice(0, -suffix.length);
};
const ASSISTANT_PREFIX_RE = /^(?:\[\[reply_to_current\]\]|\[reply_to_current\])\s*(?:\|\s*)?/i;
const stripAssistantPrefix = (text: string): string => {
if (!text) return text;
if (!ASSISTANT_PREFIX_RE.test(text)) return text;
return text.replace(ASSISTANT_PREFIX_RE, "").trimStart();
};
type ToolCallRecord = {
id?: string;
name?: string;
arguments?: unknown;
};
type ToolResultRecord = {
toolCallId?: string;
toolName?: string;
details?: Record<string, unknown> | null;
isError?: boolean;
text?: string | null;
};
const looksLikeEnvelopeHeader = (header: string): boolean => {
if (/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z\b/.test(header)) return true;
if (/\d{4}-\d{2}-\d{2} \d{2}:\d{2}\b/.test(header)) return true;
if (/[A-Za-z]{3} \d{4}-\d{2}-\d{2} \d{2}:\d{2}\b/.test(header)) return true;
return ENVELOPE_CHANNELS.some((label) => header.startsWith(`${label} `));
};
const stripEnvelope = (text: string): string => {
const match = text.match(ENVELOPE_PREFIX);
if (!match) return text;
const header = match[1] ?? "";
if (!looksLikeEnvelopeHeader(header)) return text;
return text.slice(match[0].length);
};
const stripThinkingTagsFromAssistantText = (value: string): string => {
if (!value) return value;
const hasOpen = THINKING_OPEN_RE.test(value);
const hasClose = THINKING_CLOSE_RE.test(value);
if (!hasOpen && !hasClose) return value;
if (hasOpen !== hasClose) {
if (!hasOpen) return value.replace(THINKING_CLOSE_RE, "").trimStart();
return value.replace(THINKING_OPEN_RE, "").trimStart();
}
if (!THINKING_TAG_RE.test(value)) return value;
THINKING_TAG_RE.lastIndex = 0;
let result = "";
let lastIndex = 0;
let inThinking = false;
for (const match of value.matchAll(THINKING_TAG_RE)) {
const idx = match.index ?? 0;
if (!inThinking) {
result += value.slice(lastIndex, idx);
}
const tag = match[0].toLowerCase();
inThinking = !tag.includes("/");
lastIndex = idx + match[0].length;
}
if (!inThinking) {
result += value.slice(lastIndex);
}
return result.trimStart();
};
const extractRawText = (message: unknown): string | null => {
if (!message || typeof message !== "object") return null;
const m = message as Record<string, unknown>;
const content = m.content;
if (typeof content === "string") return content;
if (Array.isArray(content)) {
const parts = content
.map((p) => {
const item = p as Record<string, unknown>;
if (item.type === "text" && typeof item.text === "string") return item.text;
return null;
})
.filter((v): v is string => typeof v === "string");
if (parts.length > 0) return parts.join("\n");
}
if (typeof m.text === "string") return m.text;
return null;
};
export const extractText = (message: unknown): string | null => {
if (!message || typeof message !== "object") {
return null;
}
const m = message as Record<string, unknown>;
const role = typeof m.role === "string" ? m.role : "";
const content = m.content;
const postProcess = (value: string): string => {
if (role === "assistant") {
return stripAssistantPrefix(stripThinkingTagsFromAssistantText(value));
}
return stripAppendedExecApprovalPolicy(stripEnvelope(value));
};
if (typeof content === "string") {
return postProcess(content);
}
if (Array.isArray(content)) {
const parts = content
.map((p) => {
const item = p as Record<string, unknown>;
if (item.type === "text" && typeof item.text === "string") return item.text;
return null;
})
.filter((v): v is string => typeof v === "string");
if (parts.length > 0) {
return postProcess(parts.join("\n"));
}
}
if (typeof m.text === "string") {
return postProcess(m.text);
}
return null;
};
export const extractTextCached = (message: unknown): string | null => {
if (!message || typeof message !== "object") return extractText(message);
const obj = message as object;
if (textCache.has(obj)) return textCache.get(obj) ?? null;
const value = extractText(message);
textCache.set(obj, value);
return value;
};
export const extractThinking = (message: unknown): string | null => {
if (!message || typeof message !== "object") return null;
const m = message as Record<string, unknown>;
const content = m.content;
const parts: string[] = [];
const extractFromRecord = (record: Record<string, unknown>): string | null => {
const directKeys = [
"thinking",
"analysis",
"reasoning",
"thinkingText",
"analysisText",
"reasoningText",
"thinking_text",
"analysis_text",
"reasoning_text",
"thinkingDelta",
"analysisDelta",
"reasoningDelta",
"thinking_delta",
"analysis_delta",
"reasoning_delta",
] as const;
for (const key of directKeys) {
const value = record[key];
if (typeof value === "string") {
const cleaned = value.trim();
if (cleaned) return cleaned;
}
if (value && typeof value === "object") {
const nested = value as Record<string, unknown>;
const nestedKeys = [
"text",
"delta",
"content",
"summary",
"analysis",
"reasoning",
"thinking",
] as const;
for (const nestedKey of nestedKeys) {
const nestedValue = nested[nestedKey];
if (typeof nestedValue === "string") {
const cleaned = nestedValue.trim();
if (cleaned) return cleaned;
}
}
}
}
return null;
};
if (Array.isArray(content)) {
for (const p of content) {
const item = p as Record<string, unknown>;
const type = typeof item.type === "string" ? item.type : "";
if (type === "thinking" || type === "analysis" || type === "reasoning") {
const extracted = extractFromRecord(item);
if (extracted) {
parts.push(extracted);
} else if (typeof item.text === "string") {
const cleaned = item.text.trim();
if (cleaned) parts.push(cleaned);
}
} else if (typeof item.thinking === "string") {
const cleaned = item.thinking.trim();
if (cleaned) parts.push(cleaned);
}
}
}
if (parts.length > 0) return parts.join("\n");
const direct = extractFromRecord(m);
if (direct) return direct;
const rawText = extractRawText(message);
if (!rawText) return null;
const matches = [...rawText.matchAll(THINKING_BLOCK_RE)];
const extracted = matches
.map((match) => (match[2] ?? "").trim())
.filter(Boolean);
if (extracted.length > 0) return extracted.join("\n");
const openTagged = extractThinkingFromTaggedStream(rawText);
return openTagged ? openTagged : null;
};
export function extractThinkingFromTaggedText(text: string): string {
if (!text) return "";
let result = "";
let lastIndex = 0;
let inThinking = false;
THINKING_STREAM_TAG_RE.lastIndex = 0;
for (const match of text.matchAll(THINKING_STREAM_TAG_RE)) {
const idx = match.index ?? 0;
if (inThinking) {
result += text.slice(lastIndex, idx);
}
const isClose = match[1] === "/";
inThinking = !isClose;
lastIndex = idx + match[0].length;
}
return result.trim();
}
export function extractThinkingFromTaggedStream(text: string): string {
if (!text) return "";
const closed = extractThinkingFromTaggedText(text);
if (closed) return closed;
const openRe = /<\s*(?:think(?:ing)?|analysis|thought|antthinking)\s*>/gi;
const closeRe = /<\s*\/\s*(?:think(?:ing)?|analysis|thought|antthinking)\s*>/gi;
const openMatches = [...text.matchAll(openRe)];
if (openMatches.length === 0) return "";
const closeMatches = [...text.matchAll(closeRe)];
const lastOpen = openMatches[openMatches.length - 1];
const lastClose = closeMatches[closeMatches.length - 1];
if (lastClose && (lastClose.index ?? -1) > (lastOpen.index ?? -1)) {
return closed;
}
const start = (lastOpen.index ?? 0) + lastOpen[0].length;
return text.slice(start).trim();
}
export const extractThinkingCached = (message: unknown): string | null => {
if (!message || typeof message !== "object") return extractThinking(message);
const obj = message as object;
if (thinkingCache.has(obj)) return thinkingCache.get(obj) ?? null;
const value = extractThinking(message);
thinkingCache.set(obj, value);
return value;
};
export const formatThinkingMarkdown = (text: string): string => {
const trimmed = text.trim();
if (!trimmed) return "";
const lines = trimmed
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => `_${line}_`);
if (lines.length === 0) return "";
return `${TRACE_MARKDOWN_PREFIX}\n${lines.join("\n\n")}`;
};
export const isTraceMarkdown = (line: string): boolean =>
line.startsWith(TRACE_MARKDOWN_PREFIX);
export const stripTraceMarkdown = (line: string): string => {
if (!isTraceMarkdown(line)) return line;
return line.slice(TRACE_MARKDOWN_PREFIX.length).trimStart();
};
const formatJson = (value: unknown): string => {
if (value === null || value === undefined) return "";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
try {
return JSON.stringify(value, null, 2);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to stringify tool args.";
console.warn(message);
return String(value);
}
};
const formatToolResultMeta = (details?: Record<string, unknown> | null, isError?: boolean) => {
const parts: string[] = [];
if (details && typeof details === "object") {
const status = details.status;
if (typeof status === "string" && status.trim()) {
parts.push(status.trim());
}
const exitCode = details.exitCode;
if (typeof exitCode === "number") {
parts.push(`exit ${exitCode}`);
}
const durationMs = details.durationMs;
if (typeof durationMs === "number") {
parts.push(`${durationMs}ms`);
}
const cwd = details.cwd;
if (typeof cwd === "string" && cwd.trim()) {
parts.push(cwd.trim());
}
}
if (isError) {
parts.push("error");
}
return parts.length ? parts.join(" · ") : "";
};
export const extractToolCalls = (message: unknown): ToolCallRecord[] => {
if (!message || typeof message !== "object") return [];
const content = (message as Record<string, unknown>).content;
if (!Array.isArray(content)) return [];
const calls: ToolCallRecord[] = [];
for (const item of content) {
if (!item || typeof item !== "object") continue;
const record = item as Record<string, unknown>;
if (record.type !== "toolCall") continue;
calls.push({
id: typeof record.id === "string" ? record.id : undefined,
name: typeof record.name === "string" ? record.name : undefined,
arguments: record.arguments,
});
}
return calls;
};
export const extractToolResult = (message: unknown): ToolResultRecord | null => {
if (!message || typeof message !== "object") return null;
const record = message as Record<string, unknown>;
const role = typeof record.role === "string" ? record.role : "";
if (role !== "toolResult" && role !== "tool") return null;
const details =
record.details && typeof record.details === "object"
? (record.details as Record<string, unknown>)
: null;
return {
toolCallId: typeof record.toolCallId === "string" ? record.toolCallId : undefined,
toolName: typeof record.toolName === "string" ? record.toolName : undefined,
details,
isError: typeof record.isError === "boolean" ? record.isError : undefined,
text: extractText(record),
};
};
export const formatToolCallMarkdown = (call: ToolCallRecord): string => {
const name = call.name?.trim() || "tool";
const suffix = call.id ? ` (${call.id})` : "";
const args = formatJson(call.arguments).trim();
if (!args) {
return `${TOOL_CALL_PREFIX} ${name}${suffix}`;
}
return `${TOOL_CALL_PREFIX} ${name}${suffix}\n\`\`\`json\n${args}\n\`\`\``;
};
export const formatToolResultMarkdown = (result: ToolResultRecord): string => {
const name = result.toolName?.trim() || "tool";
const suffix = result.toolCallId ? ` (${result.toolCallId})` : "";
const meta = formatToolResultMeta(result.details, result.isError);
const header = `${name}${suffix}`;
const bodyParts: string[] = [];
if (meta) {
bodyParts.push(meta);
}
const output = result.text?.trim();
if (output) {
bodyParts.push(`\`\`\`text\n${output}\n\`\`\``);
}
return bodyParts.length === 0
? `${TOOL_RESULT_PREFIX} ${header}`
: `${TOOL_RESULT_PREFIX} ${header}\n${bodyParts.join("\n")}`;
};
export const extractToolLines = (message: unknown): string[] => {
const lines: string[] = [];
for (const call of extractToolCalls(message)) {
lines.push(formatToolCallMarkdown(call));
}
const result = extractToolResult(message);
if (result) {
lines.push(formatToolResultMarkdown(result));
}
return lines;
};
export const isToolMarkdown = (line: string): boolean =>
line.startsWith(TOOL_CALL_PREFIX) || line.startsWith(TOOL_RESULT_PREFIX);
export const isMetaMarkdown = (line: string): boolean => line.startsWith(META_PREFIX);
export const formatMetaMarkdown = (meta: {
role: "user" | "assistant";
timestamp: number;
thinkingDurationMs?: number | null;
}): string => {
return `${META_PREFIX}${JSON.stringify({
role: meta.role,
timestamp: meta.timestamp,
...(typeof meta.thinkingDurationMs === "number" ? { thinkingDurationMs: meta.thinkingDurationMs } : {}),
})}`;
};
export const parseMetaMarkdown = (
line: string
): { role: "user" | "assistant"; timestamp: number; thinkingDurationMs?: number } | null => {
if (!isMetaMarkdown(line)) return null;
const raw = line.slice(META_PREFIX.length).trim();
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
const role = parsed.role === "user" || parsed.role === "assistant" ? parsed.role : null;
const timestamp = typeof parsed.timestamp === "number" ? parsed.timestamp : null;
if (!role || !timestamp || !Number.isFinite(timestamp) || timestamp <= 0) return null;
const thinkingDurationMs =
typeof parsed.thinkingDurationMs === "number" && Number.isFinite(parsed.thinkingDurationMs)
? parsed.thinkingDurationMs
: undefined;
return thinkingDurationMs !== undefined
? { role, timestamp, thinkingDurationMs }
: { role, timestamp };
} catch {
return null;
}
};
export const parseToolMarkdown = (
line: string
): { kind: "call" | "result"; label: string; body: string } => {
const kind = line.startsWith(TOOL_RESULT_PREFIX) ? "result" : "call";
const prefix = kind === "result" ? TOOL_RESULT_PREFIX : TOOL_CALL_PREFIX;
const content = line.slice(prefix.length).trimStart();
const [labelLine, ...rest] = content.split(/\r?\n/);
return {
kind,
label: labelLine?.trim() || (kind === "result" ? "Tool result" : "Tool call"),
body: rest.join("\n").trim(),
};
};
export const buildAgentInstruction = ({
message,
}: AgentInstructionParams): string => {
return message.trim();
};
const PROJECT_PROMPT_BLOCK_RE = /^(?:Project|Workspace) path:[\s\S]*?\n\s*\n/i;
const PROJECT_PROMPT_INLINE_RE = /^(?:Project|Workspace) path:[\s\S]*?memory_search\.\s*/i;
const RESET_PROMPT_RE =
/^A new session was started via \/new or \/reset[\s\S]*?reasoning\.\s*/i;
const SYSTEM_EVENT_BLOCK_RE = /^System:\s*\[[^\]]+\][\s\S]*?\n\s*\n/;
const MESSAGE_ID_RE = /\s*\[message_id:[^\]]+\]\s*/gi;
export const EXEC_APPROVAL_AUTO_RESUME_MARKER = "[[claw3d:auto-resume-exec-approval]]";
const LEGACY_EXEC_APPROVAL_AUTO_RESUME_RE =
/exec approval was granted[\s\S]*continue where you left off/i;
const UI_METADATA_PREFIX_RE =
/^(?:Project path:|Workspace path:|A new session was started via \/new or \/reset)/i;
const HEARTBEAT_PROMPT_RE = /^Read HEARTBEAT\.md if it exists\b/i;
const HEARTBEAT_PATH_RE = /Heartbeat file path:/i;
export const stripUiMetadata = (text: string) => {
if (!text) return text;
if (
text.includes(EXEC_APPROVAL_AUTO_RESUME_MARKER) ||
LEGACY_EXEC_APPROVAL_AUTO_RESUME_RE.test(text)
) {
return "";
}
let cleaned = text.replace(RESET_PROMPT_RE, "");
cleaned = cleaned.replace(SYSTEM_EVENT_BLOCK_RE, "");
const beforeProjectStrip = cleaned;
cleaned = cleaned.replace(PROJECT_PROMPT_INLINE_RE, "");
if (cleaned === beforeProjectStrip) {
cleaned = cleaned.replace(PROJECT_PROMPT_BLOCK_RE, "");
}
cleaned = cleaned.replace(MESSAGE_ID_RE, "").trim();
return stripEnvelope(cleaned);
};
export const isHeartbeatPrompt = (text: string) => {
if (!text) return false;
const trimmed = text.trim();
if (!trimmed) return false;
return HEARTBEAT_PROMPT_RE.test(trimmed) || HEARTBEAT_PATH_RE.test(trimmed);
};
export const isUiMetadataPrefix = (text: string) => UI_METADATA_PREFIX_RE.test(text);
+43
View File
@@ -0,0 +1,43 @@
const BACKTICK_IMAGE_RE = /`([^`]+\.(?:png|jpe?g|gif|webp))`/i;
export type SpeechImageResult = {
cleanText: string;
imageUrl: string | null;
};
/**
* Detects backtick-wrapped image file paths in agent speech text, returns a
* cleaned version of the text (without the raw paths) and a media-API URL
* for the first matched image.
*/
export function extractSpeechImage(
text: string | null | undefined,
agentId: string,
): SpeechImageResult {
const raw = text?.trim() ?? "";
if (!raw) return { cleanText: raw, imageUrl: null };
const match = raw.match(BACKTICK_IMAGE_RE);
if (!match?.[1]) return { cleanText: raw, imageUrl: null };
const imagePath = match[1].trim();
let fullPath: string;
if (imagePath.startsWith("/") || imagePath.startsWith("~/")) {
fullPath = imagePath;
} else {
fullPath = `~/.openclaw/workspace-${agentId}/${imagePath}`;
}
const imageUrl = `/api/gateway/media?path=${encodeURIComponent(fullPath)}`;
// Strip all backtick-wrapped segments and tidy up leftover punctuation.
const cleanText = raw
.replace(/`[^`]+`/g, "")
.replace(/\s*\([^)]*\)\s*/g, " ")
.replace(/:\s*\./g, ".")
.replace(/\s+/g, " ")
.trim();
return { cleanText: cleanText || raw, imageUrl };
}
+43
View File
@@ -0,0 +1,43 @@
type CryptoLike = {
randomUUID?: (() => string) | undefined;
getRandomValues?: ((array: Uint8Array) => Uint8Array) | undefined;
};
function uuidFromBytes(bytes: Uint8Array): string {
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
let hex = "";
for (let i = 0; i < bytes.length; i++) {
hex += bytes[i]!.toString(16).padStart(2, "0");
}
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(
16,
20
)}-${hex.slice(20)}`;
}
function weakRandomBytes(): Uint8Array {
const bytes = new Uint8Array(16);
const now = Date.now();
for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);
bytes[0] ^= now & 0xff;
bytes[1] ^= (now >>> 8) & 0xff;
bytes[2] ^= (now >>> 16) & 0xff;
bytes[3] ^= (now >>> 24) & 0xff;
return bytes;
}
export function randomUUID(cryptoLike: CryptoLike | null | undefined = globalThis.crypto): string {
if (cryptoLike && typeof cryptoLike.randomUUID === "function") return cryptoLike.randomUUID();
if (cryptoLike && typeof cryptoLike.getRandomValues === "function") {
const bytes = new Uint8Array(16);
cryptoLike.getRandomValues(bytes);
return uuidFromBytes(bytes);
}
return uuidFromBytes(weakRandomBytes());
}
+38
View File
@@ -0,0 +1,38 @@
export type CuratedVoiceOption = {
id: string | null;
label: string;
description: string;
};
export const CURATED_ELEVENLABS_VOICES: CuratedVoiceOption[] = [
{
id: null,
label: "Rachel",
description: "Balanced and conversational.",
},
{
id: "EXAVITQu4vr4xnSDxMaL",
label: "Bella",
description: "Warm and friendly.",
},
{
id: "MF3mGyEYCl7XYWbV9V6O",
label: "Elli",
description: "Clear and upbeat.",
},
{
id: "ErXwobaYiN019PkySvjV",
label: "Antoni",
description: "Calm and professional.",
},
{
id: "TxGEqnHWrfWFTfGW9XjX",
label: "Josh",
description: "Steady and confident.",
},
{
id: "pNInz6obpgDQGcFmaJgB",
label: "Adam",
description: "Deep and authoritative.",
},
];
+79
View File
@@ -0,0 +1,79 @@
export type VoiceReplyProvider = "elevenlabs";
export type VoiceReplySynthesisRequest = {
text: string;
provider?: VoiceReplyProvider;
voiceId?: string | null;
speed?: number;
};
const ELEVENLABS_API_URL = "https://api.elevenlabs.io/v1/text-to-speech";
const DEFAULT_VOICE_REPLY_PROVIDER: VoiceReplyProvider = "elevenlabs";
const DEFAULT_ELEVENLABS_VOICE_ID = "21m00Tcm4TlvDq8ikWAM";
const DEFAULT_ELEVENLABS_MODEL_ID =
process.env.ELEVENLABS_MODEL_ID?.trim() || "eleven_flash_v2_5";
const normalizeVoiceSpeed = (value: number | null | undefined): number => {
if (typeof value !== "number" || !Number.isFinite(value)) return 1;
return Math.min(1.2, Math.max(0.7, value));
};
const normalizeVoiceId = (value: string | null | undefined): string => {
const explicit = value?.trim();
if (explicit) return explicit;
const fromEnv = process.env.ELEVENLABS_VOICE_ID?.trim();
if (fromEnv) return fromEnv;
return DEFAULT_ELEVENLABS_VOICE_ID;
};
const synthesizeWithElevenLabs = async (
request: VoiceReplySynthesisRequest
): Promise<Response> => {
// TODO: Create Claw3D voice and text skill.
const apiKey = process.env.ELEVENLABS_API_KEY?.trim();
if (!apiKey) {
throw new Error("Missing ELEVENLABS_API_KEY.");
}
const voiceId = normalizeVoiceId(request.voiceId);
const speed = normalizeVoiceSpeed(request.speed);
const response = await fetch(
`${ELEVENLABS_API_URL}/${encodeURIComponent(voiceId)}/stream?output_format=mp3_44100_128`,
{
method: "POST",
headers: {
Accept: "audio/mpeg",
"Content-Type": "application/json",
"xi-api-key": apiKey,
},
body: JSON.stringify({
text: request.text,
model_id: DEFAULT_ELEVENLABS_MODEL_ID,
voice_settings: {
stability: 0.42,
similarity_boost: 0.88,
style: 0.2,
use_speaker_boost: true,
speed,
},
}),
cache: "no-store",
}
);
if (!response.ok) {
const detail = (await response.text().catch(() => "")).trim();
throw new Error(detail || "ElevenLabs voice synthesis failed.");
}
return response;
};
export const synthesizeVoiceReply = async (
request: VoiceReplySynthesisRequest
): Promise<Response> => {
const provider = request.provider ?? DEFAULT_VOICE_REPLY_PROVIDER;
switch (provider) {
case "elevenlabs":
return synthesizeWithElevenLabs(request);
default:
throw new Error("Unsupported voice reply provider.");
}
};