Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`).**
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion plugins/openclaw/openclaw.plugin.json
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
4 changes: 2 additions & 2 deletions plugins/openclaw/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -24,7 +24,7 @@
"pack:verify": "npm pack --dry-run"
},
"peerDependencies": {
"shieldcortex": "^4.12.4",
"shieldcortex": "^4.12.5",
"openclaw": ">=2026.3.22"
},
"peerDependenciesMeta": {
Expand Down
35 changes: 35 additions & 0 deletions scripts/lib/save-memory.mjs
Original file line number Diff line number Diff line change
@@ -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,
);
}
22 changes: 5 additions & 17 deletions scripts/pre-compact-hook.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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);
}


Expand Down
18 changes: 3 additions & 15 deletions scripts/session-end-hook.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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 ====================
Expand Down
95 changes: 95 additions & 0 deletions src/__tests__/save-auto-extracted-memory.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>> = {}) {
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']);
});
});
Loading