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:
+147
-89
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user