diff --git a/CHANGELOG.md b/CHANGELOG.md index e928c80cbde..bda8530e097 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Docs: https://docs.clawd.bot - Exec: add `/exec` directive for per-session exec defaults (host/security/ask/node). - macOS: migrate exec approvals to `~/.clawdbot/exec-approvals.json` with per-agent allowlists and skill auto-allow toggle. - macOS: add approvals socket UI server + node exec lifecycle events. +- Plugins: add typed lifecycle hooks + vector memory plugin. (#1149) — thanks @radek-paclt. - Slash commands: replace `/cost` with `/usage off|tokens|full` to control per-response usage footer; `/usage` no longer aliases `/status`. (Supersedes #1140) — thanks @Nachx639. - Sessions: add daily reset policy with per-type overrides and idle windows (default 4am local), preserving legacy idle-only configs. (#1146) — thanks @austinm911. - Docs: refresh exec/elevated/exec-approvals docs for the new flow. https://docs.clawd.bot/tools/exec-approvals diff --git a/extensions/memory/index.test.ts b/extensions/memory/index.test.ts index edf1e39836e..cc41ae9b5b9 100644 --- a/extensions/memory/index.test.ts +++ b/extensions/memory/index.test.ts @@ -9,7 +9,6 @@ */ 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"; diff --git a/extensions/memory/index.ts b/extensions/memory/index.ts index 80ed8b071ab..175df681e2d 100644 --- a/extensions/memory/index.ts +++ b/extensions/memory/index.ts @@ -29,12 +29,15 @@ type MemoryConfig = { autoRecall?: boolean; }; +const MEMORY_CATEGORIES = ["preference", "fact", "decision", "entity", "other"] as const; +type MemoryCategory = (typeof MEMORY_CATEGORIES)[number]; + type MemoryEntry = { id: string; text: string; vector: number[]; importance: number; - category: "preference" | "fact" | "decision" | "entity" | "other"; + category: MemoryCategory; createdAt: number; }; @@ -60,13 +63,14 @@ const memoryConfigSchema = { throw new Error("embedding.apiKey is required"); } + const model = + typeof embedding.model === "string" ? embedding.model : "text-embedding-3-small"; + ensureSupportedEmbeddingModel(model); + return { embedding: { provider: "openai", - model: - typeof embedding.model === "string" - ? embedding.model - : "text-embedding-3-small", + model, apiKey: resolveEnvVars(embedding.apiKey), }, dbPath: @@ -105,6 +109,28 @@ const memoryConfigSchema = { }, }; +const EMBEDDING_DIMENSIONS: Record = { + "text-embedding-3-small": 1536, + "text-embedding-3-large": 3072, +}; + +function ensureSupportedEmbeddingModel(model: string): void { + if (!EMBEDDING_DIMENSIONS[model]) { + throw new Error(`Unsupported embedding model: ${model}`); + } +} + +function stringEnum( + values: T, + options: { description?: string } = {}, +) { + return Type.Unsafe({ + type: "string", + enum: [...values], + ...options, + }); +} + function resolveEnvVars(value: string): string { return value.replace(/\$\{([^}]+)\}/g, (_, envVar) => { const envValue = process.env[envVar]; @@ -120,14 +146,15 @@ function resolveEnvVars(value: string): string { // ============================================================================ 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) {} + constructor( + private readonly dbPath: string, + private readonly vectorDim: number, + ) {} private async ensureInitialized(): Promise { if (this.table) return; @@ -148,7 +175,7 @@ class MemoryDB { { id: "__schema__", text: "", - vector: new Array(VECTOR_DIM).fill(0), + vector: new Array(this.vectorDim).fill(0), importance: 0, category: "other", createdAt: 0, @@ -274,9 +301,7 @@ function shouldCapture(text: string): boolean { return MEMORY_TRIGGERS.some((r) => r.test(text)); } -function detectCategory( - text: string, -): "preference" | "fact" | "decision" | "entity" | "other" { +function detectCategory(text: string): MemoryCategory { 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"; @@ -299,10 +324,12 @@ const memoryPlugin = { 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!); + const resolvedDbPath = api.resolvePath(cfg.dbPath!); + const vectorDim = EMBEDDING_DIMENSIONS[cfg.embedding.model ?? "text-embedding-3-small"]; + const db = new MemoryDB(resolvedDbPath, vectorDim); + const embeddings = new Embeddings(cfg.embedding.apiKey, cfg.embedding.model!); - api.logger.info(`memory: plugin registered (db: ${cfg.dbPath}, lazy init)`); + api.logger.info(`memory: plugin registered (db: ${resolvedDbPath}, lazy init)`); // ======================================================================== // Tools @@ -369,15 +396,7 @@ const memoryPlugin = { 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"), - ]), - ), + category: Type.Optional(stringEnum(MEMORY_CATEGORIES)), }), async execute(_toolCallId, params) { const { @@ -460,9 +479,9 @@ const memoryPlugin = { }; } - const list = results - .map((r) => `- [${r.entry.id.slice(0, 8)}] ${r.entry.text.slice(0, 60)}...`) - .join("\n"); + 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) => ({ @@ -658,7 +677,7 @@ const memoryPlugin = { id: "memory", start: () => { api.logger.info( - `memory: initialized (db: ${cfg.dbPath}, model: ${cfg.embedding.model})`, + `memory: initialized (db: ${resolvedDbPath}, model: ${cfg.embedding.model})`, ); }, stop: () => {