From d99d614c60ed6961559286a0b45303b1dec9d344 Mon Sep 17 00:00:00 2001 From: Fisto Date: Tue, 22 Sep 2026 09:23:11 +0200 Subject: [PATCH] Add wakeword command routing and the reply-only agent client --- CURRENT.md | 11 ++++ TODO.md | 4 ++ docs/agent.md | 26 +++++++- docs/communication.md | 8 +++ poc/public/app.js | 58 ++++++++++++++++++ poc/public/index.html | 36 +++++++++++ poc/src/agent-client.ts | 88 +++++++++++++++++++++++++++ poc/src/communication.ts | 29 +++++++-- poc/src/control-bridge.ts | 7 ++- poc/src/preferences.ts | 21 +++++++ poc/src/puppet.ts | 110 +++++++++++++++++++++++++++------- poc/src/test-communication.ts | 23 ++++++- poc/src/test-trigger.ts | 4 +- poc/src/webui.ts | 8 +++ 14 files changed, 399 insertions(+), 34 deletions(-) create mode 100644 poc/src/agent-client.ts diff --git a/CURRENT.md b/CURRENT.md index ab2b136..5bc848f 100644 --- a/CURRENT.md +++ b/CURRENT.md @@ -40,6 +40,17 @@ mobile draft at 390px. - [x] C11 preview caching: Discord-hosted image/GIF previews (WebP 320px, hash-addressed, 5 MiB cap, one attempt) run after every walk in both modes. Live run `...-20260916090722`: `previewed: 2`; thumbs served over `/api/media///thumb` (200). + - [x] C75 Wakeword routing and the reply-only agent path. Commands now need the wakeword plus a + space at the start of the message: default `!s`, so `!s whitelist list`. `!space`, + `hey!s cmd`, and bare commands no longer trigger. Settings → Commands picks a preset (`!s !x + !c !z !q !e !r !f !v !t !g !b`) or a custom 1-8 character string with no spaces. The + preferences group is `commands.wakeword`; the web handler applies it to the puppet live, and + the daemon re-applies it on every core connect. Trusted free-form messages now go to + `AgentClient` (`poc/src/agent-client.ts`) as respond-only tasks (`mode: "respond"`); the + harness returns `{reply}` and the daemon sends it through the normal paced path, so records + and the response policy apply. The Go harness still needs the respond-only branch and there is + no Go toolchain on this host. `test:trigger` is stale (the core rejects its `debug.emit` + action) — see TODO.md. - [x] C74 Nocturne fidelity pass from the third party. Integrated the applied tree from `Debug WebUI mockups(1).zip`: `tokens-status.css` (danger/warn/ok roles and the data scale), `style-overrides.css`, `settings-popups.css`, `polish.js`, the Phosphor diff --git a/TODO.md b/TODO.md index 6e82aa5..69489b6 100644 --- a/TODO.md +++ b/TODO.md @@ -92,3 +92,7 @@ Ordered after the voice backend unless the owner asks first. buffer exists but receives nothing. Decide whether to log untrusted DMs and mentions again. - **Privacy pass.** Only token-shaped and `mfa.` strings are redacted, and only in the persistent action log. Decide the fuller policy (retention, cache clearing, message redaction) later. + +- Update `poc/src/test-trigger.ts`: the core bridge rejects its `debug.emit` action (`UNKNOWN_ACTION`), so the live trigger test cannot run. +- Install the Go toolchain (owner action, no sudo here) so `agent/` can build, and add the respond-only `mode` branch plus the `{reply}` response to the harness. +- Add a fake-server test for `AgentClient` (respond-only task push, timeout, error path). diff --git a/docs/agent.md b/docs/agent.md index 198c5ff..c433930 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -33,8 +33,23 @@ double replies and keeps privileged actions out of model reach. | Auth | `VESKSLOP_AGENT_TOKEN` bearer token on the task endpoint. | | Budget | `VESKSLOP_AGENT_MAX_STEPS` (default 16). Per-task wall clock: 5 minutes. | -The harness receives tasks on `POST /task`. The daemon will push them (not implemented yet; the -scaffold accepts manual pushes). The task body: +The harness receives tasks on `POST /task`. The daemon pushes them when `VESKSLOP_AGENT_ENABLED=1` +is set: a trusted free-form message (no wakeword) becomes a task, and the harness returns the reply +text. The daemon sends that text through the normal paced path, so the action log and the response +policy still apply. + +**Respond-only mode.** The daemon sends `"mode": "respond"` in the task body. The harness must +answer with one completion and no tool calls, and it must return `{"reply": "..."}` in the HTTP +response. Tool calling waits for a later stage. + +| Variable | Meaning | +| --- | --- | +| `VESKSLOP_AGENT_ENABLED=1` | Turn the daemon-side push on. Default off. | +| `VESKSLOP_AGENT_URL` | Harness base URL. Default `http://127.0.0.1:8790`. | +| `VESKSLOP_AGENT_TOKEN` | Optional bearer token for `POST /task`. | +| `VESKSLOP_AGENT_TIMEOUT_MS` | Request timeout. Default 120000. | + +The task body: ```json { @@ -74,7 +89,12 @@ binds localhost. Scaffold implemented in `agent/` and smoke-tested on 2026-09-14: it loaded 28 tools from the daemon, ran one ReAct tool round against a fake OpenAI-compatible endpoint, and produced the final -message. Daemon push, tests, and the remote notification tools are pending. +message. + +The daemon side is in place (2026-09-22): the wakeword separates commands from chat, and +`AgentClient` posts free-form tasks in respond-only mode. The Go harness still needs the +respond-only `mode` branch and the reply response. The Go toolchain is not installed on the +current host, so the harness change waits for it. Remote notification tools are still pending. ## Run the scaffold diff --git a/docs/communication.md b/docs/communication.md index a0cfb89..5a35470 100644 --- a/docs/communication.md +++ b/docs/communication.md @@ -33,6 +33,14 @@ The owner controls the trust list. Commands from other users do not change the l The owner sends commands from Discord. In a DM, the text is the command. In a guild or a group DM, mention the puppet first. +Every command starts with the **wakeword** and a space: the default is `!s`, so +`!s whitelist list`. The wakeword must sit at the start of the message, and a space must follow it. +`whitelist list` alone, `!space`, and `hey!s whitelist list` are not commands. Settings → Commands +changes the wakeword: presets near the WASD keys plus `s`, or a custom string of 1-8 characters +with no spaces (the `!` prefill can become any symbol or letter). The match ignores letter case. + +A trusted message without the wakeword goes to the reply-only agent as free chat. + | Command | Effect | | --- | --- | | `whitelist list` | Show the owner and the trusted users with their tiers. | diff --git a/poc/public/app.js b/poc/public/app.js index 19aa924..2acb678 100644 --- a/poc/public/app.js +++ b/poc/public/app.js @@ -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 { diff --git a/poc/public/index.html b/poc/public/index.html index 79e4a7b..73690b6 100644 --- a/poc/public/index.html +++ b/poc/public/index.html @@ -416,6 +416,42 @@ +
+
+ Commands + !s +
+
+
+ Wakeword + +
+ +
+ Rule + Start of the message, then a space. Example: !s whitelist list +
+
+
+
Display diff --git a/poc/src/agent-client.ts b/poc/src/agent-client.ts new file mode 100644 index 0000000..271fe84 --- /dev/null +++ b/poc/src/agent-client.ts @@ -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 { + 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 + }); +} diff --git a/poc/src/communication.ts b/poc/src/communication.ts index 744f9e6..eec4e99 100644 --- a/poc/src/communication.ts +++ b/poc/src/communication.ts @@ -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 }; } diff --git a/poc/src/control-bridge.ts b/poc/src/control-bridge.ts index c9a4223..2844bcf 100644 --- a/poc/src/control-bridge.ts +++ b/poc/src/control-bridge.ts @@ -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 { 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 diff --git a/poc/src/preferences.ts b/poc/src/preferences.ts index c53cf03..0676dd3 100644 --- a/poc/src/preferences.ts +++ b/poc/src/preferences.ts @@ -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(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, diff --git a/poc/src/puppet.ts b/poc/src/puppet.ts index a9637a1..64029ad 100644 --- a/poc/src/puppet.ts +++ b/poc/src/puppet.ts @@ -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; } +/** 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; +} + /** The audio actions a chat command can trigger. */ export interface PuppetAudio { speak(text: string, options?: { provider?: "local" | "api"; voice?: string }): Promise; @@ -140,23 +162,27 @@ interface PollState { timer: ReturnType; } -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) { diff --git a/poc/src/test-communication.ts b/poc/src/test-communication.ts index 2260e67..2125b5b 100644 --- a/poc/src/test-communication.ts +++ b/poc/src/test-communication.ts @@ -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); diff --git a/poc/src/test-trigger.ts b/poc/src/test-trigger.ts index f6c82c3..cb35f9a 100644 --- a/poc/src/test-trigger.ts +++ b/poc/src/test-trigger.ts @@ -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 } }; diff --git a/poc/src/webui.ts b/poc/src/webui.ts index 6bc7c99..51436d9 100644 --- a/poc/src/webui.ts +++ b/poc/src/webui.ts @@ -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()) {