diff --git a/extensions/memory/index.test.ts b/extensions/memory/index.test.ts new file mode 100644 index 000000000..edf1e3983 --- /dev/null +++ b/extensions/memory/index.test.ts @@ -0,0 +1,282 @@ +/** + * Memory Plugin E2E Tests + * + * Tests the memory plugin functionality including: + * - Plugin registration and configuration + * - Memory storage and retrieval + * - Auto-recall via hooks + * - Auto-capture filtering + */ + +import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import { randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; + +// Skip if no OpenAI API key +const OPENAI_API_KEY = process.env.OPENAI_API_KEY; +const describeWithKey = OPENAI_API_KEY ? describe : describe.skip; + +describeWithKey("memory plugin e2e", () => { + let tmpDir: string; + let dbPath: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-memory-test-")); + dbPath = path.join(tmpDir, "lancedb"); + }); + + afterEach(async () => { + if (tmpDir) { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + + test("memory plugin registers and initializes correctly", async () => { + // Dynamic import to avoid loading LanceDB when not testing + const { default: memoryPlugin } = await import("./index.js"); + + expect(memoryPlugin.id).toBe("memory"); + expect(memoryPlugin.name).toBe("Memory (Vector)"); + expect(memoryPlugin.kind).toBe("memory"); + expect(memoryPlugin.configSchema).toBeDefined(); + expect(memoryPlugin.register).toBeInstanceOf(Function); + }); + + test("config schema parses valid config", async () => { + const { default: memoryPlugin } = await import("./index.js"); + + const config = memoryPlugin.configSchema?.parse?.({ + embedding: { + apiKey: OPENAI_API_KEY, + model: "text-embedding-3-small", + }, + dbPath, + autoCapture: true, + autoRecall: true, + }); + + expect(config).toBeDefined(); + expect(config?.embedding?.apiKey).toBe(OPENAI_API_KEY); + expect(config?.dbPath).toBe(dbPath); + }); + + test("config schema resolves env vars", async () => { + const { default: memoryPlugin } = await import("./index.js"); + + // Set a test env var + process.env.TEST_MEMORY_API_KEY = "test-key-123"; + + const config = memoryPlugin.configSchema?.parse?.({ + embedding: { + apiKey: "${TEST_MEMORY_API_KEY}", + }, + dbPath, + }); + + expect(config?.embedding?.apiKey).toBe("test-key-123"); + + delete process.env.TEST_MEMORY_API_KEY; + }); + + test("config schema rejects missing apiKey", async () => { + const { default: memoryPlugin } = await import("./index.js"); + + expect(() => { + memoryPlugin.configSchema?.parse?.({ + embedding: {}, + dbPath, + }); + }).toThrow("embedding.apiKey is required"); + }); + + test("shouldCapture filters correctly", async () => { + // Test the capture filtering logic by checking the rules + const triggers = [ + { text: "I prefer dark mode", shouldMatch: true }, + { text: "Remember that my name is John", shouldMatch: true }, + { text: "My email is test@example.com", shouldMatch: true }, + { text: "Call me at +1234567890123", shouldMatch: true }, + { text: "We decided to use TypeScript", shouldMatch: true }, + { text: "I always want verbose output", shouldMatch: true }, + { text: "Just a random short message", shouldMatch: false }, + { text: "x", shouldMatch: false }, // Too short + { text: "injected", shouldMatch: false }, // Skip injected + ]; + + // The shouldCapture function is internal, but we can test via the capture behavior + // For now, just verify the patterns we expect to match + for (const { text, shouldMatch } of triggers) { + const hasPreference = /prefer|radši|like|love|hate|want/i.test(text); + const hasRemember = /zapamatuj|pamatuj|remember/i.test(text); + const hasEmail = /[\w.-]+@[\w.-]+\.\w+/.test(text); + const hasPhone = /\+\d{10,}/.test(text); + const hasDecision = /rozhodli|decided|will use|budeme/i.test(text); + const hasAlways = /always|never|important/i.test(text); + const isInjected = text.includes(""); + const isTooShort = text.length < 10; + + const wouldCapture = + !isTooShort && + !isInjected && + (hasPreference || hasRemember || hasEmail || hasPhone || hasDecision || hasAlways); + + if (shouldMatch) { + expect(wouldCapture).toBe(true); + } + } + }); + + test("detectCategory classifies correctly", async () => { + // Test category detection patterns + const cases = [ + { text: "I prefer dark mode", expected: "preference" }, + { text: "We decided to use React", expected: "decision" }, + { text: "My email is test@example.com", expected: "entity" }, + { text: "The server is running on port 3000", expected: "fact" }, + ]; + + for (const { text, expected } of cases) { + const lower = text.toLowerCase(); + let category: string; + + if (/prefer|radši|like|love|hate|want/i.test(lower)) { + category = "preference"; + } else if (/rozhodli|decided|will use|budeme/i.test(lower)) { + category = "decision"; + } else if (/\+\d{10,}|@[\w.-]+\.\w+|is called|jmenuje se/i.test(lower)) { + category = "entity"; + } else if (/is|are|has|have|je|má|jsou/i.test(lower)) { + category = "fact"; + } else { + category = "other"; + } + + expect(category).toBe(expected); + } + }); +}); + +// Live tests that require OpenAI API key and actually use LanceDB +describeWithKey("memory plugin live tests", () => { + let tmpDir: string; + let dbPath: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-memory-live-")); + dbPath = path.join(tmpDir, "lancedb"); + }); + + afterEach(async () => { + if (tmpDir) { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + + test("memory tools work end-to-end", async () => { + const { default: memoryPlugin } = await import("./index.js"); + + // Mock plugin API + const registeredTools: any[] = []; + const registeredClis: any[] = []; + const registeredServices: any[] = []; + const registeredHooks: Record = {}; + const logs: string[] = []; + + const mockApi = { + id: "memory", + name: "Memory (Vector)", + source: "test", + config: {}, + pluginConfig: { + embedding: { + apiKey: OPENAI_API_KEY, + model: "text-embedding-3-small", + }, + dbPath, + autoCapture: false, + autoRecall: false, + }, + runtime: {}, + logger: { + info: (msg: string) => logs.push(`[info] ${msg}`), + warn: (msg: string) => logs.push(`[warn] ${msg}`), + error: (msg: string) => logs.push(`[error] ${msg}`), + debug: (msg: string) => logs.push(`[debug] ${msg}`), + }, + registerTool: (tool: any, opts: any) => { + registeredTools.push({ tool, opts }); + }, + registerCli: (registrar: any, opts: any) => { + registeredClis.push({ registrar, opts }); + }, + registerService: (service: any) => { + registeredServices.push(service); + }, + on: (hookName: string, handler: any) => { + if (!registeredHooks[hookName]) registeredHooks[hookName] = []; + registeredHooks[hookName].push(handler); + }, + resolvePath: (p: string) => p, + }; + + // Register plugin + await memoryPlugin.register(mockApi as any); + + // Check registration + expect(registeredTools.length).toBe(3); + expect(registeredTools.map((t) => t.opts?.name)).toContain("memory_recall"); + expect(registeredTools.map((t) => t.opts?.name)).toContain("memory_store"); + expect(registeredTools.map((t) => t.opts?.name)).toContain("memory_forget"); + expect(registeredClis.length).toBe(1); + expect(registeredServices.length).toBe(1); + + // Get tool functions + const storeTool = registeredTools.find((t) => t.opts?.name === "memory_store")?.tool; + const recallTool = registeredTools.find((t) => t.opts?.name === "memory_recall")?.tool; + const forgetTool = registeredTools.find((t) => t.opts?.name === "memory_forget")?.tool; + + // Test store + const storeResult = await storeTool.execute("test-call-1", { + text: "The user prefers dark mode for all applications", + importance: 0.8, + category: "preference", + }); + + expect(storeResult.details?.action).toBe("created"); + expect(storeResult.details?.id).toBeDefined(); + const storedId = storeResult.details?.id; + + // Test recall + const recallResult = await recallTool.execute("test-call-2", { + query: "dark mode preference", + limit: 5, + }); + + expect(recallResult.details?.count).toBeGreaterThan(0); + expect(recallResult.details?.memories?.[0]?.text).toContain("dark mode"); + + // Test duplicate detection + const duplicateResult = await storeTool.execute("test-call-3", { + text: "The user prefers dark mode for all applications", + }); + + expect(duplicateResult.details?.action).toBe("duplicate"); + + // Test forget + const forgetResult = await forgetTool.execute("test-call-4", { + memoryId: storedId, + }); + + expect(forgetResult.details?.action).toBe("deleted"); + + // Verify it's gone + const recallAfterForget = await recallTool.execute("test-call-5", { + query: "dark mode preference", + limit: 5, + }); + + expect(recallAfterForget.details?.count).toBe(0); + }, 60000); // 60s timeout for live API calls +}); diff --git a/extensions/memory/index.ts b/extensions/memory/index.ts new file mode 100644 index 000000000..80ed8b071 --- /dev/null +++ b/extensions/memory/index.ts @@ -0,0 +1,671 @@ +/** + * Clawdbot Memory Plugin + * + * Long-term memory with vector search for AI conversations. + * Uses LanceDB for storage and OpenAI for embeddings. + * Provides seamless auto-recall and auto-capture via lifecycle hooks. + */ + +import { Type } from "@sinclair/typebox"; +import * as lancedb from "@lancedb/lancedb"; +import OpenAI from "openai"; +import { randomUUID } from "node:crypto"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; + +// ============================================================================ +// Types +// ============================================================================ + +type MemoryConfig = { + embedding: { + provider: "openai"; + model?: string; + apiKey: string; + }; + dbPath?: string; + autoCapture?: boolean; + autoRecall?: boolean; +}; + +type MemoryEntry = { + id: string; + text: string; + vector: number[]; + importance: number; + category: "preference" | "fact" | "decision" | "entity" | "other"; + createdAt: number; +}; + +type MemorySearchResult = { + entry: MemoryEntry; + score: number; +}; + +// ============================================================================ +// Config Schema +// ============================================================================ + +const memoryConfigSchema = { + parse(value: unknown): MemoryConfig { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("memory config required"); + } + const cfg = value as Record; + + // Embedding config is required + const embedding = cfg.embedding as Record | undefined; + if (!embedding || typeof embedding.apiKey !== "string") { + throw new Error("embedding.apiKey is required"); + } + + return { + embedding: { + provider: "openai", + model: + typeof embedding.model === "string" + ? embedding.model + : "text-embedding-3-small", + apiKey: resolveEnvVars(embedding.apiKey), + }, + dbPath: + typeof cfg.dbPath === "string" + ? cfg.dbPath + : join(homedir(), ".clawdbot", "memory", "lancedb"), + autoCapture: cfg.autoCapture !== false, + autoRecall: cfg.autoRecall !== false, + }; + }, + uiHints: { + "embedding.apiKey": { + label: "OpenAI API Key", + sensitive: true, + placeholder: "sk-proj-...", + help: "API key for OpenAI embeddings (or use ${OPENAI_API_KEY})", + }, + "embedding.model": { + label: "Embedding Model", + placeholder: "text-embedding-3-small", + help: "OpenAI embedding model to use", + }, + dbPath: { + label: "Database Path", + placeholder: "~/.clawdbot/memory/lancedb", + advanced: true, + }, + autoCapture: { + label: "Auto-Capture", + help: "Automatically capture important information from conversations", + }, + autoRecall: { + label: "Auto-Recall", + help: "Automatically inject relevant memories into context", + }, + }, +}; + +function resolveEnvVars(value: string): string { + return value.replace(/\$\{([^}]+)\}/g, (_, envVar) => { + const envValue = process.env[envVar]; + if (!envValue) { + throw new Error(`Environment variable ${envVar} is not set`); + } + return envValue; + }); +} + +// ============================================================================ +// LanceDB Provider +// ============================================================================ + +const TABLE_NAME = "memories"; +const VECTOR_DIM = 1536; // OpenAI text-embedding-3-small + +class MemoryDB { + private db: lancedb.Connection | null = null; + private table: lancedb.Table | null = null; + private initPromise: Promise | null = null; + + constructor(private readonly dbPath: string) {} + + private async ensureInitialized(): Promise { + if (this.table) return; + if (this.initPromise) return this.initPromise; + + this.initPromise = this.doInitialize(); + return this.initPromise; + } + + private async doInitialize(): Promise { + this.db = await lancedb.connect(this.dbPath); + const tables = await this.db.tableNames(); + + if (tables.includes(TABLE_NAME)) { + this.table = await this.db.openTable(TABLE_NAME); + } else { + this.table = await this.db.createTable(TABLE_NAME, [ + { + id: "__schema__", + text: "", + vector: new Array(VECTOR_DIM).fill(0), + importance: 0, + category: "other", + createdAt: 0, + }, + ]); + await this.table.delete('id = "__schema__"'); + } + } + + async store( + entry: Omit, + ): Promise { + await this.ensureInitialized(); + + const fullEntry: MemoryEntry = { + ...entry, + id: randomUUID(), + createdAt: Date.now(), + }; + + await this.table!.add([fullEntry]); + return fullEntry; + } + + async search( + vector: number[], + limit = 5, + minScore = 0.5, + ): Promise { + await this.ensureInitialized(); + + const results = await this.table!.vectorSearch(vector).limit(limit).toArray(); + + // LanceDB uses L2 distance by default; convert to similarity score + const mapped = results.map((row) => { + const distance = row._distance ?? 0; + // Use inverse for a 0-1 range: sim = 1 / (1 + d) + const score = 1 / (1 + distance); + return { + entry: { + id: row.id as string, + text: row.text as string, + vector: row.vector as number[], + importance: row.importance as number, + category: row.category as MemoryEntry["category"], + createdAt: row.createdAt as number, + }, + score, + }; + }); + + return mapped.filter((r) => r.score >= minScore); + } + + async delete(id: string): Promise { + await this.ensureInitialized(); + // Validate UUID format to prevent injection + const uuidRegex = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if (!uuidRegex.test(id)) { + throw new Error(`Invalid memory ID format: ${id}`); + } + await this.table!.delete(`id = '${id}'`); + return true; + } + + async count(): Promise { + await this.ensureInitialized(); + return this.table!.countRows(); + } +} + +// ============================================================================ +// OpenAI Embeddings +// ============================================================================ + +class Embeddings { + private client: OpenAI; + + constructor( + apiKey: string, + private model: string, + ) { + this.client = new OpenAI({ apiKey }); + } + + async embed(text: string): Promise { + const response = await this.client.embeddings.create({ + model: this.model, + input: text, + }); + return response.data[0].embedding; + } +} + +// ============================================================================ +// Rule-based capture filter +// ============================================================================ + +const MEMORY_TRIGGERS = [ + /zapamatuj si|pamatuj|remember/i, + /preferuji|radši|nechci|prefer/i, + /rozhodli jsme|budeme používat/i, + /\+\d{10,}/, + /[\w.-]+@[\w.-]+\.\w+/, + /můj\s+\w+\s+je|je\s+můj/i, + /my\s+\w+\s+is|is\s+my/i, + /i (like|prefer|hate|love|want|need)/i, + /always|never|important/i, +]; + +function shouldCapture(text: string): boolean { + if (text.length < 10 || text.length > 500) return false; + // Skip injected context from memory recall + if (text.includes("")) return false; + // Skip system-generated content + if (text.startsWith("<") && text.includes(" 3) return false; + return MEMORY_TRIGGERS.some((r) => r.test(text)); +} + +function detectCategory( + text: string, +): "preference" | "fact" | "decision" | "entity" | "other" { + const lower = text.toLowerCase(); + if (/prefer|radši|like|love|hate|want/i.test(lower)) return "preference"; + if (/rozhodli|decided|will use|budeme/i.test(lower)) return "decision"; + if (/\+\d{10,}|@[\w.-]+\.\w+|is called|jmenuje se/i.test(lower)) + return "entity"; + if (/is|are|has|have|je|má|jsou/i.test(lower)) return "fact"; + return "other"; +} + +// ============================================================================ +// Plugin Definition +// ============================================================================ + +const memoryPlugin = { + id: "memory", + name: "Memory (Vector)", + description: "Long-term memory with vector search and seamless auto-recall/capture", + kind: "memory" as const, + configSchema: memoryConfigSchema, + + register(api: ClawdbotPluginApi) { + const cfg = memoryConfigSchema.parse(api.pluginConfig); + const db = new MemoryDB(cfg.dbPath!); + const embeddings = new Embeddings(cfg.embedding.apiKey, cfg.embedding.model!); + + api.logger.info(`memory: plugin registered (db: ${cfg.dbPath}, lazy init)`); + + // ======================================================================== + // Tools + // ======================================================================== + + api.registerTool( + { + name: "memory_recall", + label: "Memory Recall", + description: + "Search through long-term memories. Use when you need context about user preferences, past decisions, or previously discussed topics.", + parameters: Type.Object({ + query: Type.String({ description: "Search query" }), + limit: Type.Optional(Type.Number({ description: "Max results (default: 5)" })), + }), + async execute(_toolCallId, params) { + const { query, limit = 5 } = params as { query: string; limit?: number }; + + const vector = await embeddings.embed(query); + const results = await db.search(vector, limit, 0.1); + + if (results.length === 0) { + return { + content: [{ type: "text", text: "No relevant memories found." }], + details: { count: 0 }, + }; + } + + const text = results + .map( + (r, i) => + `${i + 1}. [${r.entry.category}] ${r.entry.text} (${(r.score * 100).toFixed(0)}%)`, + ) + .join("\n"); + + // Strip vector data for serialization (typed arrays can't be cloned) + const sanitizedResults = results.map((r) => ({ + id: r.entry.id, + text: r.entry.text, + category: r.entry.category, + importance: r.entry.importance, + score: r.score, + })); + + return { + content: [ + { type: "text", text: `Found ${results.length} memories:\n\n${text}` }, + ], + details: { count: results.length, memories: sanitizedResults }, + }; + }, + }, + { name: "memory_recall" }, + ); + + api.registerTool( + { + name: "memory_store", + label: "Memory Store", + description: + "Save important information in long-term memory. Use for preferences, facts, decisions.", + parameters: Type.Object({ + text: Type.String({ description: "Information to remember" }), + importance: Type.Optional( + Type.Number({ description: "Importance 0-1 (default: 0.7)" }), + ), + category: Type.Optional( + Type.Union([ + Type.Literal("preference"), + Type.Literal("fact"), + Type.Literal("decision"), + Type.Literal("entity"), + Type.Literal("other"), + ]), + ), + }), + async execute(_toolCallId, params) { + const { + text, + importance = 0.7, + category = "other", + } = params as { + text: string; + importance?: number; + category?: MemoryEntry["category"]; + }; + + const vector = await embeddings.embed(text); + + // Check for duplicates + const existing = await db.search(vector, 1, 0.95); + if (existing.length > 0) { + return { + content: [ + { type: "text", text: `Similar memory already exists: "${existing[0].entry.text}"` }, + ], + details: { action: "duplicate", existingId: existing[0].entry.id, existingText: existing[0].entry.text }, + }; + } + + const entry = await db.store({ + text, + vector, + importance, + category, + }); + + return { + content: [{ type: "text", text: `Stored: "${text.slice(0, 100)}..."` }], + details: { action: "created", id: entry.id }, + }; + }, + }, + { name: "memory_store" }, + ); + + api.registerTool( + { + name: "memory_forget", + label: "Memory Forget", + description: "Delete specific memories. GDPR-compliant.", + parameters: Type.Object({ + query: Type.Optional(Type.String({ description: "Search to find memory" })), + memoryId: Type.Optional(Type.String({ description: "Specific memory ID" })), + }), + async execute(_toolCallId, params) { + const { query, memoryId } = params as { query?: string; memoryId?: string }; + + if (memoryId) { + await db.delete(memoryId); + return { + content: [{ type: "text", text: `Memory ${memoryId} forgotten.` }], + details: { action: "deleted", id: memoryId }, + }; + } + + if (query) { + const vector = await embeddings.embed(query); + const results = await db.search(vector, 5, 0.7); + + if (results.length === 0) { + return { + content: [{ type: "text", text: "No matching memories found." }], + details: { found: 0 }, + }; + } + + if (results.length === 1 && results[0].score > 0.9) { + await db.delete(results[0].entry.id); + return { + content: [ + { type: "text", text: `Forgotten: "${results[0].entry.text}"` }, + ], + details: { action: "deleted", id: results[0].entry.id }, + }; + } + + const list = results + .map((r) => `- [${r.entry.id.slice(0, 8)}] ${r.entry.text.slice(0, 60)}...`) + .join("\n"); + + // Strip vector data for serialization + const sanitizedCandidates = results.map((r) => ({ + id: r.entry.id, + text: r.entry.text, + category: r.entry.category, + score: r.score, + })); + + return { + content: [ + { + type: "text", + text: `Found ${results.length} candidates. Specify memoryId:\n${list}`, + }, + ], + details: { action: "candidates", candidates: sanitizedCandidates }, + }; + } + + return { + content: [{ type: "text", text: "Provide query or memoryId." }], + details: { error: "missing_param" }, + }; + }, + }, + { name: "memory_forget" }, + ); + + // ======================================================================== + // CLI Commands + // ======================================================================== + + api.registerCli( + ({ program }) => { + const memory = program + .command("ltm") + .description("Long-term memory plugin commands"); + + memory + .command("list") + .description("List memories") + .action(async () => { + const count = await db.count(); + console.log(`Total memories: ${count}`); + }); + + memory + .command("search") + .description("Search memories") + .argument("", "Search query") + .option("--limit ", "Max results", "5") + .action(async (query, opts) => { + const vector = await embeddings.embed(query); + const results = await db.search(vector, parseInt(opts.limit), 0.3); + // Strip vectors for output + const output = results.map((r) => ({ + id: r.entry.id, + text: r.entry.text, + category: r.entry.category, + importance: r.entry.importance, + score: r.score, + })); + console.log(JSON.stringify(output, null, 2)); + }); + + memory + .command("stats") + .description("Show memory statistics") + .action(async () => { + const count = await db.count(); + console.log(`Total memories: ${count}`); + }); + }, + { commands: ["ltm"] }, + ); + + // ======================================================================== + // Lifecycle Hooks + // ======================================================================== + + // Auto-recall: inject relevant memories before agent starts + if (cfg.autoRecall) { + api.on("before_agent_start", async (event) => { + if (!event.prompt || event.prompt.length < 5) return; + + try { + const vector = await embeddings.embed(event.prompt); + const results = await db.search(vector, 3, 0.3); + + if (results.length === 0) return; + + const memoryContext = results + .map((r) => `- [${r.entry.category}] ${r.entry.text}`) + .join("\n"); + + api.logger.info?.( + `memory: injecting ${results.length} memories into context`, + ); + + return { + prependContext: `\nThe following memories may be relevant to this conversation:\n${memoryContext}\n`, + }; + } catch (err) { + api.logger.warn(`memory: recall failed: ${String(err)}`); + } + }); + } + + // Auto-capture: analyze and store important information after agent ends + if (cfg.autoCapture) { + api.on("agent_end", async (event) => { + if (!event.success || !event.messages || event.messages.length === 0) { + return; + } + + try { + // Extract text content from messages (handling unknown[] type) + const texts: string[] = []; + for (const msg of event.messages) { + // Type guard for message object + if (!msg || typeof msg !== "object") continue; + const msgObj = msg as Record; + + // Only process user and assistant messages + const role = msgObj.role; + if (role !== "user" && role !== "assistant") continue; + + const content = msgObj.content; + + // Handle string content directly + if (typeof content === "string") { + texts.push(content); + continue; + } + + // Handle array content (content blocks) + if (Array.isArray(content)) { + for (const block of content) { + if ( + block && + typeof block === "object" && + "type" in block && + (block as Record).type === "text" && + "text" in block && + typeof (block as Record).text === "string" + ) { + texts.push((block as Record).text as string); + } + } + } + } + + // Filter for capturable content + const toCapture = texts.filter( + (text) => text && shouldCapture(text), + ); + if (toCapture.length === 0) return; + + // Store each capturable piece (limit to 3 per conversation) + let stored = 0; + for (const text of toCapture.slice(0, 3)) { + const category = detectCategory(text); + const vector = await embeddings.embed(text); + + // Check for duplicates (high similarity threshold) + const existing = await db.search(vector, 1, 0.95); + if (existing.length > 0) continue; + + await db.store({ + text, + vector, + importance: 0.7, + category, + }); + stored++; + } + + if (stored > 0) { + api.logger.info(`memory: auto-captured ${stored} memories`); + } + } catch (err) { + api.logger.warn(`memory: capture failed: ${String(err)}`); + } + }); + } + + // ======================================================================== + // Service + // ======================================================================== + + api.registerService({ + id: "memory", + start: () => { + api.logger.info( + `memory: initialized (db: ${cfg.dbPath}, model: ${cfg.embedding.model})`, + ); + }, + stop: () => { + api.logger.info("memory: stopped"); + }, + }); + }, +}; + +export default memoryPlugin; diff --git a/extensions/memory/package.json b/extensions/memory/package.json new file mode 100644 index 000000000..cd5951486 --- /dev/null +++ b/extensions/memory/package.json @@ -0,0 +1,14 @@ +{ + "name": "@clawdbot/memory", + "version": "0.0.1", + "type": "module", + "description": "Clawdbot long-term memory plugin with vector search and seamless auto-recall/capture", + "dependencies": { + "@sinclair/typebox": "0.34.47", + "@lancedb/lancedb": "^0.15.0", + "openai": "^4.77.0" + }, + "clawdbot": { + "extensions": ["./index.ts"] + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1b3cac3f6..ed48ddf95 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -258,6 +258,18 @@ importers: specifier: 40.0.0 version: 40.0.0 + extensions/memory: + dependencies: + '@lancedb/lancedb': + specifier: ^0.15.0 + version: 0.15.0(apache-arrow@18.1.0) + '@sinclair/typebox': + specifier: 0.34.47 + version: 0.34.47 + openai: + specifier: ^4.77.0 + version: 4.104.0(ws@8.19.0)(zod@3.25.76) + extensions/memory-core: dependencies: clawdbot: @@ -958,6 +970,62 @@ packages: '@kwsites/promise-deferred@1.1.1': resolution: {integrity: sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==} + '@lancedb/lancedb-darwin-arm64@0.15.0': + resolution: {integrity: sha512-e6eiS1dUdSx3G3JXFEn5bk6I26GR7UM2QwQ1YMrTsg7IvGDqKmXc/s5j4jpJH0mzm7rwqh+OAILPIjr7DoUCDA==} + engines: {node: '>= 18'} + cpu: [arm64] + os: [darwin] + + '@lancedb/lancedb-darwin-x64@0.15.0': + resolution: {integrity: sha512-kEgigrqKf954egDbUdIp86tjVfFmTCTcq2Hydw/WLc+LI++46aeT2MsJv0CQpkNFMfh/T2G18FsDYLKH0zTaow==} + engines: {node: '>= 18'} + cpu: [x64] + os: [darwin] + + '@lancedb/lancedb-linux-arm64-gnu@0.15.0': + resolution: {integrity: sha512-TnpbBT9kaSYQqastJ+S5jm4S5ZYBx18X8PHQ1ic3yMIdPTjCWauj+owDovOpiXK9ucjmi/FnUp8bKNxGnlqmEg==} + engines: {node: '>= 18'} + cpu: [arm64] + os: [linux] + + '@lancedb/lancedb-linux-arm64-musl@0.15.0': + resolution: {integrity: sha512-fe8LnC9YKbLgEJiLQhyVj+xz1d1RgWKs+rLSYPxaD3xQBo3kMC94Esq+xfrdNkSFvPgchRTvBA9jDYJjJL8rcg==} + engines: {node: '>= 18'} + cpu: [arm64] + os: [linux] + + '@lancedb/lancedb-linux-x64-gnu@0.15.0': + resolution: {integrity: sha512-0lKEc3M06ax3RozBbxHuNN9qWqhJUiKDnRC3ttsbmo4VrOUBvAO3fKoaRkjZhAA8q4+EdhZnCaQZezsk60f7Ag==} + engines: {node: '>= 18'} + cpu: [x64] + os: [linux] + + '@lancedb/lancedb-linux-x64-musl@0.15.0': + resolution: {integrity: sha512-ls+ikV7vWyVnqVT7bMmuqfGCwVR5JzPIfJ5iZ4rkjU4iTIQRpY7u/cTe9rGKt/+psliji8x6PPZHpfdGXHmleQ==} + engines: {node: '>= 18'} + cpu: [x64] + os: [linux] + + '@lancedb/lancedb-win32-arm64-msvc@0.15.0': + resolution: {integrity: sha512-C30A+nDaJ4jhjN76hRcp28Eq+G48SR9wO3i1zGm0ZAEcRV1t9O1fAp6g18IPT65Qyu/hXJBgBdVHtent+qg9Ng==} + engines: {node: '>= 18'} + cpu: [arm64] + os: [win32] + + '@lancedb/lancedb-win32-x64-msvc@0.15.0': + resolution: {integrity: sha512-amXzIAxqrHyp+c9TpIDI8ze1uCqWC6HXQIoXkoMQrBXoUUo8tJORH2yGAsa3TSgjZDDjg0HPA33dYLhOLk1m8g==} + engines: {node: '>= 18'} + cpu: [x64] + os: [win32] + + '@lancedb/lancedb@0.15.0': + resolution: {integrity: sha512-qm3GXLA17/nFGUwrOEuFNW0Qg2gvCtp+yAs6qoCM6vftIreqzp8d4Hio6eG/YojS9XqPnR2q+zIeIFy12Ywvxg==} + engines: {node: '>= 18'} + cpu: [x64, arm64] + os: [darwin, linux, win32] + peerDependencies: + apache-arrow: '>=15.0.0 <=18.1.0' + '@lit-labs/signals@0.2.0': resolution: {integrity: sha512-68plyIbciumbwKaiilhLNyhz4Vg6/+nJwDufG2xxWA9r/fUw58jxLHCAlKs+q1CE5Lmh3cZ3ShyYKnOCebEpVA==} @@ -1954,6 +2022,9 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@swc/helpers@0.5.18': + resolution: {integrity: sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==} + '@thi.ng/bitstream@2.4.37': resolution: {integrity: sha512-ghVt+/73cChlhHDNQH9+DnxvoeVYYBu7AYsS0Gvwq25fpCa4LaqnEk5LAJfsY043HInwcV7/0KGO7P+XZCzumQ==} engines: {node: '>=18'} @@ -1988,6 +2059,12 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/command-line-args@5.2.3': + resolution: {integrity: sha512-uv0aG6R0Y8WHZLTamZwtfsDLVRnOa+n+n5rEvFWL5Na5gZ8V2Teab/duDPFzIIIhs9qizDpcavCusCLJZu62Kw==} + + '@types/command-line-usage@5.0.4': + resolution: {integrity: sha512-BwR5KP3Es/CSht0xqBcUXS3qCAUVXwpRKsV2+arxeb65atasuXG9LykC9Ab10Cw3s2raH92ZqOeILaQbsB2ACg==} + '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} @@ -2039,9 +2116,18 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node-fetch@2.6.13': + resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==} + '@types/node@10.17.60': resolution: {integrity: sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==} + '@types/node@18.19.130': + resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} + + '@types/node@20.19.30': + resolution: {integrity: sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==} + '@types/node@24.10.7': resolution: {integrity: sha512-+054pVMzVTmRQV8BhpGv3UyfZ2Llgl8rdpDTon+cUH9+na0ncBVXj3wTUKh14+Kiz18ziM3b4ikpP5/Pc0rQEQ==} @@ -2184,6 +2270,10 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + agentkeepalive@4.6.0: + resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} + engines: {node: '>= 8.0.0'} + ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} peerDependencies: @@ -2228,6 +2318,10 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} + apache-arrow@18.1.0: + resolution: {integrity: sha512-v/ShMp57iBnBp4lDgV8Jx3d3Q5/Hac25FWmQ98eMahUiHPXcvwIMKJD0hBIgclm/FCG+LwPkAKtkRO1O/W0YGg==} + hasBin: true + aproba@2.1.0: resolution: {integrity: sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==} @@ -2239,6 +2333,14 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + array-back@3.1.0: + resolution: {integrity: sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==} + engines: {node: '>=6'} + + array-back@6.2.2: + resolution: {integrity: sha512-gUAZ7HPyb4SJczXAMUXMGAvI976JoK3qEx9v1FTmeYuJj0IBiaKttG1ydtGKdkfqWkIkouke7nG8ufGy77+Cvw==} + engines: {node: '>=12.17'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -2356,6 +2458,10 @@ packages: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + chalk-template@0.4.0: + resolution: {integrity: sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==} + engines: {node: '>=12'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -2441,6 +2547,14 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + command-line-args@5.2.1: + resolution: {integrity: sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==} + engines: {node: '>=4.0.0'} + + command-line-usage@7.0.3: + resolution: {integrity: sha512-PqMLy5+YGwhMh1wS04mVG44oqDsgyLRSKJBdOo1bnYhMKBW65gZF1dRp2OZRhiTjgUHljy99qkO7bsctLaw35Q==} + engines: {node: '>=12.20.0'} + commander@10.0.1: resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} engines: {node: '>=14'} @@ -2721,6 +2835,13 @@ packages: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} + find-replace@3.0.0: + resolution: {integrity: sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==} + engines: {node: '>=4.0.0'} + + flatbuffers@24.12.23: + resolution: {integrity: sha512-dLVCAISd5mhls514keQzmEG6QHmUUsNuWsb4tFafIUwvvgDjXhtfAYSKOzt5SWOy+qByV5pbsDZ+Vb7HUOBEdA==} + follow-redirects@1.15.11: resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} engines: {node: '>=4.0'} @@ -2734,10 +2855,17 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + form-data-encoder@1.7.2: + resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==} + form-data@4.0.5: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} + formdata-node@4.4.1: + resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==} + engines: {node: '>= 12.20'} + formdata-polyfill@4.0.10: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} @@ -2896,6 +3024,9 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + humanize-ms@1.2.1: + resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} + iconv-lite@0.7.2: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} @@ -3023,6 +3154,10 @@ packages: json-bigint@1.0.0: resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + json-bignum@0.0.3: + resolution: {integrity: sha512-2WHyXj3OfHSgNyuzDbSxI1w2jgw5gkWSWhS7Qg4bWXx1nLk3jnbwfUeS0PSba3IzpTUWdHxBieELUzXRjQB2zg==} + engines: {node: '>=0.8'} + json-schema-to-ts@3.1.1: resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} engines: {node: '>=16'} @@ -3172,6 +3307,9 @@ packages: lit@3.3.2: resolution: {integrity: sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ==} + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + lodash.clonedeep@4.5.0: resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==} @@ -3477,6 +3615,18 @@ packages: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} + openai@4.104.0: + resolution: {integrity: sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==} + hasBin: true + peerDependencies: + ws: ^8.18.0 + zod: ^3.23.8 + peerDependenciesMeta: + ws: + optional: true + zod: + optional: true + openai@6.10.0: resolution: {integrity: sha512-ITxOGo7rO3XRMiKA5l7tQ43iNNu+iXGFAcf2t+aWVzzqRaS0i7m1K2BhxNdaveB+5eENhO0VY1FkiZzhBk4v3A==} hasBin: true @@ -3765,6 +3915,9 @@ packages: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -4027,6 +4180,10 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + table-layout@4.1.1: + resolution: {integrity: sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==} + engines: {node: '>=12.17'} + tailwind-merge@3.4.0: resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==} @@ -4130,6 +4287,14 @@ packages: engines: {node: '>=14.17'} hasBin: true + typical@4.0.0: + resolution: {integrity: sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==} + engines: {node: '>=8'} + + typical@7.3.0: + resolution: {integrity: sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw==} + engines: {node: '>=12.17'} + uc.micro@2.1.0: resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} @@ -4143,6 +4308,12 @@ packages: resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} engines: {node: '>=18'} + undici-types@5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} @@ -4280,6 +4451,10 @@ packages: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} + web-streams-polyfill@4.0.0-beta.3: + resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==} + engines: {node: '>= 14'} + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -4315,6 +4490,10 @@ packages: wordwrap@1.0.0: resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + wordwrapjs@5.1.1: + resolution: {integrity: sha512-0yweIbkINJodk27gX9LBGMzyQdBDan3s/dEAiwBOj+Mf0PPyWL6/rikalkv8EeD0E8jm4o5RXEOrFTP3NXbhJg==} + engines: {node: '>=12.17'} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -5223,6 +5402,44 @@ snapshots: '@kwsites/promise-deferred@1.1.1': optional: true + '@lancedb/lancedb-darwin-arm64@0.15.0': + optional: true + + '@lancedb/lancedb-darwin-x64@0.15.0': + optional: true + + '@lancedb/lancedb-linux-arm64-gnu@0.15.0': + optional: true + + '@lancedb/lancedb-linux-arm64-musl@0.15.0': + optional: true + + '@lancedb/lancedb-linux-x64-gnu@0.15.0': + optional: true + + '@lancedb/lancedb-linux-x64-musl@0.15.0': + optional: true + + '@lancedb/lancedb-win32-arm64-msvc@0.15.0': + optional: true + + '@lancedb/lancedb-win32-x64-msvc@0.15.0': + optional: true + + '@lancedb/lancedb@0.15.0(apache-arrow@18.1.0)': + dependencies: + apache-arrow: 18.1.0 + reflect-metadata: 0.2.2 + optionalDependencies: + '@lancedb/lancedb-darwin-arm64': 0.15.0 + '@lancedb/lancedb-darwin-x64': 0.15.0 + '@lancedb/lancedb-linux-arm64-gnu': 0.15.0 + '@lancedb/lancedb-linux-arm64-musl': 0.15.0 + '@lancedb/lancedb-linux-x64-gnu': 0.15.0 + '@lancedb/lancedb-linux-x64-musl': 0.15.0 + '@lancedb/lancedb-win32-arm64-msvc': 0.15.0 + '@lancedb/lancedb-win32-x64-msvc': 0.15.0 + '@lit-labs/signals@0.2.0': dependencies: lit: 3.3.2 @@ -6298,6 +6515,10 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@swc/helpers@0.5.18': + dependencies: + tslib: 2.8.1 + '@thi.ng/bitstream@2.4.37': dependencies: '@thi.ng/errors': 2.6.0 @@ -6341,6 +6562,10 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/command-line-args@5.2.3': {} + + '@types/command-line-usage@5.0.4': {} + '@types/connect@3.4.38': dependencies: '@types/node': 25.0.7 @@ -6402,8 +6627,21 @@ snapshots: '@types/ms@2.1.0': {} + '@types/node-fetch@2.6.13': + dependencies: + '@types/node': 25.0.7 + form-data: 4.0.5 + '@types/node@10.17.60': {} + '@types/node@18.19.130': + dependencies: + undici-types: 5.26.5 + + '@types/node@20.19.30': + dependencies: + undici-types: 6.21.0 + '@types/node@24.10.7': dependencies: undici-types: 7.16.0 @@ -6608,6 +6846,10 @@ snapshots: agent-base@7.1.4: {} + agentkeepalive@4.6.0: + dependencies: + humanize-ms: 1.2.1 + ajv-formats@3.0.1(ajv@8.17.1): optionalDependencies: ajv: 8.17.1 @@ -6643,6 +6885,18 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.1 + apache-arrow@18.1.0: + dependencies: + '@swc/helpers': 0.5.18 + '@types/command-line-args': 5.2.3 + '@types/command-line-usage': 5.0.4 + '@types/node': 20.19.30 + command-line-args: 5.2.1 + command-line-usage: 7.0.3 + flatbuffers: 24.12.23 + json-bignum: 0.0.3 + tslib: 2.8.1 + aproba@2.1.0: optional: true @@ -6654,6 +6908,10 @@ snapshots: argparse@2.0.1: {} + array-back@3.1.0: {} + + array-back@6.2.2: {} + assertion-error@2.0.1: {} ast-v8-to-istanbul@0.3.10: @@ -6790,6 +7048,10 @@ snapshots: chai@6.2.2: {} + chalk-template@0.4.0: + dependencies: + chalk: 4.1.2 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -6899,6 +7161,20 @@ snapshots: dependencies: delayed-stream: 1.0.0 + command-line-args@5.2.1: + dependencies: + array-back: 3.1.0 + find-replace: 3.0.0 + lodash.camelcase: 4.3.0 + typical: 4.0.0 + + command-line-usage@7.0.3: + dependencies: + array-back: 6.2.2 + chalk-template: 0.4.0 + table-layout: 4.1.1 + typical: 7.3.0 + commander@10.0.1: optional: true @@ -7196,6 +7472,12 @@ snapshots: transitivePeerDependencies: - supports-color + find-replace@3.0.0: + dependencies: + array-back: 3.1.0 + + flatbuffers@24.12.23: {} + follow-redirects@1.15.11(debug@4.4.3): optionalDependencies: debug: 4.4.3 @@ -7205,6 +7487,8 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + form-data-encoder@1.7.2: {} + form-data@4.0.5: dependencies: asynckit: 0.4.0 @@ -7213,6 +7497,11 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 + formdata-node@4.4.1: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 4.0.0-beta.3 + formdata-polyfill@4.0.10: dependencies: fetch-blob: 3.2.0 @@ -7410,6 +7699,10 @@ snapshots: transitivePeerDependencies: - supports-color + humanize-ms@1.2.1: + dependencies: + ms: 2.1.3 + iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 @@ -7533,6 +7826,8 @@ snapshots: dependencies: bignumber.js: 9.3.1 + json-bignum@0.0.3: {} + json-schema-to-ts@3.1.1: dependencies: '@babel/runtime': 7.28.6 @@ -7693,6 +7988,8 @@ snapshots: lit-element: 4.2.2 lit-html: 3.3.2 + lodash.camelcase@4.3.0: {} + lodash.clonedeep@4.5.0: {} lodash.debounce@4.0.8: @@ -8036,6 +8333,21 @@ snapshots: mimic-function: 5.0.1 optional: true + openai@4.104.0(ws@8.19.0)(zod@3.25.76): + dependencies: + '@types/node': 18.19.130 + '@types/node-fetch': 2.6.13 + abort-controller: 3.0.0 + agentkeepalive: 4.6.0 + form-data-encoder: 1.7.2 + formdata-node: 4.4.1 + node-fetch: 2.7.0 + optionalDependencies: + ws: 8.19.0 + zod: 3.25.76 + transitivePeerDependencies: + - encoding + openai@6.10.0(ws@8.19.0)(zod@4.3.5): optionalDependencies: ws: 8.19.0 @@ -8366,6 +8678,8 @@ snapshots: real-require@0.2.0: {} + reflect-metadata@0.2.2: {} + require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -8708,6 +9022,11 @@ snapshots: dependencies: has-flag: 4.0.0 + table-layout@4.1.1: + dependencies: + array-back: 6.2.2 + wordwrapjs: 5.1.1 + tailwind-merge@3.4.0: {} tailwind-variants@3.2.2(tailwind-merge@3.4.0)(tailwindcss@4.1.17): @@ -8795,6 +9114,10 @@ snapshots: typescript@5.9.3: {} + typical@4.0.0: {} + + typical@7.3.0: {} + uc.micro@2.1.0: {} uhtml@5.0.9: @@ -8805,6 +9128,10 @@ snapshots: uint8array-extras@1.5.0: {} + undici-types@5.26.5: {} + + undici-types@6.21.0: {} + undici-types@7.16.0: {} undici@7.18.2: {} @@ -8906,6 +9233,8 @@ snapshots: web-streams-polyfill@3.3.3: {} + web-streams-polyfill@4.0.0-beta.3: {} + webidl-conversions@3.0.1: {} whatwg-fetch@3.6.20: {} @@ -8944,6 +9273,8 @@ snapshots: wordwrap@1.0.0: {} + wordwrapjs@5.1.1: {} + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 60faee4f7..25ef5155f 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -64,8 +64,9 @@ import { prepareSessionManagerForRun } from "../session-manager-init.js"; import { buildEmbeddedSystemPrompt, createSystemPromptOverride } from "../system-prompt.js"; import { splitSdkTools } from "../tool-split.js"; import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../../date-time.js"; -import { mapThinkingLevel } from "../utils.js"; +import { describeUnknownError, mapThinkingLevel } from "../utils.js"; import { resolveSandboxRuntimeStatus } from "../../sandbox/runtime-status.js"; +import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js"; import type { EmbeddedRunAttemptParams, EmbeddedRunAttemptResult } from "./types.js"; @@ -458,9 +459,40 @@ export async function runEmbeddedAttempt( } } + // Get hook runner once for both before_agent_start and agent_end hooks + const hookRunner = getGlobalHookRunner(); + let promptError: unknown = null; try { const promptStartedAt = Date.now(); + + // Run before_agent_start hooks to allow plugins to inject context + let effectivePrompt = params.prompt; + if (hookRunner?.hasHooks("before_agent_start")) { + try { + const hookResult = await hookRunner.runBeforeAgentStart( + { + prompt: params.prompt, + messages: activeSession.messages, + }, + { + agentId: params.sessionKey?.split(":")[0] ?? "main", + sessionKey: params.sessionKey, + workspaceDir: params.workspaceDir, + messageProvider: params.messageProvider ?? undefined, + }, + ); + if (hookResult?.prependContext) { + effectivePrompt = `${hookResult.prependContext}\n\n${params.prompt}`; + log.debug( + `hooks: prepended context to prompt (${hookResult.prependContext.length} chars)`, + ); + } + } catch (hookErr) { + log.warn(`before_agent_start hook failed: ${String(hookErr)}`); + } + } + log.debug(`embedded run prompt start: runId=${params.runId} sessionId=${params.sessionId}`); // Repair orphaned trailing user messages so new prompts don't violate role ordering. @@ -480,7 +512,7 @@ export async function runEmbeddedAttempt( } try { - await abortable(activeSession.prompt(params.prompt, { images: params.images })); + await abortable(activeSession.prompt(effectivePrompt, { images: params.images })); } catch (err) { promptError = err; } finally { @@ -501,6 +533,29 @@ export async function runEmbeddedAttempt( messagesSnapshot = activeSession.messages.slice(); sessionIdUsed = activeSession.sessionId; + + // Run agent_end hooks to allow plugins to analyze the conversation + // This is fire-and-forget, so we don't await + if (hookRunner?.hasHooks("agent_end")) { + hookRunner + .runAgentEnd( + { + messages: messagesSnapshot, + success: !aborted && !promptError, + error: promptError ? describeUnknownError(promptError) : undefined, + durationMs: Date.now() - promptStartedAt, + }, + { + agentId: params.sessionKey?.split(":")[0] ?? "main", + sessionKey: params.sessionKey, + workspaceDir: params.workspaceDir, + messageProvider: params.messageProvider ?? undefined, + }, + ) + .catch((err) => { + log.warn(`agent_end hook failed: ${err}`); + }); + } } finally { clearTimeout(abortTimer); if (abortWarnTimer) clearTimeout(abortWarnTimer); diff --git a/src/gateway/server/__tests__/test-utils.ts b/src/gateway/server/__tests__/test-utils.ts index d22ecc63d..6aabd8b5d 100644 --- a/src/gateway/server/__tests__/test-utils.ts +++ b/src/gateway/server/__tests__/test-utils.ts @@ -5,6 +5,7 @@ export const createTestRegistry = (overrides: Partial = {}): Plu plugins: [], tools: [], hooks: [], + typedHooks: [], channels: [], providers: [], gatewayHandlers: {}, diff --git a/src/plugins/hook-runner-global.ts b/src/plugins/hook-runner-global.ts new file mode 100644 index 000000000..e01f61c51 --- /dev/null +++ b/src/plugins/hook-runner-global.ts @@ -0,0 +1,67 @@ +/** + * Global Plugin Hook Runner + * + * Singleton hook runner that's initialized when plugins are loaded + * and can be called from anywhere in the codebase. + */ + +import { createSubsystemLogger } from "../logging.js"; +import { createHookRunner, type HookRunner } from "./hooks.js"; +import type { PluginRegistry } from "./registry.js"; + +const log = createSubsystemLogger("plugins"); + +let globalHookRunner: HookRunner | null = null; +let globalRegistry: PluginRegistry | null = null; + +/** + * Initialize the global hook runner with a plugin registry. + * Called once when plugins are loaded during gateway startup. + */ +export function initializeGlobalHookRunner(registry: PluginRegistry): void { + globalRegistry = registry; + globalHookRunner = createHookRunner(registry, { + logger: { + debug: (msg) => log.debug(msg), + warn: (msg) => log.warn(msg), + error: (msg) => log.error(msg), + }, + catchErrors: true, + }); + + const hookCount = registry.hooks.length; + if (hookCount > 0) { + log.info(`hook runner initialized with ${hookCount} registered hooks`); + } +} + +/** + * Get the global hook runner. + * Returns null if plugins haven't been loaded yet. + */ +export function getGlobalHookRunner(): HookRunner | null { + return globalHookRunner; +} + +/** + * Get the global plugin registry. + * Returns null if plugins haven't been loaded yet. + */ +export function getGlobalPluginRegistry(): PluginRegistry | null { + return globalRegistry; +} + +/** + * Check if any hooks are registered for a given hook name. + */ +export function hasGlobalHooks(hookName: Parameters[0]): boolean { + return globalHookRunner?.hasHooks(hookName) ?? false; +} + +/** + * Reset the global hook runner (for testing). + */ +export function resetGlobalHookRunner(): void { + globalHookRunner = null; + globalRegistry = null; +} diff --git a/src/plugins/hooks.ts b/src/plugins/hooks.ts new file mode 100644 index 000000000..fa0591e12 --- /dev/null +++ b/src/plugins/hooks.ts @@ -0,0 +1,400 @@ +/** + * Plugin Hook Runner + * + * Provides utilities for executing plugin lifecycle hooks with proper + * error handling, priority ordering, and async support. + */ + +import type { PluginRegistry } from "./registry.js"; +import type { + PluginHookAfterCompactionEvent, + PluginHookAfterToolCallEvent, + PluginHookAgentContext, + PluginHookAgentEndEvent, + PluginHookBeforeAgentStartEvent, + PluginHookBeforeAgentStartResult, + PluginHookBeforeCompactionEvent, + PluginHookBeforeToolCallEvent, + PluginHookBeforeToolCallResult, + PluginHookGatewayContext, + PluginHookGatewayStartEvent, + PluginHookGatewayStopEvent, + PluginHookMessageContext, + PluginHookMessageReceivedEvent, + PluginHookMessageSendingEvent, + PluginHookMessageSendingResult, + PluginHookMessageSentEvent, + PluginHookName, + PluginHookRegistration, + PluginHookSessionContext, + PluginHookSessionEndEvent, + PluginHookSessionStartEvent, + PluginHookToolContext, +} from "./types.js"; + +// Re-export types for consumers +export type { + PluginHookAgentContext, + PluginHookBeforeAgentStartEvent, + PluginHookBeforeAgentStartResult, + PluginHookAgentEndEvent, + PluginHookBeforeCompactionEvent, + PluginHookAfterCompactionEvent, + PluginHookMessageContext, + PluginHookMessageReceivedEvent, + PluginHookMessageSendingEvent, + PluginHookMessageSendingResult, + PluginHookMessageSentEvent, + PluginHookToolContext, + PluginHookBeforeToolCallEvent, + PluginHookBeforeToolCallResult, + PluginHookAfterToolCallEvent, + PluginHookSessionContext, + PluginHookSessionStartEvent, + PluginHookSessionEndEvent, + PluginHookGatewayContext, + PluginHookGatewayStartEvent, + PluginHookGatewayStopEvent, +}; + +export type HookRunnerLogger = { + debug?: (message: string) => void; + warn: (message: string) => void; + error: (message: string) => void; +}; + +export type HookRunnerOptions = { + logger?: HookRunnerLogger; + /** If true, errors in hooks will be caught and logged instead of thrown */ + catchErrors?: boolean; +}; + +/** + * Get hooks for a specific hook name, sorted by priority (higher first). + */ +function getHooksForName( + registry: PluginRegistry, + hookName: K, +): PluginHookRegistration[] { + return (registry.typedHooks as PluginHookRegistration[]) + .filter((h) => h.hookName === hookName) + .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)); +} + +/** + * Create a hook runner for a specific registry. + */ +export function createHookRunner(registry: PluginRegistry, options: HookRunnerOptions = {}) { + const logger = options.logger; + const catchErrors = options.catchErrors ?? true; + + /** + * Run a hook that doesn't return a value (fire-and-forget style). + * All handlers are executed in parallel for performance. + */ + async function runVoidHook( + hookName: K, + event: Parameters["handler"]>>[0], + ctx: Parameters["handler"]>>[1], + ): Promise { + const hooks = getHooksForName(registry, hookName); + if (hooks.length === 0) return; + + logger?.debug?.(`[hooks] running ${hookName} (${hooks.length} handlers)`); + + const promises = hooks.map(async (hook) => { + try { + await (hook.handler as (event: unknown, ctx: unknown) => Promise)(event, ctx); + } catch (err) { + const msg = `[hooks] ${hookName} handler from ${hook.pluginId} failed: ${String(err)}`; + if (catchErrors) { + logger?.error(msg); + } else { + throw new Error(msg); + } + } + }); + + await Promise.all(promises); + } + + /** + * Run a hook that can return a modifying result. + * Handlers are executed sequentially in priority order, and results are merged. + */ + async function runModifyingHook( + hookName: K, + event: Parameters["handler"]>>[0], + ctx: Parameters["handler"]>>[1], + mergeResults?: (accumulated: TResult | undefined, next: TResult) => TResult, + ): Promise { + const hooks = getHooksForName(registry, hookName); + if (hooks.length === 0) return undefined; + + logger?.debug?.(`[hooks] running ${hookName} (${hooks.length} handlers, sequential)`); + + let result: TResult | undefined; + + for (const hook of hooks) { + try { + const handlerResult = await ( + hook.handler as (event: unknown, ctx: unknown) => Promise + )(event, ctx); + + if (handlerResult !== undefined && handlerResult !== null) { + if (mergeResults && result !== undefined) { + result = mergeResults(result, handlerResult); + } else { + result = handlerResult; + } + } + } catch (err) { + const msg = `[hooks] ${hookName} handler from ${hook.pluginId} failed: ${String(err)}`; + if (catchErrors) { + logger?.error(msg); + } else { + throw new Error(msg); + } + } + } + + return result; + } + + // ========================================================================= + // Agent Hooks + // ========================================================================= + + /** + * Run before_agent_start hook. + * Allows plugins to inject context into the system prompt. + * Runs sequentially, merging systemPrompt and prependContext from all handlers. + */ + async function runBeforeAgentStart( + event: PluginHookBeforeAgentStartEvent, + ctx: PluginHookAgentContext, + ): Promise { + return runModifyingHook<"before_agent_start", PluginHookBeforeAgentStartResult>( + "before_agent_start", + event, + ctx, + (acc, next) => ({ + systemPrompt: next.systemPrompt ?? acc?.systemPrompt, + prependContext: + acc?.prependContext && next.prependContext + ? `${acc.prependContext}\n\n${next.prependContext}` + : (next.prependContext ?? acc?.prependContext), + }), + ); + } + + /** + * Run agent_end hook. + * Allows plugins to analyze completed conversations. + * Runs in parallel (fire-and-forget). + */ + async function runAgentEnd( + event: PluginHookAgentEndEvent, + ctx: PluginHookAgentContext, + ): Promise { + return runVoidHook("agent_end", event, ctx); + } + + /** + * Run before_compaction hook. + */ + async function runBeforeCompaction( + event: PluginHookBeforeCompactionEvent, + ctx: PluginHookAgentContext, + ): Promise { + return runVoidHook("before_compaction", event, ctx); + } + + /** + * Run after_compaction hook. + */ + async function runAfterCompaction( + event: PluginHookAfterCompactionEvent, + ctx: PluginHookAgentContext, + ): Promise { + return runVoidHook("after_compaction", event, ctx); + } + + // ========================================================================= + // Message Hooks + // ========================================================================= + + /** + * Run message_received hook. + * Runs in parallel (fire-and-forget). + */ + async function runMessageReceived( + event: PluginHookMessageReceivedEvent, + ctx: PluginHookMessageContext, + ): Promise { + return runVoidHook("message_received", event, ctx); + } + + /** + * Run message_sending hook. + * Allows plugins to modify or cancel outgoing messages. + * Runs sequentially. + */ + async function runMessageSending( + event: PluginHookMessageSendingEvent, + ctx: PluginHookMessageContext, + ): Promise { + return runModifyingHook<"message_sending", PluginHookMessageSendingResult>( + "message_sending", + event, + ctx, + (acc, next) => ({ + content: next.content ?? acc?.content, + cancel: next.cancel ?? acc?.cancel, + }), + ); + } + + /** + * Run message_sent hook. + * Runs in parallel (fire-and-forget). + */ + async function runMessageSent( + event: PluginHookMessageSentEvent, + ctx: PluginHookMessageContext, + ): Promise { + return runVoidHook("message_sent", event, ctx); + } + + // ========================================================================= + // Tool Hooks + // ========================================================================= + + /** + * Run before_tool_call hook. + * Allows plugins to modify or block tool calls. + * Runs sequentially. + */ + async function runBeforeToolCall( + event: PluginHookBeforeToolCallEvent, + ctx: PluginHookToolContext, + ): Promise { + return runModifyingHook<"before_tool_call", PluginHookBeforeToolCallResult>( + "before_tool_call", + event, + ctx, + (acc, next) => ({ + params: next.params ?? acc?.params, + block: next.block ?? acc?.block, + blockReason: next.blockReason ?? acc?.blockReason, + }), + ); + } + + /** + * Run after_tool_call hook. + * Runs in parallel (fire-and-forget). + */ + async function runAfterToolCall( + event: PluginHookAfterToolCallEvent, + ctx: PluginHookToolContext, + ): Promise { + return runVoidHook("after_tool_call", event, ctx); + } + + // ========================================================================= + // Session Hooks + // ========================================================================= + + /** + * Run session_start hook. + * Runs in parallel (fire-and-forget). + */ + async function runSessionStart( + event: PluginHookSessionStartEvent, + ctx: PluginHookSessionContext, + ): Promise { + return runVoidHook("session_start", event, ctx); + } + + /** + * Run session_end hook. + * Runs in parallel (fire-and-forget). + */ + async function runSessionEnd( + event: PluginHookSessionEndEvent, + ctx: PluginHookSessionContext, + ): Promise { + return runVoidHook("session_end", event, ctx); + } + + // ========================================================================= + // Gateway Hooks + // ========================================================================= + + /** + * Run gateway_start hook. + * Runs in parallel (fire-and-forget). + */ + async function runGatewayStart( + event: PluginHookGatewayStartEvent, + ctx: PluginHookGatewayContext, + ): Promise { + return runVoidHook("gateway_start", event, ctx); + } + + /** + * Run gateway_stop hook. + * Runs in parallel (fire-and-forget). + */ + async function runGatewayStop( + event: PluginHookGatewayStopEvent, + ctx: PluginHookGatewayContext, + ): Promise { + return runVoidHook("gateway_stop", event, ctx); + } + + // ========================================================================= + // Utility + // ========================================================================= + + /** + * Check if any hooks are registered for a given hook name. + */ + function hasHooks(hookName: PluginHookName): boolean { + return registry.typedHooks.some((h) => h.hookName === hookName); + } + + /** + * Get count of registered hooks for a given hook name. + */ + function getHookCount(hookName: PluginHookName): number { + return registry.typedHooks.filter((h) => h.hookName === hookName).length; + } + + return { + // Agent hooks + runBeforeAgentStart, + runAgentEnd, + runBeforeCompaction, + runAfterCompaction, + // Message hooks + runMessageReceived, + runMessageSending, + runMessageSent, + // Tool hooks + runBeforeToolCall, + runAfterToolCall, + // Session hooks + runSessionStart, + runSessionEnd, + // Gateway hooks + runGatewayStart, + runGatewayStop, + // Utility + hasHooks, + getHookCount, + }; +} + +export type HookRunner = ReturnType; diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 23a9a1f5d..d53e12e5d 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -8,6 +8,7 @@ import type { GatewayRequestHandler } from "../gateway/server-methods/types.js"; import { createSubsystemLogger } from "../logging.js"; import { resolveUserPath } from "../utils.js"; import { discoverClawdbotPlugins } from "./discovery.js"; +import { initializeGlobalHookRunner } from "./hook-runner-global.js"; import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js"; import { createPluginRuntime } from "./runtime/index.js"; import { setActivePluginRegistry } from "./runtime.js"; @@ -271,6 +272,7 @@ function createPluginRecord(params: { cliCommands: [], services: [], httpHandlers: 0, + hookCount: 0, configSchema: params.configSchema, configUiHints: undefined, configJsonSchema: undefined, @@ -521,5 +523,6 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi registryCache.set(cacheKey, registry); } setActivePluginRegistry(registry, cacheKey); + initializeGlobalHookRunner(registry); return registry; } diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 1aafee404..f1fc64c0d 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -22,6 +22,9 @@ import type { PluginLogger, PluginOrigin, PluginKind, + PluginHookName, + PluginHookHandlerMap, + PluginHookRegistration as TypedPluginHookRegistration, } from "./types.js"; import type { PluginRuntime } from "./runtime/types.js"; import type { HookEntry } from "../hooks/types.js"; @@ -94,6 +97,7 @@ export type PluginRecord = { cliCommands: string[]; services: string[]; httpHandlers: number; + hookCount: number; configSchema: boolean; configUiHints?: Record; configJsonSchema?: Record; @@ -103,6 +107,7 @@ export type PluginRegistry = { plugins: PluginRecord[]; tools: PluginToolRegistration[]; hooks: PluginHookRegistration[]; + typedHooks: TypedPluginHookRegistration[]; channels: PluginChannelRegistration[]; providers: PluginProviderRegistration[]; gatewayHandlers: GatewayRequestHandlers; @@ -123,6 +128,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { plugins: [], tools: [], hooks: [], + typedHooks: [], channels: [], providers: [], gatewayHandlers: {}, @@ -346,6 +352,22 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { }); }; + const registerTypedHook = ( + record: PluginRecord, + hookName: K, + handler: PluginHookHandlerMap[K], + opts?: { priority?: number }, + ) => { + record.hookCount += 1; + registry.typedHooks.push({ + pluginId: record.id, + hookName, + handler, + priority: opts?.priority, + source: record.source, + } as TypedPluginHookRegistration); + }; + const normalizeLogger = (logger: PluginLogger): PluginLogger => ({ info: logger.info, warn: logger.warn, @@ -380,6 +402,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { registerCli: (registrar, opts) => registerCli(record, registrar, opts), registerService: (service) => registerService(record, service), resolvePath: (input: string) => resolveUserPath(input), + on: (hookName, handler, opts) => registerTypedHook(record, hookName, handler, opts), }; }; @@ -393,5 +416,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { registerGatewayMethod, registerCli, registerService, + registerHook, + registerTypedHook, }; } diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 4a95932b8..ba503338b 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -200,6 +200,12 @@ export type ClawdbotPluginApi = { registerService: (service: ClawdbotPluginService) => void; registerProvider: (provider: ProviderPlugin) => void; resolvePath: (input: string) => string; + /** Register a lifecycle hook handler */ + on: ( + hookName: K, + handler: PluginHookHandlerMap[K], + opts?: { priority?: number }, + ) => void; }; export type PluginOrigin = "bundled" | "global" | "workspace" | "config"; @@ -210,3 +216,219 @@ export type PluginDiagnostic = { pluginId?: string; source?: string; }; + +// ============================================================================ +// Plugin Hooks +// ============================================================================ + +export type PluginHookName = + | "before_agent_start" + | "agent_end" + | "before_compaction" + | "after_compaction" + | "message_received" + | "message_sending" + | "message_sent" + | "before_tool_call" + | "after_tool_call" + | "session_start" + | "session_end" + | "gateway_start" + | "gateway_stop"; + +// Agent context shared across agent hooks +export type PluginHookAgentContext = { + agentId?: string; + sessionKey?: string; + workspaceDir?: string; + messageProvider?: string; +}; + +// before_agent_start hook +export type PluginHookBeforeAgentStartEvent = { + prompt: string; + messages?: unknown[]; +}; + +export type PluginHookBeforeAgentStartResult = { + systemPrompt?: string; + prependContext?: string; +}; + +// agent_end hook +export type PluginHookAgentEndEvent = { + messages: unknown[]; + success: boolean; + error?: string; + durationMs?: number; +}; + +// Compaction hooks +export type PluginHookBeforeCompactionEvent = { + messageCount: number; + tokenCount?: number; +}; + +export type PluginHookAfterCompactionEvent = { + messageCount: number; + tokenCount?: number; + compactedCount: number; +}; + +// Message context +export type PluginHookMessageContext = { + channelId: string; + accountId?: string; + conversationId?: string; +}; + +// message_received hook +export type PluginHookMessageReceivedEvent = { + from: string; + content: string; + timestamp?: number; + metadata?: Record; +}; + +// message_sending hook +export type PluginHookMessageSendingEvent = { + to: string; + content: string; + metadata?: Record; +}; + +export type PluginHookMessageSendingResult = { + content?: string; + cancel?: boolean; +}; + +// message_sent hook +export type PluginHookMessageSentEvent = { + to: string; + content: string; + success: boolean; + error?: string; +}; + +// Tool context +export type PluginHookToolContext = { + agentId?: string; + sessionKey?: string; + toolName: string; +}; + +// before_tool_call hook +export type PluginHookBeforeToolCallEvent = { + toolName: string; + params: Record; +}; + +export type PluginHookBeforeToolCallResult = { + params?: Record; + block?: boolean; + blockReason?: string; +}; + +// after_tool_call hook +export type PluginHookAfterToolCallEvent = { + toolName: string; + params: Record; + result?: unknown; + error?: string; + durationMs?: number; +}; + +// Session context +export type PluginHookSessionContext = { + agentId?: string; + sessionId: string; +}; + +// session_start hook +export type PluginHookSessionStartEvent = { + sessionId: string; + resumedFrom?: string; +}; + +// session_end hook +export type PluginHookSessionEndEvent = { + sessionId: string; + messageCount: number; + durationMs?: number; +}; + +// Gateway context +export type PluginHookGatewayContext = { + port?: number; +}; + +// gateway_start hook +export type PluginHookGatewayStartEvent = { + port: number; +}; + +// gateway_stop hook +export type PluginHookGatewayStopEvent = { + reason?: string; +}; + +// Hook handler types mapped by hook name +export type PluginHookHandlerMap = { + before_agent_start: ( + event: PluginHookBeforeAgentStartEvent, + ctx: PluginHookAgentContext, + ) => Promise | PluginHookBeforeAgentStartResult | void; + agent_end: (event: PluginHookAgentEndEvent, ctx: PluginHookAgentContext) => Promise | void; + before_compaction: ( + event: PluginHookBeforeCompactionEvent, + ctx: PluginHookAgentContext, + ) => Promise | void; + after_compaction: ( + event: PluginHookAfterCompactionEvent, + ctx: PluginHookAgentContext, + ) => Promise | void; + message_received: ( + event: PluginHookMessageReceivedEvent, + ctx: PluginHookMessageContext, + ) => Promise | void; + message_sending: ( + event: PluginHookMessageSendingEvent, + ctx: PluginHookMessageContext, + ) => Promise | PluginHookMessageSendingResult | void; + message_sent: ( + event: PluginHookMessageSentEvent, + ctx: PluginHookMessageContext, + ) => Promise | void; + before_tool_call: ( + event: PluginHookBeforeToolCallEvent, + ctx: PluginHookToolContext, + ) => Promise | PluginHookBeforeToolCallResult | void; + after_tool_call: ( + event: PluginHookAfterToolCallEvent, + ctx: PluginHookToolContext, + ) => Promise | void; + session_start: ( + event: PluginHookSessionStartEvent, + ctx: PluginHookSessionContext, + ) => Promise | void; + session_end: ( + event: PluginHookSessionEndEvent, + ctx: PluginHookSessionContext, + ) => Promise | void; + gateway_start: ( + event: PluginHookGatewayStartEvent, + ctx: PluginHookGatewayContext, + ) => Promise | void; + gateway_stop: ( + event: PluginHookGatewayStopEvent, + ctx: PluginHookGatewayContext, + ) => Promise | void; +}; + +export type PluginHookRegistration = { + pluginId: string; + hookName: K; + handler: PluginHookHandlerMap[K]; + priority?: number; + source: string; +};