src/store/store.memory.test.ts

import assert from "node:assert/strict"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { test } from "bun:test"; import { getResolvedVoiceAdmissionClassifierBinding } from "../settings/agentStack.ts"; import { Store } from "./store.ts"; import { rmTempDir } from "../testHelpers.ts"; import { createTestSettingsPatch } from "../testSettings.ts";

async function withTempStore(run) { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clanker-store-test-")); const dbPath = path.join(dir, "clanker.db"); const store = new Store(dbPath); store.init();

try { await run(store); } finally { store.close(); await rmTempDir(dir); } }

test("memory facts support user scope across guilds and guild scope partitioning", async () => { await withTempStore(async (store) => { const userFactPayload = { channelId: "channel-1", subject: "user-1", fact: "User likes pineapple pizza.", factType: "preference", evidenceText: "likes pineapple pizza", sourceMessageId: "msg-1", confidence: 0.7 };

const insertedUser = store.addMemoryFact({
  ...userFactPayload,
  scope: "user",
  guildId: null,
  userId: "user-1"
});
const insertedGuildA = store.addMemoryFact({
  scope: "guild",
  guildId: "guild-a",
  channelId: "channel-1",
  subject: "__lore__",
  fact: "Guild A runs a Friday meme competition.",
  factType: "other",
  sourceMessageId: "msg-guild-a",
  confidence: 0.75
});
const insertedGuildB = store.addMemoryFact({
  scope: "guild",
  guildId: "guild-b",
  channelId: "channel-1",
  subject: "__lore__",
  fact: "Guild B runs a Friday meme competition.",
  factType: "other",
  sourceMessageId: "msg-guild-b",
  confidence: 0.75
});

assert.equal(insertedUser, true);
assert.equal(insertedGuildA, true);
assert.equal(insertedGuildB, true);

const userFacts = store.getFactsForSubjects(["user-1"], 10, { scope: "user" });
assert.equal(userFacts.length, 1);
assert.equal(userFacts[0]?.scope, "user");
assert.equal(userFacts[0]?.guild_id, null);
assert.equal(userFacts[0]?.user_id, "user-1");

const guildAFacts = store.getFactsForSubjects(["__lore__"], 10, { scope: "guild", guildId: "guild-a" });
const guildBFacts = store.getFactsForSubjects(["__lore__"], 10, { scope: "guild", guildId: "guild-b" });
assert.equal(guildAFacts.length, 1);
assert.equal(guildBFacts.length, 1);
assert.equal(guildAFacts[0].guild_id, "guild-a");
assert.equal(guildBFacts[0].guild_id, "guild-b");

}); });

test("memory facts support owner scope", async () => { await withTempStore(async (store) => { const inserted = store.addMemoryFact({ scope: "owner", guildId: null, userId: "owner-1", channelId: "dm-owner", subject: "owner", fact: "Remember to renew passport in May.", factType: "project", sourceMessageId: "owner-msg-1", confidence: 0.9 });

assert.equal(inserted, true);
const rows = store.getFactsForScope({ scope: "owner", subjectIds: ["__owner__"], limit: 10 });
assert.equal(rows.length, 1);
assert.equal(rows[0]?.scope, "owner");
assert.equal(rows[0]?.user_id, "owner-1");
assert.equal(rows[0]?.subject, "__owner__");

}); });

test("session summaries persist, filter by channel, and order by most recent end time", async () => { await withTempStore(async (store) => { const now = Date.now(); const endedAtA = new Date(now - 60 * 60 * 1000).toISOString(); // 1 hour ago const endedAtB = new Date(now - 50 * 60 * 1000).toISOString(); // 50 min ago const endedAtC = new Date(now - 55 * 60 * 1000).toISOString(); // 55 min ago const sinceIso = new Date(now - 2 * 60 * 60 * 1000).toISOString(); // 2 hours ago const beforeIso = new Date(now + 60 * 60 * 1000).toISOString(); // 1 hour from now

const insertedA = store.upsertSessionSummary({
  sessionId: "voice-session-a",
  guildId: "guild-a",
  channelId: "chan-1",
  summaryText: "Alice and Bob planned the build.",
  endedAt: endedAtA
});
const insertedB = store.upsertSessionSummary({
  sessionId: "voice-session-b",
  guildId: "guild-a",
  channelId: "chan-1",
  summaryText: "They narrowed the rollout to Friday.",
  endedAt: endedAtB
});
store.upsertSessionSummary({
  sessionId: "voice-session-c",
  guildId: "guild-a",
  channelId: "chan-2",
  summaryText: "Other channel summary.",
  endedAt: endedAtC
});

assert.equal(insertedA, true);
assert.equal(insertedB, true);

const rows = store.getRecentSessionSummaries({
  guildId: "guild-a",
  channelId: "chan-1",
  sinceIso,
  beforeIso,
  limit: 5
});
assert.equal(rows.length, 2);
assert.equal(rows[0]?.session_id, "voice-session-b");
assert.equal(rows[1]?.session_id, "voice-session-a");

}); });

test("memory facts canonicalize legacy fact wrappers and legacy fact types on write", async () => { await withTempStore(async (store) => { const inserted = store.addMemoryFact({ scope: "guild", guildId: "guild-a", channelId: "chan-1", subject: "lore", fact: "Memory line: Friday game night gets loud.", factType: "lore", sourceMessageId: "msg-legacy", confidence: 0.7 });

assert.equal(inserted, true);
const [row] = store.getFactsForScope({ guildId: "guild-a", limit: 10, subjectIds: ["__lore__"] });
assert.equal(row?.fact, "Friday game night gets loud.");
assert.equal(row?.fact_type, "other");

}); });

test("archiveOldFactsForSubject evicts contextual facts before core facts", async () => { await withTempStore(async (store) => { const addFact = (subject: string, fact: string, factType: string, sourceMessageId: string) => { store.addMemoryFact({ guildId: "guild-a", channelId: "chan-1", subject, fact, factType, sourceMessageId, confidence: 0.6 }); };

for (let i = 1; i <= 19; i += 1) {
  addFact("user-context-first", `Core ${i}.`, i % 2 === 0 ? "relationship" : "profile", `core-a-${i}`);
}
addFact("user-context-first", "Context 1.", "preference", "ctx-a-1");
addFact("user-context-first", "Context 2.", "preference", "ctx-a-2");
addFact("user-context-first", "Context 3.", "preference", "ctx-a-3");

const archivedContextual = store.archiveOldFactsForSubject({
  guildId: "guild-a",
  subject: "user-context-first",
  keep: 20
});
assert.equal(archivedContextual, 2);
const contextFirstFacts = store.getFactsForSubjects(["user-context-first"], 30, { guildId: "guild-a" });
assert.equal(contextFirstFacts.filter((row) => row.fact_type === "profile" || row.fact_type === "relationship").length, 19);
assert.equal(contextFirstFacts.filter((row) => row.fact_type === "preference").length, 1);

// Create enough core facts to exceed the core cap (35) plus some contextual,
// so the eviction path must archive contextual first, then overflow into core.
for (let i = 1; i <= 38; i += 1) {
  addFact("user-core-cap", `Core cap ${i}.`, i % 2 === 0 ? "relationship" : "profile", `core-b-${i}`);
}
addFact("user-core-cap", "Context survivor.", "preference", "ctx-b-1");

const archivedMixed = store.archiveOldFactsForSubject({
  guildId: "guild-a",
  subject: "user-core-cap",
  keep: 36
});
// 39 total, keep 36 → 3 to archive. 1 contextual archived first, then 2 oldest core.
assert.equal(archivedMixed, 3);
const coreCapFacts = store.getFactsForSubjects(["user-core-cap"], 50, { guildId: "guild-a" });
assert.equal(coreCapFacts.filter((row) => row.fact_type === "preference").length, 0);
assert.equal(coreCapFacts.filter((row) => row.fact_type === "profile" || row.fact_type === "relationship").length, 36);

}); });

test("memory facts support query filtering and scope filters", async () => { await withTempStore(async (store) => { store.addMemoryFact({ guildId: "guild-a", channelId: "chan-1", subject: "user-1", fact: "User likes old school DS hardware.", factType: "preference", evidenceText: "Mentioned old school DS hardware.", sourceMessageId: "msg-1", confidence: 0.77 }); store.addMemoryFact({ guildId: "guild-a", channelId: "chan-2", subject: "user-2", fact: "User likes tea.", factType: "preference", evidenceText: "Mentioned tea.", sourceMessageId: "msg-2", confidence: 0.61 });

const matching = store.getFactsForScope({
  guildId: "guild-a",
  limit: 10,
  queryText: "old school ds"
});
assert.equal(matching.length, 1);
assert.equal(matching[0]?.subject, "user-1");

const subjectFiltered = store.getFactsForScope({
  guildId: "guild-a",
  limit: 10,
  subjectIds: ["user-2"]
});
assert.equal(subjectFiltered.length, 1);
assert.equal(subjectFiltered[0]?.subject, "user-2");

const typeFiltered = store.getFactsForScope({
  guildId: "guild-a",
  limit: 10,
  factTypes: ["preference"]
});
assert.equal(typeFiltered.length, 2);

}); });

test("searchMemoryFactsLexical uses BM25/FTS for exact technical tokens", async () => { await withTempStore(async (store) => { store.addMemoryFact({ scope: "guild", guildId: "guild-a", channelId: "chan-1", subject: "lore", fact: "The fix involves ERR_MODULE_NOT_FOUND in vite-node.", factType: "other", sourceMessageId: "msg-fts-1", confidence: 0.7 }); store.addMemoryFact({ scope: "guild", guildId: "guild-a", channelId: "chan-1", subject: "lore", fact: "People were talking about tea and snacks.", factType: "other", sourceMessageId: "msg-fts-2", confidence: 0.7 });

const rows = store.searchMemoryFactsLexical({
  guildId: "guild-a",
  scope: "guild",
  queryText: "ERR_MODULE_NOT_FOUND vite-node",
  queryTokens: ["ERR_MODULE_NOT_FOUND", "vite-node"],
  limit: 5
});

assert.equal(rows.length >= 1, true);
assert.equal(rows[0]?.fact, "The fix involves ERR_MODULE_NOT_FOUND in vite-node.");
assert.equal(Number(rows[0]?.lexical_score || 0) > 0, true);

}); });

test("guild-scoped memory views can include portable user facts", async () => { await withTempStore(async (store) => { store.addMemoryFact({ scope: "user", guildId: null, userId: "user-1", channelId: "chan-user", subject: "user-1", fact: "User likes old school DS hardware.", factType: "preference", sourceMessageId: "msg-user", confidence: 0.8 }); store.addMemoryFact({ scope: "guild", guildId: "guild-a", channelId: "chan-guild", subject: "lore", fact: "Guild A has a recurring game night.", factType: "other", sourceMessageId: "msg-guild", confidence: 0.7 });

const subjects = store.getMemorySubjects(20, {
  guildId: "guild-a",
  includePortableUserScope: true
});
assert.equal(subjects.some((row) => row.subject === "user-1"), true);
assert.equal(subjects.some((row) => row.subject === "__lore__"), true);

const facts = store.getFactsForScope({
  guildId: "guild-a",
  includePortableUserScope: true,
  limit: 20
});
assert.equal(facts.some((row) => row.subject === "user-1"), true);
assert.equal(facts.some((row) => row.subject === "__lore__"), true);

}); });

test("reflection completion survives pruned action logs via durable checkpoints", async () => { await withTempStore(async (store) => { store.markReflectionCompleted("2026-03-09", "guild-a", { runId: "reflection_2026-03-09_guild-a" });

store.db.prepare("DELETE FROM actions WHERE kind IN ('memory_reflection_start', 'memory_reflection_complete', 'memory_reflection_error')").run();

assert.equal(store.hasReflectionBeenCompleted("2026-03-09", "guild-a"), true);

}); });

test("memory facts can be updated and soft-deleted while clearing stale vectors", async () => { await withTempStore(async (store) => { store.addMemoryFact({ guildId: "guild-a", channelId: "chan-1", subject: "user-1", fact: "User likes handhelds.", factType: "preference", evidenceText: "Mentioned handhelds.", sourceMessageId: "msg-1", confidence: 0.66 });

const inserted = store.getMemoryFactBySubjectAndFact({
  scope: "guild",
  guildId: "guild-a",
  subject: "user-1",
  fact: "User likes handhelds."
});
assert.ok(inserted);

const factId = Number(inserted?.id);
store.upsertMemoryFactVectorNative({
  factId,
  model: "text-embedding-3-small",
  embedding: [0.1, 0.2, 0.3]
});
const vector = store.getMemoryFactVectorNative(factId, "text-embedding-3-small");
assert.ok(vector);
assert.equal(vector?.length, 3);

const updated = store.updateMemoryFact({
  scope: "guild",
  guildId: "guild-a",
  factId,
  subject: "user-1",
  fact: "User likes handheld PCs.",
  factType: "project",
  evidenceText: "Updated by operator.",
  confidence: 0.91
});

assert.equal(updated.ok, true);
assert.equal(updated.row?.fact, "User likes handheld PCs.");
assert.equal(updated.row?.fact_type, "project");
assert.equal(updated.row?.evidence_text, "Updated by operator.");
assert.equal(updated.row?.confidence, 0.91);
assert.equal(store.getMemoryFactVectorNative(factId, "text-embedding-3-small"), null);

const deleted = store.deleteMemoryFact({
  scope: "guild",
  guildId: "guild-a",
  factId
});

assert.equal(deleted.ok, true);
assert.equal(deleted.deleted, 1);
assert.equal(store.getMemoryFactById(factId, "guild-a", "guild"), null);
assert.equal(
  store.getFactsForScope({
    guildId: "guild-a",
    limit: 10,
    subjectIds: ["user-1"]
  }).length,
  0
);

}); });

test("store init performs one-time legacy memory canonicalization", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clanker-store-legacy-test-")); const dbPath = path.join(dir, "clanker.db");

try { const store = new Store(dbPath); store.init(); const now = new Date().toISOString(); store.db.prepare( INSERT INTO memory_facts ( created_at, updated_at, scope, guild_id, channel_id, user_id, subject, fact, fact_type, evidence_text, source_message_id, confidence, is_active ) VALUES (?, ?, 'guild', 'guild-a', 'chan-1', NULL, '__lore__', 'Memory line: Friday game night gets loud.', 'lore', 'legacy lore', 'legacy-1', 0.7, 1) ).run(now, now); store.db.prepare( INSERT INTO memory_facts ( created_at, updated_at, scope, guild_id, channel_id, user_id, subject, fact, fact_type, evidence_text, source_message_id, confidence, is_active ) VALUES (?, ?, 'guild', 'guild-a', 'chan-1', NULL, '__lore__', 'Friday game night gets loud.', 'other', '', 'legacy-2', 0.8, 1) ).run(now, now); store.db.prepare( INSERT INTO memory_facts ( created_at, updated_at, scope, guild_id, channel_id, user_id, subject, fact, fact_type, evidence_text, source_message_id, confidence, is_active ) VALUES (?, ?, 'user', NULL, 'chan-2', 'user-1', 'user-1', 'Self memory: Likes handhelds.', 'general', 'legacy self', 'legacy-3', 0.6, 1) ).run(now, now); store.db.prepare( INSERT INTO memory_facts ( created_at, updated_at, scope, guild_id, channel_id, user_id, subject, fact, fact_type, evidence_text, source_message_id, confidence, is_active ) VALUES (?, ?, 'guild', 'guild-a', 'chan-3', NULL, '123456789', 'They build rhythm game controllers.', 'other', 'legacy scoped person fact', 'legacy-4', 0.65, 1) ).run(now, now); store.close();

const reopened = new Store(dbPath);
reopened.init();

const guildFacts = reopened.getFactsForScope({ guildId: "guild-a", limit: 10, subjectIds: ["__lore__"] });
assert.equal(guildFacts.length, 1);
assert.equal(guildFacts[0]?.fact, "Friday game night gets loud.");
assert.equal(guildFacts[0]?.fact_type, "other");

const userFacts = reopened.getFactsForScope({ guildId: "guild-a", includePortableUserScope: true, limit: 10, subjectIds: ["user-1"] });
assert.equal(userFacts.length, 1);
assert.equal(userFacts[0]?.fact, "Likes handhelds.");
assert.equal(userFacts[0]?.fact_type, "other");

const migratedPersonFacts = reopened.getFactsForScope({ guildId: "guild-a", includePortableUserScope: true, limit: 10, subjectIds: ["123456789"] });
assert.equal(migratedPersonFacts.length, 1);
assert.equal(migratedPersonFacts[0]?.scope, "user");
assert.equal(migratedPersonFacts[0]?.guild_id, null);
assert.equal(migratedPersonFacts[0]?.user_id, "123456789");
assert.equal(migratedPersonFacts[0]?.subject, "123456789");
reopened.close();

} finally { await rmTempDir(dir); } });

test("voice reply decision llm settings normalize provider and model", async () => { await withTempStore(async (store) => { const patched = store.patchSettings(createTestSettingsPatch({ voice: { conversationPolicy: { replyPath: "bridge" }, admission: { mode: "classifier_gate" } }, agentStack: { advancedOverridesEnabled: true, overrides: { voiceAdmissionClassifier: { mode: "dedicated_model", model: { provider: "CLAUDE-OAUTH", model: " claude-opus-4-6 " } } } } }));

const binding = getResolvedVoiceAdmissionClassifierBinding(patched);
assert.equal(binding?.provider, "claude-oauth");
assert.equal(binding?.model, "claude-opus-4-6");

}); });