feat: add SOUNDCLAW jukebox skill integration (#67)

Add the office jukebox flow so Spotify can be controlled from the SOUNDCLAW skill, manual jukebox UI, and local browser auth bridge during development.

Made-with: Cursor
This commit is contained in:
Luke The Dev
2026-03-26 18:35:19 -05:00
committed by GitHub
parent a202cdc80f
commit 3da1694085
27 changed files with 3471 additions and 983 deletions
+147 -89
View File
@@ -40,10 +40,7 @@ import {
resolveOfficeStandupDirective,
resolveOfficeTextDirective,
} from "@/lib/office/deskDirectives";
import {
extractText,
extractThinking,
} from "@/lib/text/message-extract";
import { extractText, extractThinking } from "@/lib/text/message-extract";
import { randomUUID } from "@/lib/uuid";
// Office animation is derived in two passes:
@@ -120,9 +117,11 @@ export type OfficeAnimationTriggerState = {
export type OfficeAnimationState = {
awaitingApprovalByAgentId: BooleanByAgentId;
cleaningCues: OfficeCleaningCue[];
danceUntilByAgentId: NumberByAgentId;
deskHoldByAgentId: BooleanByAgentId;
githubHoldByAgentId: BooleanByAgentId;
gymHoldByAgentId: BooleanByAgentId;
jukeboxHoldByAgentId: BooleanByAgentId;
manualGymUntilByAgentId: NumberByAgentId;
pendingStandupRequest: OfficeStandupTriggerRequest | null;
phoneBoothHoldByAgentId: BooleanByAgentId;
@@ -136,14 +135,11 @@ export type OfficeAnimationState = {
workingUntilByAgentId: NumberByAgentId;
};
const emptyObject = <T extends Record<string, unknown>>(): T => ({} as T);
const emptyObject = <T extends Record<string, unknown>>(): T => ({}) as T;
const normalizeCommandText = (value: string | null | undefined): string => {
if (!value) return "";
return value
.trim()
.toLowerCase()
.replace(/\s+/g, " ");
return value.trim().toLowerCase().replace(/\s+/g, " ");
};
const buildStableLatestRequestSeed = (value: string): string => {
@@ -167,7 +163,8 @@ const pruneStringMap = (
): StringByAgentId =>
Object.fromEntries(
Object.entries(source).filter(
([agentId, value]) => activeAgentIds.has(agentId) && value.trim().length > 0,
([agentId, value]) =>
activeAgentIds.has(agentId) && value.trim().length > 0,
),
);
@@ -181,7 +178,8 @@ const prunePhoneCallMap = (
activeAgentIds.has(agentId) &&
Boolean(request?.callee?.trim()) &&
(request.phase === "needs_message" ||
(request.phase === "ready_to_call" && Boolean(request.message?.trim()))),
(request.phase === "ready_to_call" &&
Boolean(request.message?.trim()))),
),
);
@@ -195,7 +193,8 @@ const pruneTextMessageMap = (
activeAgentIds.has(agentId) &&
Boolean(request?.recipient?.trim()) &&
(request.phase === "needs_message" ||
(request.phase === "ready_to_send" && Boolean(request.message?.trim()))),
(request.phase === "ready_to_send" &&
Boolean(request.message?.trim()))),
),
);
@@ -220,7 +219,9 @@ const resolveMessageRole = (message: unknown): string | null => {
return typeof role === "string" ? role : null;
};
const resolveChatPayloadRole = (payload: ChatEventPayload | undefined): string | null => {
const resolveChatPayloadRole = (
payload: ChatEventPayload | undefined,
): string | null => {
if (!payload) return null;
const messageRole = resolveMessageRole(payload.message);
if (messageRole) return messageRole;
@@ -231,19 +232,20 @@ const resolveChatPayloadRole = (payload: ChatEventPayload | undefined): string |
return typeof payloadRole === "string" ? payloadRole : null;
};
const isUserLikeChatRole = (role: string | null, state: ChatEventPayload["state"]): boolean => {
const isUserLikeChatRole = (
role: string | null,
state: ChatEventPayload["state"],
): boolean => {
if (role === "user" || role === "human" || role === "input") return true;
if (role === "system") return state === "final";
return role === null && state === "final";
};
const resolveLatestDirective = <TDirective>(
params: {
lastUserMessage: string | null | undefined;
transcriptEntries: TranscriptEntry[] | undefined;
resolver: (value: string | null | undefined) => TDirective | null;
},
): LatestDirective<TDirective> | null => {
const resolveLatestDirective = <TDirective>(params: {
lastUserMessage: string | null | undefined;
transcriptEntries: TranscriptEntry[] | undefined;
resolver: (value: string | null | undefined) => TDirective | null;
}): LatestDirective<TDirective> | null => {
const latestMessageDirective = params.resolver(params.lastUserMessage);
if (latestMessageDirective) {
const text = params.lastUserMessage?.trim() ?? "";
@@ -253,10 +255,17 @@ const resolveLatestDirective = <TDirective>(
text,
};
}
if (!Array.isArray(params.transcriptEntries) || params.transcriptEntries.length === 0) {
if (
!Array.isArray(params.transcriptEntries) ||
params.transcriptEntries.length === 0
) {
return null;
}
for (let index = params.transcriptEntries.length - 1; index >= 0; index -= 1) {
for (
let index = params.transcriptEntries.length - 1;
index >= 0;
index -= 1
) {
const entry = params.transcriptEntries[index];
if (!entry || entry.role !== "user") continue;
const directive = params.resolver(entry.text);
@@ -270,17 +279,23 @@ const resolveLatestDirective = <TDirective>(
return null;
};
const isTransientBoothRequestFresh = (requestedAt: number, nowMs: number): boolean =>
nowMs - requestedAt <= TRANSIENT_BOOTH_RESTORE_MAX_AGE_MS;
const isTransientBoothRequestFresh = (
requestedAt: number,
nowMs: number,
): boolean => nowMs - requestedAt <= TRANSIENT_BOOTH_RESTORE_MAX_AGE_MS;
const maybeResolveCompletedPhoneCallRequest = (
current: OfficePhoneCallRequest | null,
line: string,
): OfficePhoneCallRequest | null => {
if (!current) return null;
const match = line.match(/^\[phone booth\]\s*Call with\s+(.+)\s+finished\.$/i);
const match = line.match(
/^\[phone booth\]\s*Call with\s+(.+)\s+finished\.$/i,
);
if (!match) return current;
return normalizeCommandText(match[1]) === normalizeCommandText(current.callee) ? null : current;
return normalizeCommandText(match[1]) === normalizeCommandText(current.callee)
? null
: current;
};
const maybeResolveCompletedTextMessageRequest = (
@@ -288,9 +303,12 @@ const maybeResolveCompletedTextMessageRequest = (
line: string,
): OfficeTextMessageRequest | null => {
if (!current) return null;
const match = line.match(/^\[messaging booth\]\s*Message to\s+(.+)\s+sent\.$/i);
const match = line.match(
/^\[messaging booth\]\s*Message to\s+(.+)\s+sent\.$/i,
);
if (!match) return current;
return normalizeCommandText(match[1]) === normalizeCommandText(current.recipient)
return normalizeCommandText(match[1]) ===
normalizeCommandText(current.recipient)
? null
: current;
};
@@ -361,7 +379,9 @@ const resolveLatestPhoneCallRequest = (params: {
}
}
if (!current) return null;
return isTransientBoothRequestFresh(current.requestedAt, params.nowMs) ? current : null;
return isTransientBoothRequestFresh(current.requestedAt, params.nowMs)
? current
: null;
};
const resolveLatestTextMessageRequest = (params: {
@@ -430,7 +450,9 @@ const resolveLatestTextMessageRequest = (params: {
}
}
if (!current) return null;
return isTransientBoothRequestFresh(current.requestedAt, params.nowMs) ? current : null;
return isTransientBoothRequestFresh(current.requestedAt, params.nowMs)
? current
: null;
};
const resolveAgentIdForSessionKey = (
@@ -439,7 +461,9 @@ const resolveAgentIdForSessionKey = (
): string | null => {
const trimmed = sessionKey?.trim() ?? "";
if (!trimmed) return null;
const matched = agents.find((agent) => isSameSessionKey(agent.sessionKey, trimmed));
const matched = agents.find((agent) =>
isSameSessionKey(agent.sessionKey, trimmed),
);
if (matched) return matched.agentId;
return parseAgentIdFromSessionKey(trimmed);
};
@@ -501,13 +525,13 @@ const hasOtherOfficeDirective = (
): boolean =>
Boolean(
snapshot.desk ||
snapshot.github ||
snapshot.gym ||
snapshot.qa ||
snapshot.art ||
snapshot.standup ||
snapshot.call ||
snapshot.text,
snapshot.github ||
snapshot.gym ||
snapshot.qa ||
snapshot.art ||
snapshot.standup ||
snapshot.call ||
snapshot.text,
);
const resolvePhoneCallFollowUpRequest = (params: {
@@ -522,11 +546,14 @@ const resolvePhoneCallFollowUpRequest = (params: {
const message = params.message.trim();
if (!message) return null;
return {
key: buildPhoneCallDirectiveKey({
callee: params.current.callee,
phase: "ready_to_call",
message,
}, params.requestSeed ?? String(params.requestedAt)),
key: buildPhoneCallDirectiveKey(
{
callee: params.current.callee,
phase: "ready_to_call",
message,
},
params.requestSeed ?? String(params.requestedAt),
),
callee: params.current.callee,
message,
phase: "ready_to_call",
@@ -587,7 +614,10 @@ const pruneOfficeAnimationTriggerState = (
state.githubDirectiveKeyByAgentId,
activeAgentIds,
),
githubHoldByAgentId: pruneBooleanMap(state.githubHoldByAgentId, activeAgentIds),
githubHoldByAgentId: pruneBooleanMap(
state.githubHoldByAgentId,
activeAgentIds,
),
gymCooldownUntilByAgentId: pruneFutureMap(
state.gymCooldownUntilByAgentId,
activeAgentIds,
@@ -606,7 +636,10 @@ const pruneOfficeAnimationTriggerState = (
state.qaDirectiveKeyByAgentId,
activeAgentIds,
),
phoneCallByAgentId: prunePhoneCallMap(state.phoneCallByAgentId, activeAgentIds),
phoneCallByAgentId: prunePhoneCallMap(
state.phoneCallByAgentId,
activeAgentIds,
),
phoneCallDirectiveKeyByAgentId: pruneStringMap(
state.phoneCallDirectiveKeyByAgentId,
activeAgentIds,
@@ -686,7 +719,10 @@ const recordThinkingActivity = (
nowMs: number,
): NumberByAgentId => ({
...current,
[agentId]: Math.max(current[agentId] ?? 0, nowMs + THINKING_ACTIVITY_LATCH_MS),
[agentId]: Math.max(
current[agentId] ?? 0,
nowMs + THINKING_ACTIVITY_LATCH_MS,
),
});
const applyUserMessageTriggers = (params: {
@@ -717,7 +753,8 @@ const applyUserMessageTriggers = (params: {
if (githubDirective) {
const directiveKey = normalizeCommandText(params.message);
const isSuppressed =
next.suppressedGithubDirectiveKeyByAgentId[params.agentId] === directiveKey;
next.suppressedGithubDirectiveKeyByAgentId[params.agentId] ===
directiveKey;
next = {
...next,
githubDirectiveKeyByAgentId: {
@@ -769,10 +806,7 @@ const applyUserMessageTriggers = (params: {
},
};
}
if (
params.agentId === "main" &&
intentSnapshot.standup === "standup"
) {
if (params.agentId === "main" && intentSnapshot.standup === "standup") {
const requestKey = normalizeCommandText(params.message);
if (next.pendingStandupRequest?.key !== requestKey) {
next = {
@@ -862,33 +896,34 @@ const applyUserMessageTriggers = (params: {
return next;
};
export const createOfficeAnimationTriggerState = (): OfficeAnimationTriggerState => ({
cleaningCues: [],
deskDirectiveKeyByAgentId: emptyObject(),
deskHoldByAgentId: emptyObject(),
githubDirectiveKeyByAgentId: emptyObject(),
githubHoldByAgentId: emptyObject(),
gymCooldownUntilByAgentId: emptyObject(),
lastManualGymCommandKeyByAgentId: emptyObject(),
manualGymUntilByAgentId: emptyObject(),
pendingStandupRequest: null,
phoneCallByAgentId: emptyObject(),
phoneCallDirectiveKeyByAgentId: emptyObject(),
qaDirectiveKeyByAgentId: emptyObject(),
qaHoldByAgentId: emptyObject(),
sessionEpochSnapshot: {},
skillGymDirectiveKeyByAgentId: emptyObject(),
skillGymHoldByAgentId: emptyObject(),
streamingUntilByAgentId: emptyObject(),
suppressedPhoneCallDirectiveKeyByAgentId: emptyObject(),
suppressedGithubDirectiveKeyByAgentId: emptyObject(),
suppressedQaDirectiveKeyByAgentId: emptyObject(),
suppressedTextMessageDirectiveKeyByAgentId: emptyObject(),
textMessageByAgentId: emptyObject(),
textMessageDirectiveKeyByAgentId: emptyObject(),
thinkingUntilByAgentId: emptyObject(),
workingUntilByAgentId: emptyObject(),
});
export const createOfficeAnimationTriggerState =
(): OfficeAnimationTriggerState => ({
cleaningCues: [],
deskDirectiveKeyByAgentId: emptyObject(),
deskHoldByAgentId: emptyObject(),
githubDirectiveKeyByAgentId: emptyObject(),
githubHoldByAgentId: emptyObject(),
gymCooldownUntilByAgentId: emptyObject(),
lastManualGymCommandKeyByAgentId: emptyObject(),
manualGymUntilByAgentId: emptyObject(),
pendingStandupRequest: null,
phoneCallByAgentId: emptyObject(),
phoneCallDirectiveKeyByAgentId: emptyObject(),
qaDirectiveKeyByAgentId: emptyObject(),
qaHoldByAgentId: emptyObject(),
sessionEpochSnapshot: {},
skillGymDirectiveKeyByAgentId: emptyObject(),
skillGymHoldByAgentId: emptyObject(),
streamingUntilByAgentId: emptyObject(),
suppressedPhoneCallDirectiveKeyByAgentId: emptyObject(),
suppressedGithubDirectiveKeyByAgentId: emptyObject(),
suppressedQaDirectiveKeyByAgentId: emptyObject(),
suppressedTextMessageDirectiveKeyByAgentId: emptyObject(),
textMessageByAgentId: emptyObject(),
textMessageDirectiveKeyByAgentId: emptyObject(),
thinkingUntilByAgentId: emptyObject(),
workingUntilByAgentId: emptyObject(),
});
export const reduceOfficeAnimationTriggerEvent = (params: {
agents: AgentState[];
@@ -897,7 +932,11 @@ export const reduceOfficeAnimationTriggerEvent = (params: {
state: OfficeAnimationTriggerState;
}): OfficeAnimationTriggerState => {
const nowMs = params.nowMs ?? Date.now();
let next = pruneOfficeAnimationTriggerState(params.state, params.agents, nowMs);
let next = pruneOfficeAnimationTriggerState(
params.state,
params.agents,
nowMs,
);
const kind = classifyGatewayEventKind(params.event.event);
if (kind === "runtime-chat") {
@@ -908,7 +947,8 @@ export const reduceOfficeAnimationTriggerEvent = (params: {
);
if (!payload || !agentId) return next;
const messageText = extractText(payload.message)?.trim() ?? "";
const thinkingText = extractThinking(payload.message ?? payload)?.trim() ?? "";
const thinkingText =
extractThinking(payload.message ?? payload)?.trim() ?? "";
const role = resolveChatPayloadRole(payload);
if (payload.runId) {
next = {
@@ -1015,7 +1055,9 @@ export const reduceOfficeAnimationTriggerEvent = (params: {
const resolved = parseExecApprovalResolved(params.event);
if (resolved) {
const approvalAgentId = params.agents.find((agent) => agent.awaitingUserInput)?.agentId;
const approvalAgentId = params.agents.find(
(agent) => agent.awaitingUserInput,
)?.agentId;
if (approvalAgentId) {
next = {
...next,
@@ -1039,7 +1081,11 @@ export const reconcileOfficeAnimationTriggerState = (params: {
// Reconciliation is the durable source of truth. It replays the latest user-visible intent
// from current agent state so recovered history can restore holds even when chat events were missed.
const nowMs = params.nowMs ?? Date.now();
const next = pruneOfficeAnimationTriggerState(params.state, params.agents, nowMs);
const next = pruneOfficeAnimationTriggerState(
params.state,
params.agents,
nowMs,
);
const activeAgentIds = new Set(params.agents.map((agent) => agent.agentId));
const currentImmediateGymKeys = pruneStringMap(
@@ -1100,7 +1146,8 @@ export const reconcileOfficeAnimationTriggerState = (params: {
});
if (githubDirective) {
githubDirectiveKeyByAgentId[agentId] = githubDirective.key;
const suppressedKey = next.suppressedGithubDirectiveKeyByAgentId[agentId] ?? "";
const suppressedKey =
next.suppressedGithubDirectiveKeyByAgentId[agentId] ?? "";
if (
githubDirective.directive !== "release" &&
suppressedKey !== githubDirective.key
@@ -1118,8 +1165,12 @@ export const reconcileOfficeAnimationTriggerState = (params: {
});
if (qaDirective) {
qaDirectiveKeyByAgentId[agentId] = qaDirective.key;
const suppressedKey = next.suppressedQaDirectiveKeyByAgentId[agentId] ?? "";
if (qaDirective.directive !== "release" && suppressedKey !== qaDirective.key) {
const suppressedKey =
next.suppressedQaDirectiveKeyByAgentId[agentId] ?? "";
if (
qaDirective.directive !== "release" &&
suppressedKey !== qaDirective.key
) {
qaHoldByAgentId[agentId] = true;
}
} else if (next.qaHoldByAgentId[agentId]) {
@@ -1190,7 +1241,9 @@ export const reconcileOfficeAnimationTriggerState = (params: {
previous: next.sessionEpochSnapshot,
agents: params.agents,
});
const agentMap = new Map(params.agents.map((agent) => [agent.agentId, agent]));
const agentMap = new Map(
params.agents.map((agent) => [agent.agentId, agent]),
);
const cleaningCues = [...next.cleaningCues];
for (const agentId of triggeredAgentIds) {
const agent = agentMap.get(agentId);
@@ -1248,7 +1301,8 @@ export const clearOfficeAnimationTriggerHold = (params: {
};
}
if (params.hold === "call") {
const directiveKey = next.phoneCallDirectiveKeyByAgentId[params.agentId] ?? "";
const directiveKey =
next.phoneCallDirectiveKeyByAgentId[params.agentId] ?? "";
const phoneCallByAgentId = { ...next.phoneCallByAgentId };
delete phoneCallByAgentId[params.agentId];
return {
@@ -1263,7 +1317,8 @@ export const clearOfficeAnimationTriggerHold = (params: {
};
}
if (params.hold === "text") {
const directiveKey = next.textMessageDirectiveKeyByAgentId[params.agentId] ?? "";
const directiveKey =
next.textMessageDirectiveKeyByAgentId[params.agentId] ?? "";
const textMessageByAgentId = { ...next.textMessageByAgentId };
delete textMessageByAgentId[params.agentId];
return {
@@ -1305,6 +1360,7 @@ export const buildOfficeAnimationState = (params: {
const awaitingApprovalByAgentId: BooleanByAgentId = {};
const deskHoldByAgentId: BooleanByAgentId = {};
const gymHoldByAgentId: BooleanByAgentId = {};
const jukeboxHoldByAgentId: BooleanByAgentId = {};
const phoneBoothHoldByAgentId: BooleanByAgentId = {};
const phoneCallByAgentId: PhoneCallByAgentId = {};
const smsBoothHoldByAgentId: BooleanByAgentId = {};
@@ -1353,9 +1409,11 @@ export const buildOfficeAnimationState = (params: {
return {
awaitingApprovalByAgentId,
cleaningCues: params.state.cleaningCues,
danceUntilByAgentId: {},
deskHoldByAgentId,
githubHoldByAgentId: params.state.githubHoldByAgentId,
gymHoldByAgentId,
jukeboxHoldByAgentId,
manualGymUntilByAgentId: params.state.manualGymUntilByAgentId,
pendingStandupRequest: params.state.pendingStandupRequest,
phoneBoothHoldByAgentId,
+26 -1
View File
@@ -3,17 +3,20 @@ export const OFFICE_INTERACTION_TARGETS = [
"server_room",
"meeting_room",
"gym",
"jukebox",
"qa_lab",
"sms_booth",
"phone_booth",
] as const;
export type OfficeInteractionTargetId = (typeof OFFICE_INTERACTION_TARGETS)[number];
export type OfficeInteractionTargetId =
(typeof OFFICE_INTERACTION_TARGETS)[number];
export const OFFICE_SKILL_TRIGGER_MOVEMENT_TARGETS = [
"desk",
"github",
"gym",
"jukebox",
"qa_lab",
] as const;
@@ -24,6 +27,7 @@ type OfficeSkillTriggerAnimationHoldKey =
| "deskHoldByAgentId"
| "githubHoldByAgentId"
| "gymHoldByAgentId"
| "jukeboxHoldByAgentId"
| "qaHoldByAgentId";
export const OFFICE_SKILL_TRIGGER_PLACE_REGISTRY: Record<
@@ -51,6 +55,11 @@ export const OFFICE_SKILL_TRIGGER_PLACE_REGISTRY: Record<
animationHoldKey: "gymHoldByAgentId",
alsoSetsSkillGymHold: true,
},
jukebox: {
label: "Jukebox",
interactionTarget: "jukebox",
animationHoldKey: "jukeboxHoldByAgentId",
},
qa_lab: {
label: "QA Lab",
interactionTarget: "qa_lab",
@@ -85,6 +94,20 @@ export const DEFAULT_SKILL_TRIGGER_FALLBACKS_BY_SKILL_KEY: Record<
movementTarget: "desk",
skipIfAlreadyThere: true,
},
soundclaw: {
anyPhrases: [
"spotify",
"play a song",
"play this song",
"play music",
"play a playlist",
"find a song",
"queue this song",
"music link",
],
movementTarget: "jukebox",
skipIfAlreadyThere: true,
},
};
export const buildOfficeSkillTriggerHoldMaps = (
@@ -93,6 +116,7 @@ export const buildOfficeSkillTriggerHoldMaps = (
deskHoldByAgentId: Record<string, boolean>;
githubHoldByAgentId: Record<string, boolean>;
gymHoldByAgentId: Record<string, boolean>;
jukeboxHoldByAgentId: Record<string, boolean>;
qaHoldByAgentId: Record<string, boolean>;
skillGymHoldByAgentId: Record<string, boolean>;
} => {
@@ -100,6 +124,7 @@ export const buildOfficeSkillTriggerHoldMaps = (
deskHoldByAgentId: {} as Record<string, boolean>,
githubHoldByAgentId: {} as Record<string, boolean>,
gymHoldByAgentId: {} as Record<string, boolean>,
jukeboxHoldByAgentId: {} as Record<string, boolean>,
qaHoldByAgentId: {} as Record<string, boolean>,
skillGymHoldByAgentId: {} as Record<string, boolean>,
};
+30 -10
View File
@@ -1,6 +1,9 @@
import type { RemovableSkillSource, SkillStatusEntry } from "@/lib/skills/types";
import type {
RemovableSkillSource,
SkillStatusEntry,
} from "@/lib/skills/types";
export type PackagedSkillId = "todo-board";
export type PackagedSkillId = "soundclaw" | "todo-board";
export type PackagedSkillDefinition = {
packageId: PackagedSkillId;
@@ -30,20 +33,35 @@ const PACKAGED_SKILLS: PackagedSkillDefinition[] = [
creatorName: "iamlukethedev",
creatorUrl: "http://x.com/iamlukethedev/",
},
{
packageId: "soundclaw",
skillKey: "soundclaw",
name: "soundclaw",
description: "Control Spotify playback, search music, and return shareable music links.",
installSource: "openclaw-workspace",
creatorName: "iamlukethedev",
creatorUrl: "https://github.com/iamlukethedev",
},
];
export const listPackagedSkills = (): PackagedSkillDefinition[] => [...PACKAGED_SKILLS];
export const listPackagedSkills = (): PackagedSkillDefinition[] => [
...PACKAGED_SKILLS,
];
export const getPackagedSkillById = (packageId: string): PackagedSkillDefinition | null =>
export const getPackagedSkillById = (
packageId: string,
): PackagedSkillDefinition | null =>
PACKAGED_SKILLS.find((skill) => skill.packageId === packageId) ?? null;
export const getPackagedSkillBySkillKey = (skillKey: string): PackagedSkillDefinition | null => {
export const getPackagedSkillBySkillKey = (
skillKey: string,
): PackagedSkillDefinition | null => {
const normalized = skillKey.trim();
return PACKAGED_SKILLS.find((skill) => skill.skillKey === normalized) ?? null;
};
export const buildPackagedSkillStatusEntry = (
skill: PackagedSkillDefinition
skill: PackagedSkillDefinition,
): SkillStatusEntry => ({
name: skill.name,
description: skill.description,
@@ -62,11 +80,13 @@ export const buildPackagedSkillStatusEntry = (
install: [],
});
export const appendPackagedSkillsToMarketplace = (skills: SkillStatusEntry[]): SkillStatusEntry[] => {
export const appendPackagedSkillsToMarketplace = (
skills: SkillStatusEntry[],
): SkillStatusEntry[] => {
const presentKeys = new Set(skills.map((skill) => skill.skillKey.trim()));
const additions = PACKAGED_SKILLS.filter((skill) => !presentKeys.has(skill.skillKey)).map(
buildPackagedSkillStatusEntry
);
const additions = PACKAGED_SKILLS.filter(
(skill) => !presentKeys.has(skill.skillKey),
).map(buildPackagedSkillStatusEntry);
if (additions.length === 0) {
return skills;
}
+70 -18
View File
@@ -42,11 +42,18 @@ export type SkillMarketplaceEntry = {
missingDetails: string[];
};
const SKILL_MARKETPLACE_OVERRIDES: Record<string, Partial<SkillMarketplaceMetadata>> = {
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"],
capabilities: [
"Pull request support",
"Issue context",
"Repository operations",
],
featured: true,
editorBadge: "Popular",
rating: 4.9,
@@ -64,14 +71,19 @@ const SKILL_MARKETPLACE_OVERRIDES: Record<string, Partial<SkillMarketplaceMetada
slack: {
category: "Communication",
tagline: "Keeps agents plugged into team channels and notifications.",
capabilities: ["Channel updates", "Message drafting", "Notification routing"],
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.",
tagline:
"Brings issue tracking and execution loops directly into agent workflows.",
capabilities: ["Issue lookup", "Status updates", "Planning workflows"],
featured: true,
rating: 4.7,
@@ -79,12 +91,26 @@ const SKILL_MARKETPLACE_OVERRIDES: Record<string, Partial<SkillMarketplaceMetada
},
"todo-board": {
category: "Productivity",
tagline: "Gives agents a shared workspace TODO board with blocked-task tracking.",
capabilities: ["Task capture", "Blocked tracking", "Shared workspace state"],
tagline:
"Gives agents a shared workspace TODO board with blocked-task tracking.",
capabilities: [
"Task capture",
"Blocked tracking",
"Shared workspace state",
],
featured: true,
editorBadge: "Claw3D test",
hideStats: true,
},
soundclaw: {
category: "Audio",
tagline:
"Lets agents search Spotify, control playback, and return music links on the current channel.",
capabilities: ["Spotify search", "Playback control", "Same-channel link sharing"],
featured: true,
editorBadge: "Office demo",
hideStats: true,
},
};
const hashString = (value: string): number => {
@@ -122,7 +148,9 @@ const buildFallbackCapabilities = (skill: SkillStatusEntry): string[] => {
return capabilities.slice(0, 3);
};
const buildFallbackMetadata = (skill: SkillStatusEntry): SkillMarketplaceMetadata => {
const buildFallbackMetadata = (
skill: SkillStatusEntry,
): SkillMarketplaceMetadata => {
const normalizedKey = skill.skillKey.trim().toLowerCase();
const source = skill.source.trim();
const seed = hashString(`${normalizedKey}:${source}`);
@@ -146,7 +174,9 @@ const buildFallbackMetadata = (skill: SkillStatusEntry): SkillMarketplaceMetadat
: "Community";
return {
category,
tagline: skill.description.trim() || `${titleCaseWords(skill.name)} capability pack.`,
tagline:
skill.description.trim() ||
`${titleCaseWords(skill.name)} capability pack.`,
trustLabel,
capabilities: buildFallbackCapabilities(skill),
featured: skill.bundled || source === "openclaw-managed",
@@ -155,7 +185,9 @@ const buildFallbackMetadata = (skill: SkillStatusEntry): SkillMarketplaceMetadat
};
};
export const resolveSkillMarketplaceMetadata = (skill: SkillStatusEntry): SkillMarketplaceMetadata => {
export const resolveSkillMarketplaceMetadata = (
skill: SkillStatusEntry,
): SkillMarketplaceMetadata => {
const normalizedKey = skill.skillKey.trim().toLowerCase();
const fallback = buildFallbackMetadata(skill);
const override = SKILL_MARKETPLACE_OVERRIDES[normalizedKey];
@@ -178,11 +210,15 @@ export const resolveSkillMarketplaceMetadata = (skill: SkillStatusEntry): SkillM
};
};
export const buildSkillMarketplaceEntry = (skill: SkillStatusEntry): SkillMarketplaceEntry => {
export const buildSkillMarketplaceEntry = (
skill: SkillStatusEntry,
): SkillMarketplaceEntry => {
const packagedSkill = getPackagedSkillBySkillKey(skill.skillKey);
const missingDetails = buildSkillMissingDetails(skill);
if (packagedSkill && !skill.baseDir.trim()) {
missingDetails.unshift("Install this packaged Claw3D skill to make it available on the gateway.");
missingDetails.unshift(
"Install this packaged Claw3D skill to make it available on the gateway.",
);
}
return {
skill,
@@ -195,7 +231,7 @@ export const buildSkillMarketplaceEntry = (skill: SkillStatusEntry): SkillMarket
};
export const buildSkillMarketplaceCollections = (
skills: SkillStatusEntry[]
skills: SkillStatusEntry[],
): Array<{
id: SkillMarketplaceCollectionId;
label: string;
@@ -209,24 +245,40 @@ export const buildSkillMarketplaceCollections = (
entries: SkillMarketplaceEntry[];
}> = [];
const featured = entries.filter((entry) => entry.metadata.featured).slice(0, 6);
const featured = entries
.filter((entry) => entry.metadata.featured)
.slice(0, 6);
if (featured.length > 0) {
collections.push({ id: "featured", label: "Featured", entries: featured });
}
const claw3d = entries.filter((entry) => getPackagedSkillBySkillKey(entry.skill.skillKey));
const claw3d = entries.filter((entry) =>
getPackagedSkillBySkillKey(entry.skill.skillKey),
);
if (claw3d.length > 0) {
collections.push({ id: "claw3d", label: "Claw3D", entries: claw3d });
}
const installed = entries.filter((entry) => entry.readiness === "ready" || entry.skill.disabled);
const installed = entries.filter(
(entry) => entry.readiness === "ready" || entry.skill.disabled,
);
if (installed.length > 0) {
collections.push({ id: "installed", label: "Installed", entries: installed });
collections.push({
id: "installed",
label: "Installed",
entries: installed,
});
}
const setupRequired = entries.filter((entry) => entry.readiness === "needs-setup");
const setupRequired = entries.filter(
(entry) => entry.readiness === "needs-setup",
);
if (setupRequired.length > 0) {
collections.push({ id: "setup-required", label: "Needs setup", entries: setupRequired });
collections.push({
id: "setup-required",
label: "Needs setup",
entries: setupRequired,
});
}
for (const group of sourceGroups) {
+56 -1
View File
@@ -140,6 +140,53 @@ const TODO_BOARD_EXAMPLE_JSON = `{
}
`;
// Keep this string synchronized with assets/skills/soundclaw/SKILL.md.
const SOUNDCLAW_SKILL_MD = `---
name: soundclaw
description: Control Spotify playback, search music, and return shareable music links.
metadata: {"openclaw":{"skillKey":"soundclaw"}}
---
# SOUNDCLAW
Use this skill when the user wants an agent to search for music, play a song or playlist, control Spotify playback, or send back a shareable Spotify link on the same channel the request came from.
## Trigger
\`\`\`json
{
"activation": {
"anyPhrases": [
"spotify",
"play a song",
"play this song",
"play music",
"play a playlist",
"find a song",
"queue this song",
"music link"
]
},
"movement": {
"target": "jukebox",
"skipIfAlreadyThere": true
}
}
\`\`\`
When this skill is activated, the agent should walk to the office jukebox before handling the request.
- Treat requests from Telegram or any other external surface as valid triggers when they ask for Spotify playback, search, queueing, or music-link sharing.
- The physical behavior for this skill is: go to the jukebox, perform the music-selection workflow, then report the result.
- If the agent is already at the jukebox, continue without adding extra movement narration.
## Channel behavior
- Reply on the same active channel or session that received the request.
- If playback cannot start but a matching track, album, or playlist is found, send back the best Spotify link instead of failing silently.
- If multiple matches are plausible, ask a clarifying question instead of guessing.
`;
const PACKAGED_SKILL_FILES: Record<string, PackagedSkillFile[]> = {
"todo-board": [
{
@@ -151,9 +198,17 @@ const PACKAGED_SKILL_FILES: Record<string, PackagedSkillFile[]> = {
content: TODO_BOARD_EXAMPLE_JSON,
},
],
soundclaw: [
{
relativePath: "SKILL.md",
content: SOUNDCLAW_SKILL_MD,
},
],
};
export const readPackagedSkillFiles = (packageId: string): PackagedSkillFile[] => {
export const readPackagedSkillFiles = (
packageId: string,
): PackagedSkillFile[] => {
const files = PACKAGED_SKILL_FILES[packageId];
if (!files || files.length === 0) {
throw new Error(`Packaged skill assets are missing: ${packageId}`);