First Release of Claw3D (#11)
Co-authored-by: iamlukethedev <iamlukethedev@users.noreply.github.com>
This commit is contained in:
@@ -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 };
|
||||
};
|
||||
@@ -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 }>;
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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)}`;
|
||||
};
|
||||
+1
File diff suppressed because one or more lines are too long
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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`;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
@@ -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.",
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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}.`,
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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],
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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(``);
|
||||
out.push("");
|
||||
out.push(`MEDIA: ${mediaPath}`);
|
||||
if (consumesNextLine) {
|
||||
idx += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
out.push(line);
|
||||
}
|
||||
|
||||
return out.join("\n");
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
];
|
||||
@@ -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.");
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user