Add text features: trusted triggers, context cache, response policy, MCP config tools
This commit is contained in:
+203
-25
@@ -1,26 +1,29 @@
|
||||
/*
|
||||
* Veskslop daemon — puppet state: trust list, trigger log and Discord-facing
|
||||
* command handling. All of this used to live inside the Vesktop renderer; it
|
||||
* now lives here so Vesktop stays stock.
|
||||
* Veskslop daemon — puppet state: trust list, trigger log, response policy,
|
||||
* conversation context, and Discord-facing command handling.
|
||||
*
|
||||
* Trigger scope for now: direct messages from trusted users and direct @
|
||||
* mentions from trusted users. Mentions in guild channels and group DMs also
|
||||
* collect recent context for later LLM use.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
|
||||
import {
|
||||
assembleContext,
|
||||
isTrusted,
|
||||
MessageLog,
|
||||
parseCommand,
|
||||
randomActionDelay,
|
||||
stripMentions,
|
||||
toCachedMessage,
|
||||
type AssembledContext,
|
||||
type CachedMessage,
|
||||
type ParsedCommand,
|
||||
type PuppetMessage
|
||||
} from "./communication.js";
|
||||
|
||||
/** The subset of the transport that the puppet logic needs. */
|
||||
export interface PuppetTransport {
|
||||
getSelfId(): Promise<string | undefined>;
|
||||
sendMessage(channelId: string, content: string): Promise<void>;
|
||||
}
|
||||
import { MessageCache } from "./message-cache.js";
|
||||
import { paced } from "./pacing.js";
|
||||
|
||||
const DM_CHANNEL = 1;
|
||||
const GROUP_DM_CHANNEL = 3;
|
||||
@@ -29,9 +32,58 @@ const DEFAULT_OWNER_ID = "177117296140091393";
|
||||
const STATE_FILE =
|
||||
process.env.VESKSLOP_STATE_FILE ?? new URL("../.veskslop-state.json", import.meta.url).pathname;
|
||||
|
||||
export interface ResponsePolicy {
|
||||
defaultMode: "channel" | "dm";
|
||||
sensitiveCommands: string[];
|
||||
overrides: Record<string, "channel" | "dm">;
|
||||
}
|
||||
|
||||
export interface PuppetConfig {
|
||||
contextMessages: number;
|
||||
contextChars: number;
|
||||
responsePolicy: ResponsePolicy;
|
||||
}
|
||||
|
||||
export interface PuppetConfigPatch {
|
||||
contextMessages?: number;
|
||||
contextChars?: number;
|
||||
responsePolicy?: {
|
||||
defaultMode?: "channel" | "dm";
|
||||
sensitiveCommands?: string[];
|
||||
overrides?: Record<string, "channel" | "dm">;
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: PuppetConfig = {
|
||||
contextMessages: 15,
|
||||
contextChars: 4000,
|
||||
responsePolicy: {
|
||||
defaultMode: "channel",
|
||||
sensitiveCommands: ["whitelist", "config"],
|
||||
overrides: {}
|
||||
}
|
||||
};
|
||||
|
||||
interface PersistedState {
|
||||
ownerId?: string;
|
||||
whitelist?: string[];
|
||||
config?: Partial<PuppetConfig>;
|
||||
}
|
||||
|
||||
/** The transport methods that the puppet logic needs. */
|
||||
export interface PuppetTransport {
|
||||
getSelfId(): Promise<string | undefined>;
|
||||
sendMessage(channelId: string, content: string): Promise<void>;
|
||||
sendDm(userId: string, content: string): Promise<unknown>;
|
||||
fetchMessages(
|
||||
channelId: string,
|
||||
limit: number,
|
||||
options?: { before?: string; after?: string }
|
||||
): Promise<{ messages?: unknown[] }>;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
export class Puppet {
|
||||
@@ -39,13 +91,28 @@ export class Puppet {
|
||||
|
||||
private ownerId = DEFAULT_OWNER_ID;
|
||||
private whitelistIds: string[] = [];
|
||||
private config: PuppetConfig = structuredClone(DEFAULT_CONFIG);
|
||||
private selfId?: string;
|
||||
private readonly cache = new MessageCache();
|
||||
|
||||
constructor(private readonly discord: PuppetTransport) {
|
||||
constructor(private readonly core: PuppetTransport) {
|
||||
try {
|
||||
const state = JSON.parse(readFileSync(STATE_FILE, "utf8")) as PersistedState;
|
||||
if (state.ownerId) this.ownerId = state.ownerId;
|
||||
if (Array.isArray(state.whitelist)) this.whitelistIds = state.whitelist;
|
||||
if (state.config) {
|
||||
this.config = {
|
||||
contextMessages: clamp(state.config.contextMessages ?? DEFAULT_CONFIG.contextMessages, 1, 50),
|
||||
contextChars: clamp(state.config.contextChars ?? DEFAULT_CONFIG.contextChars, 200, 20000),
|
||||
responsePolicy: {
|
||||
defaultMode: state.config.responsePolicy?.defaultMode ?? DEFAULT_CONFIG.responsePolicy.defaultMode,
|
||||
sensitiveCommands:
|
||||
state.config.responsePolicy?.sensitiveCommands ??
|
||||
DEFAULT_CONFIG.responsePolicy.sensitiveCommands,
|
||||
overrides: state.config.responsePolicy?.overrides ?? {}
|
||||
}
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// first run: defaults apply
|
||||
}
|
||||
@@ -59,6 +126,27 @@ export class Puppet {
|
||||
return this.ownerId;
|
||||
}
|
||||
|
||||
getConfig(): PuppetConfig {
|
||||
return structuredClone(this.config);
|
||||
}
|
||||
|
||||
updateConfig(patch: PuppetConfigPatch): PuppetConfig {
|
||||
const policy = patch.responsePolicy ?? {};
|
||||
|
||||
this.config = {
|
||||
contextMessages: clamp(patch.contextMessages ?? this.config.contextMessages, 1, 50),
|
||||
contextChars: clamp(patch.contextChars ?? this.config.contextChars, 200, 20000),
|
||||
responsePolicy: {
|
||||
defaultMode: policy.defaultMode ?? this.config.responsePolicy.defaultMode,
|
||||
sensitiveCommands: policy.sensitiveCommands ?? this.config.responsePolicy.sensitiveCommands,
|
||||
overrides: { ...this.config.responsePolicy.overrides, ...(policy.overrides ?? {}) }
|
||||
}
|
||||
};
|
||||
|
||||
this.save();
|
||||
return this.getConfig();
|
||||
}
|
||||
|
||||
getLog() {
|
||||
return { ...this.log.snapshot(), ownerId: this.ownerId, whitelist: this.whitelist };
|
||||
}
|
||||
@@ -85,12 +173,60 @@ export class Puppet {
|
||||
|
||||
private save() {
|
||||
try {
|
||||
writeFileSync(STATE_FILE, JSON.stringify({ ownerId: this.ownerId, whitelist: this.whitelistIds }, null, 4));
|
||||
writeFileSync(
|
||||
STATE_FILE,
|
||||
JSON.stringify(
|
||||
{ ownerId: this.ownerId, whitelist: this.whitelistIds, config: this.config },
|
||||
null,
|
||||
4
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[puppet] failed to save state:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/* Context */
|
||||
|
||||
private async refreshCache(channelId: string): Promise<CachedMessage[]> {
|
||||
const cached = this.cache.get(channelId);
|
||||
const newest = cached.at(-1)?.id;
|
||||
|
||||
const raw = await paced(() =>
|
||||
newest
|
||||
? this.core.fetchMessages(channelId, 100, { after: newest })
|
||||
: this.core.fetchMessages(channelId, Math.min(100, this.config.contextMessages + 5))
|
||||
);
|
||||
|
||||
return this.cache.merge(channelId, (raw?.messages ?? []).map(toCachedMessage));
|
||||
}
|
||||
|
||||
async getContext(
|
||||
channelId: string,
|
||||
options: { maxMessages?: number; maxChars?: number; refresh?: boolean } = {}
|
||||
): Promise<AssembledContext & { channelId: string }> {
|
||||
const merged =
|
||||
options.refresh === false ? this.cache.get(channelId) : await this.refreshCache(channelId);
|
||||
|
||||
const assembled = assembleContext(merged, {
|
||||
maxMessages: options.maxMessages ?? this.config.contextMessages,
|
||||
maxChars: options.maxChars ?? this.config.contextChars
|
||||
});
|
||||
|
||||
return { channelId, ...assembled };
|
||||
}
|
||||
|
||||
private async collectContext(channelId: string, excludeId?: string): Promise<AssembledContext> {
|
||||
const merged = await this.refreshCache(channelId);
|
||||
return assembleContext(merged, {
|
||||
maxMessages: this.config.contextMessages,
|
||||
maxChars: this.config.contextChars,
|
||||
excludeId
|
||||
});
|
||||
}
|
||||
|
||||
/* Triggers */
|
||||
|
||||
async handleEvent(event: any) {
|
||||
if (event?.type !== "MESSAGE_CREATE") return;
|
||||
|
||||
@@ -98,16 +234,18 @@ export class Puppet {
|
||||
if (!author || !channelId) return;
|
||||
if (author.bot) return;
|
||||
|
||||
this.selfId ??= await this.discord.getSelfId();
|
||||
this.selfId ??= await this.core.getSelfId();
|
||||
if (!this.selfId || author.id === this.selfId) return;
|
||||
|
||||
// Whitelist-only for now.
|
||||
if (!this.isTrusted(author.id)) return;
|
||||
|
||||
const isGroupDm = channelType === GROUP_DM_CHANNEL;
|
||||
const isDirectDm = !guildId && (channelType === DM_CHANNEL || (!isGroupDm && channelType === null));
|
||||
|
||||
const mentionIds = new Set<string>(mentions ?? []);
|
||||
if (!isDirectDm && !mentionIds.has(this.selfId)) return;
|
||||
|
||||
const trusted = this.isTrusted(author.id);
|
||||
const text = isDirectDm ? (content ?? "") : stripMentions(content ?? "", new Set([this.selfId]));
|
||||
const command = parseCommand(text);
|
||||
|
||||
@@ -119,13 +257,23 @@ export class Puppet {
|
||||
authorName: author.username,
|
||||
content: content ?? "",
|
||||
timestamp: event.timestamp ?? new Date().toISOString(),
|
||||
trusted,
|
||||
trusted: true,
|
||||
command: command ? `${command.name}${command.args.length ? " " + command.args.join(" ") : ""}` : undefined
|
||||
};
|
||||
|
||||
// Mentions need surrounding context for later LLM use. Direct messages do not.
|
||||
if (!isDirectDm) {
|
||||
try {
|
||||
const context = await this.collectContext(channelId, event.id);
|
||||
if (context.messages.length) entry.context = context.messages;
|
||||
} catch (error) {
|
||||
console.error("[puppet] context collection failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
this.log.add(entry);
|
||||
|
||||
if (command?.name === "whitelist" && trusted) {
|
||||
if (command?.name === "whitelist" && author.id === this.ownerId) {
|
||||
await this.handleWhitelistCommand(command, author.id, channelId);
|
||||
}
|
||||
}
|
||||
@@ -138,32 +286,35 @@ export class Puppet {
|
||||
}
|
||||
|
||||
private async handleWhitelistCommand(command: ParsedCommand, authorId: string, channelId: string) {
|
||||
if (authorId !== this.ownerId) return;
|
||||
|
||||
const [sub, ...rest] = command.args;
|
||||
|
||||
switch (sub) {
|
||||
case "list":
|
||||
await this.reply(
|
||||
return this.respond(
|
||||
authorId,
|
||||
channelId,
|
||||
"whitelist",
|
||||
`Trusted users:\n${[this.ownerId, ...this.whitelistIds].join("\n")}`
|
||||
);
|
||||
return;
|
||||
|
||||
case "add": {
|
||||
const ids = this.extractUserIds(rest.join(" "));
|
||||
if (!ids.length) return this.reply(channelId, "Usage: whitelist add @user");
|
||||
if (!ids.length) return this.respond(authorId, channelId, "whitelist", "Usage: whitelist add @user");
|
||||
|
||||
this.addToWhitelist(ids);
|
||||
return this.reply(channelId, `Added to whitelist:\n${ids.join("\n")}`);
|
||||
return this.respond(authorId, channelId, "whitelist", `Added to whitelist:\n${ids.join("\n")}`);
|
||||
}
|
||||
|
||||
case "remove": {
|
||||
const ids = this.extractUserIds(rest.join(" "));
|
||||
if (!ids.length) return this.reply(channelId, "Usage: whitelist remove @user");
|
||||
if (!ids.length) return this.respond(authorId, channelId, "whitelist", "Usage: whitelist remove @user");
|
||||
|
||||
const removed = ids.filter(id => id !== this.ownerId);
|
||||
this.removeFromWhitelist(removed);
|
||||
return this.reply(
|
||||
return this.respond(
|
||||
authorId,
|
||||
channelId,
|
||||
"whitelist",
|
||||
[
|
||||
removed.length ? `Removed from whitelist:\n${removed.join("\n")}` : "Nothing removed.",
|
||||
ids.length !== removed.length ? "The owner cannot be removed." : ""
|
||||
@@ -174,17 +325,44 @@ export class Puppet {
|
||||
}
|
||||
|
||||
default:
|
||||
return this.reply(channelId, "Usage: whitelist add|remove|list");
|
||||
return this.respond(authorId, channelId, "whitelist", "Usage: whitelist add|remove|list");
|
||||
}
|
||||
}
|
||||
|
||||
/* Replies */
|
||||
|
||||
private async reply(channelId: string, content: string) {
|
||||
// Autonomous replies use the same 500-2800 ms pacing as everything else.
|
||||
await new Promise(resolve => setTimeout(resolve, randomActionDelay()));
|
||||
try {
|
||||
await this.discord.sendMessage(channelId, content);
|
||||
await this.core.sendMessage(channelId, content);
|
||||
} catch (error) {
|
||||
console.error("[puppet] reply failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
private async respond(
|
||||
requesterId: string,
|
||||
originChannelId: string,
|
||||
commandName: string | undefined,
|
||||
content: string
|
||||
) {
|
||||
const policy = this.config.responsePolicy;
|
||||
const override = commandName ? policy.overrides[commandName] : undefined;
|
||||
const sensitive = !!commandName && policy.sensitiveCommands.includes(commandName);
|
||||
const mode = override ?? (sensitive ? "dm" : policy.defaultMode);
|
||||
|
||||
if (mode === "dm") {
|
||||
await new Promise(resolve => setTimeout(resolve, randomActionDelay()));
|
||||
try {
|
||||
await this.core.sendDm(requesterId, content);
|
||||
} catch (error) {
|
||||
// Never fall back to a public channel for sensitive content.
|
||||
console.error("[puppet] sensitive reply by DM failed:", error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await this.reply(originChannelId, content);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user