mirror of
https://github.com/proxysoul/Empryo.git
synced 2026-11-07 06:25:57 -10:00
Track which hints actually change agent behavior so we can demote chronically-ignored ones later. - Schema v3 adds surface_count + surface_acted_count via ALTER ADD COLUMN with DEFAULT 0. Forward and backward compatible: old code ignores the new columns, new code reads 0 on rows written by old versions. No downgrade required for existing users. - MemoryDB.recordSurface(id, acted) — best-effort, swallows errors so telemetry can never crash a tool result. - Hint builder bumps surface_count on every surfaced hint. memory(get) resolves the id and bumps surface_acted_count via recordMemoryAction — the agent's follow-up read on the surfaced id is the "acted" signal. - Test: extended v1→v3 migration coverage + new recordSurface unit test. Co-Authored-By: SoulForge <[email protected]>
973 lines
35 KiB
TypeScript
973 lines
35 KiB
TypeScript
/**
|
|
* Adversarial sweep — what could actually go wrong with the memory subsystem.
|
|
* No happy-path tests here; that's what the per-phase test files cover.
|
|
*
|
|
* Categories:
|
|
* 1. db.ts schema/dedup/FTS/file-refs/edges/embeddings
|
|
* 2. recall.ts empty inputs, broken intel, threshold edges
|
|
* 3. embedder.ts degenerate inputs, buffer roundtrip
|
|
* 4. manager.ts scope confusion, config corruption
|
|
*/
|
|
|
|
import { Database } from "bun:sqlite";
|
|
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { MemoryDB } from "../src/core/memory/db.js";
|
|
import {
|
|
bufferToVector,
|
|
cosine,
|
|
embed,
|
|
EMBED_DIM,
|
|
vectorToBuffer,
|
|
} from "../src/core/memory/embedder.js";
|
|
import { MemoryManager } from "../src/core/memory/manager.js";
|
|
import { MemoryRecall } from "../src/core/memory/recall.js";
|
|
|
|
function makeTmpDir(label: string): string {
|
|
return mkdtempSync(join(tmpdir(), `mem-edge-${label}-`));
|
|
}
|
|
|
|
function adapt(db: MemoryDB) {
|
|
return {
|
|
searchUnicode: (q: string, l?: number) => db.searchUnicode(q, l),
|
|
searchTrigram: (q: string, l?: number) => db.searchTrigram(q, l),
|
|
searchTrigramWithBigram: (q: string, l?: number) => db.searchTrigramWithBigram(q, l),
|
|
findByFileIds: (ids: number[], l?: number) => db.findByFileIds(ids, l),
|
|
findByPaths: (paths: string[], l?: number) => db.findByPaths(paths, l),
|
|
topByUsage: (l?: number) => db.topByUsage(l),
|
|
readMany: (ids: string[]) => db.readMany(ids),
|
|
fileIdsByMemoryIds: (ids: string[]) => db.fileIdsByMemoryIds(ids),
|
|
listEmbeddings: (model?: string) => db.listEmbeddings(model),
|
|
getEmbedding: (id: string) => db.getEmbedding(id),
|
|
};
|
|
}
|
|
|
|
// ─── 1. db.ts ────────────────────────────────────────────────────────────
|
|
|
|
describe("MemoryDB — schema migration v1 → v3", () => {
|
|
let dir: string;
|
|
beforeEach(() => {
|
|
dir = makeTmpDir("migrate");
|
|
});
|
|
afterEach(() => {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("opens a v1 DB, adds embedding columns + memory_edges + surface telemetry, preserves rows", () => {
|
|
const dbPath = join(dir, "v1.db");
|
|
// Hand-roll a v1 schema (Phase 1, pre-Phase-4): no embedding columns,
|
|
// no memory_edges table. Includes the same FTS triggers v1 had.
|
|
const raw = new Database(dbPath);
|
|
raw.run("CREATE TABLE schema_version (version INTEGER PRIMARY KEY, applied_at TEXT)");
|
|
raw.run(`
|
|
CREATE TABLE memories (
|
|
id TEXT PRIMARY KEY,
|
|
category TEXT,
|
|
summary TEXT NOT NULL,
|
|
details TEXT NOT NULL DEFAULT '',
|
|
topics TEXT NOT NULL DEFAULT '[]',
|
|
source TEXT NOT NULL,
|
|
session_id TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
last_used_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
use_count INTEGER NOT NULL DEFAULT 0,
|
|
content_hash TEXT NOT NULL UNIQUE,
|
|
pinned INTEGER NOT NULL DEFAULT 0,
|
|
hidden INTEGER NOT NULL DEFAULT 0,
|
|
superseded_by TEXT
|
|
)
|
|
`);
|
|
raw.run(`
|
|
CREATE TABLE memory_files (
|
|
memory_id TEXT NOT NULL,
|
|
file_id INTEGER,
|
|
path TEXT NOT NULL,
|
|
PRIMARY KEY (memory_id, path)
|
|
)
|
|
`);
|
|
raw.run(`
|
|
CREATE VIRTUAL TABLE memories_fts USING fts5(
|
|
summary, details, topics,
|
|
content='memories', content_rowid='rowid',
|
|
tokenize='unicode61 remove_diacritics 2'
|
|
)
|
|
`);
|
|
raw.run(`
|
|
CREATE VIRTUAL TABLE memories_fts_tri USING fts5(
|
|
summary, details, topics,
|
|
content='memories', content_rowid='rowid',
|
|
tokenize="trigram case_sensitive 0 remove_diacritics 1"
|
|
)
|
|
`);
|
|
const hash = MemoryDB.computeContentHash("legacy-row", "from-v1");
|
|
raw.run(
|
|
`INSERT INTO memories (id, summary, details, source, content_hash) VALUES ('legacy-1','legacy-row','from-v1','agent', ?)`,
|
|
[hash],
|
|
);
|
|
raw.run("INSERT INTO schema_version (version) VALUES (1)");
|
|
raw.close();
|
|
|
|
// Now open with the current MemoryDB — should migrate.
|
|
const db = new MemoryDB(dbPath, "project");
|
|
try {
|
|
// Old row preserved
|
|
const r = db.read("legacy-1");
|
|
expect(r).not.toBeNull();
|
|
expect(r!.summary).toBe("legacy-row");
|
|
|
|
// New columns exist
|
|
const cols = (db as unknown as { db: Database }).db
|
|
.query<{ name: string }, []>("PRAGMA table_info(memories)")
|
|
.all()
|
|
.map((c) => c.name);
|
|
expect(cols).toContain("embedding");
|
|
expect(cols).toContain("embedding_model");
|
|
expect(cols).toContain("embedding_dim");
|
|
expect(cols).toContain("surface_count");
|
|
expect(cols).toContain("surface_acted_count");
|
|
|
|
// Edges table exists
|
|
const edgesTable = (db as unknown as { db: Database }).db
|
|
.query<{ name: string }, []>(
|
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='memory_edges'",
|
|
)
|
|
.get();
|
|
expect(edgesTable).not.toBeNull();
|
|
|
|
// schema_version bumped
|
|
const v = (db as unknown as { db: Database }).db
|
|
.query<{ version: number }, []>("SELECT MAX(version) as version FROM schema_version")
|
|
.get();
|
|
expect(v?.version).toBe(3);
|
|
|
|
// Legacy row has 0 for telemetry columns (default), not null
|
|
const tel = (db as unknown as { db: Database }).db
|
|
.query<{ s: number; a: number }, []>(
|
|
"SELECT surface_count as s, surface_acted_count as a FROM memories WHERE id='legacy-1'",
|
|
)
|
|
.get();
|
|
expect(tel?.s).toBe(0);
|
|
expect(tel?.a).toBe(0);
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
it("recordSurface increments surface_count, optionally surface_acted_count", () => {
|
|
const db = new MemoryDB(":memory:", "project");
|
|
try {
|
|
const r = db.write({ summary: "telemetry-row", details: "", category: null, source: "agent" });
|
|
const read = (): { s: number; a: number } | null =>
|
|
(db as unknown as { db: Database }).db
|
|
.query<{ s: number; a: number }, [string]>(
|
|
"SELECT surface_count as s, surface_acted_count as a FROM memories WHERE id = ?",
|
|
)
|
|
.get(r.record.id);
|
|
expect(read()).toEqual({ s: 0, a: 0 });
|
|
db.recordSurface(r.record.id, false);
|
|
expect(read()).toEqual({ s: 1, a: 0 });
|
|
db.recordSurface(r.record.id, true);
|
|
expect(read()).toEqual({ s: 2, a: 1 });
|
|
db.recordSurface("unknown-id", true); // silent no-op
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
it("backfillEmbeddings catches up legacy rows that lack embeddings", () => {
|
|
const db = new MemoryDB(":memory:", "project");
|
|
try {
|
|
const a = db.write({ summary: "row-a", details: "", category: null, source: "agent" });
|
|
const b = db.write({ summary: "row-b", details: "", category: null, source: "agent" });
|
|
// Strip embeddings (simulating legacy)
|
|
const internal = (db as unknown as { db: Database }).db;
|
|
internal.run("UPDATE memories SET embedding = NULL, embedding_model = NULL");
|
|
expect(db.getEmbedding(a.record.id)).toBeNull();
|
|
expect(db.getEmbedding(b.record.id)).toBeNull();
|
|
|
|
const missing = db.listMissingEmbeddings(undefined, 100);
|
|
expect(missing.length).toBe(2);
|
|
|
|
for (const m of missing) db.embedAndLink(m.id);
|
|
expect(db.getEmbedding(a.record.id)).not.toBeNull();
|
|
expect(db.getEmbedding(b.record.id)).not.toBeNull();
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("MemoryDB — content_hash + dedup edge cases", () => {
|
|
it("same summary+details but different topics+category → same hash, single row", () => {
|
|
const db = new MemoryDB(":memory:", "project");
|
|
try {
|
|
const a = db.write({
|
|
summary: "X",
|
|
details: "Y",
|
|
category: "decision",
|
|
topics: ["one"],
|
|
source: "agent",
|
|
});
|
|
const b = db.write({
|
|
summary: "X",
|
|
details: "Y",
|
|
category: "gotcha",
|
|
topics: ["two"],
|
|
source: "agent",
|
|
});
|
|
// Hash ignores category and topics — same row, deduped.
|
|
expect(b.deduped).toBe(true);
|
|
expect(b.record.id).toBe(a.record.id);
|
|
expect(db.list().length).toBe(1);
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
it("dedup increments use_count and bumps last_used_at", async () => {
|
|
const db = new MemoryDB(":memory:", "project");
|
|
try {
|
|
const a = db.write({ summary: "S", details: "D", category: null, source: "agent" });
|
|
const before = a.record.last_used_at;
|
|
expect(a.record.use_count).toBe(0);
|
|
|
|
// Sleep enough that ISO timestamps differ
|
|
await new Promise((r) => setTimeout(r, 1200));
|
|
|
|
const dup = db.write({ summary: "S", details: "D", category: null, source: "agent" });
|
|
expect(dup.deduped).toBe(true);
|
|
expect(dup.record.use_count).toBe(1);
|
|
expect(dup.record.last_used_at >= before).toBe(true);
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
it("upsert by id refuses to clobber another row's content_hash", () => {
|
|
const db = new MemoryDB(":memory:", "project");
|
|
try {
|
|
const a = db.write({ summary: "alpha", details: "", category: null, source: "agent" });
|
|
// Different id, same content as alpha would dedup, but here we craft
|
|
// a NEW row and then try to upsert b's id with a's content.
|
|
const b = db.write({ summary: "beta", details: "", category: null, source: "agent" });
|
|
expect(() =>
|
|
db.write({
|
|
id: b.record.id,
|
|
summary: "alpha",
|
|
details: "",
|
|
category: null,
|
|
source: "agent",
|
|
}),
|
|
).toThrow(/another memory .* already has the same content_hash/);
|
|
// a + b still intact
|
|
expect(db.read(a.record.id)?.summary).toBe("alpha");
|
|
expect(db.read(b.record.id)?.summary).toBe("beta");
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
it("upsert by id un-hides a soft-deleted target", () => {
|
|
const db = new MemoryDB(":memory:", "project");
|
|
try {
|
|
const a = db.write({ summary: "old", details: "", category: null, source: "agent" });
|
|
db.softDelete(a.record.id);
|
|
expect(db.read(a.record.id)?.hidden).toBe(true);
|
|
|
|
const upd = db.write({
|
|
id: a.record.id,
|
|
summary: "new",
|
|
details: "",
|
|
category: null,
|
|
source: "agent",
|
|
});
|
|
expect(upd.deduped).toBe(false);
|
|
expect(upd.record.summary).toBe("new");
|
|
expect(db.read(a.record.id)?.hidden).toBe(false);
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("MemoryDB — FTS query edge cases", () => {
|
|
it("empty / whitespace / 1-char queries return [] without crashing", () => {
|
|
const db = new MemoryDB(":memory:", "project");
|
|
try {
|
|
db.write({ summary: "filler row", details: "", category: null, source: "agent" });
|
|
expect(db.searchUnicode("")).toEqual([]);
|
|
expect(db.searchUnicode(" ")).toEqual([]);
|
|
expect(db.searchUnicode("a")).toEqual([]); // 1-char ignored by tokenizer rules
|
|
expect(db.searchTrigram("")).toEqual([]);
|
|
expect(db.searchTrigram("ab")).toEqual([]); // <3 chars, no trigram match
|
|
expect(db.searchTrigramWithBigram("")).toEqual([]);
|
|
expect(db.searchTrigramWithBigram("a")).toEqual([]); // 1-char dropped
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
it("query of pure FTS operators yields no error and no false matches", () => {
|
|
const db = new MemoryDB(":memory:", "project");
|
|
try {
|
|
db.write({ summary: "real content here", details: "", category: null, source: "agent" });
|
|
// These get sanitized to empty by buildFtsQuery
|
|
expect(db.searchUnicode('()*"')).toEqual([]);
|
|
expect(db.searchUnicode("::::")).toEqual([]);
|
|
expect(db.searchTrigram("()*")).toEqual([]);
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
it("searchUnicode prefix-matches but never returns hidden rows", () => {
|
|
const db = new MemoryDB(":memory:", "project");
|
|
try {
|
|
const a = db.write({
|
|
summary: "uniquetokenxyz hidden test",
|
|
details: "",
|
|
category: null,
|
|
source: "agent",
|
|
});
|
|
db.write({
|
|
summary: "uniquetokenxyz visible test",
|
|
details: "",
|
|
category: null,
|
|
source: "agent",
|
|
});
|
|
expect(db.searchUnicode("uniquetokenxyz").length).toBe(2);
|
|
db.softDelete(a.record.id);
|
|
expect(db.searchUnicode("uniquetokenxyz").length).toBe(1);
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
it("clearAll then re-write with same content does not dedup (FTS rebuilt)", () => {
|
|
const db = new MemoryDB(":memory:", "project");
|
|
try {
|
|
const a = db.write({
|
|
summary: "regen-test",
|
|
details: "body",
|
|
category: null,
|
|
source: "agent",
|
|
});
|
|
expect(db.searchUnicode("regen").length).toBe(1);
|
|
const cleared = db.clearAll();
|
|
expect(cleared).toBe(1);
|
|
expect(db.list().length).toBe(0);
|
|
expect(db.searchUnicode("regen").length).toBe(0);
|
|
|
|
// Re-write: must succeed (no dedup against deleted row)
|
|
const b = db.write({
|
|
summary: "regen-test",
|
|
details: "body",
|
|
category: null,
|
|
source: "agent",
|
|
});
|
|
expect(b.deduped).toBe(false);
|
|
expect(b.record.id).not.toBe(a.record.id);
|
|
expect(db.searchUnicode("regen").length).toBe(1);
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("MemoryDB — file refs + topic filter edge cases", () => {
|
|
it("file_id 0 is treated as a real id (not coerced to null)", () => {
|
|
const db = new MemoryDB(":memory:", "project");
|
|
try {
|
|
const a = db.write({ summary: "x", details: "", category: null, source: "agent" });
|
|
db.addFileRef(a.record.id, "foo.ts", 0);
|
|
expect(db.findByFileIds([0])).toEqual([a.record.id]);
|
|
const fileMap = db.fileIdsByMemoryIds([a.record.id]);
|
|
expect(fileMap.get(a.record.id)).toEqual([0]);
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
it("addFileRef on same (memory_id, path) updates file_id (no duplicate)", () => {
|
|
const db = new MemoryDB(":memory:", "project");
|
|
try {
|
|
const a = db.write({ summary: "x", details: "", category: null, source: "agent" });
|
|
db.addFileRef(a.record.id, "foo.ts", null);
|
|
db.addFileRef(a.record.id, "foo.ts", 99);
|
|
const refs = db.listFileRefs(a.record.id);
|
|
expect(refs.length).toBe(1);
|
|
expect(refs[0]!.file_id).toBe(99);
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
it("removeFileRef on unknown returns false; listFileRefs of unknown id is []", () => {
|
|
const db = new MemoryDB(":memory:", "project");
|
|
try {
|
|
expect(db.removeFileRef("ghost", "any.ts")).toBe(false);
|
|
expect(db.listFileRefs("ghost")).toEqual([]);
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
it("topic with apostrophe / quote / json-meta chars filters exactly", () => {
|
|
const db = new MemoryDB(":memory:", "project");
|
|
try {
|
|
db.write({
|
|
summary: "weird-topic",
|
|
details: "",
|
|
category: null,
|
|
topics: ["it's complex"],
|
|
source: "agent",
|
|
});
|
|
db.write({
|
|
summary: "json-meta",
|
|
details: "",
|
|
category: null,
|
|
topics: ['"escaped"', "[bracket]"],
|
|
source: "agent",
|
|
});
|
|
expect(db.list({ topic: "it's complex" }).length).toBe(1);
|
|
expect(db.list({ topic: '"escaped"' }).length).toBe(1);
|
|
expect(db.list({ topic: "[bracket]" }).length).toBe(1);
|
|
// No collision with prefix
|
|
expect(db.list({ topic: "it's" }).length).toBe(0);
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
it("findByPaths with an empty array short-circuits (no SQL syntax error)", () => {
|
|
const db = new MemoryDB(":memory:", "project");
|
|
try {
|
|
expect(db.findByPaths([])).toEqual([]);
|
|
expect(db.findByFileIds([])).toEqual([]);
|
|
expect(db.fileIdsByMemoryIds([])).toEqual(new Map());
|
|
expect(db.readMany([])).toEqual([]);
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
it("readMany silently drops unknown ids (does not throw)", () => {
|
|
const db = new MemoryDB(":memory:", "project");
|
|
try {
|
|
const a = db.write({ summary: "x", details: "", category: null, source: "agent" });
|
|
const out = db.readMany([a.record.id, "ghost-1", "ghost-2"]);
|
|
expect(out.length).toBe(1);
|
|
expect(out[0]!.id).toBe(a.record.id);
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("MemoryDB — pinned vs hidden interaction", () => {
|
|
it("a pinned row that is then soft-deleted disappears from active list", () => {
|
|
const db = new MemoryDB(":memory:", "project");
|
|
try {
|
|
const a = db.write({ summary: "p", details: "", category: null, source: "agent" });
|
|
db.pin(a.record.id);
|
|
db.softDelete(a.record.id);
|
|
expect(db.list().length).toBe(0);
|
|
expect(db.list({ includeHidden: true }).length).toBe(1);
|
|
expect(db.activeCount()).toBe(0);
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
it("findDeadFileRefs excludes pinned rows even when all paths are missing", () => {
|
|
const db = new MemoryDB(":memory:", "project");
|
|
try {
|
|
const a = db.write({ summary: "p", details: "", category: null, source: "agent" });
|
|
db.addFileRef(a.record.id, "gone.ts", null);
|
|
db.pin(a.record.id);
|
|
expect(db.findDeadFileRefs(() => false).length).toBe(0);
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
it("staleCandidates excludes pinned even if old", () => {
|
|
const db = new MemoryDB(":memory:", "project");
|
|
try {
|
|
const a = db.write({ summary: "old-pinned", details: "", category: null, source: "agent" });
|
|
db.pin(a.record.id);
|
|
(db as unknown as { db: Database }).db.run(
|
|
`UPDATE memories SET last_used_at = datetime('now', '-365 days') WHERE id = ?`,
|
|
[a.record.id],
|
|
);
|
|
expect(db.staleCandidates(50).find((s) => s.record.id === a.record.id)).toBeUndefined();
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("MemoryDB — supersede edge cases", () => {
|
|
it("self-supersede is silently ignored and does not corrupt the row", () => {
|
|
const db = new MemoryDB(":memory:", "project");
|
|
try {
|
|
const a = db.write({ summary: "x", details: "", category: null, source: "agent" });
|
|
// Self-supersede sets superseded_by=self AND hidden=1 — that's a footgun.
|
|
// Caller should never do it; tool layer does not expose this. We only
|
|
// verify it doesn't throw / corrupt, and the row remains queryable.
|
|
const ok = db.supersede(a.record.id, a.record.id);
|
|
expect(ok).toBe(true);
|
|
const r = db.read(a.record.id);
|
|
expect(r).not.toBeNull();
|
|
expect(r!.superseded_by).toBe(a.record.id);
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
it("supersede on unknown id returns false", () => {
|
|
const db = new MemoryDB(":memory:", "project");
|
|
try {
|
|
expect(db.supersede("ghost", "also-ghost")).toBe(false);
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
it("superseded rows are excluded from findDuplicates (curation respected)", () => {
|
|
const db = new MemoryDB(":memory:", "project");
|
|
try {
|
|
// Force two rows with the same content_hash via direct INSERT.
|
|
const internal = (db as unknown as { db: Database }).db;
|
|
internal.run(`DROP TRIGGER IF EXISTS memories_ai`);
|
|
internal.run(`DROP TRIGGER IF EXISTS memories_au`);
|
|
internal.run(`DROP TRIGGER IF EXISTS memories_ad`);
|
|
internal.run(`DROP TABLE memories`);
|
|
internal.run(`
|
|
CREATE TABLE memories (
|
|
id TEXT PRIMARY KEY, category TEXT,
|
|
summary TEXT NOT NULL, details TEXT NOT NULL DEFAULT '',
|
|
topics TEXT NOT NULL DEFAULT '[]', source TEXT NOT NULL,
|
|
session_id TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
last_used_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
use_count INTEGER NOT NULL DEFAULT 0,
|
|
content_hash TEXT NOT NULL, pinned INTEGER NOT NULL DEFAULT 0,
|
|
hidden INTEGER NOT NULL DEFAULT 0, superseded_by TEXT,
|
|
embedding BLOB, embedding_model TEXT, embedding_dim INTEGER
|
|
)
|
|
`);
|
|
const h = MemoryDB.computeContentHash("dup", "body");
|
|
internal.run(
|
|
`INSERT INTO memories (id, summary, details, source, content_hash, superseded_by) VALUES ('a','dup','body','agent', ?, NULL)`,
|
|
[h],
|
|
);
|
|
internal.run(
|
|
`INSERT INTO memories (id, summary, details, source, content_hash, superseded_by) VALUES ('b','dup','body','agent', ?, 'a')`,
|
|
[h],
|
|
);
|
|
// 'b' is superseded → excluded from duplicate group
|
|
expect(db.findDuplicates()).toEqual([]);
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("MemoryDB — embeddings + edges edge cases", () => {
|
|
it("listEmbeddings(model) is exact: rows with mismatched model are excluded", () => {
|
|
const db = new MemoryDB(":memory:", "project");
|
|
try {
|
|
const a = db.write({ summary: "a", details: "", category: null, source: "agent" });
|
|
const b = db.write({ summary: "b", details: "", category: null, source: "agent" });
|
|
// Mark `b` with a different model name
|
|
(db as unknown as { db: Database }).db.run(
|
|
`UPDATE memories SET embedding_model = 'other-v2' WHERE id = ?`,
|
|
[b.record.id],
|
|
);
|
|
const ours = db.listEmbeddings("hashbag-v2").map((e) => e.id);
|
|
expect(ours).toContain(a.record.id);
|
|
expect(ours).not.toContain(b.record.id);
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
it("embedAndLink on a hidden row still embeds but skips edge inference targets", () => {
|
|
const db = new MemoryDB(":memory:", "project");
|
|
try {
|
|
const a = db.write({
|
|
summary: "hidden auth flow",
|
|
details: "",
|
|
category: null,
|
|
source: "agent",
|
|
});
|
|
const b = db.write({
|
|
summary: "visible auth flow",
|
|
details: "",
|
|
category: null,
|
|
source: "agent",
|
|
});
|
|
db.softDelete(a.record.id);
|
|
// Re-run embedAndLink on the hidden row
|
|
const linked = db.embedAndLink(a.record.id);
|
|
// Hidden rows are EXCLUDED from listEmbeddings via WHERE hidden = 0,
|
|
// so 'a' has no peers visible to itself either — but 'b' exists, so
|
|
// we expect at most some links. Either way, no crash + no edges to
|
|
// hidden rows from `b`'s perspective:
|
|
const fromB = db.listEdges(b.record.id, "similar");
|
|
for (const e of fromB) {
|
|
const peer = e.src_id === b.record.id ? e.dst_id : e.src_id;
|
|
expect(peer).not.toBe(a.record.id);
|
|
}
|
|
// After softDelete, ALL edges touching `a` are pruned — listEdges
|
|
// must never return `a` as a peer of any other row.
|
|
expect(Array.isArray(linked)).toBe(true);
|
|
const fromBAgain = db.listEdges(b.record.id, "similar");
|
|
for (const e of fromBAgain) {
|
|
const peer = e.src_id === b.record.id ? e.dst_id : e.src_id;
|
|
expect(peer).not.toBe(a.record.id);
|
|
}
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
it("listEdges(unknown) returns []", () => {
|
|
const db = new MemoryDB(":memory:", "project");
|
|
try {
|
|
expect(db.listEdges("ghost")).toEqual([]);
|
|
expect(db.listEdges("ghost", "similar")).toEqual([]);
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
it("similarClusters with no edges → []", () => {
|
|
const db = new MemoryDB(":memory:", "project");
|
|
try {
|
|
db.write({ summary: "lone-1", details: "", category: null, source: "agent" });
|
|
db.write({ summary: "totally-different-x", details: "", category: null, source: "agent" });
|
|
// Likely no edges past 0.85 threshold
|
|
expect(db.similarClusters(0.95)).toEqual([]);
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
it("embedAndLink tolerates an empty embed source (no summary words) → []", () => {
|
|
const db = new MemoryDB(":memory:", "project");
|
|
try {
|
|
// Whitespace-only summary; tokenizer drops it → zero vector source.
|
|
const a = db.write({
|
|
summary: " ",
|
|
details: "",
|
|
category: null,
|
|
topics: [],
|
|
source: "agent",
|
|
});
|
|
// memoryEmbedSource trims to "" → embedAndLink returns [] without crashing.
|
|
const linked = db.embedAndLink(a.record.id);
|
|
expect(linked).toEqual([]);
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── 2. recall.ts ────────────────────────────────────────────────────────
|
|
|
|
describe("MemoryRecall — empty and degenerate inputs", () => {
|
|
it("empty query + no editedFiles → []", async () => {
|
|
const db = new MemoryDB(":memory:", "project");
|
|
try {
|
|
db.write({ summary: "anything", details: "", category: null, source: "agent" });
|
|
const r = new MemoryRecall([{ scope: "project" as const, db: adapt(db) }], null);
|
|
const hits = await r.recall({ query: "", editedFiles: [] });
|
|
expect(hits).toEqual([]);
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
it("limit=0 returns []", async () => {
|
|
const db = new MemoryDB(":memory:", "project");
|
|
try {
|
|
db.write({ summary: "match-me", details: "", category: null, source: "agent" });
|
|
const r = new MemoryRecall([{ scope: "project" as const, db: adapt(db) }], null);
|
|
const hits = await r.recall({ query: "match-me", limit: 0 });
|
|
expect(hits).toEqual([]);
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
it("threshold=Infinity returns []", async () => {
|
|
const db = new MemoryDB(":memory:", "project");
|
|
try {
|
|
db.write({ summary: "anything-test", details: "", category: null, source: "agent" });
|
|
const r = new MemoryRecall([{ scope: "project" as const, db: adapt(db) }], null);
|
|
const hits = await r.recall({
|
|
query: "anything-test",
|
|
threshold: Number.POSITIVE_INFINITY,
|
|
limit: 5,
|
|
});
|
|
expect(hits).toEqual([]);
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
it("readScope filter excludes non-matching scope adapters", async () => {
|
|
const db = new MemoryDB(":memory:", "project");
|
|
try {
|
|
db.write({ summary: "project-row", details: "", category: null, source: "agent" });
|
|
// Configure both scopes but only project has the adapter wired
|
|
const r = new MemoryRecall([{ scope: "project" as const, db: adapt(db) }], null);
|
|
const projHits = await r.recall({ query: "project-row", readScope: "project" });
|
|
const globHits = await r.recall({ query: "project-row", readScope: "global" });
|
|
expect(projHits.length).toBe(1);
|
|
expect(globHits).toEqual([]);
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
it("intel.getFileBlastRadiusById throwing does not crash recall", async () => {
|
|
const db = new MemoryDB(":memory:", "project");
|
|
try {
|
|
const a = db.write({ summary: "blast-test", details: "", category: null, source: "agent" });
|
|
db.addFileRef(a.record.id, "x.ts", 7);
|
|
const intel = {
|
|
getFileIdByPath: async () => 7,
|
|
getFileBlastRadiusById: async () => {
|
|
throw new Error("offline");
|
|
},
|
|
};
|
|
const r = new MemoryRecall([{ scope: "project" as const, db: adapt(db) }], intel);
|
|
const hits = await r.recall({ query: "blast-test", editedFiles: ["x.ts"], limit: 5 });
|
|
expect(hits.length).toBe(1);
|
|
expect(hits[0]!.signals.blast_radius).toBe(0); // failure → 0
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
it("intel.getFileIdByPath rejecting falls back to path-based affinity", async () => {
|
|
const db = new MemoryDB(":memory:", "project");
|
|
try {
|
|
const a = db.write({ summary: "path-test", details: "", category: null, source: "agent" });
|
|
db.addFileRef(a.record.id, "auth/x.ts", null);
|
|
const intel = {
|
|
getFileIdByPath: async () => {
|
|
throw new Error("nope");
|
|
},
|
|
getFileBlastRadiusById: async () => 0,
|
|
};
|
|
const r = new MemoryRecall([{ scope: "project" as const, db: adapt(db) }], intel);
|
|
const hits = await r.recall({
|
|
query: "path-test",
|
|
editedFiles: ["auth/x.ts"],
|
|
limit: 5,
|
|
});
|
|
expect(hits.length).toBe(1);
|
|
expect(hits[0]!.signals.file_affinity).toBe(1);
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
it("adapter without listEmbeddings (pre-Phase-4) skips semantic without crashing", async () => {
|
|
const db = new MemoryDB(":memory:", "project");
|
|
try {
|
|
db.write({ summary: "legacy-adapter", details: "", category: null, source: "agent" });
|
|
// Strip the optional methods
|
|
const partial = {
|
|
searchUnicode: (q: string, l?: number) => db.searchUnicode(q, l),
|
|
searchTrigram: (q: string, l?: number) => db.searchTrigram(q, l),
|
|
searchTrigramWithBigram: (q: string, l?: number) => db.searchTrigramWithBigram(q, l),
|
|
findByFileIds: (ids: number[], l?: number) => db.findByFileIds(ids, l),
|
|
findByPaths: (paths: string[], l?: number) => db.findByPaths(paths, l),
|
|
topByUsage: (l?: number) => db.topByUsage(l),
|
|
readMany: (ids: string[]) => db.readMany(ids),
|
|
};
|
|
const r = new MemoryRecall([{ scope: "project" as const, db: partial }], null);
|
|
const hits = await r.recall({ query: "legacy-adapter", limit: 5 });
|
|
expect(hits.length).toBe(1);
|
|
expect(hits[0]!.signals.semantic).toBeNull();
|
|
expect(hits[0]!.signals.semantic_rank).toBeNull();
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
it("normalized_score handles all-zero scored set without divide-by-zero", async () => {
|
|
const db = new MemoryDB(":memory:", "project");
|
|
try {
|
|
// Empty DB → empty hits → no divide.
|
|
const r = new MemoryRecall([{ scope: "project" as const, db: adapt(db) }], null);
|
|
const hits = await r.recall({ query: "anything", limit: 5 });
|
|
expect(hits).toEqual([]);
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── 3. embedder.ts ──────────────────────────────────────────────────────
|
|
|
|
describe("embedder — degenerate inputs", () => {
|
|
it("punctuation-only text → zero vector (no tokens)", () => {
|
|
const v = embed("!!!??? ...");
|
|
let n = 0;
|
|
for (let i = 0; i < v.length; i++) n += (v[i] ?? 0) ** 2;
|
|
expect(n).toBe(0);
|
|
});
|
|
|
|
it("single-character text → zero vector (tokens require length ≥ 2)", () => {
|
|
const v = embed("a");
|
|
let n = 0;
|
|
for (let i = 0; i < v.length; i++) n += (v[i] ?? 0) ** 2;
|
|
expect(n).toBe(0);
|
|
});
|
|
|
|
it("very long single token (1000 chars) hashes without crashing", () => {
|
|
const long = "x".repeat(1000);
|
|
const v = embed(long);
|
|
expect(v.length).toBe(EMBED_DIM);
|
|
let n = 0;
|
|
for (let i = 0; i < v.length; i++) n += (v[i] ?? 0) ** 2;
|
|
expect(Math.abs(Math.sqrt(n) - 1)).toBeLessThan(1e-6);
|
|
});
|
|
|
|
it("CJK-only text produces a non-zero vector", () => {
|
|
const v = embed("中文 日本語");
|
|
let n = 0;
|
|
for (let i = 0; i < v.length; i++) n += (v[i] ?? 0) ** 2;
|
|
expect(n).toBeCloseTo(1, 5);
|
|
});
|
|
|
|
it("emoji-only text → zero vector (\\p{L}\\p{N} excludes emoji)", () => {
|
|
const v = embed("🎉🚀✨");
|
|
let n = 0;
|
|
for (let i = 0; i < v.length; i++) n += (v[i] ?? 0) ** 2;
|
|
expect(n).toBe(0);
|
|
});
|
|
|
|
it("buffer roundtrip preserves vector exactly", () => {
|
|
const v = embed("roundtrip the vector");
|
|
const buf = vectorToBuffer(v);
|
|
const back = bufferToVector(buf);
|
|
expect(back.length).toBe(v.length);
|
|
for (let i = 0; i < v.length; i++) {
|
|
expect(back[i]).toBeCloseTo(v[i] ?? 0, 6);
|
|
}
|
|
});
|
|
|
|
it("cosine of mismatched-length vectors returns 0 (defensive, no NaN)", () => {
|
|
const a = new Float32Array([1, 0, 0]);
|
|
const b = new Float32Array([1, 0]);
|
|
expect(cosine(a, b)).toBe(0);
|
|
});
|
|
|
|
it("cosine of two zero vectors returns 0 (no NaN)", () => {
|
|
const a = new Float32Array(EMBED_DIM);
|
|
const b = new Float32Array(EMBED_DIM);
|
|
const c = cosine(a, b);
|
|
expect(Number.isFinite(c)).toBe(true);
|
|
expect(c).toBe(0);
|
|
});
|
|
});
|
|
// ─── 6. manager.ts ───────────────────────────────────────────────────────
|
|
|
|
describe("MemoryManager — config + scope edge cases", () => {
|
|
let dir: string;
|
|
let mgr: MemoryManager;
|
|
|
|
beforeEach(() => {
|
|
dir = makeTmpDir("mgr-edge");
|
|
});
|
|
|
|
afterEach(() => {
|
|
mgr?.close();
|
|
rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("corrupt scope config file falls back to defaults (no throw)", () => {
|
|
const globalDir = join(dir, "home-global");
|
|
require("node:fs").mkdirSync(globalDir, { recursive: true });
|
|
require("node:fs").mkdirSync(join(dir, ".soulforge"), { recursive: true });
|
|
writeFileSync(join(dir, ".soulforge", "memory-config.json"), "{not-json", "utf-8");
|
|
mgr = new MemoryManager(dir, globalDir);
|
|
expect(mgr.scopeConfig.writeScope).toBe("project");
|
|
expect(mgr.scopeConfig.readScope).toBe("all");
|
|
});
|
|
|
|
it("config with missing required fields is rejected, defaults stand", () => {
|
|
const globalDir = join(dir, "home-global");
|
|
require("node:fs").mkdirSync(globalDir, { recursive: true });
|
|
require("node:fs").mkdirSync(join(dir, ".soulforge"), { recursive: true });
|
|
writeFileSync(
|
|
join(dir, ".soulforge", "memory-config.json"),
|
|
JSON.stringify({ writeScope: "global" }),
|
|
"utf-8",
|
|
);
|
|
mgr = new MemoryManager(dir, globalDir);
|
|
expect(mgr.scopeConfig.writeScope).toBe("project");
|
|
});
|
|
|
|
it("setSettingsScope writes new file before deleting old (atomic-ish)", () => {
|
|
const globalDir = join(dir, "home-global");
|
|
require("node:fs").mkdirSync(globalDir, { recursive: true });
|
|
mgr = new MemoryManager(dir, globalDir);
|
|
// Force a project-scoped config to disk
|
|
mgr.saveConfig("project");
|
|
const projectPath = join(dir, ".soulforge", "memory-config.json");
|
|
expect(existsSync(projectPath)).toBe(true);
|
|
|
|
// Switch to global
|
|
mgr.setSettingsScope("global");
|
|
const globalPath = join(globalDir, "memory-config.json");
|
|
expect(existsSync(globalPath)).toBe(true);
|
|
expect(existsSync(projectPath)).toBe(false);
|
|
});
|
|
|
|
it("findById across scopes returns scope tag of the originating DB", () => {
|
|
const globalDir = join(dir, "home-global");
|
|
require("node:fs").mkdirSync(globalDir, { recursive: true });
|
|
mgr = new MemoryManager(dir, globalDir);
|
|
const proj = mgr.write("project", {
|
|
summary: "p-only",
|
|
details: "",
|
|
category: null,
|
|
source: "agent",
|
|
});
|
|
const found = mgr.findById("all", proj.record.id);
|
|
expect(found).not.toBeNull();
|
|
expect(found!.scope).toBe("project");
|
|
});
|
|
|
|
it("clearScope('all') zeroes both DBs and bumps generation", () => {
|
|
const globalDir = join(dir, "home-global");
|
|
require("node:fs").mkdirSync(globalDir, { recursive: true });
|
|
mgr = new MemoryManager(dir, globalDir);
|
|
mgr.write("project", { summary: "p", details: "", category: null, source: "agent" });
|
|
mgr.write("global", { summary: "g", details: "", category: null, source: "agent" });
|
|
const gen = mgr.generation;
|
|
const cleared = mgr.clearScope("all");
|
|
expect(cleared).toBe(2);
|
|
expect(mgr.list("all").length).toBe(0);
|
|
expect(mgr.generation).toBeGreaterThan(gen);
|
|
});
|
|
|
|
it("clearScope on empty scope does NOT bump generation (no-op)", () => {
|
|
const globalDir = join(dir, "home-global");
|
|
require("node:fs").mkdirSync(globalDir, { recursive: true });
|
|
mgr = new MemoryManager(dir, globalDir);
|
|
const gen = mgr.generation;
|
|
expect(mgr.clearScope("project")).toBe(0);
|
|
expect(mgr.generation).toBe(gen);
|
|
});
|
|
});
|