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
+11
View File
@@ -40,6 +40,17 @@ mobile draft at 390px.
- [x] C11 preview caching: Discord-hosted image/GIF previews (WebP 320px, hash-addressed, 5 MiB - [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`: cap, one attempt) run after every walk in both modes. Live run `...-20260916090722`:
`previewed: 2`; thumbs served over `/api/media/<scrape>/<item>/thumb` (200). `previewed: 2`; thumbs served over `/api/media/<scrape>/<item>/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 - [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 `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 scale), `style-overrides.css`, `settings-popups.css`, `polish.js`, the Phosphor
+4
View File
@@ -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. 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 - **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. 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).
+23 -3
View File
@@ -33,8 +33,23 @@ double replies and keeps privileged actions out of model reach.
| Auth | `VESKSLOP_AGENT_TOKEN` bearer token on the task endpoint. | | Auth | `VESKSLOP_AGENT_TOKEN` bearer token on the task endpoint. |
| Budget | `VESKSLOP_AGENT_MAX_STEPS` (default 16). Per-task wall clock: 5 minutes. | | 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 The harness receives tasks on `POST /task`. The daemon pushes them when `VESKSLOP_AGENT_ENABLED=1`
scaffold accepts manual pushes). The task body: 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 ```json
{ {
@@ -74,7 +89,12 @@ binds localhost.
Scaffold implemented in `agent/` and smoke-tested on 2026-09-14: it loaded 28 tools from the 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 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 ## Run the scaffold
+8
View File
@@ -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, 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. 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 | | Command | Effect |
| --- | --- | | --- | --- |
| `whitelist list` | Show the owner and the trusted users with their tiers. | | `whitelist list` | Show the owner and the trusted users with their tiers. |
+58
View File
@@ -26,6 +26,7 @@ const state = {
visibleGuildsSaved: null, visibleGuildsSaved: null,
timeZoneOverride: localStorage.getItem("veskslop-timezone") ?? "", timeZoneOverride: localStorage.getItem("veskslop-timezone") ?? "",
speech: { provider: null, language: null, voice: null }, speech: { provider: null, language: null, voice: null },
commands: { wakeword: "!s" },
profileDefaults: {}, profileDefaults: {},
expressionDefault: null, expressionDefault: null,
scope: "guild", scope: "guild",
@@ -1788,6 +1789,45 @@ async function refreshAudio() {
await refreshSpeech(); 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). */ /* Discord voice processing gates (Settings -> TTS panel). */
function fillVoiceGates(voice) { function fillVoiceGates(voice) {
const gates = voice ?? {}; const gates = voice ?? {};
@@ -1846,7 +1886,9 @@ async function refreshSpeech() {
try { try {
const prefs = await VK.api.get("/api/preferences"); const prefs = await VK.api.get("/api/preferences");
state.speech = prefs.tts ?? { provider: null, language: null, voice: null, speed: null, pitch: null }; state.speech = prefs.tts ?? { provider: null, language: null, voice: null, speed: null, pitch: null };
state.commands = prefs.commands ?? { wakeword: "!s" };
fillVoiceGates(prefs.voice); fillVoiceGates(prefs.voice);
fillWakewordControls();
} catch { } catch {
// preferences are optional // preferences are optional
} }
@@ -2764,6 +2806,22 @@ function init() {
$(id)?.addEventListener("change", () => void saveVoiceGates()); $(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 () => { $("#tts-duck").addEventListener("click", async () => {
const on = !$("#tts-duck").classList.contains("vk-chip-on"); const on = !$("#tts-duck").classList.contains("vk-chip-on");
try { try {
+36
View File
@@ -416,6 +416,42 @@
</div> </div>
</section> </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"> <section class="vk-settings-section">
<div class="vk-section-head"> <div class="vk-section-head">
<span class="vk-rail-label vk-rail-label-flat">Display</span> <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[]; args: string[];
} }
export function parseCommand(text: string): ParsedCommand | undefined { export const DEFAULT_WAKEWORD = "!s";
const trimmed = text.trim();
if (!trimmed) return undefined;
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 }; 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 { CoreClient, coreSocketPath } from "./core-client.js";
import { CoreProcess, staleBundleWarning } from "./core-process.js"; import { CoreProcess, staleBundleWarning } from "./core-process.js";
import { dedupe, getQueryCount, paced, pacedSend, pacedVoice, sleep } from "./pacing.js"; import { dedupe, getQueryCount, paced, pacedSend, pacedVoice, sleep } from "./pacing.js";
import { agentFromEnv } from "./agent-client.js";
import { MediaService } from "./media.js"; import { MediaService } from "./media.js";
import { PreferencesStore } from "./preferences.js"; import { PreferencesStore } from "./preferences.js";
import { Puppet } from "./puppet.js"; import { Puppet } from "./puppet.js";
@@ -52,7 +53,10 @@ async function connectLoop(): Promise<void> {
const client = await CoreClient.connect(); const client = await CoreClient.connect();
core = client; 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 => { client.onEvent(event => {
// Speaking hints add to the reconciled set so the poller can // 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(), contextChars: z.number().int().min(200).max(20000).optional(),
fetchCooldownSeconds: z.number().int().min(0).max(300).optional(), fetchCooldownSeconds: z.number().int().min(0).max(300).optional(),
continuationSeconds: 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(), pollSeconds: z.number().int().min(10).max(3600).optional(),
voiceLeaveDelaySeconds: z.number().int().min(0).max(600).optional(), voiceLeaveDelaySeconds: z.number().int().min(0).max(600).optional(),
responsePolicy: z responsePolicy: z
+21
View File
@@ -6,6 +6,8 @@
*/ */
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { DEFAULT_WAKEWORD, normalizeWakeword } from "./communication.js";
import { dirname } from "node:path"; import { dirname } from "node:path";
export interface TtsPreferences { 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. */ /** Discord's microphone processing gates, plus a TTS bypass. */
export interface VoicePreferences { export interface VoicePreferences {
noiseSuppression: boolean; noiseSuppression: boolean;
@@ -151,6 +159,7 @@ export interface MediaPreferences {
export interface WebPreferences { export interface WebPreferences {
/** null means every guild is visible. An empty array hides all. */ /** null means every guild is visible. An empty array hides all. */
visibleGuilds: string[] | null; visibleGuilds: string[] | null;
commands: CommandPreferences;
tts: TtsPreferences; tts: TtsPreferences;
audio: AudioPreferences; audio: AudioPreferences;
voice: VoicePreferences; voice: VoicePreferences;
@@ -160,6 +169,9 @@ export interface WebPreferences {
const DEFAULTS: WebPreferences = { const DEFAULTS: WebPreferences = {
visibleGuilds: null, visibleGuilds: null,
commands: {
wakeword: DEFAULT_WAKEWORD
},
tts: { tts: {
provider: null, provider: null,
language: null, language: null,
@@ -393,6 +405,12 @@ export class PreferencesStore {
: null; : null;
} }
if (patch.commands !== undefined) {
this.preferences.commands = {
wakeword: normalizeWakeword(patch.commands?.wakeword)
};
}
if (patch.tts !== undefined) { if (patch.tts !== undefined) {
this.preferences.tts = { this.preferences.tts = {
provider: normalizeProvider(patch.tts?.provider), provider: normalizeProvider(patch.tts?.provider),
@@ -475,6 +493,9 @@ export class PreferencesStore {
visibleGuilds: Array.isArray(raw?.visibleGuilds) visibleGuilds: Array.isArray(raw?.visibleGuilds)
? [...new Set<string>(raw.visibleGuilds.map((id: unknown) => String(id)))] ? [...new Set<string>(raw.visibleGuilds.map((id: unknown) => String(id)))]
: null, : null,
commands: {
wakeword: normalizeWakeword(raw?.commands?.wakeword)
},
tts: { tts: {
provider: normalizeProvider(provider), provider: normalizeProvider(provider),
language: raw?.tts?.language ? String(raw.tts.language).slice(0, 16) : null, language: raw?.tts?.language ? String(raw.tts.language).slice(0, 16) : null,
+84 -18
View File
@@ -13,8 +13,10 @@ import { dirname, join } from "node:path";
import { ActionLog } from "./action-log.js"; import { ActionLog } from "./action-log.js";
import { import {
assembleContext, assembleContext,
DEFAULT_WAKEWORD,
hasTier, hasTier,
MessageLog, MessageLog,
normalizeWakeword,
parseCommand, parseCommand,
parsePoll, parsePoll,
parseTier, parseTier,
@@ -50,6 +52,7 @@ export interface PuppetConfig {
contextChars: number; contextChars: number;
fetchCooldownSeconds: number; fetchCooldownSeconds: number;
continuationSeconds: number; continuationSeconds: number;
wakeword: string;
pollSeconds: number; pollSeconds: number;
voiceLeaveDelaySeconds: number; voiceLeaveDelaySeconds: number;
responsePolicy: ResponsePolicy; responsePolicy: ResponsePolicy;
@@ -60,6 +63,8 @@ export interface PuppetConfigPatch {
contextChars?: number; contextChars?: number;
fetchCooldownSeconds?: number; fetchCooldownSeconds?: number;
continuationSeconds?: number; continuationSeconds?: number;
/** Command prefix: a message must start with it plus a space. */
wakeword?: string;
pollSeconds?: number; pollSeconds?: number;
voiceLeaveDelaySeconds?: number; voiceLeaveDelaySeconds?: number;
responsePolicy?: { responsePolicy?: {
@@ -74,6 +79,7 @@ const DEFAULT_CONFIG: PuppetConfig = {
contextChars: 4000, contextChars: 4000,
fetchCooldownSeconds: 5, fetchCooldownSeconds: 5,
continuationSeconds: 10, continuationSeconds: 10,
wakeword: DEFAULT_WAKEWORD,
pollSeconds: 120, pollSeconds: 120,
voiceLeaveDelaySeconds: clamp(Number(process.env.VESKSLOP_VOICE_LEAVE_DELAY ?? 30), 0, 600), voiceLeaveDelaySeconds: clamp(Number(process.env.VESKSLOP_VOICE_LEAVE_DELAY ?? 30), 0, 600),
responsePolicy: { responsePolicy: {
@@ -104,6 +110,22 @@ export interface PuppetTransport {
voiceLeave(): Promise<unknown>; 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. */ /** The audio actions a chat command can trigger. */
export interface PuppetAudio { export interface PuppetAudio {
speak(text: string, options?: { provider?: "local" | "api"; voice?: string }): Promise<unknown>; speak(text: string, options?: { provider?: "local" | "api"; voice?: string }): Promise<unknown>;
@@ -140,23 +162,27 @@ interface PollState {
timer: ReturnType<typeof setTimeout>; timer: ReturnType<typeof setTimeout>;
} }
const WHITELIST_HELP = [ function whitelistHelp(wakeword: string): string {
"whitelist — manage who can use the puppet.", return [
`${wakeword} whitelist — manage who can use the puppet.`,
"Usage:", "Usage:",
" whitelist Show this help", ` ${wakeword} whitelist Show this help`,
" whitelist list Show the owner and the trusted users", ` ${wakeword} whitelist list Show the owner and the trusted users`,
" whitelist add @user [tier] Add a user. Tier: user, trusted, admin (default user)", ` ${wakeword} whitelist add @user [tier] Add a user. Tier: user, trusted, admin (default user)`,
" whitelist remove @user Remove a user" ` ${wakeword} whitelist remove @user Remove a user`
].join("\n"); ].join("\n");
}
const SCRAPE_HELP = [ function scrapeHelp(wakeword: string): string {
"scrape media — archive this channel's media.", return [
`${wakeword} scrape media — archive this channel's media.`,
"Usage:", "Usage:",
" scrape media Index links (default)", ` ${wakeword} scrape media Index links (default)`,
" scrape media download Also download attachment bytes (admin)", ` ${wakeword} scrape media download Also download attachment bytes (admin)`,
" scrape media status Show this channel's latest scrape", ` ${wakeword} scrape media status Show this channel's latest scrape`,
" scrape media stop Stop it (admin)" ` ${wakeword} scrape media stop Stop it (admin)`
].join("\n"); ].join("\n");
}
function clamp(value: number, min: number, max: number): number { function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max); return Math.min(Math.max(value, min), max);
@@ -195,7 +221,8 @@ export class Puppet {
constructor( constructor(
private readonly core: PuppetTransport, private readonly core: PuppetTransport,
private readonly audio?: PuppetAudio, private readonly audio?: PuppetAudio,
private readonly media?: PuppetMedia private readonly media?: PuppetMedia,
private readonly agent?: PuppetAgent
) { ) {
try { try {
const state = JSON.parse(readFileSync(STATE_FILE, "utf8")) as PersistedState; const state = JSON.parse(readFileSync(STATE_FILE, "utf8")) as PersistedState;
@@ -224,6 +251,7 @@ export class Puppet {
0, 0,
300 300
), ),
wakeword: normalizeWakeword(state.config.wakeword ?? DEFAULT_CONFIG.wakeword),
pollSeconds: clamp(state.config.pollSeconds ?? DEFAULT_CONFIG.pollSeconds, 10, 3600), pollSeconds: clamp(state.config.pollSeconds ?? DEFAULT_CONFIG.pollSeconds, 10, 3600),
voiceLeaveDelaySeconds: clamp( voiceLeaveDelaySeconds: clamp(
state.config.voiceLeaveDelaySeconds ?? DEFAULT_CONFIG.voiceLeaveDelaySeconds, state.config.voiceLeaveDelaySeconds ?? DEFAULT_CONFIG.voiceLeaveDelaySeconds,
@@ -277,6 +305,7 @@ export class Puppet {
300 300
), ),
continuationSeconds: clamp(patch.continuationSeconds ?? this.config.continuationSeconds, 0, 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), pollSeconds: clamp(patch.pollSeconds ?? this.config.pollSeconds, 10, 3600),
voiceLeaveDelaySeconds: clamp( voiceLeaveDelaySeconds: clamp(
patch.voiceLeaveDelaySeconds ?? this.config.voiceLeaveDelaySeconds, patch.voiceLeaveDelaySeconds ?? this.config.voiceLeaveDelaySeconds,
@@ -499,7 +528,7 @@ export class Puppet {
} }
const text = isDirectDm ? (content ?? "") : stripMentions(content ?? "", new Set([this.selfId])); const text = isDirectDm ? (content ?? "") : stripMentions(content ?? "", new Set([this.selfId]));
const command = parseCommand(text); const command = parseCommand(text, this.config.wakeword);
const entry: PuppetMessage = { const entry: PuppetMessage = {
id: event.id, id: event.id,
@@ -565,7 +594,44 @@ export class Puppet {
} else if (command?.name === "scrape") { } else if (command?.name === "scrape") {
await this.handleScrapeCommand(command, author.id, channelId, guildId, tier); await this.handleScrapeCommand(command, author.id, channelId, guildId, tier);
} else if (POLL_ENABLED && command?.name === "poll") { } 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. // No arguments or "help" shows the help. No permission needed for help.
if (!sub || sub === "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")) { if (!hasTier(tier, "admin")) {
@@ -655,7 +721,7 @@ export class Puppet {
} }
default: 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"; const knownSub = sub === undefined || sub === "download" || sub === "status" || sub === "stop";
if (kind !== "media" || sub === "help" || !knownSub) { 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) { if (!this.media) {
+21 -2
View File
@@ -16,6 +16,8 @@ import {
formatContextLine, formatContextLine,
hasTier, hasTier,
isTrusted, isTrusted,
DEFAULT_WAKEWORD,
normalizeWakeword,
parseCommand, parseCommand,
parsePoll, parsePoll,
parseTier, parseTier,
@@ -65,11 +67,28 @@ function entry(id: number, trusted: boolean): PuppetMessage {
// commands // commands
{ {
assert.deepEqual(parseCommand(" WHITELIST add <@123> "), { name: "whitelist", args: ["add", "<@123>"] }); assert.deepEqual(parseCommand(" !S WHITELIST add <@123> "), { name: "whitelist", args: ["add", "<@123>"] });
assert.deepEqual(parseCommand("whitelist list"), { name: "whitelist", args: ["list"] }); 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); 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 // trust
{ {
assert.equal(isTrusted("owner", "owner", []), true); 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"; import { coreSocketPath } from "./core-client.js";
const authorId = process.env.TEST_TRIGGER_AUTHOR ?? "177117296140091393"; 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 guildId = process.env.TEST_TRIGGER_GUILD;
const isGuild = !!guildId; const isGuild = !!guildId;
const mention = isGuild && process.env.TEST_TRIGGER_MENTION !== "0"; const mention = isGuild && process.env.TEST_TRIGGER_MENTION !== "0";
@@ -44,7 +44,7 @@ const updateEvent = {
id: process.env.TEST_TRIGGER_MESSAGE_ID ?? "400000000000000004", id: process.env.TEST_TRIGGER_MESSAGE_ID ?? "400000000000000004",
channelId: process.env.TEST_TRIGGER_CHANNEL ?? "200000000000000001", channelId: process.env.TEST_TRIGGER_CHANNEL ?? "200000000000000001",
guildId: guildId ?? "100000000000000001", guildId: guildId ?? "100000000000000001",
content: content === "whitelist list" ? "edited content" : content, content: content === "!s whitelist list" ? "edited content" : content,
editedAt: new Date().toISOString(), editedAt: new Date().toISOString(),
author: { id: authorId, username: "zzib", bot: false } author: { id: authorId, username: "zzib", bot: false }
}; };
+8
View File
@@ -8,6 +8,8 @@
*/ */
import express, { type Request, type Response } from "express"; import express, { type Request, type Response } from "express";
import { normalizeWakeword } from "./communication.js";
import { existsSync, readFileSync } from "node:fs"; import { existsSync, readFileSync } from "node:fs";
import { Readable } from "node:stream"; import { Readable } from "node:stream";
import http from "node:http"; import http from "node:http";
@@ -486,6 +488,7 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
wrap(async (req, res) => { wrap(async (req, res) => {
const { const {
visibleGuilds, visibleGuilds,
commands: commandPrefs,
tts, tts,
audio, audio,
voice: voicePrefs, voice: voicePrefs,
@@ -556,6 +559,10 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
deps.audio.setDuckEnabled(patch.audio.duck); deps.audio.setDuckEnabled(patch.audio.duck);
} }
if (commandPrefs !== undefined) {
patch.commands = { wakeword: normalizeWakeword(commandPrefs?.wakeword) };
}
if (voicePrefs !== undefined) { if (voicePrefs !== undefined) {
patch.voice = { patch.voice = {
noiseSuppression: voicePrefs?.noiseSuppression === true, noiseSuppression: voicePrefs?.noiseSuppression === true,
@@ -568,6 +575,7 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
const updated = deps.preferences.update(patch); const updated = deps.preferences.update(patch);
if (mediaPrefs !== undefined) deps.media.applyTunables(); if (mediaPrefs !== undefined) deps.media.applyTunables();
if (commandPrefs !== undefined) deps.getPuppet()?.updateConfig({ wakeword: updated.commands.wakeword });
if (voicePrefs !== undefined) { if (voicePrefs !== undefined) {
const core = deps.getCore(); const core = deps.getCore();
if (core && deps.isCoreConnected()) { if (core && deps.isCoreConnected()) {