Add tiers, continuation, fetch cooldown, persistent action log, voice read, batch MCP
This commit is contained in:
+232
-65
@@ -1,26 +1,30 @@
|
||||
/*
|
||||
* Veskslop daemon — puppet state: trust list, trigger log, response policy,
|
||||
* conversation context, and Discord-facing command handling.
|
||||
* Veskslop daemon — puppet state: trust tiers, trigger log, response policy,
|
||||
* conversation context, continuation windows, and Discord-facing commands.
|
||||
*
|
||||
* 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.
|
||||
* Trigger scope: direct messages and direct mentions from trusted users.
|
||||
* Trust tiers: user, trusted, admin. The owner is an admin. Whitelist
|
||||
* management requires the admin tier.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import { ActionLog } from "./action-log.js";
|
||||
import {
|
||||
assembleContext,
|
||||
isTrusted,
|
||||
hasTier,
|
||||
MessageLog,
|
||||
parseCommand,
|
||||
parseTier,
|
||||
randomActionDelay,
|
||||
stripMentions,
|
||||
toCachedMessage,
|
||||
type AssembledContext,
|
||||
type CachedMessage,
|
||||
type ParsedCommand,
|
||||
type PuppetMessage
|
||||
type PuppetMessage,
|
||||
type TrustTier
|
||||
} from "./communication.js";
|
||||
import { MessageCache } from "./message-cache.js";
|
||||
import { paced } from "./pacing.js";
|
||||
@@ -41,12 +45,16 @@ export interface ResponsePolicy {
|
||||
export interface PuppetConfig {
|
||||
contextMessages: number;
|
||||
contextChars: number;
|
||||
fetchCooldownSeconds: number;
|
||||
continuationSeconds: number;
|
||||
responsePolicy: ResponsePolicy;
|
||||
}
|
||||
|
||||
export interface PuppetConfigPatch {
|
||||
contextMessages?: number;
|
||||
contextChars?: number;
|
||||
fetchCooldownSeconds?: number;
|
||||
continuationSeconds?: number;
|
||||
responsePolicy?: {
|
||||
defaultMode?: "channel" | "dm";
|
||||
sensitiveCommands?: string[];
|
||||
@@ -57,6 +65,8 @@ export interface PuppetConfigPatch {
|
||||
const DEFAULT_CONFIG: PuppetConfig = {
|
||||
contextMessages: 15,
|
||||
contextChars: 4000,
|
||||
fetchCooldownSeconds: 5,
|
||||
continuationSeconds: 10,
|
||||
responsePolicy: {
|
||||
defaultMode: "channel",
|
||||
sensitiveCommands: ["whitelist", "config"],
|
||||
@@ -66,7 +76,7 @@ const DEFAULT_CONFIG: PuppetConfig = {
|
||||
|
||||
interface PersistedState {
|
||||
ownerId?: string;
|
||||
whitelist?: string[];
|
||||
whitelist?: string[] | Record<string, TrustTier>;
|
||||
config?: Partial<PuppetConfig>;
|
||||
}
|
||||
|
||||
@@ -89,21 +99,50 @@ function clamp(value: number, min: number, max: number): number {
|
||||
export class Puppet {
|
||||
readonly log = new MessageLog();
|
||||
|
||||
private readonly actionLog = new ActionLog(
|
||||
process.env.VESKSLOP_ACTION_LOG ?? join(dirname(STATE_FILE), "actions.jsonl")
|
||||
);
|
||||
private readonly cache = new MessageCache();
|
||||
private readonly tiers = new Map<string, TrustTier>();
|
||||
private readonly seen = new Set<string>();
|
||||
private readonly seenOrder: string[] = [];
|
||||
private readonly lastFetchAt = new Map<string, number>();
|
||||
private readonly pending = new Map<string, { entryId: string; expiresAt: number }>();
|
||||
|
||||
private ownerId = DEFAULT_OWNER_ID;
|
||||
private whitelistIds: string[] = [];
|
||||
private config: PuppetConfig = structuredClone(DEFAULT_CONFIG);
|
||||
private selfId?: string;
|
||||
private readonly cache = new MessageCache();
|
||||
private queue: Promise<void> = Promise.resolve();
|
||||
|
||||
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;
|
||||
|
||||
// Accept the old array shape and the new tier map.
|
||||
if (Array.isArray(state.whitelist)) {
|
||||
for (const id of state.whitelist) this.tiers.set(id, "user");
|
||||
} else if (state.whitelist) {
|
||||
for (const [id, tier] of Object.entries(state.whitelist)) {
|
||||
const parsed = parseTier(tier);
|
||||
if (parsed) this.tiers.set(id, parsed);
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
fetchCooldownSeconds: clamp(
|
||||
state.config.fetchCooldownSeconds ?? DEFAULT_CONFIG.fetchCooldownSeconds,
|
||||
0,
|
||||
300
|
||||
),
|
||||
continuationSeconds: clamp(
|
||||
state.config.continuationSeconds ?? DEFAULT_CONFIG.continuationSeconds,
|
||||
0,
|
||||
300
|
||||
),
|
||||
responsePolicy: {
|
||||
defaultMode: state.config.responsePolicy?.defaultMode ?? DEFAULT_CONFIG.responsePolicy.defaultMode,
|
||||
sensitiveCommands:
|
||||
@@ -118,14 +157,22 @@ export class Puppet {
|
||||
}
|
||||
}
|
||||
|
||||
get whitelist(): string[] {
|
||||
return [...this.whitelistIds];
|
||||
get whitelist(): Record<string, TrustTier> {
|
||||
return Object.fromEntries(this.tiers);
|
||||
}
|
||||
|
||||
get owner() {
|
||||
return this.ownerId;
|
||||
}
|
||||
|
||||
tierOf(userId: string): TrustTier | undefined {
|
||||
return userId === this.ownerId ? "admin" : this.tiers.get(userId);
|
||||
}
|
||||
|
||||
isTrusted(userId: string): boolean {
|
||||
return this.tierOf(userId) !== undefined;
|
||||
}
|
||||
|
||||
getConfig(): PuppetConfig {
|
||||
return structuredClone(this.config);
|
||||
}
|
||||
@@ -136,6 +183,12 @@ export class Puppet {
|
||||
this.config = {
|
||||
contextMessages: clamp(patch.contextMessages ?? this.config.contextMessages, 1, 50),
|
||||
contextChars: clamp(patch.contextChars ?? this.config.contextChars, 200, 20000),
|
||||
fetchCooldownSeconds: clamp(
|
||||
patch.fetchCooldownSeconds ?? this.config.fetchCooldownSeconds,
|
||||
0,
|
||||
300
|
||||
),
|
||||
continuationSeconds: clamp(patch.continuationSeconds ?? this.config.continuationSeconds, 0, 300),
|
||||
responsePolicy: {
|
||||
defaultMode: policy.defaultMode ?? this.config.responsePolicy.defaultMode,
|
||||
sensitiveCommands: policy.sensitiveCommands ?? this.config.responsePolicy.sensitiveCommands,
|
||||
@@ -151,22 +204,21 @@ export class Puppet {
|
||||
return { ...this.log.snapshot(), ownerId: this.ownerId, whitelist: this.whitelist };
|
||||
}
|
||||
|
||||
isTrusted(userId: string): boolean {
|
||||
return isTrusted(userId, this.ownerId, this.whitelistIds);
|
||||
readActionLog(limit = 100) {
|
||||
return this.actionLog.read(limit);
|
||||
}
|
||||
|
||||
addToWhitelist(userIds: string[]): string[] {
|
||||
const next = new Set(this.whitelistIds);
|
||||
for (const id of userIds) if (id && id !== this.ownerId) next.add(id);
|
||||
addToWhitelist(userIds: string[], tier: TrustTier = "user"): Record<string, TrustTier> {
|
||||
for (const id of userIds) {
|
||||
if (id && id !== this.ownerId) this.tiers.set(id, tier);
|
||||
}
|
||||
|
||||
this.whitelistIds = [...next];
|
||||
this.save();
|
||||
return this.whitelist;
|
||||
}
|
||||
|
||||
removeFromWhitelist(userIds: string[]): string[] {
|
||||
const remove = new Set(userIds);
|
||||
this.whitelistIds = this.whitelistIds.filter(id => !remove.has(id));
|
||||
removeFromWhitelist(userIds: string[]): Record<string, TrustTier> {
|
||||
for (const id of userIds) this.tiers.delete(id);
|
||||
this.save();
|
||||
return this.whitelist;
|
||||
}
|
||||
@@ -176,13 +228,14 @@ export class Puppet {
|
||||
writeFileSync(
|
||||
STATE_FILE,
|
||||
JSON.stringify(
|
||||
{ ownerId: this.ownerId, whitelist: this.whitelistIds, config: this.config },
|
||||
{ ownerId: this.ownerId, whitelist: this.whitelist, config: this.config },
|
||||
null,
|
||||
4
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[puppet] failed to save state:", error);
|
||||
this.actionLog.append({ type: "error", scope: "state-save", message: String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,8 +243,12 @@ export class Puppet {
|
||||
|
||||
private async refreshCache(channelId: string): Promise<CachedMessage[]> {
|
||||
const cached = this.cache.get(channelId);
|
||||
const newest = cached.at(-1)?.id;
|
||||
|
||||
const lastFetch = this.lastFetchAt.get(channelId) ?? 0;
|
||||
if (Date.now() - lastFetch < this.config.fetchCooldownSeconds * 1000) return cached;
|
||||
this.lastFetchAt.set(channelId, Date.now());
|
||||
|
||||
const newest = cached.at(-1)?.id;
|
||||
const raw = await paced(() =>
|
||||
newest
|
||||
? this.core.fetchMessages(channelId, 100, { after: newest })
|
||||
@@ -227,6 +284,28 @@ export class Puppet {
|
||||
|
||||
/* Triggers */
|
||||
|
||||
/** Process events one at a time. Order matters for continuation windows. */
|
||||
enqueueEvent(event: any): Promise<void> {
|
||||
this.queue = this.queue
|
||||
.then(() => this.handleEvent(event))
|
||||
.catch(error => {
|
||||
console.error("[puppet] event failed:", error);
|
||||
this.actionLog.append({ type: "error", scope: "event", message: String(error) });
|
||||
});
|
||||
|
||||
return this.queue;
|
||||
}
|
||||
|
||||
private remember(id: string) {
|
||||
this.seen.add(id);
|
||||
this.seenOrder.push(id);
|
||||
|
||||
if (this.seenOrder.length > 1000) {
|
||||
const oldest = this.seenOrder.shift()!;
|
||||
this.seen.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
async handleEvent(event: any) {
|
||||
if (event?.type !== "MESSAGE_CREATE") return;
|
||||
|
||||
@@ -234,17 +313,52 @@ export class Puppet {
|
||||
if (!author || !channelId) return;
|
||||
if (author.bot) return;
|
||||
|
||||
// Drop replayed events after a reconnect.
|
||||
if (event.id) {
|
||||
if (this.seen.has(event.id)) return;
|
||||
this.remember(event.id);
|
||||
}
|
||||
|
||||
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 tier = this.tierOf(author.id);
|
||||
if (!tier) return;
|
||||
|
||||
const isGroupDm = channelType === GROUP_DM_CHANNEL;
|
||||
const isDirectDm = !guildId && (channelType === DM_CHANNEL || (!isGroupDm && channelType === null));
|
||||
const mentioned = new Set<string>(mentions ?? []).has(this.selfId);
|
||||
const isTrigger = isDirectDm || mentioned;
|
||||
|
||||
const mentionIds = new Set<string>(mentions ?? []);
|
||||
if (!isDirectDm && !mentionIds.has(this.selfId)) return;
|
||||
const key = `${channelId}:${author.id}`;
|
||||
|
||||
// A rushed follow-up message joins the previous request as context.
|
||||
if (!isTrigger) {
|
||||
const pending = this.pending.get(key);
|
||||
if (pending && Date.now() < pending.expiresAt) {
|
||||
const entry = this.log.find(pending.entryId);
|
||||
if (entry) {
|
||||
const followUp: CachedMessage = {
|
||||
id: event.id,
|
||||
authorId: author.id,
|
||||
authorName: author.username,
|
||||
content: content ?? "",
|
||||
timestamp: event.timestamp ?? new Date().toISOString()
|
||||
};
|
||||
|
||||
entry.context = [...(entry.context ?? []), followUp];
|
||||
pending.expiresAt = Date.now() + this.config.continuationSeconds * 1000;
|
||||
this.actionLog.append({
|
||||
type: "continuation",
|
||||
messageId: event.id,
|
||||
entryId: entry.id,
|
||||
channelId,
|
||||
authorId: author.id
|
||||
});
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const text = isDirectDm ? (content ?? "") : stripMentions(content ?? "", new Set([this.selfId]));
|
||||
const command = parseCommand(text);
|
||||
@@ -261,20 +375,37 @@ export class Puppet {
|
||||
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.actionLog.append({ type: "error", scope: "context", channelId, message: String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
this.log.add(entry);
|
||||
|
||||
if (command?.name === "whitelist" && author.id === this.ownerId) {
|
||||
await this.handleWhitelistCommand(command, author.id, channelId);
|
||||
if (this.config.continuationSeconds > 0) {
|
||||
this.pending.set(key, {
|
||||
entryId: entry.id,
|
||||
expiresAt: Date.now() + this.config.continuationSeconds * 1000
|
||||
});
|
||||
}
|
||||
|
||||
this.actionLog.append({
|
||||
type: "trigger",
|
||||
messageId: entry.id,
|
||||
channelId,
|
||||
guildId: guildId ?? null,
|
||||
authorId: author.id,
|
||||
tier,
|
||||
command: entry.command,
|
||||
contextCount: entry.context?.length ?? 0
|
||||
});
|
||||
|
||||
if (command?.name === "whitelist") {
|
||||
await this.handleWhitelistCommand(command, author.id, tier, channelId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,38 +416,72 @@ export class Puppet {
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
private async handleWhitelistCommand(command: ParsedCommand, authorId: string, channelId: string) {
|
||||
private async handleWhitelistCommand(
|
||||
command: ParsedCommand,
|
||||
authorId: string,
|
||||
tier: TrustTier,
|
||||
channelId: string
|
||||
) {
|
||||
if (!hasTier(tier, "admin")) {
|
||||
return this.respond(authorId, channelId, "whitelist", "You do not have permission for this command.");
|
||||
}
|
||||
|
||||
const [sub, ...rest] = command.args;
|
||||
|
||||
switch (sub) {
|
||||
case "list":
|
||||
case "list": {
|
||||
const lines = [this.ownerId, ...this.tiers.keys()].map(id => `${id} — ${this.tierOf(id)}`);
|
||||
this.actionLog.append({ type: "command", command: "whitelist list", requesterId: authorId, result: "ok" });
|
||||
return this.respond(authorId, channelId, "whitelist", `Trusted users:\n${lines.join("\n")}`);
|
||||
}
|
||||
|
||||
case "add": {
|
||||
const ids = this.extractUserIds(rest.join(" "));
|
||||
if (!ids.length) {
|
||||
return this.respond(authorId, channelId, "whitelist", "Usage: whitelist add @user [user|trusted|admin]");
|
||||
}
|
||||
|
||||
const last = rest.at(-1)?.toLowerCase();
|
||||
const requestedTier = (last && parseTier(last)) || "user";
|
||||
this.addToWhitelist(ids, requestedTier);
|
||||
|
||||
this.actionLog.append({
|
||||
type: "command",
|
||||
command: `whitelist add ${ids.join(",")} ${requestedTier}`,
|
||||
requesterId: authorId,
|
||||
result: "ok"
|
||||
});
|
||||
|
||||
return this.respond(
|
||||
authorId,
|
||||
channelId,
|
||||
"whitelist",
|
||||
`Trusted users:\n${[this.ownerId, ...this.whitelistIds].join("\n")}`
|
||||
`Added:\n${ids.map(id => `${id} — ${requestedTier}`).join("\n")}`
|
||||
);
|
||||
|
||||
case "add": {
|
||||
const ids = this.extractUserIds(rest.join(" "));
|
||||
if (!ids.length) return this.respond(authorId, channelId, "whitelist", "Usage: whitelist add @user");
|
||||
|
||||
this.addToWhitelist(ids);
|
||||
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.respond(authorId, channelId, "whitelist", "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);
|
||||
|
||||
this.actionLog.append({
|
||||
type: "command",
|
||||
command: `whitelist remove ${removed.join(",")}`,
|
||||
requesterId: authorId,
|
||||
result: "ok"
|
||||
});
|
||||
|
||||
return this.respond(
|
||||
authorId,
|
||||
channelId,
|
||||
"whitelist",
|
||||
[
|
||||
removed.length ? `Removed from whitelist:\n${removed.join("\n")}` : "Nothing removed.",
|
||||
removed.length ? `Removed:\n${removed.join("\n")}` : "Nothing removed.",
|
||||
ids.length !== removed.length ? "The owner cannot be removed." : ""
|
||||
]
|
||||
.filter(Boolean)
|
||||
@@ -331,16 +496,6 @@ export class Puppet {
|
||||
|
||||
/* 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.core.sendMessage(channelId, content);
|
||||
} catch (error) {
|
||||
console.error("[puppet] reply failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
private async respond(
|
||||
requesterId: string,
|
||||
originChannelId: string,
|
||||
@@ -352,17 +507,29 @@ export class Puppet {
|
||||
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 new Promise(resolve => setTimeout(resolve, randomActionDelay()));
|
||||
|
||||
await this.reply(originChannelId, content);
|
||||
try {
|
||||
if (mode === "dm") await this.core.sendDm(requesterId, content);
|
||||
else await this.core.sendMessage(originChannelId, content);
|
||||
|
||||
this.actionLog.append({
|
||||
type: "reply",
|
||||
command: commandName,
|
||||
mode,
|
||||
target: mode === "dm" ? requesterId : originChannelId,
|
||||
result: "ok"
|
||||
});
|
||||
} catch (error) {
|
||||
// Never fall back to a public channel for sensitive content.
|
||||
this.actionLog.append({
|
||||
type: "reply",
|
||||
command: commandName,
|
||||
mode,
|
||||
target: mode === "dm" ? requesterId : originChannelId,
|
||||
result: "error",
|
||||
error: String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user