Add wakeword command routing and the reply-only agent client
This commit is contained in:
+11
@@ -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/<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
|
||||
`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
|
||||
|
||||
@@ -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).
|
||||
|
||||
+23
-3
@@ -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
|
||||
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
+84
-18
@@ -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.",
|
||||
function whitelistHelp(wakeword: string): string {
|
||||
return [
|
||||
`${wakeword} 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"
|
||||
` ${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.",
|
||||
function scrapeHelp(wakeword: string): string {
|
||||
return [
|
||||
`${wakeword} 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)"
|
||||
` ${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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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()) {
|
||||
|
||||
Reference in New Issue
Block a user