import { IdentityDB, extractFact, type Fact as IdentityFact, type FactExtractor } from 'identitydb'; import type { BoxBrainMemoryStore, FactDraft, MemorySpace, ScheduleEntry, StoredFact } from './types'; function normalizeTopics(topics: string[]): string[] { return [...new Set(topics.map((topic) => topic.trim()).filter(Boolean))]; } function includesAnyTopic(fact: StoredFact, topics: string[]): boolean { const normalized = new Set(normalizeTopics(topics).map((topic) => topic.toLowerCase())); return fact.topics.some((topic) => normalized.has(topic.toLowerCase())); } export class InMemoryMemoryStore implements BoxBrainMemoryStore { readonly spaces = new Map(); readonly facts = new Map(); readonly schedules = new Map(); async createSpace(input: { displayName: string; seedMessage: string; now: string }): Promise { const slug = input.displayName.toLowerCase().replace(/[^a-z0-9가-힣]+/gi, '-').replace(/^-|-$/g, '') || 'persona'; const space: MemorySpace = { id: `persona-${slug}-${crypto.randomUUID()}`, displayName: input.displayName, createdAt: input.now, metadata: { seedMessage: input.seedMessage }, }; this.spaces.set(space.id, space); return space; } async getSpace(spaceId: string): Promise { return this.spaces.get(spaceId) ?? null; } async addFact(spaceId: string, fact: FactDraft): Promise { const stored: StoredFact = { id: crypto.randomUUID(), statement: fact.statement, topics: normalizeTopics(fact.topics), createdAt: new Date().toISOString(), ...(fact.confidence === undefined ? {} : { confidence: fact.confidence }), ...(fact.source === undefined ? {} : { source: fact.source }), ...(fact.metadata === undefined ? {} : { metadata: fact.metadata }), }; const existing = this.facts.get(spaceId) ?? []; existing.push(stored); this.facts.set(spaceId, existing); return stored; } async listFacts(spaceId: string): Promise { return [...(this.facts.get(spaceId) ?? [])].sort((a, b) => a.createdAt.localeCompare(b.createdAt)); } async findFacts(spaceId: string, topics: string[]): Promise { const facts = this.facts.get(spaceId) ?? []; if (topics.length === 0) return [...facts]; return facts.filter((fact) => includesAnyTopic(fact, topics)); } async saveScheduleEntries(spaceId: string, entries: ScheduleEntry[]): Promise { const existing = (this.schedules.get(spaceId) ?? []).filter((entry) => !entries.some((incoming) => incoming.id === entry.id)); this.schedules.set(spaceId, [...existing, ...entries].sort((a, b) => a.startAt.localeCompare(b.startAt))); } async listScheduleEntries(spaceId: string, fromInclusive: string, toExclusive: string): Promise { return (this.schedules.get(spaceId) ?? []) .filter((entry) => entry.startAt < toExclusive && entry.endAt > fromInclusive) .sort((a, b) => a.startAt.localeCompare(b.startAt)); } async deleteScheduleEntriesBefore(spaceId: string, cutoffExclusive: string): Promise { const entries = this.schedules.get(spaceId) ?? []; const kept = entries.filter((entry) => entry.endAt > cutoffExclusive); this.schedules.set(spaceId, kept); return entries.length - kept.length; } async ingestStatement(spaceId: string, statement: string, extractor: FactExtractor): Promise { const extracted = await extractFact(statement, extractor); return this.addFact(spaceId, { statement: extracted.statement ?? statement, topics: extracted.topics.map((t) => t.name), ...(typeof extracted.confidence === 'number' ? { confidence: extracted.confidence } : {}), ...(typeof extracted.source === 'string' ? { source: extracted.source } : {}), ...(extracted.metadata !== undefined && extracted.metadata !== null ? { metadata: extracted.metadata as Record } : {}), }); } } export interface IdentityDbMemoryStoreOptions { db: IdentityDB; } export class IdentityDbMemoryStore implements BoxBrainMemoryStore { constructor(private readonly options: IdentityDbMemoryStoreOptions) {} async createSpace(input: { displayName: string; seedMessage: string; now: string }): Promise { const slug = input.displayName.toLowerCase().replace(/[^a-z0-9가-힣]+/gi, '-').replace(/^-|-$/g, '') || 'persona'; const spaceName = `persona-${slug}-${crypto.randomUUID()}`; const space = await this.options.db.upsertSpace({ name: spaceName, description: `BoxBrain persona space for ${input.displayName}`, metadata: { boxbrainType: 'persona-space', displayName: input.displayName, seedMessage: input.seedMessage, createdAt: input.now, }, }); return { id: space.name, displayName: input.displayName, createdAt: space.createdAt, metadata: { seedMessage: input.seedMessage, identityDbSpaceId: space.id }, }; } async getSpace(spaceId: string): Promise { const space = await this.options.db.getSpaceByName(spaceId); if (!space) return null; const metadata = typeof space.metadata === 'object' && space.metadata !== null && !Array.isArray(space.metadata) ? space.metadata as Record : {}; const displayName = typeof metadata['displayName'] === 'string' ? metadata['displayName'] : space.name; return { id: space.name, displayName, createdAt: space.createdAt, metadata }; } async addFact(spaceId: string, fact: FactDraft): Promise { const stored = await this.options.db.addFact({ spaceName: spaceId, statement: fact.statement, confidence: fact.confidence ?? null, source: fact.source ?? null, metadata: (fact.metadata ?? null) as never, topics: normalizeTopics(fact.topics).map((topic) => ({ name: topic, category: 'entity' as const, granularity: 'concrete' as const })), }); return this.fromIdentityFact(stored); } async listFacts(spaceId: string): Promise { const topics = await this.options.db.listTopics({ spaceName: spaceId, includeFacts: true }); const collected = new Map(); for (const topic of topics) { for (const fact of topic.facts) { collected.set(fact.id, this.fromIdentityFact(fact)); } } return [...collected.values()].sort((a, b) => a.createdAt.localeCompare(b.createdAt)); } async findFacts(spaceId: string, topics: string[]): Promise { const uniqueTopics = normalizeTopics(topics); const collected = new Map(); for (const topic of uniqueTopics) { const facts = await this.options.db.getTopicFacts(topic, { spaceName: spaceId }); for (const fact of facts) { collected.set(fact.id, this.fromIdentityFact(fact)); } } return [...collected.values()].sort((a, b) => a.createdAt.localeCompare(b.createdAt)); } async saveScheduleEntries(spaceId: string, entries: ScheduleEntry[]): Promise { for (const entry of entries) { await this.addFact(spaceId, { statement: `${entry.title} from ${entry.startAt} to ${entry.endAt}.`, topics: ['schedule', entry.startAt.slice(0, 10), entry.activity, 'persona'], source: 'boxbrain.schedule', metadata: { ...entry.metadata, scheduleEntry: entry }, }); } } async listScheduleEntries(spaceId: string, fromInclusive: string, toExclusive: string): Promise { const facts = await this.findFacts(spaceId, ['schedule']); return facts .map((fact) => fact.metadata?.['scheduleEntry']) .filter((value): value is ScheduleEntry => typeof value === 'object' && value !== null && !Array.isArray(value)) .filter((entry) => entry.startAt < toExclusive && entry.endAt > fromInclusive) .sort((a, b) => a.startAt.localeCompare(b.startAt)); } async deleteScheduleEntriesBefore(_spaceId: string, _cutoffExclusive: string): Promise { // IdentityDB is append-oriented at the public API level. Record schedule deletion as a fact at the Persona layer. return 0; } async ingestStatement(spaceId: string, statement: string, extractor: FactExtractor): Promise { const fact = await this.options.db.ingestStatement(statement, { extractor, spaceName: spaceId }); return this.fromIdentityFact(fact); } private fromIdentityFact(fact: IdentityFact): StoredFact { const metadata = typeof fact.metadata === 'object' && fact.metadata !== null && !Array.isArray(fact.metadata) ? fact.metadata as Record : undefined; return { id: fact.id, statement: fact.statement, topics: fact.topics.map((topic) => topic.name), createdAt: fact.createdAt, ...(fact.confidence === null ? {} : { confidence: fact.confidence }), ...(fact.source === null ? {} : { source: fact.source }), ...(metadata === undefined ? {} : { metadata }), }; } } export async function createSqliteIdentityMemoryStore(filename: string): Promise { const db = await IdentityDB.connect({ client: 'sqlite', filename }); await db.initialize(); return new IdentityDbMemoryStore({ db }); }