From 600f5ff0bc0d28dea01accdfad15af7ce61c1bfa Mon Sep 17 00:00:00 2001 From: p-sw Date: Sun, 17 May 2026 23:41:02 +0900 Subject: [PATCH] feat: use FactExtractor --- src/conversation.ts | 10 ---- src/memory.ts | 18 ++++++- src/persona.ts | 99 ++++++++++++++++++++++++-------------- src/types.ts | 26 ++-------- tests/conversation.test.ts | 4 +- tests/persona.test.ts | 18 +++---- tests/sleep-memory.test.ts | 21 ++++---- 7 files changed, 106 insertions(+), 90 deletions(-) diff --git a/src/conversation.ts b/src/conversation.ts index c6aef33..a956cac 100644 --- a/src/conversation.ts +++ b/src/conversation.ts @@ -30,16 +30,6 @@ export function conversationInstruction(): string { ].join("\n"); } -export function memoryExtractionInstruction(now: string): string { - return [ - `Current objective time: ${now}.`, - "Read the message history and extract durable facts worth remembering.", - "Objectivize subjective statements before storage.", - 'Example: "I started TypeScript in 2025" becomes "The user started TypeScript in 2025."', - "Prefer facts about the persona, the user, their relationship, preferences, history, schedule-relevant events, and stable traits.", - ].join("\n"); -} - export async function buildMandatoryConversationContext(input: { persona: MemorySpace; now: DateTimeInput; diff --git a/src/memory.ts b/src/memory.ts index 458ebab..dea816c 100644 --- a/src/memory.ts +++ b/src/memory.ts @@ -1,4 +1,4 @@ -import { IdentityDB, type Fact as IdentityFact } from 'identitydb'; +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[] { @@ -74,6 +74,17 @@ export class InMemoryMemoryStore implements BoxBrainMemoryStore { 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 { @@ -172,6 +183,11 @@ export class IdentityDbMemoryStore implements BoxBrainMemoryStore { 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 { diff --git a/src/persona.ts b/src/persona.ts index ce4e754..09d234d 100644 --- a/src/persona.ts +++ b/src/persona.ts @@ -10,11 +10,11 @@ import { startOfUtcDay, toIso, } from "./schedule"; +import { extractFact } from "identitydb"; import { buildMandatoryConversationContext, conversationInstruction, formatMessageHistory, - memoryExtractionInstruction, } from "./conversation"; import type { BoxBrainMemoryStore, @@ -334,35 +334,50 @@ export class Persona { messageHistory: PersonaMessage[]; }): Promise { const persona = await this.ready(); - if (!this.options.models?.memoryExtraction) { - throw new Error("sleepMemory requires options.models.memoryExtraction."); + if (!this.options.models?.factExtractor) { + throw new Error("sleepMemory requires options.models.factExtractor."); } const contextFacts = await this.memory.findFacts(persona.id, [ "persona", persona.displayName, "user", ]); - const drafts = await this.options.models.memoryExtraction.extract({ - persona, - now: toIso(input.datetime), - formattedMessageHistory: formatMessageHistory({ + const statement = [ + `Current objective time: ${toIso(input.datetime)}.`, + "Read the message history and extract durable facts worth remembering.", + "Objectivize subjective statements before storage.", + 'Example: "I started TypeScript in 2025" becomes "The user started TypeScript in 2025."', + "Prefer facts about the persona, the user, their relationship, preferences, history, schedule-relevant events, and stable traits.", + "", + "Context facts:", + ...contextFacts.map((f) => `- ${f.statement}`), + "", + "Message history:", + formatMessageHistory({ personaName: persona.displayName, messages: input.messageHistory, }), - contextFacts, - instruction: memoryExtractionInstruction(toIso(input.datetime)), - }); - for (const draft of drafts) { - await this.memory.addFact(persona.id, { - ...draft, - topics: [...draft.topics, "sleepMemory"], - source: draft.source ?? "boxbrain.sleepMemory", - }); - } + ].join("\n"); + const extracted = await extractFact( + statement, + this.options.models.factExtractor, + ); + const draft: FactDraft = { + statement: extracted.statement ?? statement, + topics: [...extracted.topics.map((t) => t.name), "sleepMemory"], + source: extracted.source ?? "boxbrain.sleepMemory", + ...(typeof extracted.confidence === 'number' + ? { confidence: extracted.confidence } + : {}), + ...(extracted.metadata !== undefined && extracted.metadata !== null + ? { metadata: extracted.metadata as Record } + : {}), + }; + await this.memory.addFact(persona.id, draft); await this.emit("persona.memory.sleep.persisted", { - factCount: drafts.length, + factCount: 1, }); - return drafts; + return [draft]; } private async initialize(): Promise { @@ -381,24 +396,38 @@ export class Persona { seedMessage: this.mode.seedMessage, now, }); - const modelFacts = this.options.models?.initialization - ? await this.options.models.initialization.extractInitialFacts({ - displayName: this.mode.displayName, - seedMessage: this.mode.seedMessage, - now, - }) - : undefined; - const facts = modelFacts ?? [ - defaultInitialFact(this.mode.displayName, this.mode.seedMessage), - ]; - for (const fact of facts) { + if (this.options.models?.factExtractor) { + const statement = `Persona: ${this.mode.displayName}\nSeed: ${this.mode.seedMessage}`; + const extracted = await extractFact( + statement, + this.options.models.factExtractor, + ); + const draft: FactDraft = { + statement: extracted.statement ?? statement, + topics: extracted.topics.map((t) => t.name), + source: extracted.source ?? "boxbrain.persona.initialization", + ...(typeof extracted.confidence === 'number' + ? { confidence: extracted.confidence } + : {}), + ...(extracted.metadata !== undefined && extracted.metadata !== null + ? { metadata: extracted.metadata as Record } + : {}), + }; + await this.memory.addFact(space.id, draft); + await this.emit( + "persona.initialized", + { displayName: space.displayName, factCount: 1 }, + space.id, + ); + } else { + const fact = defaultInitialFact(this.mode.displayName, this.mode.seedMessage); await this.memory.addFact(space.id, fact); + await this.emit( + "persona.initialized", + { displayName: space.displayName, factCount: 1 }, + space.id, + ); } - await this.emit( - "persona.initialized", - { displayName: space.displayName, factCount: facts.length }, - space.id, - ); await this.refreshAvailability(now, space); return space; } diff --git a/src/types.ts b/src/types.ts index 8660bcd..60341a4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,5 @@ +import type { FactExtractor } from 'identitydb'; + export type DateTimeInput = Date | string | number; export type PersonaConstructorMode = 'create' | 'load'; @@ -117,18 +119,6 @@ export interface RewriteModel { decide(input: RewriteDecisionInput): Promise; } -export interface MemoryExtractionInput { - persona: MemorySpace; - now: string; - formattedMessageHistory: string; - contextFacts: StoredFact[]; - instruction: string; -} - -export interface MemoryExtractionModel { - extract(input: MemoryExtractionInput): Promise; -} - export interface ScheduleBlock { startTime: string; endTime: string; @@ -158,19 +148,10 @@ export interface ScheduleModel { generateMonthlySchedule(input: MonthlyScheduleGenerationInput): Promise; } -export interface PersonaInitializationModel { - extractInitialFacts(input: { - displayName: string; - seedMessage: string; - now: string; - }): Promise; -} - export interface PersonaModels { - initialization?: PersonaInitializationModel; + factExtractor?: FactExtractor; conversation?: ConversationModel; rewrite?: RewriteModel; - memoryExtraction?: MemoryExtractionModel; schedule?: ScheduleModel; } @@ -190,4 +171,5 @@ export interface BoxBrainMemoryStore { 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; } diff --git a/tests/conversation.test.ts b/tests/conversation.test.ts index 141b991..6569251 100644 --- a/tests/conversation.test.ts +++ b/tests/conversation.test.ts @@ -55,7 +55,6 @@ describe('Conversation API', () => { memory, now: '2026-05-01T10:00:00.000Z', models: { - initialization: { async extractInitialFacts() { return []; } }, conversation: { async generateReply(input) { memorySummary = input.context.memorySummary; @@ -64,7 +63,8 @@ describe('Conversation API', () => { }, }, }); - await persona.ready(); + const space = await persona.ready(); + memory.facts.set(space.id, []); await persona.sendMessage({ datetime: '2026-05-01T12:00:00.000Z', diff --git a/tests/persona.test.ts b/tests/persona.test.ts index 14262ac..d27fd4f 100644 --- a/tests/persona.test.ts +++ b/tests/persona.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { InMemoryMemoryStore, Persona, type FactDraft } from '../src'; +import { InMemoryMemoryStore, Persona } from '../src'; describe('Persona initialization', () => { it('creates a new isolated persona space from displayName and seed message', async () => { @@ -10,15 +10,13 @@ describe('Persona initialization', () => { now: '2026-05-01T10:00:00.000Z', debug: (event) => { debug.push(event.name); }, models: { - initialization: { - async extractInitialFacts(input): Promise { - return [ - { - statement: `${input.displayName} likes quiet cafes.`, - topics: ['persona', input.displayName], - source: 'test', - }, - ]; + factExtractor: { + async extract(input) { + return { + statement: 'Mina likes quiet cafes.', + topics: [{ name: 'persona' }, { name: 'Mina' }], + source: 'test', + }; }, }, }, diff --git a/tests/sleep-memory.test.ts b/tests/sleep-memory.test.ts index 1eb9f54..4cbf4cf 100644 --- a/tests/sleep-memory.test.ts +++ b/tests/sleep-memory.test.ts @@ -8,17 +8,18 @@ describe('sleepMemory', () => { memory, now: '2026-05-01T10:00:00.000Z', models: { - memoryExtraction: { + factExtractor: { async extract(input) { - expect(input.formattedMessageHistory).toContain('user@2026-05-01T15:00:00.000Z: 나는 타입스크립트를 2025년부터 시작했어'); - expect(input.instruction).toContain('Objectivize'); - return [ - { - statement: 'The user started TypeScript in 2025.', - topics: ['user', 'TypeScript', '2025'], - confidence: 0.9, - }, - ]; + if (input.includes('Seed:')) { + return { statement: 'Mina remembers stable details.', topics: [{ name: 'persona' }, { name: 'Mina' }] }; + } + expect(input).toContain('user@2026-05-01T15:00:00.000Z: 나는 타입스크립트를 2025년부터 시작했어'); + expect(input).toContain('Objectivize'); + return { + statement: 'The user started TypeScript in 2025.', + topics: [{ name: 'user' }, { name: 'TypeScript' }, { name: '2025' }], + confidence: 0.9, + }; }, }, },