diff --git a/src/memory.ts b/src/memory.ts index dea816c..12bff4f 100644 --- a/src/memory.ts +++ b/src/memory.ts @@ -1,12 +1,26 @@ -import { IdentityDB, extractFact, type Fact as IdentityFact, type FactExtractor } from 'identitydb'; -import type { BoxBrainMemoryStore, FactDraft, MemorySpace, ScheduleEntry, StoredFact } from './types'; +import { + IdentityDB, + extractFacts, + type Fact as IdentityFact, + type FactExtractor, +} from "identitydb"; +import type { + BoxBrainMemoryStore, + FactDraft, + MemorySpace, + ScheduleEntry, + StoredFact, +} from "./types"; +import { extractedToDraft } from "./utils"; 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())); + const normalized = new Set( + normalizeTopics(topics).map((topic) => topic.toLowerCase()), + ); return fact.topics.some((topic) => normalized.has(topic.toLowerCase())); } @@ -15,8 +29,16 @@ export class InMemoryMemoryStore implements BoxBrainMemoryStore { 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'; + 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, @@ -48,7 +70,9 @@ export class InMemoryMemoryStore implements BoxBrainMemoryStore { } async listFacts(spaceId: string): Promise { - return [...(this.facts.get(spaceId) ?? [])].sort((a, b) => a.createdAt.localeCompare(b.createdAt)); + return [...(this.facts.get(spaceId) ?? [])].sort((a, b) => + a.createdAt.localeCompare(b.createdAt), + ); } async findFacts(spaceId: string, topics: string[]): Promise { @@ -57,33 +81,56 @@ export class InMemoryMemoryStore implements BoxBrainMemoryStore { 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 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 { + async listScheduleEntries( + spaceId: string, + fromInclusive: string, + toExclusive: string, + ): Promise { return (this.schedules.get(spaceId) ?? []) - .filter((entry) => entry.startAt < toExclusive && entry.endAt > fromInclusive) + .filter( + (entry) => entry.startAt < toExclusive && entry.endAt > fromInclusive, + ) .sort((a, b) => a.startAt.localeCompare(b.startAt)); } - async deleteScheduleEntriesBefore(spaceId: string, cutoffExclusive: string): Promise { + 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 } : {}), - }); + async ingestStatement( + spaceId: string, + statement: string, + extractor: FactExtractor, + ): Promise { + const extracted = await extractFacts(statement, extractor); + const stored: StoredFact[] = []; + for (const fact of extracted) { + stored.push( + await this.addFact(spaceId, extractedToDraft(fact, statement)), + ); + } + return stored; } } @@ -94,14 +141,22 @@ export interface IdentityDbMemoryStoreOptions { 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'; + 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', + boxbrainType: "persona-space", displayName: input.displayName, seedMessage: input.seedMessage, createdAt: input.now, @@ -118,9 +173,22 @@ export class IdentityDbMemoryStore implements BoxBrainMemoryStore { 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 }; + 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 { @@ -130,66 +198,111 @@ export class IdentityDbMemoryStore implements BoxBrainMemoryStore { 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 })), + 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 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)); + 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 }); + 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)); + return [...collected.values()].sort((a, b) => + a.createdAt.localeCompare(b.createdAt), + ); } - async saveScheduleEntries(spaceId: string, entries: ScheduleEntry[]): Promise { + 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', + 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']); + 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) + .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 { + 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); + async ingestStatement( + spaceId: string, + statement: string, + extractor: FactExtractor, + ): Promise { + const facts = await this.options.db.ingestStatements(statement, { + extractor, + spaceName: spaceId, + }); + return facts.map((fact) => 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; + 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, @@ -202,8 +315,10 @@ export class IdentityDbMemoryStore implements BoxBrainMemoryStore { } } -export async function createSqliteIdentityMemoryStore(filename: string): Promise { - const db = await IdentityDB.connect({ client: 'sqlite', filename }); +export async function createSqliteIdentityMemoryStore( + filename: string, +): Promise { + const db = await IdentityDB.connect({ client: "sqlite", filename }); await db.initialize(); return new IdentityDbMemoryStore({ db }); } diff --git a/src/persona.ts b/src/persona.ts index 018c6b9..fc62f65 100644 --- a/src/persona.ts +++ b/src/persona.ts @@ -28,6 +28,7 @@ import type { ScheduleEntry, ScheduledAvailabilitySnapshot, } from "./types"; +import { extractedToDraft } from "./utils"; interface CreateMode { type: "create"; @@ -331,20 +332,6 @@ export class Persona { return draft; } - private extractedToDraft(fact: ExtractedFact, statement: string): FactDraft { - return { - statement: fact.statement ?? statement, - topics: [...fact.topics.map((t) => t.name), "sleepMemory"], - source: fact.source ?? "boxbrain.sleepMemory", - ...(typeof fact.confidence === "number" - ? { confidence: fact.confidence } - : {}), - ...(fact.metadata !== undefined && fact.metadata !== null - ? { metadata: fact.metadata as Record } - : {}), - }; - } - async sleepMemory(input: { datetime: DateTimeInput; messageHistory: PersonaMessage[]; @@ -376,7 +363,7 @@ export class Persona { ].join("\n"); const extractedFacts = ( await extractFacts(statement, this.options.models.factExtractor) - ).map((fact) => this.extractedToDraft(fact, statement)); + ).map((fact) => extractedToDraft(fact, statement)); for (const fact of extractedFacts) { await this.memory.addFact(persona.id, fact); @@ -407,7 +394,7 @@ export class Persona { const statement = `Persona: ${this.mode.displayName}\nSeed: ${this.mode.seedMessage}`; const extracteds = ( await extractFacts(statement, this.options.models.factExtractor) - ).map((fact) => this.extractedToDraft(fact, statement)); + ).map((fact) => extractedToDraft(fact, statement)); for (const fact of extracteds) { await this.memory.addFact(space.id, fact); diff --git a/src/types.ts b/src/types.ts index 2a62b60..624a57d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,14 +1,14 @@ -import type { FactExtractor } from 'identitydb'; +import type { FactExtractor } from "identitydb"; export type DateTimeInput = Date | string | number; -export type PersonaConstructorMode = 'create' | 'load'; +export type PersonaConstructorMode = "create" | "load"; -export type ScheduleGranularity = 'day' | 'ten-minute'; +export type ScheduleGranularity = "day" | "ten-minute"; export type ScheduleActivity = string; -export type AvailabilityMode = 'online' | 'do-not-disturb' | 'offline'; +export type AvailabilityMode = "online" | "do-not-disturb" | "offline"; export interface MemorySpace { id: string; @@ -59,7 +59,7 @@ export interface ScheduledAvailabilitySnapshot { } export interface PersonaMessage { - sender: 'persona' | 'user'; + sender: "persona" | "user"; time: DateTimeInput; content: string; } @@ -85,7 +85,7 @@ export interface MandatoryConversationContext { export interface ReplyGenerationInput { persona: MemorySpace; now: string; - mode: 'reply' | 'start-conversation'; + mode: "reply" | "start-conversation"; context: MandatoryConversationContext; userMessage?: string; instruction: string; @@ -144,8 +144,12 @@ export interface MonthlyScheduleGenerationInput { } export interface ScheduleModel { - generateDailySchedule(input: DailyScheduleGenerationInput): Promise; - generateMonthlySchedule(input: MonthlyScheduleGenerationInput): Promise; + generateDailySchedule( + input: DailyScheduleGenerationInput, + ): Promise; + generateMonthlySchedule( + input: MonthlyScheduleGenerationInput, + ): Promise; } export interface PersonaModels { @@ -164,13 +168,28 @@ export interface PersonaOptions { } export interface BoxBrainMemoryStore { - createSpace(input: { displayName: string; seedMessage: string; now: string }): Promise; + createSpace(input: { + displayName: string; + seedMessage: string; + now: string; + }): Promise; getSpace(spaceId: string): Promise; addFact(spaceId: string, fact: FactDraft): Promise; listFacts(spaceId: string): Promise; findFacts(spaceId: string, topics: string[]): Promise; saveScheduleEntries(spaceId: string, entries: ScheduleEntry[]): Promise; - listScheduleEntries(spaceId: string, fromInclusive: string, toExclusive: string): Promise; - deleteScheduleEntriesBefore(spaceId: string, cutoffExclusive: string): Promise; - ingestStatement(spaceId: string, statement: string, extractor: FactExtractor): Promise; + listScheduleEntries( + spaceId: string, + fromInclusive: string, + toExclusive: string, + ): Promise; + deleteScheduleEntriesBefore( + spaceId: string, + cutoffExclusive: string, + ): Promise; + ingestStatement( + spaceId: string, + statement: string, + extractor: FactExtractor, + ): Promise; } diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..46715eb --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,19 @@ +import { ExtractedFact } from "identitydb"; +import { FactDraft } from "./types"; + +export function extractedToDraft( + fact: ExtractedFact, + statement: string, +): FactDraft { + return { + statement: fact.statement ?? statement, + topics: [...fact.topics.map((t) => t.name), "sleepMemory"], + source: fact.source ?? "boxbrain.sleepMemory", + ...(typeof fact.confidence === "number" + ? { confidence: fact.confidence } + : {}), + ...(fact.metadata !== undefined && fact.metadata !== null + ? { metadata: fact.metadata as Record } + : {}), + }; +}