diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ad765382..9f0af3e7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,13 +10,13 @@ Docs: https://docs.clawd.bot - Swabble: use the tagged Commander Swift package release. - CLI: add `clawdbot acp client` interactive ACP harness for debugging. - Plugins: route command detection/text chunking helpers through the plugin runtime and drop runtime exports from the SDK. -- Memory: add native Gemini embeddings provider for memory search. (#1151) — thanks @steipete. +- Memory: add native Gemini embeddings provider for memory search. (#1151) ### Fixes - Auth profiles: keep auto-pinned preference while allowing rotation on failover; user pins stay locked. (#1138) — thanks @cheeeee. - macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105) -- Memory: index atomically so failed reindex preserves the previous memory database. (#1151) — thanks @steipete. -- Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151) — thanks @steipete. +- Memory: index atomically so failed reindex preserves the previous memory database. (#1151) +- Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151) ## 2026.1.18-3 diff --git a/src/memory/manager.vector-dedupe.test.ts b/src/memory/manager.vector-dedupe.test.ts new file mode 100644 index 000000000..936bb8410 --- /dev/null +++ b/src/memory/manager.vector-dedupe.test.ts @@ -0,0 +1,93 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { getMemorySearchManager, type MemoryIndexManager } from "./index.js"; +import { buildFileEntry } from "./internal.js"; + +vi.mock("./embeddings.js", () => { + return { + createEmbeddingProvider: async () => ({ + requestedProvider: "openai", + provider: { + id: "mock", + model: "mock-embed", + embedQuery: async () => [0.1, 0.2, 0.3], + embedBatch: async (texts: string[]) => texts.map((_, index) => [index + 1, 0, 0]), + }, + }), + }; +}); + +describe("memory vector dedupe", () => { + let workspaceDir: string; + let indexPath: string; + let manager: MemoryIndexManager | null = null; + + beforeEach(async () => { + workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-mem-")); + indexPath = path.join(workspaceDir, "index.sqlite"); + await fs.mkdir(path.join(workspaceDir, "memory")); + await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), "Hello memory."); + }); + + afterEach(async () => { + if (manager) { + await manager.close(); + manager = null; + } + await fs.rm(workspaceDir, { recursive: true, force: true }); + }); + + it("deletes existing vector rows before inserting replacements", async () => { + const cfg = { + agents: { + defaults: { + workspace: workspaceDir, + memorySearch: { + provider: "openai", + model: "mock-embed", + store: { path: indexPath, vector: { enabled: true } }, + sync: { watch: false, onSessionStart: false, onSearch: false }, + cache: { enabled: false }, + }, + }, + list: [{ id: "main", default: true }], + }, + }; + + const result = await getMemorySearchManager({ cfg, agentId: "main" }); + expect(result.manager).not.toBeNull(); + if (!result.manager) throw new Error("manager missing"); + manager = result.manager; + + const db = (manager as unknown as { db: { exec: (sql: string) => void; prepare: (sql: string) => unknown } }).db; + db.exec("CREATE TABLE IF NOT EXISTS chunks_vec (id TEXT PRIMARY KEY, embedding BLOB)"); + + const sqlSeen: string[] = []; + const originalPrepare = db.prepare.bind(db); + db.prepare = (sql: string) => { + if (sql.includes("chunks_vec")) { + sqlSeen.push(sql); + } + return originalPrepare(sql); + }; + + (manager as unknown as { ensureVectorReady: (dims?: number) => Promise }).ensureVectorReady = + async () => true; + + const entry = await buildFileEntry(path.join(workspaceDir, "MEMORY.md"), workspaceDir); + await (manager as unknown as { indexFile: (entry: unknown, options: { source: "memory" }) => Promise }).indexFile( + entry, + { source: "memory" }, + ); + + const deleteIndex = sqlSeen.findIndex((sql) => sql.includes("DELETE FROM chunks_vec WHERE id = ?")); + const insertIndex = sqlSeen.findIndex((sql) => sql.includes("INSERT INTO chunks_vec")); + expect(deleteIndex).toBeGreaterThan(-1); + expect(insertIndex).toBeGreaterThan(-1); + expect(deleteIndex).toBeLessThan(insertIndex); + }); +});