Add wakeword command routing and the reply-only agent client
This commit is contained in:
+88
-22
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user