10 Commits

Author SHA1 Message Date
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
9 changed files with 159 additions and 100 deletions

View File

@@ -5,7 +5,7 @@
"": { "": {
"name": "boxbrain", "name": "boxbrain",
"dependencies": { "dependencies": {
"identitydb": "0.2.1", "identitydb": "^0.4.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.4.0", "", { "dependencies": { "kysely": "^0.28.8", "mysql2": "^3.15.3", "pg": "^8.16.0" } }, "sha512-DAyipdrApjmI1HoHfhT9zuMfNLiWaYe7/k/FrUa55h7WUUASGV192AhrC6KUiMTS55dfYKDBWi0AcS7crSw+bA=="],
"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.1", "version": "0.4.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.4.0"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "latest",

View File

@@ -20,24 +20,16 @@ export function formatMessageHistory(input: {
.join("\n"); .join("\n");
} }
export function conversationInstruction(): string { export function conversationInstruction(baseSystemPrompt?: string): string {
return [ const parts = [
...(baseSystemPrompt === undefined ? [] : [baseSystemPrompt]),
"You are controlling the persona, not a generic assistant.", "You are controlling the persona, not a generic assistant.",
"Use the send_message tool conceptually: return one or more outgoing messages.", "Use the send_message tool conceptually: return one or more outgoing messages.",
"Unless the persona strongly prefers otherwise, keep each outgoing message to at most one sentence.", "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.", "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: {

View File

@@ -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'; import type { BoxBrainMemoryStore, FactDraft, MemorySpace, ScheduleEntry, StoredFact } from './types';
function normalizeTopics(topics: string[]): string[] { function normalizeTopics(topics: string[]): string[] {
@@ -74,6 +74,17 @@ export class InMemoryMemoryStore implements BoxBrainMemoryStore {
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 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<string, unknown> } : {}),
});
}
} }
export interface IdentityDbMemoryStoreOptions { export interface IdentityDbMemoryStoreOptions {
@@ -172,6 +183,11 @@ export class IdentityDbMemoryStore implements BoxBrainMemoryStore {
return 0; return 0;
} }
async ingestStatement(spaceId: string, statement: string, extractor: FactExtractor): Promise<StoredFact> {
const fact = await this.options.db.ingestStatement(statement, { extractor, spaceName: spaceId });
return 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 {

View File

@@ -10,11 +10,11 @@ import {
startOfUtcDay, startOfUtcDay,
toIso, toIso,
} from "./schedule"; } from "./schedule";
import { extractFact } from "identitydb";
import { import {
buildMandatoryConversationContext, buildMandatoryConversationContext,
conversationInstruction, conversationInstruction,
formatMessageHistory, formatMessageHistory,
memoryExtractionInstruction,
} from "./conversation"; } from "./conversation";
import type { import type {
BoxBrainMemoryStore, BoxBrainMemoryStore,
@@ -68,6 +68,7 @@ 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( constructor(
displayName: string, displayName: string,
@@ -88,6 +89,7 @@ export class Persona {
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();
} }
@@ -247,7 +249,7 @@ export class Persona {
mode: "reply", mode: "reply",
context, context,
...(userMessage === undefined ? {} : { userMessage }), ...(userMessage === undefined ? {} : { userMessage }),
instruction: conversationInstruction(), instruction: conversationInstruction(this.baseSystemPrompt),
}), }),
); );
@@ -281,7 +283,7 @@ export class Persona {
now: toIso(input.datetime), now: toIso(input.datetime),
mode: "reply", mode: "reply",
context: latestContext, context: latestContext,
instruction: conversationInstruction(), instruction: conversationInstruction(this.baseSystemPrompt),
})), })),
); );
} }
@@ -320,7 +322,7 @@ export class Persona {
now: toIso(input.datetime), now: toIso(input.datetime),
mode: "start-conversation", mode: "start-conversation",
context, context,
instruction: conversationInstruction(), instruction: conversationInstruction(this.baseSystemPrompt),
}), }),
); );
await this.emit("persona.conversation.started", { await this.emit("persona.conversation.started", {
@@ -334,35 +336,50 @@ 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, [ const contextFacts = await this.memory.findFacts(persona.id, [
"persona", "persona",
persona.displayName, persona.displayName,
"user", "user",
]); ]);
const drafts = await this.options.models.memoryExtraction.extract({ const statement = [
persona, `Current objective time: ${toIso(input.datetime)}.`,
now: toIso(input.datetime), "Read the message history and extract durable facts worth remembering.",
formattedMessageHistory: formatMessageHistory({ "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, personaName: persona.displayName,
messages: input.messageHistory, messages: input.messageHistory,
}), }),
contextFacts, ].join("\n");
instruction: memoryExtractionInstruction(toIso(input.datetime)), const extracted = await extractFact(
}); statement,
for (const draft of drafts) { this.options.models.factExtractor,
await this.memory.addFact(persona.id, { );
...draft, const draft: FactDraft = {
topics: [...draft.topics, "sleepMemory"], statement: extracted.statement ?? statement,
source: draft.source ?? "boxbrain.sleepMemory", 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<string, unknown> }
: {}),
};
await this.memory.addFact(persona.id, draft);
await this.emit("persona.memory.sleep.persisted", { await this.emit("persona.memory.sleep.persisted", {
factCount: drafts.length, factCount: 1,
}); });
return drafts; return [draft];
} }
private async initialize(): Promise<MemorySpace> { private async initialize(): Promise<MemorySpace> {
@@ -381,24 +398,38 @@ export class Persona {
seedMessage: this.mode.seedMessage, seedMessage: this.mode.seedMessage,
now, now,
}); });
const modelFacts = this.options.models?.initialization if (this.options.models?.factExtractor) {
? await this.options.models.initialization.extractInitialFacts({ const statement = `Persona: ${this.mode.displayName}\nSeed: ${this.mode.seedMessage}`;
displayName: this.mode.displayName, const extracted = await extractFact(
seedMessage: this.mode.seedMessage, statement,
now, this.options.models.factExtractor,
}) );
: undefined; const draft: FactDraft = {
const facts = modelFacts ?? [ statement: extracted.statement ?? statement,
defaultInitialFact(this.mode.displayName, this.mode.seedMessage), topics: extracted.topics.map((t) => t.name),
]; source: extracted.source ?? "boxbrain.persona.initialization",
for (const fact of facts) { ...(typeof extracted.confidence === 'number'
await this.memory.addFact(space.id, fact); ? { confidence: extracted.confidence }
} : {}),
...(extracted.metadata !== undefined && extracted.metadata !== null
? { metadata: extracted.metadata as Record<string, unknown> }
: {}),
};
await this.memory.addFact(space.id, draft);
await this.emit( await this.emit(
"persona.initialized", "persona.initialized",
{ displayName: space.displayName, factCount: facts.length }, { displayName: space.displayName, factCount: 1 },
space.id, 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.refreshAvailability(now, space); await this.refreshAvailability(now, space);
return space; return space;
} }

View File

@@ -1,3 +1,5 @@
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';
@@ -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;
@@ -158,19 +148,10 @@ export interface ScheduleModel {
generateMonthlySchedule(input: MonthlyScheduleGenerationInput): Promise<ScheduleBlock[]>; generateMonthlySchedule(input: MonthlyScheduleGenerationInput): Promise<ScheduleBlock[]>;
} }
export interface PersonaInitializationModel {
extractInitialFacts(input: {
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,6 +160,7 @@ export interface PersonaOptions {
models?: PersonaModels; models?: PersonaModels;
debug?: DebugHook; debug?: DebugHook;
now?: DateTimeInput; now?: DateTimeInput;
baseSystemPrompt?: string;
} }
export interface BoxBrainMemoryStore { export interface BoxBrainMemoryStore {
@@ -190,4 +172,5 @@ export interface BoxBrainMemoryStore {
saveScheduleEntries(spaceId: string, entries: ScheduleEntry[]): Promise<void>; saveScheduleEntries(spaceId: string, entries: ScheduleEntry[]): Promise<void>;
listScheduleEntries(spaceId: string, fromInclusive: string, toExclusive: string): Promise<ScheduleEntry[]>; listScheduleEntries(spaceId: string, fromInclusive: string, toExclusive: string): Promise<ScheduleEntry[]>;
deleteScheduleEntriesBefore(spaceId: string, cutoffExclusive: string): Promise<number>; deleteScheduleEntriesBefore(spaceId: string, cutoffExclusive: string): Promise<number>;
ingestStatement(spaceId: string, statement: string, extractor: FactExtractor): Promise<StoredFact>;
} }

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년부터 시작했어');
expect(input).toContain('Objectivize');
return {
statement: 'The user started TypeScript in 2025.', statement: 'The user started TypeScript in 2025.',
topics: ['user', 'TypeScript', '2025'], topics: [{ name: 'user' }, { name: 'TypeScript' }, { name: '2025' }],
confidence: 0.9, confidence: 0.9,
}, };
];
}, },
}, },
}, },