16 Commits

Author SHA1 Message Date
d8da1ec998 fix: fix all type error caused by identitydb
All checks were successful
CI / verify (push) Successful in 13s
Publish / publish (push) Successful in 22s
2026-05-20 23:32:13 +09:00
b52d37170c v0.5.0
Some checks failed
CI / verify (push) Failing after 10s
Publish / publish (push) Failing after 9s
2026-05-20 23:22:58 +09:00
6f4f65a8ee test: make test to work with latest identitydb 2026-05-20 23:22:46 +09:00
488ba20eb6 feat: update identitydb to 0.5.0 2026-05-20 23:22:32 +09:00
05f077b798 v0.4.0
All checks were successful
CI / verify (push) Successful in 12s
Publish / publish (push) Successful in 21s
2026-05-19 23:24:42 +09:00
f964d4de9b feat: add baseSystemPrompt 2026-05-19 23:24:34 +09:00
882e12340c v0.3.5
All checks were successful
CI / verify (push) Successful in 14s
Publish / publish (push) Successful in 21s
2026-05-19 22:31:57 +09:00
fb89ffbc16 feat: upgrade identitydb to 0.4.0 2026-05-19 22:31:52 +09:00
8e051a12e1 v0.3.4
All checks were successful
CI / verify (push) Successful in 15s
Publish / publish (push) Successful in 27s
2026-05-19 22:10:03 +09:00
c66b315fe5 feat: upgrade identitydb to 0.3.0 2026-05-19 22:10:01 +09:00
d2a3bfcd15 v0.3.3
All checks were successful
CI / verify (push) Successful in 13s
Publish / publish (push) Successful in 21s
2026-05-17 23:42:25 +09:00
600f5ff0bc feat: use FactExtractor
All checks were successful
CI / verify (push) Successful in 11s
2026-05-17 23:41:02 +09:00
4ef1b89a2d v0.3.2
All checks were successful
CI / verify (push) Successful in 10s
Publish / publish (push) Successful in 19s
2026-05-17 23:14:51 +09:00
f9f37b0835 fix: upgrade identitydb to ^0.2.2 2026-05-17 23:14:38 +09:00
43b5147f45 v0.3.1
All checks were successful
CI / verify (push) Successful in 12s
Publish / publish (push) Successful in 19s
2026-05-17 23:13:42 +09:00
239d63dff7 feat: add spaceId getter
Some checks failed
CI / verify (push) Successful in 11s
Publish / publish (push) Failing after 18s
2026-05-17 23:06:23 +09:00
10 changed files with 551 additions and 223 deletions

View File

@@ -5,7 +5,7 @@
"": { "": {
"name": "boxbrain", "name": "boxbrain",
"dependencies": { "dependencies": {
"identitydb": "0.2.1", "identitydb": "^0.5.0",
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "latest",
@@ -249,7 +249,7 @@
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
"identitydb": ["identitydb@0.2.1", "", { "dependencies": { "kysely": "^0.28.8", "mysql2": "^3.15.3", "pg": "^8.16.0" } }, "sha512-e+caNqI7F6JaqgyIFQbdiT5/2Frs5PEJgy3mmF+qUVspHZ4z6QtFF5jonDnpYtJpZ9guPWfeQ/xteeaiwOJ5zA=="], "identitydb": ["identitydb@0.5.0", "", { "dependencies": { "kysely": "^0.28.8", "mysql2": "^3.15.3", "pg": "^8.16.0" } }, "sha512-3cp14fb5nDKFakRqHdJrOBOgpDWLlvJ/K2q8405muAfqcJja9ds2tS9ksoaEiMgGU5r+mIz6lADZ3DYNpGqHVQ=="],
"is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="], "is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="],

View File

@@ -1,6 +1,6 @@
{ {
"name": "boxbrain", "name": "boxbrain",
"version": "0.3.0", "version": "0.5.0",
"description": "Human-like persona harness framework powered by LLMs and IdentityDB.", "description": "Human-like persona harness framework powered by LLMs and IdentityDB.",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
@@ -48,7 +48,7 @@
"prepublishOnly": "bun run check && bun run test && bun run build && bun run pack:check" "prepublishOnly": "bun run check && bun run test && bun run build && bun run pack:check"
}, },
"dependencies": { "dependencies": {
"identitydb": "0.2.1" "identitydb": "^0.5.0"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "latest",

View File

@@ -5,36 +5,31 @@ import type {
MemorySpace, MemorySpace,
PersonaMessage, PersonaMessage,
ScheduledAvailabilitySnapshot, ScheduledAvailabilitySnapshot,
} from './types'; } from "./types";
import { addUtcDays, buildAvailabilitySnapshot, dateKeysAround, startOfUtcDay, toIso } from './schedule'; import { addUtcDays, dateKeysAround, startOfUtcDay, toIso } from "./schedule";
export function formatMessageHistory(input: { personaName: string; messages: PersonaMessage[] }): string { export function formatMessageHistory(input: {
personaName: string;
messages: PersonaMessage[];
}): string {
return input.messages return input.messages
.map((message) => { .map((message) => {
const sender = message.sender === 'persona' ? input.personaName : 'user'; const sender = message.sender === "persona" ? input.personaName : "user";
return `${sender}@${toIso(message.time)}: ${message.content}`; return `${sender}@${toIso(message.time)}: ${message.content}`;
}) })
.join('\n'); .join("\n");
} }
export function conversationInstruction(): string { export function conversationInstruction(baseSystemPrompt?: string): string {
return [ const parts = [
'You are controlling the persona, not a generic assistant.', ...(baseSystemPrompt === undefined ? [] : [baseSystemPrompt]),
'Use the send_message tool conceptually: return one or more outgoing messages.', "You are controlling the persona, not a generic assistant.",
'Unless the persona strongly prefers otherwise, keep each outgoing message to at most one sentence.', "Use the send_message tool conceptually: return one or more outgoing messages.",
'Prefer short, natural, chat-like wording and allow splitting one thought into multiple messages.', "Unless the persona strongly prefers otherwise, keep each outgoing message to at most one sentence.",
"Prefer short, natural, chat-like wording and allow splitting one thought into multiple messages.",
'If mandatory memory says "기억이 없음", the persona may naturally wonder about missing context instead of pretending to remember.', 'If mandatory memory says "기억이 없음", the persona may naturally wonder about missing context instead of pretending to remember.',
].join('\n'); ];
} return parts.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: { export async function buildMandatoryConversationContext(input: {
@@ -47,15 +42,27 @@ export async function buildMandatoryConversationContext(input: {
const now = startOfUtcDay(input.now); const now = startOfUtcDay(input.now);
const from = addUtcDays(now, -1).toISOString(); const from = addUtcDays(now, -1).toISOString();
const to = addUtcDays(now, 2).toISOString(); const to = addUtcDays(now, 2).toISOString();
const scheduleEntries = await input.memory.listScheduleEntries(input.persona.id, from, to); const scheduleEntries = await input.memory.listScheduleEntries(
const personaAndUserFacts = await input.memory.findFacts(input.persona.id, ['persona', input.persona.displayName, 'user']); input.persona.id,
const memorySummary = personaAndUserFacts.length === 0 from,
? '기억이 없음' to,
: personaAndUserFacts.map((fact) => `- ${fact.statement}`).join('\n'); );
const personaAndUserFacts = await input.memory.findFacts(input.persona.id, [
"persona",
input.persona.displayName,
"user",
]);
const memorySummary =
personaAndUserFacts.length === 0
? "기억이 없음"
: personaAndUserFacts.map((fact) => `- ${fact.statement}`).join("\n");
return { return {
formattedMessageHistory: formatMessageHistory({ personaName: input.persona.displayName, messages: input.messages }), formattedMessageHistory: formatMessageHistory({
conversationWindowLabel: `Required conversation window: yesterday/today. Schedule dates: ${dateKeysAround(input.now).join(', ')}.`, personaName: input.persona.displayName,
messages: input.messages,
}),
conversationWindowLabel: `Required conversation window: yesterday/today. Schedule dates: ${dateKeysAround(input.now).join(", ")}.`,
memorySummary, memorySummary,
personaAndUserFacts, personaAndUserFacts,
scheduleEntries, scheduleEntries,

View File

@@ -1,12 +1,26 @@
import { IdentityDB, type Fact as IdentityFact } from 'identitydb'; import {
import type { BoxBrainMemoryStore, FactDraft, MemorySpace, ScheduleEntry, StoredFact } from './types'; 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[] { function normalizeTopics(topics: string[]): string[] {
return [...new Set(topics.map((topic) => topic.trim()).filter(Boolean))]; return [...new Set(topics.map((topic) => topic.trim()).filter(Boolean))];
} }
function includesAnyTopic(fact: StoredFact, topics: string[]): 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())); return fact.topics.some((topic) => normalized.has(topic.toLowerCase()));
} }
@@ -15,8 +29,16 @@ export class InMemoryMemoryStore implements BoxBrainMemoryStore {
readonly facts = new Map<string, StoredFact[]>(); readonly facts = new Map<string, StoredFact[]>();
readonly schedules = new Map<string, ScheduleEntry[]>(); readonly schedules = new Map<string, ScheduleEntry[]>();
async createSpace(input: { displayName: string; seedMessage: string; now: string }): Promise<MemorySpace> { async createSpace(input: {
const slug = input.displayName.toLowerCase().replace(/[^a-z0-9가-힣]+/gi, '-').replace(/^-|-$/g, '') || 'persona'; displayName: string;
seedMessage: string;
now: string;
}): Promise<MemorySpace> {
const slug =
input.displayName
.toLowerCase()
.replace(/[^a-z0-9가-힣]+/gi, "-")
.replace(/^-|-$/g, "") || "persona";
const space: MemorySpace = { const space: MemorySpace = {
id: `persona-${slug}-${crypto.randomUUID()}`, id: `persona-${slug}-${crypto.randomUUID()}`,
displayName: input.displayName, displayName: input.displayName,
@@ -48,7 +70,9 @@ export class InMemoryMemoryStore implements BoxBrainMemoryStore {
} }
async listFacts(spaceId: string): Promise<StoredFact[]> { async listFacts(spaceId: string): Promise<StoredFact[]> {
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<StoredFact[]> { async findFacts(spaceId: string, topics: string[]): Promise<StoredFact[]> {
@@ -57,23 +81,57 @@ export class InMemoryMemoryStore implements BoxBrainMemoryStore {
return facts.filter((fact) => includesAnyTopic(fact, topics)); return facts.filter((fact) => includesAnyTopic(fact, topics));
} }
async saveScheduleEntries(spaceId: string, entries: ScheduleEntry[]): Promise<void> { async saveScheduleEntries(
const existing = (this.schedules.get(spaceId) ?? []).filter((entry) => !entries.some((incoming) => incoming.id === entry.id)); spaceId: string,
this.schedules.set(spaceId, [...existing, ...entries].sort((a, b) => a.startAt.localeCompare(b.startAt))); entries: ScheduleEntry[],
): Promise<void> {
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<ScheduleEntry[]> { async listScheduleEntries(
spaceId: string,
fromInclusive: string,
toExclusive: string,
): Promise<ScheduleEntry[]> {
return (this.schedules.get(spaceId) ?? []) 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)); .sort((a, b) => a.startAt.localeCompare(b.startAt));
} }
async deleteScheduleEntriesBefore(spaceId: string, cutoffExclusive: string): Promise<number> { async deleteScheduleEntriesBefore(
spaceId: string,
cutoffExclusive: string,
): Promise<number> {
const entries = this.schedules.get(spaceId) ?? []; const entries = this.schedules.get(spaceId) ?? [];
const kept = entries.filter((entry) => entry.endAt > cutoffExclusive); const kept = entries.filter((entry) => entry.endAt > cutoffExclusive);
this.schedules.set(spaceId, kept); this.schedules.set(spaceId, kept);
return entries.length - kept.length; return entries.length - kept.length;
} }
async ingestStatement(
spaceId: string,
statement: string,
extractor: FactExtractor,
): Promise<StoredFact[]> {
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;
}
} }
export interface IdentityDbMemoryStoreOptions { export interface IdentityDbMemoryStoreOptions {
@@ -83,14 +141,22 @@ export interface IdentityDbMemoryStoreOptions {
export class IdentityDbMemoryStore implements BoxBrainMemoryStore { export class IdentityDbMemoryStore implements BoxBrainMemoryStore {
constructor(private readonly options: IdentityDbMemoryStoreOptions) {} constructor(private readonly options: IdentityDbMemoryStoreOptions) {}
async createSpace(input: { displayName: string; seedMessage: string; now: string }): Promise<MemorySpace> { async createSpace(input: {
const slug = input.displayName.toLowerCase().replace(/[^a-z0-9가-힣]+/gi, '-').replace(/^-|-$/g, '') || 'persona'; displayName: string;
seedMessage: string;
now: string;
}): Promise<MemorySpace> {
const slug =
input.displayName
.toLowerCase()
.replace(/[^a-z0-9가-힣]+/gi, "-")
.replace(/^-|-$/g, "") || "persona";
const spaceName = `persona-${slug}-${crypto.randomUUID()}`; const spaceName = `persona-${slug}-${crypto.randomUUID()}`;
const space = await this.options.db.upsertSpace({ const space = await this.options.db.upsertSpace({
name: spaceName, name: spaceName,
description: `BoxBrain persona space for ${input.displayName}`, description: `BoxBrain persona space for ${input.displayName}`,
metadata: { metadata: {
boxbrainType: 'persona-space', boxbrainType: "persona-space",
displayName: input.displayName, displayName: input.displayName,
seedMessage: input.seedMessage, seedMessage: input.seedMessage,
createdAt: input.now, createdAt: input.now,
@@ -107,9 +173,22 @@ export class IdentityDbMemoryStore implements BoxBrainMemoryStore {
async getSpace(spaceId: string): Promise<MemorySpace | null> { async getSpace(spaceId: string): Promise<MemorySpace | null> {
const space = await this.options.db.getSpaceByName(spaceId); const space = await this.options.db.getSpaceByName(spaceId);
if (!space) return null; if (!space) return null;
const metadata = typeof space.metadata === 'object' && space.metadata !== null && !Array.isArray(space.metadata) ? space.metadata as Record<string, unknown> : {}; const metadata =
const displayName = typeof metadata['displayName'] === 'string' ? metadata['displayName'] : space.name; typeof space.metadata === "object" &&
return { id: space.name, displayName, createdAt: space.createdAt, metadata }; space.metadata !== null &&
!Array.isArray(space.metadata)
? (space.metadata as Record<string, unknown>)
: {};
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<StoredFact> { async addFact(spaceId: string, fact: FactDraft): Promise<StoredFact> {
@@ -119,61 +198,111 @@ export class IdentityDbMemoryStore implements BoxBrainMemoryStore {
confidence: fact.confidence ?? null, confidence: fact.confidence ?? null,
source: fact.source ?? null, source: fact.source ?? null,
metadata: (fact.metadata ?? null) as never, 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); return this.fromIdentityFact(stored);
} }
async listFacts(spaceId: string): Promise<StoredFact[]> { async listFacts(spaceId: string): Promise<StoredFact[]> {
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<string, StoredFact>(); const collected = new Map<string, StoredFact>();
for (const topic of topics) { for (const topic of topics) {
for (const fact of topic.facts) { for (const fact of topic.facts) {
collected.set(fact.id, this.fromIdentityFact(fact)); 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<StoredFact[]> { async findFacts(spaceId: string, topics: string[]): Promise<StoredFact[]> {
const uniqueTopics = normalizeTopics(topics); const uniqueTopics = normalizeTopics(topics);
const collected = new Map<string, StoredFact>(); const collected = new Map<string, StoredFact>();
for (const topic of uniqueTopics) { 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) { for (const fact of facts) {
collected.set(fact.id, this.fromIdentityFact(fact)); 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<void> { async saveScheduleEntries(
spaceId: string,
entries: ScheduleEntry[],
): Promise<void> {
for (const entry of entries) { for (const entry of entries) {
await this.addFact(spaceId, { await this.addFact(spaceId, {
statement: `${entry.title} from ${entry.startAt} to ${entry.endAt}.`, statement: `${entry.title} from ${entry.startAt} to ${entry.endAt}.`,
topics: ['schedule', entry.startAt.slice(0, 10), entry.activity, 'persona'], topics: [
source: 'boxbrain.schedule', "schedule",
entry.startAt.slice(0, 10),
entry.activity,
"persona",
],
source: "boxbrain.schedule",
metadata: { ...entry.metadata, scheduleEntry: entry }, metadata: { ...entry.metadata, scheduleEntry: entry },
}); });
} }
} }
async listScheduleEntries(spaceId: string, fromInclusive: string, toExclusive: string): Promise<ScheduleEntry[]> { async listScheduleEntries(
const facts = await this.findFacts(spaceId, ['schedule']); spaceId: string,
fromInclusive: string,
toExclusive: string,
): Promise<ScheduleEntry[]> {
const facts = await this.findFacts(spaceId, ["schedule"]);
return facts return facts
.map((fact) => fact.metadata?.['scheduleEntry']) .map((fact) => fact.metadata?.["scheduleEntry"])
.filter((value): value is ScheduleEntry => typeof value === 'object' && value !== null && !Array.isArray(value)) .filter(
.filter((entry) => entry.startAt < toExclusive && entry.endAt > fromInclusive) (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)); .sort((a, b) => a.startAt.localeCompare(b.startAt));
} }
async deleteScheduleEntriesBefore(_spaceId: string, _cutoffExclusive: string): Promise<number> { async deleteScheduleEntriesBefore(
_spaceId: string,
_cutoffExclusive: string,
): Promise<number> {
// IdentityDB is append-oriented at the public API level. Record schedule deletion as a fact at the Persona layer. // IdentityDB is append-oriented at the public API level. Record schedule deletion as a fact at the Persona layer.
return 0; return 0;
} }
async ingestStatement(
spaceId: string,
statement: string,
extractor: FactExtractor,
): Promise<StoredFact[]> {
const facts = await this.options.db.ingestStatements(statement, {
extractor,
spaceName: spaceId,
});
return facts.map((fact) => this.fromIdentityFact(fact));
}
private fromIdentityFact(fact: IdentityFact): StoredFact { private fromIdentityFact(fact: IdentityFact): StoredFact {
const metadata = typeof fact.metadata === 'object' && fact.metadata !== null && !Array.isArray(fact.metadata) ? fact.metadata as Record<string, unknown> : undefined; const metadata =
typeof fact.metadata === "object" &&
fact.metadata !== null &&
!Array.isArray(fact.metadata)
? (fact.metadata as Record<string, unknown>)
: undefined;
return { return {
id: fact.id, id: fact.id,
statement: fact.statement, statement: fact.statement,
@@ -186,8 +315,10 @@ export class IdentityDbMemoryStore implements BoxBrainMemoryStore {
} }
} }
export async function createSqliteIdentityMemoryStore(filename: string): Promise<IdentityDbMemoryStore> { export async function createSqliteIdentityMemoryStore(
const db = await IdentityDB.connect({ client: 'sqlite', filename }); filename: string,
): Promise<IdentityDbMemoryStore> {
const db = await IdentityDB.connect({ client: "sqlite", filename });
await db.initialize(); await db.initialize();
return new IdentityDbMemoryStore({ db }); return new IdentityDbMemoryStore({ db });
} }

View File

@@ -1,4 +1,4 @@
import { InMemoryMemoryStore } from './memory'; import { InMemoryMemoryStore } from "./memory";
import { import {
addUtcDays, addUtcDays,
blocksToDailySchedule, blocksToDailySchedule,
@@ -9,13 +9,13 @@ import {
scheduleTargetDay, scheduleTargetDay,
startOfUtcDay, startOfUtcDay,
toIso, toIso,
} from './schedule'; } from "./schedule";
import { ExtractedFact, extractFacts } from "identitydb";
import { import {
buildMandatoryConversationContext, buildMandatoryConversationContext,
conversationInstruction, conversationInstruction,
formatMessageHistory, formatMessageHistory,
memoryExtractionInstruction, } from "./conversation";
} from './conversation';
import type { import type {
BoxBrainMemoryStore, BoxBrainMemoryStore,
DateTimeInput, DateTimeInput,
@@ -27,28 +27,32 @@ import type {
PersonaOptions, PersonaOptions,
ScheduleEntry, ScheduleEntry,
ScheduledAvailabilitySnapshot, ScheduledAvailabilitySnapshot,
} from './types'; } from "./types";
import { extractedToDraft } from "./utils";
interface CreateMode { interface CreateMode {
type: 'create'; type: "create";
displayName: string; displayName: string;
seedMessage: string; seedMessage: string;
} }
interface LoadMode { interface LoadMode {
type: 'load'; type: "load";
spaceId: string; spaceId: string;
} }
type Mode = CreateMode | LoadMode; type Mode = CreateMode | LoadMode;
function defaultInitialFact(displayName: string, seedMessage: string): FactDraft { function defaultInitialFact(
displayName: string,
seedMessage: string,
): FactDraft {
return { return {
statement: `${displayName} is a BoxBrain persona initialized from this seed: ${seedMessage}`, statement: `${displayName} is a BoxBrain persona initialized from this seed: ${seedMessage}`,
topics: ['persona', displayName], topics: ["persona", displayName],
source: 'boxbrain.persona.initialization', source: "boxbrain.persona.initialization",
confidence: 1, confidence: 1,
metadata: { boxbrainType: 'persona-initial-fact' }, metadata: { boxbrainType: "persona-initial-fact" },
}; };
} }
@@ -65,18 +69,28 @@ export class Persona {
private readonly mode: Mode; private readonly mode: Mode;
private readonly readyPromise: Promise<MemorySpace>; private readonly readyPromise: Promise<MemorySpace>;
private availabilitySnapshot?: ScheduledAvailabilitySnapshot; private availabilitySnapshot?: ScheduledAvailabilitySnapshot;
readonly baseSystemPrompt: string | undefined;
constructor(displayName: string, seedMessage: string, options?: PersonaOptions); constructor(
displayName: string,
seedMessage: string,
options?: PersonaOptions,
);
constructor(spaceId: string, options?: PersonaOptions); constructor(spaceId: string, options?: PersonaOptions);
constructor(first: string, second?: string | PersonaOptions, third?: PersonaOptions) { constructor(
if (typeof second === 'string') { first: string,
this.mode = { type: 'create', displayName: first, seedMessage: second }; second?: string | PersonaOptions,
third?: PersonaOptions,
) {
if (typeof second === "string") {
this.mode = { type: "create", displayName: first, seedMessage: second };
this.options = third ?? {}; this.options = third ?? {};
} else { } else {
this.mode = { type: 'load', spaceId: first }; this.mode = { type: "load", spaceId: first };
this.options = second ?? {}; this.options = second ?? {};
} }
this.memory = this.options.memory ?? new InMemoryMemoryStore(); this.memory = this.options.memory ?? new InMemoryMemoryStore();
this.baseSystemPrompt = this.options.baseSystemPrompt;
this.readyPromise = this.initialize(); this.readyPromise = this.initialize();
} }
@@ -84,10 +98,17 @@ export class Persona {
return this.readyPromise; return this.readyPromise;
} }
async createDailySchedule(datetime: DateTimeInput, message: string): Promise<ScheduleEntry[]> { get spaceId(): Promise<string> {
return this.readyPromise.then((v) => v.id);
}
async createDailySchedule(
datetime: DateTimeInput,
message: string,
): Promise<ScheduleEntry[]> {
const persona = await this.ready(); const persona = await this.ready();
if (!this.options.models?.schedule) { if (!this.options.models?.schedule) {
throw new Error('createDailySchedule requires options.models.schedule.'); throw new Error("createDailySchedule requires options.models.schedule.");
} }
const targetDay = scheduleTargetDay(datetime); const targetDay = scheduleTargetDay(datetime);
const blocks = await this.options.models.schedule.generateDailySchedule({ const blocks = await this.options.models.schedule.generateDailySchedule({
@@ -96,17 +117,31 @@ export class Persona {
message, message,
instruction: scheduleInstruction(), instruction: scheduleInstruction(),
}); });
const entries = blocksToDailySchedule({ persona, targetDay, message, blocks }); const entries = blocksToDailySchedule({
await this.emit('persona.schedule.daily.generated', { targetDay: targetDay.toISOString(), count: entries.length, message }); persona,
targetDay,
message,
blocks,
});
await this.emit("persona.schedule.daily.generated", {
targetDay: targetDay.toISOString(),
count: entries.length,
message,
});
await this.memory.saveScheduleEntries(persona.id, entries); await this.memory.saveScheduleEntries(persona.id, entries);
await this.refreshAvailability(datetime); await this.refreshAvailability(datetime);
return entries; return entries;
} }
async createMonthlySchedule(datetime: DateTimeInput, message: string): Promise<ScheduleEntry[]> { async createMonthlySchedule(
datetime: DateTimeInput,
message: string,
): Promise<ScheduleEntry[]> {
const persona = await this.ready(); const persona = await this.ready();
if (!this.options.models?.schedule) { if (!this.options.models?.schedule) {
throw new Error('createMonthlySchedule requires options.models.schedule.'); throw new Error(
"createMonthlySchedule requires options.models.schedule.",
);
} }
const fromDay = scheduleTargetDay(datetime); const fromDay = scheduleTargetDay(datetime);
const days = daysInMonth(fromDay); const days = daysInMonth(fromDay);
@@ -117,8 +152,16 @@ export class Persona {
days, days,
instruction: scheduleInstruction(), instruction: scheduleInstruction(),
}); });
const entries = blocksToMonthlySchedule({ persona, fromDay, message, blocks }); const entries = blocksToMonthlySchedule({
await this.emit('persona.schedule.monthly.generated', { count: entries.length, message }); persona,
fromDay,
message,
blocks,
});
await this.emit("persona.schedule.monthly.generated", {
count: entries.length,
message,
});
await this.memory.saveScheduleEntries(persona.id, entries); await this.memory.saveScheduleEntries(persona.id, entries);
await this.refreshAvailability(datetime); await this.refreshAvailability(datetime);
return entries; return entries;
@@ -127,14 +170,24 @@ export class Persona {
async deleteSchedulesBefore(cutoffExclusive: DateTimeInput): Promise<number> { async deleteSchedulesBefore(cutoffExclusive: DateTimeInput): Promise<number> {
const persona = await this.ready(); const persona = await this.ready();
const cutoff = toIso(cutoffExclusive); const cutoff = toIso(cutoffExclusive);
const deleted = await this.memory.deleteScheduleEntriesBefore(persona.id, cutoff); const deleted = await this.memory.deleteScheduleEntriesBefore(
persona.id,
cutoff,
);
await this.memory.addFact(persona.id, { await this.memory.addFact(persona.id, {
statement: `Schedules before ${cutoff} were deleted or marked inactive.`, statement: `Schedules before ${cutoff} were deleted or marked inactive.`,
topics: ['persona.schedule.deleted', 'schedule', cutoff.slice(0, 10)], topics: ["persona.schedule.deleted", "schedule", cutoff.slice(0, 10)],
source: 'boxbrain.schedule.prune', source: "boxbrain.schedule.prune",
metadata: { boxbrainType: 'schedule-deletion', cutoffExclusive: cutoff, deleted }, metadata: {
boxbrainType: "schedule-deletion",
cutoffExclusive: cutoff,
deleted,
},
});
await this.emit("persona.schedule.deleted", {
cutoffExclusive: cutoff,
deleted,
}); });
await this.emit('persona.schedule.deleted', { cutoffExclusive: cutoff, deleted });
return deleted; return deleted;
} }
@@ -142,12 +195,15 @@ export class Persona {
return this.deleteSchedulesBefore(datetime); return this.deleteSchedulesBefore(datetime);
} }
async getTodayScheduledAvailability(datetime: DateTimeInput): Promise<ScheduledAvailabilitySnapshot> { async getTodayScheduledAvailability(
datetime: DateTimeInput,
): Promise<ScheduledAvailabilitySnapshot> {
if (!this.availabilitySnapshot) { if (!this.availabilitySnapshot) {
await this.refreshAvailability(datetime); await this.refreshAvailability(datetime);
} }
const snapshot = this.availabilitySnapshot; const snapshot = this.availabilitySnapshot;
if (!snapshot) throw new Error('Availability snapshot was not initialized.'); if (!snapshot)
throw new Error("Availability snapshot was not initialized.");
const today = startOfUtcDay(datetime).toISOString(); const today = startOfUtcDay(datetime).toISOString();
if (snapshot.windowStartAt !== today) { if (snapshot.windowStartAt !== today) {
@@ -155,7 +211,8 @@ export class Persona {
} }
const refreshed = this.availabilitySnapshot; const refreshed = this.availabilitySnapshot;
if (!refreshed) throw new Error('Availability snapshot was not initialized.'); if (!refreshed)
throw new Error("Availability snapshot was not initialized.");
return refreshed; return refreshed;
} }
@@ -166,9 +223,11 @@ export class Persona {
}): Promise<OutgoingMessageDraft> { }): Promise<OutgoingMessageDraft> {
const persona = await this.ready(); const persona = await this.ready();
if (!this.options.models?.conversation) { if (!this.options.models?.conversation) {
throw new Error('sendMessage requires options.models.conversation.'); throw new Error("sendMessage requires options.models.conversation.");
} }
const availability = await this.getTodayScheduledAvailability(input.datetime); const availability = await this.getTodayScheduledAvailability(
input.datetime,
);
const context = await buildMandatoryConversationContext({ const context = await buildMandatoryConversationContext({
persona, persona,
now: input.datetime, now: input.datetime,
@@ -176,20 +235,24 @@ export class Persona {
messages: input.messageHistory, messages: input.messageHistory,
availability, availability,
}); });
await this.emit('persona.conversation.context.loaded', { await this.emit("persona.conversation.context.loaded", {
factCount: context.personaAndUserFacts.length, factCount: context.personaAndUserFacts.length,
scheduleEntryCount: context.scheduleEntries.length, scheduleEntryCount: context.scheduleEntries.length,
}); });
const userMessage = [...input.messageHistory].reverse().find((message) => message.sender === 'user')?.content; const userMessage = [...input.messageHistory]
let draft = ensureDraft(await this.options.models.conversation.generateReply({ .reverse()
persona, .find((message) => message.sender === "user")?.content;
now: toIso(input.datetime), let draft = ensureDraft(
mode: 'reply', await this.options.models.conversation.generateReply({
context, persona,
...(userMessage === undefined ? {} : { userMessage }), now: toIso(input.datetime),
instruction: conversationInstruction(), mode: "reply",
})); context,
...(userMessage === undefined ? {} : { userMessage }),
instruction: conversationInstruction(this.baseSystemPrompt),
}),
);
if (input.getLatestMessageHistory && this.options.models.rewrite) { if (input.getLatestMessageHistory && this.options.models.rewrite) {
const latest = await input.getLatestMessageHistory(); const latest = await input.getLatestMessageHistory();
@@ -209,20 +272,28 @@ export class Persona {
draft, draft,
context: latestContext, context: latestContext,
}); });
await this.emit('persona.conversation.rewrite.checked', { rewrite: decision.rewrite, reason: decision.reason ?? null }); await this.emit("persona.conversation.rewrite.checked", {
rewrite: decision.rewrite,
reason: decision.reason ?? null,
});
if (decision.rewrite) { if (decision.rewrite) {
draft = ensureDraft(decision.draft ?? await this.options.models.conversation.generateReply({ draft = ensureDraft(
persona, decision.draft ??
now: toIso(input.datetime), (await this.options.models.conversation.generateReply({
mode: 'reply', persona,
context: latestContext, now: toIso(input.datetime),
instruction: conversationInstruction(), mode: "reply",
})); context: latestContext,
instruction: conversationInstruction(this.baseSystemPrompt),
})),
);
} }
} }
} }
await this.emit('persona.conversation.reply.generated', { messageCount: draft.messages.length }); await this.emit("persona.conversation.reply.generated", {
messageCount: draft.messages.length,
});
return draft; return draft;
} }
@@ -232,9 +303,13 @@ export class Persona {
}): Promise<OutgoingMessageDraft> { }): Promise<OutgoingMessageDraft> {
const persona = await this.ready(); const persona = await this.ready();
if (!this.options.models?.conversation) { if (!this.options.models?.conversation) {
throw new Error('startConversation requires options.models.conversation.'); throw new Error(
"startConversation requires options.models.conversation.",
);
} }
const availability = await this.getTodayScheduledAvailability(input.datetime); const availability = await this.getTodayScheduledAvailability(
input.datetime,
);
const context = await buildMandatoryConversationContext({ const context = await buildMandatoryConversationContext({
persona, persona,
now: input.datetime, now: input.datetime,
@@ -242,14 +317,18 @@ export class Persona {
messages: input.messageHistory, messages: input.messageHistory,
availability, availability,
}); });
const draft = ensureDraft(await this.options.models.conversation.generateReply({ const draft = ensureDraft(
persona, await this.options.models.conversation.generateReply({
now: toIso(input.datetime), persona,
mode: 'start-conversation', now: toIso(input.datetime),
context, mode: "start-conversation",
instruction: conversationInstruction(), context,
})); instruction: conversationInstruction(this.baseSystemPrompt),
await this.emit('persona.conversation.started', { messageCount: draft.messages.length }); }),
);
await this.emit("persona.conversation.started", {
messageCount: draft.messages.length,
});
return draft; return draft;
} }
@@ -258,65 +337,118 @@ export class Persona {
messageHistory: PersonaMessage[]; messageHistory: PersonaMessage[];
}): Promise<FactDraft[]> { }): Promise<FactDraft[]> {
const persona = await this.ready(); const persona = await this.ready();
if (!this.options.models?.memoryExtraction) { if (!this.options.models?.factExtractor) {
throw new Error('sleepMemory requires options.models.memoryExtraction.'); throw new Error("sleepMemory requires options.models.factExtractor.");
} }
const contextFacts = await this.memory.findFacts(persona.id, ['persona', persona.displayName, 'user']); const contextFacts = await this.memory.findFacts(persona.id, [
const drafts = await this.options.models.memoryExtraction.extract({ "persona",
persona, persona.displayName,
now: toIso(input.datetime), "user",
formattedMessageHistory: formatMessageHistory({ personaName: persona.displayName, messages: input.messageHistory }), ]);
contextFacts, const statement = [
instruction: memoryExtractionInstruction(toIso(input.datetime)), `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,
}),
].join("\n");
const extractedFacts = (
await extractFacts(statement, this.options.models.factExtractor)
).map((fact) => extractedToDraft(fact, statement));
for (const fact of extractedFacts) {
await this.memory.addFact(persona.id, fact);
}
await this.emit("persona.memory.sleep.persisted", {
factCount: extractedFacts.length,
}); });
for (const draft of drafts) { return extractedFacts;
await this.memory.addFact(persona.id, {
...draft,
topics: [...draft.topics, 'sleepMemory'],
source: draft.source ?? 'boxbrain.sleepMemory',
});
}
await this.emit('persona.memory.sleep.persisted', { factCount: drafts.length });
return drafts;
} }
private async initialize(): Promise<MemorySpace> { private async initialize(): Promise<MemorySpace> {
const now = toIso(this.options.now ?? new Date()); const now = toIso(this.options.now ?? new Date());
if (this.mode.type === 'load') { if (this.mode.type === "load") {
const existing = await this.memory.getSpace(this.mode.spaceId); const existing = await this.memory.getSpace(this.mode.spaceId);
if (!existing) throw new Error(`Persona space not found: ${this.mode.spaceId}`); if (!existing)
await this.emit('persona.loaded', { displayName: existing.displayName }); throw new Error(`Persona space not found: ${this.mode.spaceId}`);
await this.emit("persona.loaded", { displayName: existing.displayName });
await this.refreshAvailability(now, existing); await this.refreshAvailability(now, existing);
return existing; return existing;
} }
const space = await this.memory.createSpace({ displayName: this.mode.displayName, seedMessage: this.mode.seedMessage, now }); const space = await this.memory.createSpace({
const modelFacts = this.options.models?.initialization displayName: this.mode.displayName,
? await this.options.models.initialization.extractInitialFacts({ seedMessage: this.mode.seedMessage,
displayName: this.mode.displayName, now,
seedMessage: this.mode.seedMessage, });
now, if (this.options.models?.factExtractor) {
}) const statement = `Persona: ${this.mode.displayName}\nSeed: ${this.mode.seedMessage}`;
: undefined; const extracteds = (
const facts = modelFacts ?? [defaultInitialFact(this.mode.displayName, this.mode.seedMessage)]; await extractFacts(statement, this.options.models.factExtractor)
for (const fact of facts) { ).map((fact) => extractedToDraft(fact, statement));
for (const fact of extracteds) {
await this.memory.addFact(space.id, fact);
}
await this.emit(
"persona.initialized",
{ displayName: space.displayName, factCount: extracteds.length },
space.id,
);
} else {
const fact = defaultInitialFact(
this.mode.displayName,
this.mode.seedMessage,
);
await this.memory.addFact(space.id, fact); 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); await this.refreshAvailability(now, space);
return space; return space;
} }
private async refreshAvailability(datetime: DateTimeInput, knownPersona?: MemorySpace): Promise<void> { private async refreshAvailability(
const persona = knownPersona ?? await this.ready(); datetime: DateTimeInput,
knownPersona?: MemorySpace,
): Promise<void> {
const persona = knownPersona ?? (await this.ready());
const start = startOfUtcDay(datetime); const start = startOfUtcDay(datetime);
const end = addUtcDays(start, 2); const end = addUtcDays(start, 2);
const entries = await this.memory.listScheduleEntries(persona.id, start.toISOString(), end.toISOString()); const entries = await this.memory.listScheduleEntries(
this.availabilitySnapshot = buildAvailabilitySnapshot({ now: datetime, entries }); persona.id,
await this.emit('persona.availability.refreshed', { rangeCount: this.availabilitySnapshot.ranges.length }, persona.id); start.toISOString(),
end.toISOString(),
);
this.availabilitySnapshot = buildAvailabilitySnapshot({
now: datetime,
entries,
});
await this.emit(
"persona.availability.refreshed",
{ rangeCount: this.availabilitySnapshot.ranges.length },
persona.id,
);
} }
private async emit(name: string, data?: Record<string, unknown>, explicitSpaceId?: string): Promise<void> { private async emit(
name: string,
data?: Record<string, unknown>,
explicitSpaceId?: string,
): Promise<void> {
if (!this.options.debug) return; if (!this.options.debug) return;
const event: DebugEvent = { const event: DebugEvent = {
name, name,

View File

@@ -1,12 +1,14 @@
import type { FactExtractor } from "identitydb";
export type DateTimeInput = Date | string | number; 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 ScheduleActivity = string;
export type AvailabilityMode = 'online' | 'do-not-disturb' | 'offline'; export type AvailabilityMode = "online" | "do-not-disturb" | "offline";
export interface MemorySpace { export interface MemorySpace {
id: string; id: string;
@@ -57,7 +59,7 @@ export interface ScheduledAvailabilitySnapshot {
} }
export interface PersonaMessage { export interface PersonaMessage {
sender: 'persona' | 'user'; sender: "persona" | "user";
time: DateTimeInput; time: DateTimeInput;
content: string; content: string;
} }
@@ -83,7 +85,7 @@ export interface MandatoryConversationContext {
export interface ReplyGenerationInput { export interface ReplyGenerationInput {
persona: MemorySpace; persona: MemorySpace;
now: string; now: string;
mode: 'reply' | 'start-conversation'; mode: "reply" | "start-conversation";
context: MandatoryConversationContext; context: MandatoryConversationContext;
userMessage?: string; userMessage?: string;
instruction: string; instruction: string;
@@ -117,18 +119,6 @@ export interface RewriteModel {
decide(input: RewriteDecisionInput): Promise<RewriteDecision>; decide(input: RewriteDecisionInput): Promise<RewriteDecision>;
} }
export interface MemoryExtractionInput {
persona: MemorySpace;
now: string;
formattedMessageHistory: string;
contextFacts: StoredFact[];
instruction: string;
}
export interface MemoryExtractionModel {
extract(input: MemoryExtractionInput): Promise<FactDraft[]>;
}
export interface ScheduleBlock { export interface ScheduleBlock {
startTime: string; startTime: string;
endTime: string; endTime: string;
@@ -154,23 +144,18 @@ export interface MonthlyScheduleGenerationInput {
} }
export interface ScheduleModel { export interface ScheduleModel {
generateDailySchedule(input: DailyScheduleGenerationInput): Promise<ScheduleBlock[]>; generateDailySchedule(
generateMonthlySchedule(input: MonthlyScheduleGenerationInput): Promise<ScheduleBlock[]>; input: DailyScheduleGenerationInput,
} ): Promise<ScheduleBlock[]>;
generateMonthlySchedule(
export interface PersonaInitializationModel { input: MonthlyScheduleGenerationInput,
extractInitialFacts(input: { ): Promise<ScheduleBlock[]>;
displayName: string;
seedMessage: string;
now: string;
}): Promise<FactDraft[]>;
} }
export interface PersonaModels { export interface PersonaModels {
initialization?: PersonaInitializationModel; factExtractor?: FactExtractor;
conversation?: ConversationModel; conversation?: ConversationModel;
rewrite?: RewriteModel; rewrite?: RewriteModel;
memoryExtraction?: MemoryExtractionModel;
schedule?: ScheduleModel; schedule?: ScheduleModel;
} }
@@ -179,15 +164,32 @@ export interface PersonaOptions {
models?: PersonaModels; models?: PersonaModels;
debug?: DebugHook; debug?: DebugHook;
now?: DateTimeInput; now?: DateTimeInput;
baseSystemPrompt?: string;
} }
export interface BoxBrainMemoryStore { export interface BoxBrainMemoryStore {
createSpace(input: { displayName: string; seedMessage: string; now: string }): Promise<MemorySpace>; createSpace(input: {
displayName: string;
seedMessage: string;
now: string;
}): Promise<MemorySpace>;
getSpace(spaceId: string): Promise<MemorySpace | null>; getSpace(spaceId: string): Promise<MemorySpace | null>;
addFact(spaceId: string, fact: FactDraft): Promise<StoredFact>; addFact(spaceId: string, fact: FactDraft): Promise<StoredFact>;
listFacts(spaceId: string): Promise<StoredFact[]>; listFacts(spaceId: string): Promise<StoredFact[]>;
findFacts(spaceId: string, topics: string[]): Promise<StoredFact[]>; findFacts(spaceId: string, topics: string[]): Promise<StoredFact[]>;
saveScheduleEntries(spaceId: string, entries: ScheduleEntry[]): Promise<void>; saveScheduleEntries(spaceId: string, entries: ScheduleEntry[]): Promise<void>;
listScheduleEntries(spaceId: string, fromInclusive: string, toExclusive: string): Promise<ScheduleEntry[]>; listScheduleEntries(
deleteScheduleEntriesBefore(spaceId: string, cutoffExclusive: string): Promise<number>; spaceId: string,
fromInclusive: string,
toExclusive: string,
): Promise<ScheduleEntry[]>;
deleteScheduleEntriesBefore(
spaceId: string,
cutoffExclusive: string,
): Promise<number>;
ingestStatement(
spaceId: string,
statement: string,
extractor: FactExtractor,
): Promise<StoredFact[]>;
} }

19
src/utils.ts Normal file
View File

@@ -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<string, unknown> }
: {}),
};
}

View File

@@ -55,7 +55,6 @@ describe('Conversation API', () => {
memory, memory,
now: '2026-05-01T10:00:00.000Z', now: '2026-05-01T10:00:00.000Z',
models: { models: {
initialization: { async extractInitialFacts() { return []; } },
conversation: { conversation: {
async generateReply(input) { async generateReply(input) {
memorySummary = input.context.memorySummary; 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({ await persona.sendMessage({
datetime: '2026-05-01T12:00:00.000Z', datetime: '2026-05-01T12:00:00.000Z',
@@ -124,4 +124,30 @@ describe('Conversation API', () => {
expect(mode).toBe('start-conversation'); expect(mode).toBe('start-conversation');
expect(started.messages).toEqual(['오늘 좀 조용하네.']); expect(started.messages).toEqual(['오늘 좀 조용하네.']);
}); });
it('includes baseSystemPrompt at the start of the instruction when provided', async () => {
const memory = new InMemoryMemoryStore();
let captured: ReplyGenerationInput | undefined;
const persona = new Persona('Mina', 'Mina likes quiet cafes.', {
memory,
now: '2026-05-01T10:00:00.000Z',
baseSystemPrompt: 'You are a helpful assistant. Always be kind.',
models: {
conversation: {
async generateReply(input) {
captured = input;
return { messages: ['Hello!'] };
},
},
},
});
await persona.ready();
await persona.sendMessage({
datetime: '2026-05-01T12:00:00.000Z',
messageHistory: [{ sender: 'user', time: '2026-05-01T12:00:00.000Z', content: 'Hi' }],
});
expect(captured?.instruction.startsWith('You are a helpful assistant. Always be kind.')).toBe(true);
});
}); });

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { InMemoryMemoryStore, Persona, type FactDraft } from '../src'; import { InMemoryMemoryStore, Persona } from '../src';
describe('Persona initialization', () => { describe('Persona initialization', () => {
it('creates a new isolated persona space from displayName and seed message', async () => { 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', now: '2026-05-01T10:00:00.000Z',
debug: (event) => { debug.push(event.name); }, debug: (event) => { debug.push(event.name); },
models: { models: {
initialization: { factExtractor: {
async extractInitialFacts(input): Promise<FactDraft[]> { async extract(input) {
return [ return [{
{ statement: 'Mina likes quiet cafes.',
statement: `${input.displayName} likes quiet cafes.`, topics: [{ name: 'persona' }, { name: 'Mina' }],
topics: ['persona', input.displayName], source: 'test',
source: 'test', }];
},
];
}, },
}, },
}, },
@@ -31,6 +29,18 @@ describe('Persona initialization', () => {
expect(debug).toContain('persona.initialized'); expect(debug).toContain('persona.initialized');
}); });
it('exposes baseSystemPrompt on the persona instance when provided', async () => {
const memory = new InMemoryMemoryStore();
const persona = new Persona('Hana', 'Hana is a cheerful barista.', {
memory,
now: '2026-05-01T10:00:00.000Z',
baseSystemPrompt: 'You are a helpful assistant. Always be kind.',
});
await persona.ready();
expect(persona.baseSystemPrompt).toBe('You are a helpful assistant. Always be kind.');
});
it('loads an existing persona space by space id without creating another space', async () => { it('loads an existing persona space by space id without creating another space', async () => {
const memory = new InMemoryMemoryStore(); const memory = new InMemoryMemoryStore();
const created = new Persona('Joon', 'Joon is a freelance designer.', { memory, now: '2026-05-01T10:00:00.000Z' }); const created = new Persona('Joon', 'Joon is a freelance designer.', { memory, now: '2026-05-01T10:00:00.000Z' });

View File

@@ -8,17 +8,18 @@ describe('sleepMemory', () => {
memory, memory,
now: '2026-05-01T10:00:00.000Z', now: '2026-05-01T10:00:00.000Z',
models: { models: {
memoryExtraction: { factExtractor: {
async extract(input) { async extract(input) {
expect(input.formattedMessageHistory).toContain('user@2026-05-01T15:00:00.000Z: 나는 타입스크립트를 2025년부터 시작했어'); if (input.includes('Seed:')) {
expect(input.instruction).toContain('Objectivize'); return [{ statement: 'Mina remembers stable details.', topics: [{ name: 'persona' }, { name: 'Mina' }] }];
return [ }
{ expect(input).toContain('user@2026-05-01T15:00:00.000Z: 나는 타입스크립트를 2025년부터 시작했어');
statement: 'The user started TypeScript in 2025.', expect(input).toContain('Objectivize');
topics: ['user', 'TypeScript', '2025'], return [{
confidence: 0.9, statement: 'The user started TypeScript in 2025.',
}, topics: [{ name: 'user' }, { name: 'TypeScript' }, { name: '2025' }],
]; confidence: 0.9,
}];
}, },
}, },
}, },