Add text features: trusted triggers, context cache, response policy, MCP config tools
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* Veskslop daemon — per-channel message cache in a tmp folder with a size limit.
|
||||
*
|
||||
* The cache backs conversation context. It keeps the newest messages per
|
||||
* channel and evicts the least recently written channel files when the folder
|
||||
* exceeds the size limit.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { compareSnowflakes, type CachedMessage } from "./communication.js";
|
||||
|
||||
export const CACHE_DEFAULT_MAX_BYTES = 50 * 1024 * 1024;
|
||||
const MAX_MESSAGES_PER_CHANNEL = 200;
|
||||
|
||||
interface ChannelCacheFile {
|
||||
messages: CachedMessage[];
|
||||
}
|
||||
|
||||
export class MessageCache {
|
||||
private readonly dir: string;
|
||||
private readonly maxBytes: number;
|
||||
|
||||
constructor(
|
||||
dir = process.env.VESKSLOP_CACHE_DIR ?? join(tmpdir(), "veskslop-cache"),
|
||||
maxBytes = Number(process.env.VESKSLOP_CACHE_MAX_BYTES ?? CACHE_DEFAULT_MAX_BYTES)
|
||||
) {
|
||||
this.dir = dir;
|
||||
this.maxBytes = maxBytes;
|
||||
mkdirSync(this.dir, { recursive: true });
|
||||
}
|
||||
|
||||
get(channelId: string): CachedMessage[] {
|
||||
const file = this.file(channelId);
|
||||
if (!existsSync(file)) return [];
|
||||
|
||||
try {
|
||||
return (JSON.parse(readFileSync(file, "utf8")) as ChannelCacheFile).messages;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
newestId(channelId: string): string | undefined {
|
||||
return this.get(channelId).at(-1)?.id;
|
||||
}
|
||||
|
||||
/** Merge messages by ID, keep the newest, write, then evict old files. */
|
||||
merge(channelId: string, incoming: readonly CachedMessage[]): CachedMessage[] {
|
||||
const byId = new Map<string, CachedMessage>();
|
||||
for (const message of [...this.get(channelId), ...incoming]) byId.set(message.id, message);
|
||||
|
||||
const merged = [...byId.values()]
|
||||
.sort((a, b) => compareSnowflakes(a.id, b.id))
|
||||
.slice(-MAX_MESSAGES_PER_CHANNEL);
|
||||
|
||||
this.write(channelId, merged);
|
||||
this.evict();
|
||||
return merged;
|
||||
}
|
||||
|
||||
private file(channelId: string): string {
|
||||
return join(this.dir, `${channelId}.json`);
|
||||
}
|
||||
|
||||
private write(channelId: string, messages: CachedMessage[]) {
|
||||
try {
|
||||
writeFileSync(this.file(channelId), JSON.stringify({ messages } satisfies ChannelCacheFile));
|
||||
} catch (error) {
|
||||
console.error("[cache] write failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
private evict() {
|
||||
try {
|
||||
const files = readdirSync(this.dir)
|
||||
.filter(name => name.endsWith(".json"))
|
||||
.map(name => {
|
||||
const path = join(this.dir, name);
|
||||
const stats = statSync(path);
|
||||
return { path, size: stats.size, mtime: stats.mtimeMs };
|
||||
});
|
||||
|
||||
let total = files.reduce((sum, file) => sum + file.size, 0);
|
||||
if (total <= this.maxBytes) return;
|
||||
|
||||
files.sort((a, b) => a.mtime - b.mtime);
|
||||
for (const file of files) {
|
||||
if (total <= this.maxBytes) break;
|
||||
rmSync(file.path, { force: true });
|
||||
total -= file.size;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[cache] eviction failed:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user