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
+88 -22
View File
@@ -13,8 +13,10 @@ import { dirname, join } from "node:path";
import { ActionLog } from "./action-log.js";
import {
assembleContext,
DEFAULT_WAKEWORD,
hasTier,
MessageLog,
normalizeWakeword,
parseCommand,
parsePoll,
parseTier,
@@ -50,6 +52,7 @@ export interface PuppetConfig {
contextChars: number;
fetchCooldownSeconds: number;
continuationSeconds: number;
wakeword: string;
pollSeconds: number;
voiceLeaveDelaySeconds: number;
responsePolicy: ResponsePolicy;
@@ -60,6 +63,8 @@ export interface PuppetConfigPatch {
contextChars?: number;
fetchCooldownSeconds?: number;
continuationSeconds?: number;
/** Command prefix: a message must start with it plus a space. */
wakeword?: string;
pollSeconds?: number;
voiceLeaveDelaySeconds?: number;
responsePolicy?: {
@@ -74,6 +79,7 @@ const DEFAULT_CONFIG: PuppetConfig = {
contextChars: 4000,
fetchCooldownSeconds: 5,
continuationSeconds: 10,
wakeword: DEFAULT_WAKEWORD,
pollSeconds: 120,
voiceLeaveDelaySeconds: clamp(Number(process.env.VESKSLOP_VOICE_LEAVE_DELAY ?? 30), 0, 600),
responsePolicy: {
@@ -104,6 +110,22 @@ export interface PuppetTransport {
voiceLeave(): Promise<unknown>;
}
/** A trusted free-form message, for the reply-only agent. */
export interface PuppetAgentRequest {
id: string;
authorId: string;
authorName: string;
channelId: string;
guildId?: string;
content: string;
context: Array<{ author: string; content: string; timestamp: string }>;
}
/** The reply-only LLM harness. It never receives commands and cannot act. */
export interface PuppetAgent {
respond(request: PuppetAgentRequest): Promise<string | undefined>;
}
/** The audio actions a chat command can trigger. */
export interface PuppetAudio {
speak(text: string, options?: { provider?: "local" | "api"; voice?: string }): Promise<unknown>;
@@ -140,23 +162,27 @@ interface PollState {
timer: ReturnType<typeof setTimeout>;
}
const WHITELIST_HELP = [
"whitelist — manage who can use the puppet.",
"Usage:",
" whitelist Show this help",
" whitelist list Show the owner and the trusted users",
" whitelist add @user [tier] Add a user. Tier: user, trusted, admin (default user)",
" whitelist remove @user Remove a user"
].join("\n");
function whitelistHelp(wakeword: string): string {
return [
`${wakeword} whitelist — manage who can use the puppet.`,
"Usage:",
` ${wakeword} whitelist Show this help`,
` ${wakeword} whitelist list Show the owner and the trusted users`,
` ${wakeword} whitelist add @user [tier] Add a user. Tier: user, trusted, admin (default user)`,
` ${wakeword} whitelist remove @user Remove a user`
].join("\n");
}
const SCRAPE_HELP = [
"scrape media — archive this channel's media.",
"Usage:",
" scrape media Index links (default)",
" scrape media download Also download attachment bytes (admin)",
" scrape media status Show this channel's latest scrape",
" scrape media stop Stop it (admin)"
].join("\n");
function scrapeHelp(wakeword: string): string {
return [
`${wakeword} scrape media — archive this channel's media.`,
"Usage:",
` ${wakeword} scrape media Index links (default)`,
` ${wakeword} scrape media download Also download attachment bytes (admin)`,
` ${wakeword} scrape media status Show this channel's latest scrape`,
` ${wakeword} scrape media stop Stop it (admin)`
].join("\n");
}
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
@@ -195,7 +221,8 @@ export class Puppet {
constructor(
private readonly core: PuppetTransport,
private readonly audio?: PuppetAudio,
private readonly media?: PuppetMedia
private readonly media?: PuppetMedia,
private readonly agent?: PuppetAgent
) {
try {
const state = JSON.parse(readFileSync(STATE_FILE, "utf8")) as PersistedState;
@@ -224,6 +251,7 @@ export class Puppet {
0,
300
),
wakeword: normalizeWakeword(state.config.wakeword ?? DEFAULT_CONFIG.wakeword),
pollSeconds: clamp(state.config.pollSeconds ?? DEFAULT_CONFIG.pollSeconds, 10, 3600),
voiceLeaveDelaySeconds: clamp(
state.config.voiceLeaveDelaySeconds ?? DEFAULT_CONFIG.voiceLeaveDelaySeconds,
@@ -277,6 +305,7 @@ export class Puppet {
300
),
continuationSeconds: clamp(patch.continuationSeconds ?? this.config.continuationSeconds, 0, 300),
wakeword: normalizeWakeword(patch.wakeword ?? this.config.wakeword),
pollSeconds: clamp(patch.pollSeconds ?? this.config.pollSeconds, 10, 3600),
voiceLeaveDelaySeconds: clamp(
patch.voiceLeaveDelaySeconds ?? this.config.voiceLeaveDelaySeconds,
@@ -499,7 +528,7 @@ export class Puppet {
}
const text = isDirectDm ? (content ?? "") : stripMentions(content ?? "", new Set([this.selfId]));
const command = parseCommand(text);
const command = parseCommand(text, this.config.wakeword);
const entry: PuppetMessage = {
id: event.id,
@@ -565,7 +594,44 @@ export class Puppet {
} else if (command?.name === "scrape") {
await this.handleScrapeCommand(command, author.id, channelId, guildId, tier);
} else if (POLL_ENABLED && command?.name === "poll") {
await this.handlePollCommand(author.id, channelId, text);
const body = text.trim().slice(this.config.wakeword.length).trim();
await this.handlePollCommand(author.id, channelId, body);
} else {
// No command: the message belongs to the reply-only agent.
void this.handleFreeform(entry, text);
}
}
/**
* Send a trusted free-form message to the agent and reply with its answer.
* Runs detached: the agent can take minutes and must not block other events.
*/
private async handleFreeform(entry: PuppetMessage, text: string) {
if (!this.agent) return;
try {
const reply = await this.agent.respond({
id: entry.id,
authorId: entry.authorId,
authorName: entry.authorName,
channelId: entry.channelId,
...(entry.guildId ? { guildId: entry.guildId } : {}),
content: text,
context: (entry.context ?? []).map(message => ({
author: message.authorName,
content: message.content,
timestamp: message.timestamp
}))
});
if (!reply) {
this.actionLog.append({ type: "agent", messageId: entry.id, result: "empty" });
return;
}
await this.respond(entry.authorId, entry.channelId, "chat", reply);
} catch (error) {
this.actionLog.append({ type: "agent", messageId: entry.id, result: "error", error: String(error) });
}
}
@@ -586,7 +652,7 @@ export class Puppet {
// No arguments or "help" shows the help. No permission needed for help.
if (!sub || sub === "help") {
return this.respond(authorId, channelId, "whitelist", WHITELIST_HELP);
return this.respond(authorId, channelId, "whitelist", whitelistHelp(this.config.wakeword));
}
if (!hasTier(tier, "admin")) {
@@ -655,7 +721,7 @@ export class Puppet {
}
default:
return this.respond(authorId, channelId, "whitelist", WHITELIST_HELP);
return this.respond(authorId, channelId, "whitelist", whitelistHelp(this.config.wakeword));
}
}
@@ -715,7 +781,7 @@ export class Puppet {
const knownSub = sub === undefined || sub === "download" || sub === "status" || sub === "stop";
if (kind !== "media" || sub === "help" || !knownSub) {
return this.respond(authorId, channelId, "scrape", SCRAPE_HELP);
return this.respond(authorId, channelId, "scrape", scrapeHelp(this.config.wakeword));
}
if (!this.media) {