Add wakeword command routing and the reply-only agent client

This commit is contained in:
Fisto
2026-09-22 09:23:11 +02:00
parent a4d61781e9
commit d99d614c60
14 changed files with 399 additions and 34 deletions
+58
View File
@@ -26,6 +26,7 @@ const state = {
visibleGuildsSaved: null,
timeZoneOverride: localStorage.getItem("veskslop-timezone") ?? "",
speech: { provider: null, language: null, voice: null },
commands: { wakeword: "!s" },
profileDefaults: {},
expressionDefault: null,
scope: "guild",
@@ -1788,6 +1789,45 @@ async function refreshAudio() {
await refreshSpeech();
}
/* Command wakeword (Settings -> Commands). */
const WAKEWORD_CHOICES = ["!s", "!x", "!c", "!z", "!q", "!e", "!r", "!f", "!v", "!t", "!g", "!b"];
function fillWakewordControls() {
const wakeword = state.commands?.wakeword || "!s";
const select = $("#wakeword-select");
const customRow = $("#wakeword-custom-row");
if (!select || !customRow) return;
if (WAKEWORD_CHOICES.includes(wakeword)) {
select.value = wakeword;
customRow.hidden = true;
} else {
select.value = "custom";
$("#wakeword-custom").value = wakeword;
customRow.hidden = false;
}
$("#wakeword-status").textContent = wakeword;
}
async function saveWakeword(value) {
const wakeword = String(value ?? "").trim();
if (!wakeword || wakeword.length > 8 || /\s/.test(wakeword)) {
toast("the wakeword must be 1-8 characters with no spaces", "error");
fillWakewordControls();
return;
}
try {
const prefs = await VK.api.post("/api/preferences", { commands: { wakeword } });
state.commands = prefs.commands ?? { wakeword };
fillWakewordControls();
} catch (error) {
toast(`wakeword save failed: ${error.message}`, "error");
fillWakewordControls();
}
}
/* Discord voice processing gates (Settings -> TTS panel). */
function fillVoiceGates(voice) {
const gates = voice ?? {};
@@ -1846,7 +1886,9 @@ async function refreshSpeech() {
try {
const prefs = await VK.api.get("/api/preferences");
state.speech = prefs.tts ?? { provider: null, language: null, voice: null, speed: null, pitch: null };
state.commands = prefs.commands ?? { wakeword: "!s" };
fillVoiceGates(prefs.voice);
fillWakewordControls();
} catch {
// preferences are optional
}
@@ -2764,6 +2806,22 @@ function init() {
$(id)?.addEventListener("change", () => void saveVoiceGates());
}
$("#wakeword-select")?.addEventListener("change", () => {
const value = $("#wakeword-select").value;
if (value === "custom") {
$("#wakeword-custom").value = WAKEWORD_CHOICES.includes(state.commands?.wakeword)
? "!"
: state.commands?.wakeword || "!";
$("#wakeword-custom-row").hidden = false;
$("#wakeword-custom").focus();
return;
}
void saveWakeword(value);
});
$("#wakeword-custom")?.addEventListener("change", () => void saveWakeword($("#wakeword-custom").value));
$("#wakeword-save")?.addEventListener("click", () => void saveWakeword($("#wakeword-custom").value));
$("#tts-duck").addEventListener("click", async () => {
const on = !$("#tts-duck").classList.contains("vk-chip-on");
try {
+36
View File
@@ -416,6 +416,42 @@
</div>
</section>
<section class="vk-settings-section">
<div class="vk-section-head">
<span class="vk-rail-label vk-rail-label-flat">Commands</span>
<span class="vk-mono vk-dim" id="wakeword-status">!s</span>
</div>
<div class="vk-settings-card">
<div class="vk-status-row">
<span class="vk-status-label">Wakeword</span>
<select class="vk-select" id="wakeword-select">
<option value="!s">!s</option>
<option value="!x">!x</option>
<option value="!c">!c</option>
<option value="!z">!z</option>
<option value="!q">!q</option>
<option value="!e">!e</option>
<option value="!r">!r</option>
<option value="!f">!f</option>
<option value="!v">!v</option>
<option value="!t">!t</option>
<option value="!g">!g</option>
<option value="!b">!b</option>
<option value="custom">custom…</option>
</select>
</div>
<div class="vk-status-row" id="wakeword-custom-row" hidden>
<span class="vk-status-label">Custom</span>
<input class="input" id="wakeword-custom" maxlength="8" placeholder="!" />
<button id="wakeword-save" class="btn btn-secondary vk-small-btn" type="button">Save</button>
</div>
<div class="vk-status-row">
<span class="vk-status-label">Rule</span>
<span class="vk-status-value vk-dim">Start of the message, then a space. Example: <code>!s whitelist list</code></span>
</div>
</div>
</section>
<section class="vk-settings-section">
<div class="vk-section-head">
<span class="vk-rail-label vk-rail-label-flat">Display</span>
+88
View File
@@ -0,0 +1,88 @@
/*
* Veskslop agent client — the daemon side of the local LLM harness.
*
* Respond-only for now: the daemon posts a task and the harness returns the
* reply text. The daemon sends that text through the normal paced path, so the
* harness never talks to Discord and no tools are exposed to the model.
*/
export interface AgentTask {
id: string;
authorId: string;
authorName: string;
channelId: string;
guildId?: string;
content: string;
context: Array<{ author: string; content: string; timestamp: string }>;
}
export interface AgentClientOptions {
url: string;
token?: string;
timeoutMs?: number;
}
export class AgentClient {
private readonly base: string;
private readonly timeoutMs: number;
constructor(private readonly options: AgentClientOptions) {
this.base = options.url.replace(/\/+$/, "");
this.timeoutMs = options.timeoutMs ?? 120_000;
}
get url(): string {
return this.base;
}
/**
* Ask the harness for a reply. Returns the reply text, or undefined when
* the harness answers with an empty reply.
*/
async respond(task: AgentTask): Promise<string | undefined> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
try {
const response = await fetch(`${this.base}/task`, {
method: "POST",
headers: {
"content-type": "application/json",
...(this.options.token ? { authorization: `Bearer ${this.options.token}` } : {})
},
// mode: respond tells the harness to answer without tool calls.
body: JSON.stringify({ ...task, mode: "respond" }),
signal: controller.signal
});
if (!response.ok) {
const detail = await response.text().catch(() => "");
throw new Error(`agent returned ${response.status}${detail ? `: ${detail.slice(0, 200)}` : ""}`);
}
const body = (await response.json().catch(() => ({}))) as { reply?: unknown };
const reply = typeof body.reply === "string" ? body.reply.trim() : "";
return reply || undefined;
} finally {
clearTimeout(timer);
}
}
}
/**
* Build the client from the environment. The harness stays off unless
* VESKSLOP_AGENT_ENABLED=1 is set.
*/
export function agentFromEnv(): AgentClient | undefined {
if (process.env.VESKSLOP_AGENT_ENABLED !== "1") return undefined;
const url = process.env.VESKSLOP_AGENT_URL ?? "http://127.0.0.1:8790";
const token = process.env.VESKSLOP_AGENT_TOKEN;
const timeoutMs = Number(process.env.VESKSLOP_AGENT_TIMEOUT_MS ?? 120_000);
return new AgentClient({
url,
...(token ? { token } : {}),
timeoutMs: Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 120_000
});
}
+25 -4
View File
@@ -77,11 +77,32 @@ export interface ParsedCommand {
args: string[];
}
export function parseCommand(text: string): ParsedCommand | undefined {
const trimmed = text.trim();
if (!trimmed) return undefined;
export const DEFAULT_WAKEWORD = "!s";
const [name, ...args] = trimmed.split(/\s+/);
/** Keep the wakeword short and free of whitespace. Bad input falls back. */
export function normalizeWakeword(value: unknown): string {
if (typeof value !== "string") return DEFAULT_WAKEWORD;
const trimmed = value.trim();
if (!trimmed || trimmed.length > 8 || /\s/.test(trimmed)) return DEFAULT_WAKEWORD;
return trimmed;
}
/**
* Parse a command. The message must start with the wakeword followed by a
* space, so `!space` and `hey!s cmd` do not match. The match ignores letter
* case; the returned name is lowercase.
*/
export function parseCommand(text: string, wakeword: string = DEFAULT_WAKEWORD): ParsedCommand | undefined {
const trimmed = text.trim();
const prefix = `${normalizeWakeword(wakeword)} `;
if (trimmed.length <= prefix.length) return undefined;
if (!trimmed.toLowerCase().startsWith(prefix.toLowerCase())) return undefined;
const rest = trimmed.slice(prefix.length).trim();
if (!rest) return undefined;
const [name, ...args] = rest.split(/\s+/);
return { name: name.toLowerCase(), args };
}
+6 -1
View File
@@ -27,6 +27,7 @@ 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 { agentFromEnv } from "./agent-client.js";
import { MediaService } from "./media.js";
import { PreferencesStore } from "./preferences.js";
import { Puppet } from "./puppet.js";
@@ -52,7 +53,10 @@ async function connectLoop(): Promise<void> {
const client = await CoreClient.connect();
core = client;
puppet = new Puppet(client, audio, media);
puppet = new Puppet(client, audio, media, agentFromEnv());
// The web preferences own the wakeword; the puppet's own state holds a copy.
puppet.updateConfig({ wakeword: preferences.get().commands.wakeword });
client.onEvent(event => {
// Speaking hints add to the reconciled set so the poller can
@@ -472,6 +476,7 @@ function buildServer(): McpServer {
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(),
wakeword: z.string().min(1).max(8).optional(),
pollSeconds: z.number().int().min(10).max(3600).optional(),
voiceLeaveDelaySeconds: z.number().int().min(0).max(600).optional(),
responsePolicy: z
+21
View File
@@ -6,6 +6,8 @@
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { DEFAULT_WAKEWORD, normalizeWakeword } from "./communication.js";
import { dirname } from "node:path";
export interface TtsPreferences {
@@ -128,6 +130,12 @@ export interface MediaTunables {
};
}
/** Chat command settings. */
export interface CommandPreferences {
/** A message must start with this string plus a space to be a command. */
wakeword: string;
}
/** Discord's microphone processing gates, plus a TTS bypass. */
export interface VoicePreferences {
noiseSuppression: boolean;
@@ -151,6 +159,7 @@ export interface MediaPreferences {
export interface WebPreferences {
/** null means every guild is visible. An empty array hides all. */
visibleGuilds: string[] | null;
commands: CommandPreferences;
tts: TtsPreferences;
audio: AudioPreferences;
voice: VoicePreferences;
@@ -160,6 +169,9 @@ export interface WebPreferences {
const DEFAULTS: WebPreferences = {
visibleGuilds: null,
commands: {
wakeword: DEFAULT_WAKEWORD
},
tts: {
provider: null,
language: null,
@@ -393,6 +405,12 @@ export class PreferencesStore {
: null;
}
if (patch.commands !== undefined) {
this.preferences.commands = {
wakeword: normalizeWakeword(patch.commands?.wakeword)
};
}
if (patch.tts !== undefined) {
this.preferences.tts = {
provider: normalizeProvider(patch.tts?.provider),
@@ -475,6 +493,9 @@ export class PreferencesStore {
visibleGuilds: Array.isArray(raw?.visibleGuilds)
? [...new Set<string>(raw.visibleGuilds.map((id: unknown) => String(id)))]
: null,
commands: {
wakeword: normalizeWakeword(raw?.commands?.wakeword)
},
tts: {
provider: normalizeProvider(provider),
language: raw?.tts?.language ? String(raw.tts.language).slice(0, 16) : null,
+88 -22
View File
@@ -13,8 +13,10 @@ import { dirname, join } from "node:path";
import { ActionLog } from "./action-log.js";
import {
assembleContext,
DEFAULT_WAKEWORD,
hasTier,
MessageLog,
normalizeWakeword,
parseCommand,
parsePoll,
parseTier,
@@ -50,6 +52,7 @@ export interface PuppetConfig {
contextChars: number;
fetchCooldownSeconds: number;
continuationSeconds: number;
wakeword: string;
pollSeconds: number;
voiceLeaveDelaySeconds: number;
responsePolicy: ResponsePolicy;
@@ -60,6 +63,8 @@ export interface PuppetConfigPatch {
contextChars?: number;
fetchCooldownSeconds?: number;
continuationSeconds?: number;
/** Command prefix: a message must start with it plus a space. */
wakeword?: string;
pollSeconds?: number;
voiceLeaveDelaySeconds?: number;
responsePolicy?: {
@@ -74,6 +79,7 @@ const DEFAULT_CONFIG: PuppetConfig = {
contextChars: 4000,
fetchCooldownSeconds: 5,
continuationSeconds: 10,
wakeword: DEFAULT_WAKEWORD,
pollSeconds: 120,
voiceLeaveDelaySeconds: clamp(Number(process.env.VESKSLOP_VOICE_LEAVE_DELAY ?? 30), 0, 600),
responsePolicy: {
@@ -104,6 +110,22 @@ export interface PuppetTransport {
voiceLeave(): Promise<unknown>;
}
/** A trusted free-form message, for the reply-only agent. */
export interface PuppetAgentRequest {
id: string;
authorId: string;
authorName: string;
channelId: string;
guildId?: string;
content: string;
context: Array<{ author: string; content: string; timestamp: string }>;
}
/** The reply-only LLM harness. It never receives commands and cannot act. */
export interface PuppetAgent {
respond(request: PuppetAgentRequest): Promise<string | undefined>;
}
/** The audio actions a chat command can trigger. */
export interface PuppetAudio {
speak(text: string, options?: { provider?: "local" | "api"; voice?: string }): Promise<unknown>;
@@ -140,23 +162,27 @@ interface PollState {
timer: ReturnType<typeof setTimeout>;
}
const WHITELIST_HELP = [
"whitelist — manage who can use the puppet.",
"Usage:",
" whitelist Show this help",
" whitelist list Show the owner and the trusted users",
" whitelist add @user [tier] Add a user. Tier: user, trusted, admin (default user)",
" whitelist remove @user Remove a user"
].join("\n");
function whitelistHelp(wakeword: string): string {
return [
`${wakeword} whitelist — manage who can use the puppet.`,
"Usage:",
` ${wakeword} whitelist Show this help`,
` ${wakeword} whitelist list Show the owner and the trusted users`,
` ${wakeword} whitelist add @user [tier] Add a user. Tier: user, trusted, admin (default user)`,
` ${wakeword} whitelist remove @user Remove a user`
].join("\n");
}
const SCRAPE_HELP = [
"scrape media — archive this channel's media.",
"Usage:",
" scrape media Index links (default)",
" scrape media download Also download attachment bytes (admin)",
" scrape media status Show this channel's latest scrape",
" scrape media stop Stop it (admin)"
].join("\n");
function scrapeHelp(wakeword: string): string {
return [
`${wakeword} scrape media — archive this channel's media.`,
"Usage:",
` ${wakeword} scrape media Index links (default)`,
` ${wakeword} scrape media download Also download attachment bytes (admin)`,
` ${wakeword} scrape media status Show this channel's latest scrape`,
` ${wakeword} scrape media stop Stop it (admin)`
].join("\n");
}
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
@@ -195,7 +221,8 @@ export class Puppet {
constructor(
private readonly core: PuppetTransport,
private readonly audio?: PuppetAudio,
private readonly media?: PuppetMedia
private readonly media?: PuppetMedia,
private readonly agent?: PuppetAgent
) {
try {
const state = JSON.parse(readFileSync(STATE_FILE, "utf8")) as PersistedState;
@@ -224,6 +251,7 @@ export class Puppet {
0,
300
),
wakeword: normalizeWakeword(state.config.wakeword ?? DEFAULT_CONFIG.wakeword),
pollSeconds: clamp(state.config.pollSeconds ?? DEFAULT_CONFIG.pollSeconds, 10, 3600),
voiceLeaveDelaySeconds: clamp(
state.config.voiceLeaveDelaySeconds ?? DEFAULT_CONFIG.voiceLeaveDelaySeconds,
@@ -277,6 +305,7 @@ export class Puppet {
300
),
continuationSeconds: clamp(patch.continuationSeconds ?? this.config.continuationSeconds, 0, 300),
wakeword: normalizeWakeword(patch.wakeword ?? this.config.wakeword),
pollSeconds: clamp(patch.pollSeconds ?? this.config.pollSeconds, 10, 3600),
voiceLeaveDelaySeconds: clamp(
patch.voiceLeaveDelaySeconds ?? this.config.voiceLeaveDelaySeconds,
@@ -499,7 +528,7 @@ export class Puppet {
}
const text = isDirectDm ? (content ?? "") : stripMentions(content ?? "", new Set([this.selfId]));
const command = parseCommand(text);
const command = parseCommand(text, this.config.wakeword);
const entry: PuppetMessage = {
id: event.id,
@@ -565,7 +594,44 @@ export class Puppet {
} else if (command?.name === "scrape") {
await this.handleScrapeCommand(command, author.id, channelId, guildId, tier);
} else if (POLL_ENABLED && command?.name === "poll") {
await this.handlePollCommand(author.id, channelId, text);
const body = text.trim().slice(this.config.wakeword.length).trim();
await this.handlePollCommand(author.id, channelId, body);
} else {
// No command: the message belongs to the reply-only agent.
void this.handleFreeform(entry, text);
}
}
/**
* Send a trusted free-form message to the agent and reply with its answer.
* Runs detached: the agent can take minutes and must not block other events.
*/
private async handleFreeform(entry: PuppetMessage, text: string) {
if (!this.agent) return;
try {
const reply = await this.agent.respond({
id: entry.id,
authorId: entry.authorId,
authorName: entry.authorName,
channelId: entry.channelId,
...(entry.guildId ? { guildId: entry.guildId } : {}),
content: text,
context: (entry.context ?? []).map(message => ({
author: message.authorName,
content: message.content,
timestamp: message.timestamp
}))
});
if (!reply) {
this.actionLog.append({ type: "agent", messageId: entry.id, result: "empty" });
return;
}
await this.respond(entry.authorId, entry.channelId, "chat", reply);
} catch (error) {
this.actionLog.append({ type: "agent", messageId: entry.id, result: "error", error: String(error) });
}
}
@@ -586,7 +652,7 @@ export class Puppet {
// No arguments or "help" shows the help. No permission needed for help.
if (!sub || sub === "help") {
return this.respond(authorId, channelId, "whitelist", WHITELIST_HELP);
return this.respond(authorId, channelId, "whitelist", whitelistHelp(this.config.wakeword));
}
if (!hasTier(tier, "admin")) {
@@ -655,7 +721,7 @@ export class Puppet {
}
default:
return this.respond(authorId, channelId, "whitelist", WHITELIST_HELP);
return this.respond(authorId, channelId, "whitelist", whitelistHelp(this.config.wakeword));
}
}
@@ -715,7 +781,7 @@ export class Puppet {
const knownSub = sub === undefined || sub === "download" || sub === "status" || sub === "stop";
if (kind !== "media" || sub === "help" || !knownSub) {
return this.respond(authorId, channelId, "scrape", SCRAPE_HELP);
return this.respond(authorId, channelId, "scrape", scrapeHelp(this.config.wakeword));
}
if (!this.media) {
+21 -2
View File
@@ -16,6 +16,8 @@ import {
formatContextLine,
hasTier,
isTrusted,
DEFAULT_WAKEWORD,
normalizeWakeword,
parseCommand,
parsePoll,
parseTier,
@@ -65,11 +67,28 @@ function entry(id: number, trusted: boolean): PuppetMessage {
// commands
{
assert.deepEqual(parseCommand(" WHITELIST add <@123> "), { name: "whitelist", args: ["add", "<@123>"] });
assert.deepEqual(parseCommand("whitelist list"), { name: "whitelist", args: ["list"] });
assert.deepEqual(parseCommand(" !S WHITELIST add <@123> "), { name: "whitelist", args: ["add", "<@123>"] });
assert.deepEqual(parseCommand("!s whitelist list"), { name: "whitelist", args: ["list"] });
assert.deepEqual(parseCommand("?q speak stop", "?q"), { name: "speak", args: ["stop"] });
// The wakeword must sit at the start and a space must follow it.
assert.equal(parseCommand("whitelist list"), undefined);
assert.equal(parseCommand("!space"), undefined);
assert.equal(parseCommand("fuckface!s whitelist list"), undefined);
assert.equal(parseCommand("!s"), undefined);
assert.equal(parseCommand(" "), undefined);
}
// wakeword values
{
assert.equal(normalizeWakeword(" !x "), "!x");
assert.equal(normalizeWakeword("?"), "?");
assert.equal(normalizeWakeword(""), DEFAULT_WAKEWORD);
assert.equal(normalizeWakeword("has space"), DEFAULT_WAKEWORD);
assert.equal(normalizeWakeword("waytoolong"), DEFAULT_WAKEWORD);
assert.equal(normalizeWakeword(undefined), DEFAULT_WAKEWORD);
}
// trust
{
assert.equal(isTrusted("owner", "owner", []), true);
+2 -2
View File
@@ -13,7 +13,7 @@ import net from "node:net";
import { coreSocketPath } from "./core-client.js";
const authorId = process.env.TEST_TRIGGER_AUTHOR ?? "177117296140091393";
const content = process.env.TEST_TRIGGER_CONTENT ?? "whitelist list";
const content = process.env.TEST_TRIGGER_CONTENT ?? "!s whitelist list";
const guildId = process.env.TEST_TRIGGER_GUILD;
const isGuild = !!guildId;
const mention = isGuild && process.env.TEST_TRIGGER_MENTION !== "0";
@@ -44,7 +44,7 @@ const updateEvent = {
id: process.env.TEST_TRIGGER_MESSAGE_ID ?? "400000000000000004",
channelId: process.env.TEST_TRIGGER_CHANNEL ?? "200000000000000001",
guildId: guildId ?? "100000000000000001",
content: content === "whitelist list" ? "edited content" : content,
content: content === "!s whitelist list" ? "edited content" : content,
editedAt: new Date().toISOString(),
author: { id: authorId, username: "zzib", bot: false }
};
+8
View File
@@ -8,6 +8,8 @@
*/
import express, { type Request, type Response } from "express";
import { normalizeWakeword } from "./communication.js";
import { existsSync, readFileSync } from "node:fs";
import { Readable } from "node:stream";
import http from "node:http";
@@ -486,6 +488,7 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
wrap(async (req, res) => {
const {
visibleGuilds,
commands: commandPrefs,
tts,
audio,
voice: voicePrefs,
@@ -556,6 +559,10 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
deps.audio.setDuckEnabled(patch.audio.duck);
}
if (commandPrefs !== undefined) {
patch.commands = { wakeword: normalizeWakeword(commandPrefs?.wakeword) };
}
if (voicePrefs !== undefined) {
patch.voice = {
noiseSuppression: voicePrefs?.noiseSuppression === true,
@@ -568,6 +575,7 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
const updated = deps.preferences.update(patch);
if (mediaPrefs !== undefined) deps.media.applyTunables();
if (commandPrefs !== undefined) deps.getPuppet()?.updateConfig({ wakeword: updated.commands.wakeword });
if (voicePrefs !== undefined) {
const core = deps.getCore();
if (core && deps.isCoreConnected()) {