Files
Veskslop-t/poc/src/control-bridge.ts
T

1073 lines
39 KiB
TypeScript

/*
* Veskslop daemon — MCP control bridge over the validated core bridge.
*
* The daemon holds all logic. It sends validated actions to Vesktop and
* receives normalized events. See docs/core-bridge.md.
*
* Usage:
* pnpm control:bridge
* VESKSLOP_SOCKET=/path/to/vesktop-puppet.sock MCP_PORT=8788 pnpm control:bridge
*/
import { spawn } from "node:child_process";
import { closeSync, openSync } from "node:fs";
import { config as loadEnv } from "dotenv";
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import * as z from "zod/v4";
import { AuthService } from "./auth-service.js";
import { AudioService } from "./audio-service.js";
import { tokenFile } from "./config.js";
import { dirname, join } from "node:path";
import { Stt } from "./stt.js";
import { TranscriptStore } from "./transcript.js";
import { DEFAULT_VAD } from "./vad.js";
import { CoreClient, coreSocketPath } from "./core-client.js";
import { CoreProcess, staleBundleWarning } from "./core-process.js";
import { dedupe, getQueryCount, paced, pacedSend, pacedVoice, sleep } from "./pacing.js";
import { MediaService } from "./media.js";
import { PreferencesStore } from "./preferences.js";
import { Puppet } from "./puppet.js";
import { startWebUi } from "./webui.js";
loadEnv({ override: true });
const HOST = process.env.MCP_HOST ?? "127.0.0.1";
const PORT = Number(process.env.MCP_PORT ?? 8788);
let core: CoreClient | undefined;
let puppet: Puppet | undefined;
let coreConnected = false;
let connecting = false;
const media = new MediaService({
getCore: () => core,
isCoreConnected: () => coreConnected,
getMediaPrefs: () => preferences.get().media,
log: line => console.log(`[media] ${line}`)
});
async function connectLoop(): Promise<void> {
if (connecting) return;
connecting = true;
try {
for (;;) {
try {
const client = await CoreClient.connect();
core = client;
puppet = new Puppet(client, audio, media);
client.onEvent(event => {
// Speaking hints add to the reconciled set so the poller can
// clear users the store no longer reports as speaking.
if (event?.type === "VOICE_SPEAKING" && event.userId) {
if (event.speaking) speakingUsers.add(String(event.userId));
else speakingUsers.delete(String(event.userId));
}
void puppet!.enqueueEvent(event);
void audio.onEvent(event);
webui.broadcast(event);
});
client.onClose(() => {
console.warn("[daemon] core bridge disconnected");
stopSpeakingPoll();
coreConnected = false;
core = undefined;
puppet = undefined;
webui.broadcast({ type: "CORE_STATUS", coreConnected: false });
void connectLoop();
});
coreConnected = true;
console.log(`[daemon] connected to the core bridge at ${coreSocketPath}`);
webui.broadcast({ type: "CORE_STATUS", coreConnected: true });
startSpeakingPoll();
void autoConnectAudioWithRetry(client);
return;
} catch (error) {
console.warn(`[daemon] waiting for the core bridge: ${(error as Error).message}`);
await sleep(2000);
}
}
} finally {
connecting = false;
}
}
function requireCore(): CoreClient {
if (!coreConnected || !core) throw new Error("Not connected to Vesktop (is the core bridge running?)");
return core;
}
function requirePuppet(): Puppet {
if (!puppet) throw new Error("Puppet not connected yet");
return puppet;
}
/*
* Discord's dispatcher only sends speaking-start events, so the UI and the
* transcript attribution need the store's authoritative set. Poll it once a
* second and synthesize the missing stop/start events. Dispatcher events feed
* the same set so a user the store no longer reports is cleared.
*/
let speakingTimer: ReturnType<typeof setInterval> | undefined;
const speakingUsers = new Set<string>();
function emitCoreEvent(event: any): void {
void puppet?.enqueueEvent(event);
void audio.onEvent(event);
webui.broadcast(event);
}
async function pollSpeaking(): Promise<void> {
if (!core) return;
try {
const result = (await core.voiceSpeaking()) as { channelId?: string | null; userIds?: string[] };
const next = new Set<string>(result?.channelId ? (result.userIds ?? []) : []);
for (const userId of next) {
if (!speakingUsers.has(userId)) {
emitCoreEvent({
type: "VOICE_SPEAKING",
userId,
speaking: true,
channelId: result.channelId ?? null
});
}
}
for (const userId of speakingUsers) {
if (!next.has(userId)) {
emitCoreEvent({
type: "VOICE_SPEAKING",
userId,
speaking: false,
channelId: result?.channelId ?? null
});
}
}
speakingUsers.clear();
for (const userId of next) speakingUsers.add(userId);
} catch {
// The close handler stops the poller.
}
}
function startSpeakingPoll(): void {
stopSpeakingPoll();
speakingTimer = setInterval(() => void pollSpeaking(), 1000);
}
function stopSpeakingPoll(): void {
if (speakingTimer) clearInterval(speakingTimer);
speakingTimer = undefined;
speakingUsers.clear();
}
const coreProcess = new CoreProcess({ tokenFile });
const transcripts = new TranscriptStore(
process.env.VESKSLOP_TRANSCRIPT ?? join(dirname(tokenFile), "transcripts.jsonl")
);
const preferences = new PreferencesStore(
process.env.VESKSLOP_PREFERENCES ?? join(dirname(tokenFile), "preferences.json"),
{ legacyFile: process.env.VESKSLOP_STATE_FILE ?? join(dirname(tokenFile), ".veskslop-state.json") }
);
const stt = new Stt();
const audio = new AudioService({
cacheDir: process.env.VESKSLOP_CACHE_DIR,
transcripts,
stt,
autoCapture: process.env.VESKSLOP_CAPTURE_AUTO === "1",
vad: {
...DEFAULT_VAD,
thresholdDb: Number(process.env.VESKSLOP_VAD_THRESHOLD_DB ?? DEFAULT_VAD.thresholdDb),
minSpeechMs: Number(process.env.VESKSLOP_VAD_MIN_SPEECH_MS ?? DEFAULT_VAD.minSpeechMs),
silenceMs: Number(process.env.VESKSLOP_VAD_SILENCE_MS ?? DEFAULT_VAD.silenceMs),
preRollMs: Number(process.env.VESKSLOP_VAD_PRE_ROLL_MS ?? DEFAULT_VAD.preRollMs),
maxSegmentMs: Number(process.env.VESKSLOP_VAD_MAX_SEGMENT_MS ?? DEFAULT_VAD.maxSegmentMs)
},
getSelfId: async () => (core ? await core.getSelfId() : undefined),
getVoiceContext: async () => {
if (!core) return {};
const state = (await core.voiceGet()) as { guildId?: string | null; channelId?: string | null };
return { guildId: state?.guildId ?? null, channelId: state?.channelId ?? null };
},
getSpeakerName: async userId => {
if (!core) return undefined;
const state = (await core.voiceGet(userId)) as {
members?: Array<{ id: string; username?: string }>;
};
return state?.members?.find(member => member.id === userId)?.username;
},
onTranscript: event => webui.broadcast(event)
});
audio.setDuckEnabled(preferences.get().audio?.duck !== false);
void audio.init();
/** Point the client at the virtual microphone and capture sink when present. */
async function autoConnectAudio(client: CoreClient): Promise<{ connected: boolean; message: string }> {
if (process.env.VESKSLOP_AUDIO_AUTOCONNECT === "0") {
return { connected: false, message: "auto-connect is disabled" };
}
const status = audio.status();
if (!status.available) {
return { connected: false, message: status.hint ?? status.error ?? "audio is not available" };
}
try {
await audio.manager.ensureDevices();
const devices = (await client.voiceDevices()) as {
input?: Array<{ id: string; label: string }>;
output?: Array<{ id: string; label: string }>;
};
const input = devices.input?.find(device => device.label.includes("VeskslopMic"));
const output = devices.output?.find(device => device.label.includes("VeskslopCapture"));
if (!input && !output) {
return { connected: false, message: "the client does not see the virtual devices yet" };
}
const result = await client.voiceSetDevices(input?.id, output?.id);
console.log("[audio] client devices set:", JSON.stringify(result));
return { connected: true, message: "the client now uses the virtual devices" };
} catch (error) {
return { connected: false, message: (error as Error).message };
}
}
/**
* The bridge socket is up before the renderer finishes loading, so the first
* device calls can fail with "core is not ready" style errors. Retry.
*/
async function autoConnectAudioWithRetry(client: CoreClient): Promise<void> {
for (let attempt = 1; attempt <= 10; attempt++) {
if (attempt > 1) await sleep(2500);
const result = await autoConnectAudio(client);
if (result.connected) return;
console.warn(`[audio] device auto-connect attempt ${attempt}: ${result.message}`);
}
}
const auth = new AuthService(tokenFile, update => {
webui.broadcast({ type: "AUTH_QR", update });
if (update.state === "finished" && coreProcess.status().running) {
setTimeout(() => {
void coreProcess.restart(auth.consumeFreshLogin()).catch(error => {
console.error("[core] restart after login failed:", error);
});
}, 500);
}
});
const webui = startWebUi({
getCore: () => core,
getPuppet: () => puppet,
isCoreConnected: () => coreConnected,
coreProcess,
auth,
audio,
transcripts,
preferences,
media,
restartDaemon,
connectAudioDevices: () => autoConnectAudio(requireCore())
});
coreProcess.onExit(() => {
webui.broadcast({ type: "CORE_STATUS", coreConnected, process: coreProcess.status() });
});
function jsonText(value: unknown) {
return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] };
}
/** Read-only tools that the batch tool can run. Mutating tools stay out. */
const READ_ONLY_TOOLS: Record<string, (args: any) => Promise<unknown> | unknown> = {
get_state: () => requireCore().getState(),
get_voice_state: args => requireCore().voiceGet(args?.userId, args?.guildId),
get_voice_overview: args => requireCore().voiceOverview(args?.guildId),
search_users: args => requireCore().userSearch(args?.query, args?.guildId, args?.limit),
list_dms: args => requireCore().dmList(args?.limit),
get_audio_status: () => audio.status(),
list_audio_devices: () => requireCore().voiceDevices(),
get_transcript: args => transcripts.readSegments(args?.limit ?? 100, args?.sessionId),
get_context: args =>
requirePuppet().getContext(args?.channelId, {
maxMessages: args?.maxMessages,
maxChars: args?.maxChars,
refresh: args?.refresh
}),
get_message_log: () => requirePuppet().getLog(),
get_action_log: args => requirePuppet().readActionLog(args?.limit ?? 100),
get_puppet_config: () => requirePuppet().getConfig(),
whitelist_list: () => ({ ownerId: requirePuppet().owner, whitelist: requirePuppet().whitelist }),
read_messages: args => paced(() => requireCore().readMessages(args?.channelId, args?.limit ?? 20)),
list_media_scrapes: () => media.listScrapes(),
get_media_storage: () => media.refreshRetention(),
query_media: args =>
media.queryMedia({
kind: args?.kind,
scrapeId: args?.scrapeId,
limit: args?.limit,
offset: args?.offset
})
};
function buildServer(): McpServer {
const server = new McpServer({ name: "veskslop-control", version: "0.0.0" });
server.registerTool(
"get_state",
{
description: "Get the logged-in user, guilds and channels. Cache-only, no Discord queries."
},
async () => jsonText(await requireCore().getState())
);
server.registerTool(
"send_message",
{
description: "Send a message as the logged-in user through the real client. Query-paced.",
inputSchema: {
channelId: z.string().describe("Target channel ID"),
content: z.string().min(1).describe("Message content")
}
},
async ({ channelId, content }) => {
await pacedSend(() => requireCore().sendMessage(channelId, content));
return jsonText({ channelId, sent: true, queriesUsed: getQueryCount() });
}
);
server.registerTool(
"send_dm",
{
description: "Send a DM as the logged-in user. Sanctioned live-test target: user zzib.",
inputSchema: {
userId: z.string().describe("Recipient user ID"),
content: z.string().min(1).describe("Message content")
}
},
async ({ userId, content }) => {
const result = await pacedSend(() => requireCore().sendDm(userId, content));
return jsonText({ ...result, userId, queriesUsed: getQueryCount() });
}
);
server.registerTool(
"read_messages",
{
description: "Read messages from the client's cache. Identical concurrent reads are coalesced.",
inputSchema: {
channelId: z.string().describe("Target channel ID"),
limit: z.number().int().min(1).max(100).default(20).describe("How many messages")
}
},
async ({ channelId, limit }) => {
const result = await dedupe<{ cached: number; messages: unknown[] }>(`read:${channelId}:${limit}`, () =>
paced(() => requireCore().readMessages(channelId, limit))
);
return jsonText({ ...result, channelId, queriesUsed: getQueryCount() });
}
);
server.registerTool(
"get_message_log",
{
description: "Read the trigger log: last 100 messages from trusted users, last 30 from untrusted."
},
async () => jsonText(requirePuppet().getLog())
);
server.registerTool(
"get_context",
{
description:
"Assemble recent conversation context for a channel. Fetches new messages incrementally by default.",
inputSchema: {
channelId: z.string().describe("Channel ID"),
maxMessages: z.number().int().min(1).max(50).optional().describe("Message limit"),
maxChars: z.number().int().min(200).max(20000).optional().describe("Character limit"),
refresh: z.boolean().optional().describe("Fetch new messages first. Defaults to true.")
}
},
async ({ channelId, maxMessages, maxChars, refresh }) =>
jsonText(await requirePuppet().getContext(channelId, { maxMessages, maxChars, refresh }))
);
server.registerTool(
"get_puppet_config",
{
description: "Read the response policy and context settings."
},
async () => jsonText(requirePuppet().getConfig())
);
server.registerTool(
"set_puppet_config",
{
description: "Update the response policy and context settings. Sensitive commands reply by DM.",
inputSchema: {
config: z.object({
contextMessages: z.number().int().min(1).max(50).optional(),
contextChars: z.number().int().min(200).max(20000).optional(),
fetchCooldownSeconds: z.number().int().min(0).max(300).optional(),
continuationSeconds: z.number().int().min(0).max(300).optional(),
pollSeconds: z.number().int().min(10).max(3600).optional(),
voiceLeaveDelaySeconds: z.number().int().min(0).max(600).optional(),
responsePolicy: z
.object({
defaultMode: z.enum(["channel", "dm"]).optional(),
sensitiveCommands: z.array(z.string()).optional(),
overrides: z.record(z.string(), z.enum(["channel", "dm"])).optional()
})
.optional()
})
}
},
async ({ config }) => jsonText(requirePuppet().updateConfig(config))
);
server.registerTool(
"get_voice_state",
{
description: "Which voice channel a user is in and who else is there. Defaults to the logged-in user.",
inputSchema: {
userId: z.string().optional().describe("User ID. Defaults to the logged-in user."),
guildId: z.string().optional().describe("Guild ID. Omit to search all guilds.")
}
},
async ({ userId, guildId }) => jsonText(await requireCore().voiceGet(userId, guildId))
);
server.registerTool(
"get_voice_overview",
{
description: "List the occupied voice channels in a guild and their members.",
inputSchema: {
guildId: z.string().describe("Guild ID")
}
},
async ({ guildId }) => jsonText(await requireCore().voiceOverview(guildId))
);
server.registerTool(
"voice_join",
{
description: "Join a voice channel as the logged-in user. Joins muted by default.",
inputSchema: {
channelId: z.string().describe("Voice channel ID"),
mute: z.boolean().optional().describe("Join muted. Defaults to true.")
}
},
async ({ channelId, mute }) =>
jsonText(await pacedVoice(() => requireCore().voiceJoin(channelId, mute ?? true)))
);
server.registerTool(
"voice_leave",
{
description: "Leave the current voice channel."
},
async () => jsonText(await pacedVoice(() => requireCore().voiceLeave()))
);
server.registerTool(
"voice_move",
{
description: "Move the client to another voice channel.",
inputSchema: {
channelId: z.string().describe("Channel ID")
}
},
async ({ channelId }) => jsonText(await pacedVoice(() => requireCore().voiceMove(channelId)))
);
server.registerTool(
"get_user_volume",
{
description:
"Per-user playback volume (0 to 200, 100 is default) and the client's own output volume. A volume of 0 silences that user.",
inputSchema: {
userIds: z.array(z.string()).min(1).max(50).describe("User IDs to look up")
}
},
async ({ userIds }) => jsonText(await requireCore().userVolume(userIds))
);
server.registerTool(
"set_user_volume",
{
description: "Set a user's playback volume. 0 silences the user; 100 is default; 200 is the maximum.",
inputSchema: {
userId: z.string().describe("User ID"),
volume: z.number().int().min(0).max(200).describe("Volume from 0 to 200")
}
},
async ({ userId, volume }) => jsonText(await pacedVoice(() => requireCore().userSetVolume(userId, volume)))
);
server.registerTool(
"get_audio_status",
{
description: "Audio stack, virtual devices, TTS providers, queue, ducking, and capture state."
},
async () => jsonText(audio.status())
);
server.registerTool(
"speak",
{
description: "Queue text to speech into the bot's virtual microphone.",
inputSchema: {
text: z.string().min(1).max(1000).describe("Text to speak"),
provider: z
.enum(["local", "espeak", "command", "api"])
.optional()
.describe("TTS engine: command is the configured VESKSLOP_TTS_CMD engine"),
voice: z.string().optional().describe("Voice name for the provider"),
speed: z.number().min(0.5).max(2).optional().describe("Rate multiplier, 1.0 is natural"),
pitch: z.number().min(-12).max(12).optional().describe("Pitch offset in semitones"),
expression: z
.number()
.min(0)
.max(100)
.optional()
.describe("Flextalk Expression profile value (Keith Bell, Kerry Watson)"),
profile: z
.record(z.string(), z.number())
.optional()
.describe("Flextalk profile overrides (pitch, rate, prominences, head_size, lowering)"),
highpass: z.number().min(40).max(400).optional().describe("High-pass filter Hz"),
echo: z.number().min(0).max(100).optional().describe("Mono echo amount"),
words: z.boolean().optional().describe("Synthesize each word and merge with gaps"),
wordGapMs: z.number().min(0).max(500).optional().describe("Silence between words in ms")
}
},
async ({ text, provider, voice, speed, pitch, expression, profile, highpass, echo, words, wordGapMs }) =>
jsonText(
await audio.speak(text, {
provider,
voice,
speed,
pitch,
expression,
profile,
highpass,
echo,
words,
wordGapMs
})
)
);
server.registerTool(
"start_tts_engine",
{
description: "Start (warm up) the TTS engine so the first spoken line is fast.",
inputSchema: {
engine: z
.enum(["local", "espeak", "command", "api"])
.optional()
.describe("Engine to start. Defaults to the configured one.")
}
},
async ({ engine }) => jsonText(await audio.warmTts(engine))
);
server.registerTool(
"stop_audio",
{
description: "Stop speech and clear the queue."
},
async () => {
audio.stop();
return jsonText(audio.status());
}
);
server.registerTool(
"start_capture",
{
description: "Record the client output sink to a WAV file on the host."
},
async () => jsonText(await audio.startCapture())
);
server.registerTool(
"stop_capture",
{
description: "Stop the current capture."
},
async () => {
audio.stopCapture();
return jsonText(audio.status());
}
);
server.registerTool(
"list_audio_devices",
{
description: "List the client's input and output audio devices."
},
async () => jsonText(await requireCore().voiceDevices())
);
server.registerTool(
"set_audio_devices",
{
description: "Set the client's input and output audio devices.",
inputSchema: {
inputDeviceId: z.string().optional().describe("Input device ID"),
outputDeviceId: z.string().optional().describe("Output device ID")
}
},
async ({ inputDeviceId, outputDeviceId }) =>
jsonText(await requireCore().voiceSetDevices(inputDeviceId, outputDeviceId))
);
server.registerTool(
"search_users",
{
description: "Search users in the client cache by username, global name, guild nickname, or user ID.",
inputSchema: {
query: z.string().min(1).describe("Username, global name, nickname, or user ID"),
guildId: z.string().optional().describe("Limit the search to one guild"),
limit: z.number().int().min(1).max(25).default(10).describe("Max results")
}
},
async ({ query, guildId, limit }) => jsonText(await requireCore().userSearch(query, guildId, limit))
);
server.registerTool(
"list_dms",
{
description: "List the cached DM and group DM channels with their recipients.",
inputSchema: {
limit: z.number().int().min(1).max(50).default(25).describe("Max channels")
}
},
async ({ limit }) => jsonText(await requireCore().dmList(limit))
);
server.registerTool(
"get_action_log",
{
description: "Read the persistent action log: triggers, commands, replies, and errors.",
inputSchema: {
limit: z.number().int().min(1).max(500).default(100).describe("How many entries")
}
},
async ({ limit }) => jsonText(requirePuppet().readActionLog(limit))
);
server.registerTool(
"get_transcript",
{
description: "Read captured call segments: time, speaker, WAV path, and text when STT is configured.",
inputSchema: {
limit: z.number().int().min(1).max(500).default(100).describe("How many segments"),
sessionId: z.string().optional().describe("Only this capture session")
}
},
async ({ limit, sessionId }) => jsonText(transcripts.readSegments(limit, sessionId))
);
server.registerTool(
"run_commands",
{
description: "Run several read-only commands in one call, in order.",
inputSchema: {
commands: z
.array(
z.object({
tool: z.string().describe("Read-only tool name"),
arguments: z.record(z.string(), z.any()).optional()
})
)
.min(1)
.max(10)
}
},
async ({ commands }) => {
const results: Array<Record<string, unknown>> = [];
for (const item of commands) {
const handler = READ_ONLY_TOOLS[item.tool];
if (!handler) {
results.push({ tool: item.tool, ok: false, error: "unknown or non-read-only tool" });
continue;
}
try {
results.push({ tool: item.tool, ok: true, result: await handler((item.arguments ?? {}) as any) });
} catch (error) {
results.push({ tool: item.tool, ok: false, error: String((error as Error).message) });
}
}
return jsonText({ results });
}
);
server.registerTool(
"whitelist_list",
{
description: "List the puppet owner and trusted users with their tiers."
},
async () => jsonText({ ownerId: requirePuppet().owner, whitelist: requirePuppet().whitelist })
);
server.registerTool(
"whitelist_add",
{
description: "Add users to the puppet whitelist with an optional tier. Defaults to 'user'.",
inputSchema: {
userIds: z.array(z.string()).min(1).describe("User IDs to trust"),
tier: z.enum(["user", "trusted", "admin"]).optional().describe("Trust tier")
}
},
async ({ userIds, tier }) =>
jsonText({ whitelist: requirePuppet().addToWhitelist(userIds, tier ?? "user") })
);
server.registerTool(
"whitelist_remove",
{
description: "Remove users from the puppet whitelist. The owner cannot be removed.",
inputSchema: {
userIds: z.array(z.string()).min(1).describe("User IDs to untrust")
}
},
async ({ userIds }) => jsonText({ whitelist: requirePuppet().removeFromWhitelist(userIds) })
);
server.registerTool(
"scrape_media",
{
description:
"Start a media scrape of a channel. Uses slopbot for guild channels it is in, otherwise the client. Near the storage limit it fails with MEDIA_LIMIT unless confirm is true.",
inputSchema: {
channelId: z.string().describe("Channel ID to scrape"),
guildId: z.string().optional().describe("Guild ID; slopbot needs it to match"),
mode: z
.enum(["links", "download"])
.optional()
.describe("links indexes URLs (default); download fetches attachment bytes"),
backend: z
.enum(["auto", "slopbot", "puppet"])
.optional()
.describe("auto (default) picks slopbot for its guilds, the client otherwise"),
confirm: z.boolean().optional().describe("Accept the near-limit warning"),
downloadDir: z
.string()
.optional()
.describe("Absolute destination for downloads; previews stay local"),
after: z.string().optional().describe("From-date ISO; the walk stops below it"),
before: z.string().optional().describe("To-date ISO; the walk starts just below it")
}
},
async ({ channelId, guildId, mode, backend, confirm, downloadDir, after, before }) =>
jsonText(
await media.startScrape({
channelId,
guildId: guildId ?? null,
mode,
backend,
confirm,
downloadDir: downloadDir ?? null,
after: after ?? null,
before: before ?? null
})
)
);
server.registerTool(
"count_media_messages",
{
description:
"Discord search total for a channel or date range. Guild channels with slopbot only; DMs and group chats are not available.",
inputSchema: {
channelId: z.string(),
guildId: z.string().optional(),
after: z.string().optional().describe("From-date ISO"),
before: z.string().optional().describe("To-date ISO")
}
},
async ({ channelId, guildId, after, before }) =>
jsonText(
await media.countMessages({
channelId,
guildId: guildId ?? null,
after: after ?? null,
before: before ?? null
})
)
);
server.registerTool(
"list_media_scrapes",
{ description: "List media scrapes with state, pages, messages, and media counts." },
async () => jsonText(media.listScrapes())
);
server.registerTool(
"get_media_scrape",
{
description: "Get one media scrape by ID.",
inputSchema: { scrapeId: z.string().describe("Scrape ID") }
},
async ({ scrapeId }) => jsonText(media.getScrape(scrapeId))
);
server.registerTool(
"resume_media_scrape",
{
description: "Continue a paused media scrape from its cursor.",
inputSchema: { scrapeId: z.string().describe("Scrape ID") }
},
async ({ scrapeId }) => jsonText(await media.resumeScrape(scrapeId))
);
server.registerTool(
"stop_media_scrape",
{
description: "Stop a media scrape. It pauses after the current page.",
inputSchema: { scrapeId: z.string().describe("Scrape ID") }
},
async ({ scrapeId }) => {
media.stopScrape(scrapeId);
return jsonText({ stopped: scrapeId });
}
);
server.registerTool(
"get_media_storage",
{
description:
"Media storage usage against the limit, per-kind sizes, and the scrape list. Marks old scrapes when over the limit."
},
async () => jsonText(media.refreshRetention())
);
server.registerTool(
"query_media",
{
description: "Query media items across scrapes, newest scrape first.",
inputSchema: {
kind: z.enum(["image", "gif", "video", "audio"]).optional(),
source: z
.enum(["attachment", "sticker", "embed"])
.optional()
.describe("Where the item came from"),
scrapeId: z.string().optional().describe("Restrict to one scrape"),
channelId: z.string().optional().describe("Restrict to one source channel"),
authorIds: z.array(z.string()).optional().describe("Restrict to these posters"),
origin: z.enum(["dm", "guild"]).optional().describe("Server channels or DMs"),
cachedOnly: z.boolean().optional().describe("Only items with a cached preview"),
exts: z.array(z.string()).optional().describe("Only these file extensions (mp4, webm, png, ...)"),
excludeHosts: z.array(z.string()).optional().describe("Hide URLs from these hosts"),
aspect: z
.enum(["vertical", "square", "wide43", "wide169", "ultrawide", "other"])
.optional()
.describe("Aspect-ratio bucket"),
resolution: z
.enum(["sd", "hd", "fhd", "qhd", "uhd", "unknown"])
.optional()
.describe("Resolution bucket by shortest side"),
after: z.string().optional().describe("Only items posted at or after this ISO time"),
before: z.string().optional().describe("Only items posted at or before this ISO time"),
limit: z.number().int().min(1).max(500).optional(),
offset: z.number().int().min(0).optional()
}
},
async ({
kind,
source,
scrapeId,
channelId,
authorIds,
origin,
cachedOnly,
exts,
excludeHosts,
aspect,
resolution,
after,
before,
limit,
offset
}) =>
jsonText(
media.queryMedia({
kind,
source,
scrapeId,
channelId,
authorIds,
origin,
cachedOnly,
exts,
excludeHosts,
aspect,
resolution,
after,
before,
limit,
offset
})
)
);
server.registerTool(
"get_media_item",
{
description: "Get one media item by scrape ID and item ID.",
inputSchema: {
scrapeId: z.string().describe("Scrape ID"),
itemId: z.number().int().min(1).describe("Media row ID inside the scrape")
}
},
async ({ scrapeId, itemId }) => jsonText(media.getMediaItem(scrapeId, itemId))
);
server.registerTool(
"delete_media_scrape",
{
description: "Delete a scrape and its files. Irreversible.",
inputSchema: { scrapeId: z.string().describe("Scrape ID") }
},
async ({ scrapeId }) => jsonText({ deleted: media.cleanMedia([scrapeId]) === 1 })
);
return server;
}
void connectLoop();
const app = createMcpExpressApp({ host: HOST });
app.post("/mcp", async (req, res) => {
const server = buildServer();
try {
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
res.on("close", () => {
transport.close();
server.close();
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
} catch (error) {
console.error("Error handling MCP request:", error);
if (!res.headersSent) {
res.status(500).json({
jsonrpc: "2.0",
error: { code: -32603, message: "Internal server error" },
id: null
});
}
}
});
app.get("/mcp", (_req, res) => {
res.writeHead(405).end(
JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message: "Method not allowed." }, id: null })
);
});
app.delete("/mcp", (_req, res) => {
res.writeHead(405).end(
JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message: "Method not allowed." }, id: null })
);
});
app.get("/health", async (_req, res) => {
let selfId = puppet?.self;
if (!selfId && core) {
try {
selfId = await core.getSelfId();
} catch {
// The core may not be ready yet.
}
}
res.json({
ok: true,
coreConnected,
socketPath: coreSocketPath,
ownerId: puppet?.owner,
selfId,
queries: getQueryCount()
});
});
app.listen(PORT, HOST, (error?: Error) => {
if (error) {
console.error("Failed to start control bridge:", error);
process.exit(1);
}
console.log(`[daemon] MCP listening on http://${HOST}:${PORT}/mcp`);
const stale = staleBundleWarning();
if (stale) console.warn(`[daemon] ${stale}`);
});
function shutdown(keepCore = false) {
stopSpeakingPoll();
core?.close();
const tasks: Array<Promise<unknown>> = [audio.dispose()];
if (!keepCore) tasks.push(coreProcess.dispose(4000));
void Promise.allSettled(tasks).finally(() => process.exit(0));
}
/** Replace this daemon with a fresh copy, leaving the client running. */
let restartingDaemon = false;
function restartDaemon(): void {
if (restartingDaemon) return;
restartingDaemon = true;
const quote = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`;
const args = [...process.execArgv, ...process.argv.slice(1)];
const command = `${quote(process.execPath)} ${args.map(quote).join(" ")}`;
const logPath = process.env.VESKSLOP_DAEMON_LOG ?? join(dirname(tokenFile), "daemon.log");
console.log(`[daemon] restart requested: spawning a replacement, appending to ${logPath}`);
const logFd = openSync(logPath, "a");
const child = spawn("sh", ["-c", `sleep 2; exec ${command}`], {
detached: true,
stdio: ["ignore", logFd, logFd],
cwd: process.cwd(),
env: { ...process.env, VESKSLOP_DAEMON_RESTART: "1" }
});
child.unref();
closeSync(logFd);
setTimeout(() => shutdown(true), 300);
}
process.on("SIGINT", () => shutdown());
process.on("SIGTERM", () => shutdown());