diff --git a/CHANGELOG.md b/CHANGELOG.md index 33e9b78..2cbabdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,40 @@ All notable changes to this project will be documented in this file. +## v4.12.5 — 25 April 2026 + +**Fix: auto-extracted memories were silently failing every INSERT with `NOT NULL constraint failed: memories.uuid`.** + +v4.12.4 unblocked the read side (the path-encoding fix), but the write side still failed. The pre-compact and session-end hooks' `saveMemory()` functions built INSERT statements that omitted the `uuid` column. The schema declares `uuid TEXT NOT NULL UNIQUE` with no default, so every insert errored out — silently from the user's perspective: + +```text +[auto-extract] Read 4 messages from session JSONL (5186 chars) +[auto-extract] Failed to save "Decision: X, fix Y, prefer Z": + NOT NULL constraint failed: memories.uuid +[shieldcortex] Pre-compact complete: 0 memories auto-extracted +``` + +Reproduced on TARS 2026-04-25 immediately after upgrading to v4.12.4. + +### Fix + +- New `scripts/lib/save-memory.mjs` — single source of truth for hook-side memory writes. Generates a `crypto.randomUUID()` and binds it to the INSERT. +- `scripts/pre-compact-hook.mjs` and `scripts/session-end-hook.mjs` both delegate to it via thin wrappers, so they can no longer drift apart and produce "one hook works, the other silently fails" bugs. + +### Tests + +5 new cases in `src/__tests__/save-auto-extracted-memory.test.ts` against a fresh SQLite DB built from the real `src/database/schema.sql`: + +- Inserts a memory row (the v4.12.4 NOT NULL bug repro) +- Generates a unique UUID per insert (no collision on bulk auto-extract) +- Respects the `uuid UNIQUE` constraint over multiple writes +- Accepts `null` project (sessions without a scoped project) +- Persists `tags` as JSON-encoded text (matches existing reader contract) + +### Why it matters + +This was the second silent zero-memory bug in 24 hours (v4.12.4 closed the path-encoding side; v4.12.5 closes the write side). Both shipped because the original auto-extract path had no end-to-end test exercising the actual SQLite schema. The new shared `save-memory.mjs` lib gives every hook one tested write path so the next bug in this area can't hide in two places. + ## v4.12.4 — 25 April 2026 **Fix: silent zero-memory issue when running under dotfile-prefixed working directories (e.g. `~/.openclaw/`, `~/.config/`).** diff --git a/package-lock.json b/package-lock.json index 6cda3c1..9f7c4a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "shieldcortex", - "version": "4.12.4", + "version": "4.12.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "shieldcortex", - "version": "4.12.4", + "version": "4.12.5", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 6ef3a51..cdae56a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "shieldcortex", - "version": "4.12.4", + "version": "4.12.5", "description": "Trustworthy memory and security for AI agents. Recall debugging, review queue, OpenClaw session capture, and memory poisoning defence for Claude Code, Codex, OpenClaw, LangChain, and MCP agents.", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/plugins/openclaw/openclaw.plugin.json b/plugins/openclaw/openclaw.plugin.json index 18fbf5b..8f79c8f 100644 --- a/plugins/openclaw/openclaw.plugin.json +++ b/plugins/openclaw/openclaw.plugin.json @@ -1,6 +1,6 @@ { "id": "shieldcortex-realtime", - "version": "4.12.4", + "version": "4.12.5", "name": "ShieldCortex Real-time Scanner", "description": "Real-time defence scanning on LLM input, memory extraction on LLM output, and active tool call interception with approval gating.", "kind": null, diff --git a/plugins/openclaw/package.json b/plugins/openclaw/package.json index 7d80e70..c9ba269 100644 --- a/plugins/openclaw/package.json +++ b/plugins/openclaw/package.json @@ -1,6 +1,6 @@ { "name": "@drakon-systems/shieldcortex-realtime", - "version": "4.12.4", + "version": "4.12.5", "description": "OpenClaw plugin for ShieldCortex real-time defence scanning and optional memory extraction.", "type": "module", "main": "index.ts", @@ -24,7 +24,7 @@ "pack:verify": "npm pack --dry-run" }, "peerDependencies": { - "shieldcortex": "^4.12.4", + "shieldcortex": "^4.12.5", "openclaw": ">=2026.3.22" }, "peerDependenciesMeta": { diff --git a/scripts/lib/save-memory.mjs b/scripts/lib/save-memory.mjs new file mode 100644 index 0000000..5da3aeb --- /dev/null +++ b/scripts/lib/save-memory.mjs @@ -0,0 +1,35 @@ +import { randomUUID } from 'crypto'; + +/** + * Insert an auto-extracted memory into the SC database. + * + * Single source of truth for hook-side memory writes. Both the pre-compact + * and session-end hooks call this — keeping them in sync prevents the + * "one hook works, the other silently fails" bug class (e.g. v4.12.4 + * post-fix where pre-compact and session-end would have drifted). + * + * Schema invariant: `memories.uuid` is `TEXT NOT NULL UNIQUE` with no + * default. Every write path MUST supply a UUID. + * + * @param {import('better-sqlite3').Database} db - Open DB handle + * @param {{ title: string, content: string, category: string, salience: number, tags: string[] }} memory + * @param {string|null} [project] - Optional project scope + */ +export function saveAutoExtractedMemory(db, memory, project) { + const timestamp = new Date().toISOString(); + const stmt = db.prepare(` + INSERT INTO memories (uuid, title, content, type, category, salience, tags, project, created_at, last_accessed) + VALUES (?, ?, ?, 'short_term', ?, ?, ?, ?, ?, ?) + `); + stmt.run( + randomUUID(), + memory.title, + memory.content, + memory.category, + memory.salience, + JSON.stringify(memory.tags), + project || null, + timestamp, + timestamp, + ); +} diff --git a/scripts/pre-compact-hook.mjs b/scripts/pre-compact-hook.mjs index 36d1daa..7ae4c64 100755 --- a/scripts/pre-compact-hook.mjs +++ b/scripts/pre-compact-hook.mjs @@ -16,6 +16,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'fs'; import { join } from 'path'; import { homedir } from 'os'; import { encodeClaudeProjectDir } from './lib/claude-project-dir.mjs'; +import { saveAutoExtractedMemory } from './lib/save-memory.mjs'; // Database paths (with legacy fallback) const NEW_DB_DIR = join(homedir(), '.shieldcortex'); @@ -485,24 +486,11 @@ function calculateOverlap(text1, text2) { // ==================== DATABASE OPERATIONS ==================== +// Thin wrapper to keep the existing call sites unchanged. The actual +// write lives in scripts/lib/save-memory.mjs so pre-compact and +// session-end share one code path (and one regression test). function saveMemory(db, memory, project) { - const timestamp = new Date().toISOString(); - - const stmt = db.prepare(` - INSERT INTO memories (title, content, type, category, salience, tags, project, created_at, last_accessed) - VALUES (?, ?, 'short_term', ?, ?, ?, ?, ?, ?) - `); - - stmt.run( - memory.title, - memory.content, - memory.category, - memory.salience, - JSON.stringify(memory.tags), - project || null, - timestamp, - timestamp - ); + saveAutoExtractedMemory(db, memory, project); } diff --git a/scripts/session-end-hook.mjs b/scripts/session-end-hook.mjs index 1f31809..1972a04 100644 --- a/scripts/session-end-hook.mjs +++ b/scripts/session-end-hook.mjs @@ -25,6 +25,7 @@ import Database from 'better-sqlite3'; import { existsSync, mkdirSync, readFileSync } from 'fs'; import { join } from 'path'; import { homedir } from 'os'; +import { saveAutoExtractedMemory } from './lib/save-memory.mjs'; // Database paths (with legacy fallback) const NEW_DB_DIR = join(homedir(), '.shieldcortex'); @@ -399,22 +400,9 @@ function calculateOverlap(text1, text2) { // ==================== DATABASE OPERATIONS ==================== +// Thin wrapper to keep the existing call sites unchanged. function saveMemory(db, memory, project) { - const timestamp = new Date().toISOString(); - const stmt = db.prepare(` - INSERT INTO memories (title, content, type, category, salience, tags, project, created_at, last_accessed) - VALUES (?, ?, 'short_term', ?, ?, ?, ?, ?, ?) - `); - stmt.run( - memory.title, - memory.content, - memory.category, - memory.salience, - JSON.stringify(memory.tags), - project || null, - timestamp, - timestamp - ); + saveAutoExtractedMemory(db, memory, project); } // ==================== TRANSCRIPT READING ==================== diff --git a/src/__tests__/save-auto-extracted-memory.test.ts b/src/__tests__/save-auto-extracted-memory.test.ts new file mode 100644 index 0000000..928660a --- /dev/null +++ b/src/__tests__/save-auto-extracted-memory.test.ts @@ -0,0 +1,95 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { afterEach, beforeEach, describe, expect, it } from '@jest/globals'; +import Database from 'better-sqlite3'; +// @ts-expect-error -- importing a .mjs hook util +import { saveAutoExtractedMemory } from '../../scripts/lib/save-memory.mjs'; + +/** + * v4.12.4's auto-extract path silently failed every insert with + * "NOT NULL constraint failed: memories.uuid" because the inline + * INSERT in pre-compact-hook.mjs was missing the uuid column. + * Reproduced on TARS 2026-04-25 immediately after the v4.12.4 + * path-encoding fix unblocked the read side. + * + * v4.12.5 extracts the writer into `scripts/lib/save-memory.mjs` and + * generates a UUID before INSERT. This regression test wires up a fresh + * temp DB against the real schema and asserts the row lands. + */ +describe('saveAutoExtractedMemory — auto-extract write path', () => { + const thisFile = fileURLToPath(import.meta.url); + const repoRoot = path.resolve(path.dirname(thisFile), '..', '..'); + const schemaPath = path.join(repoRoot, 'src', 'database', 'schema.sql'); + + let tempDir: string; + let dbPath: string; + let db: Database.Database; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'shieldcortex-save-memory-')); + dbPath = path.join(tempDir, 'memories.db'); + db = new Database(dbPath); + const schema = fs.readFileSync(schemaPath, 'utf-8'); + db.exec(schema); + }); + + afterEach(() => { + db.close(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + function makeMemory(overrides: Partial> = {}) { + return { + title: 'Decision: chose Drizzle for the SaaS schema', + content: 'After comparing Prisma and Kysely we decided Drizzle for the SaaS layer because…', + category: 'architecture', + salience: 0.45, + tags: ['auto-extracted', 'decision'], + ...overrides, + } as { title: string; content: string; category: string; salience: number; tags: string[] }; + } + + it('inserts a memory row (the v4.12.4 NOT NULL uuid bug repro)', () => { + expect(() => saveAutoExtractedMemory(db, makeMemory(), 'shieldcortex')).not.toThrow(); + + const row = db.prepare('SELECT uuid, title, project, type FROM memories WHERE title = ?') + .get('Decision: chose Drizzle for the SaaS schema') as { uuid: string; title: string; project: string; type: string }; + + expect(row).toBeDefined(); + expect(row.uuid).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); + expect(row.project).toBe('shieldcortex'); + expect(row.type).toBe('short_term'); + }); + + it('generates a unique UUID per insert (no collision on bulk auto-extract)', () => { + for (let i = 0; i < 5; i++) { + saveAutoExtractedMemory(db, makeMemory({ title: `Memory ${i}` }), 'p'); + } + const uuids = db.prepare('SELECT uuid FROM memories').all() as Array<{ uuid: string }>; + expect(uuids).toHaveLength(5); + expect(new Set(uuids.map((r) => r.uuid)).size).toBe(5); + }); + + it('respects the uuid UNIQUE constraint by always producing fresh values', () => { + saveAutoExtractedMemory(db, makeMemory({ title: 'A' }), 'p'); + saveAutoExtractedMemory(db, makeMemory({ title: 'B' }), 'p'); + const count = (db.prepare('SELECT COUNT(*) AS c FROM memories').get() as { c: number }).c; + expect(count).toBe(2); + }); + + it('accepts null project (Claude Code session without a scoped project)', () => { + expect(() => saveAutoExtractedMemory(db, makeMemory(), null)).not.toThrow(); + const row = db.prepare('SELECT project FROM memories WHERE title = ?') + .get('Decision: chose Drizzle for the SaaS schema') as { project: string | null }; + expect(row.project).toBeNull(); + }); + + it('persists tags as JSON-encoded text (matches existing reader contract)', () => { + saveAutoExtractedMemory(db, makeMemory({ tags: ['decision', 'architecture'] }), 'p'); + const row = db.prepare('SELECT tags FROM memories WHERE title = ?') + .get('Decision: chose Drizzle for the SaaS schema') as { tags: string }; + expect(JSON.parse(row.tags)).toEqual(['decision', 'architecture']); + }); +});