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("")) return false;
+ // Skip agent summary responses (contain markdown formatting)
+ if (text.includes("**") && text.includes("\n-")) return false;
+ // Skip emoji-heavy responses (likely agent output)
+ const emojiCount = (text.match(/[\u{1F300}-\u{1F9FF}]/gu) || []).length;
+ if (emojiCount > 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;
+};