This commit is contained in:
64
src/conversation.ts
Normal file
64
src/conversation.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type {
|
||||
BoxBrainMemoryStore,
|
||||
DateTimeInput,
|
||||
MandatoryConversationContext,
|
||||
MemorySpace,
|
||||
PersonaMessage,
|
||||
ScheduledAvailabilitySnapshot,
|
||||
} from './types';
|
||||
import { addUtcDays, buildAvailabilitySnapshot, dateKeysAround, startOfUtcDay, toIso } from './schedule';
|
||||
|
||||
export function formatMessageHistory(input: { personaName: string; messages: PersonaMessage[] }): string {
|
||||
return input.messages
|
||||
.map((message) => {
|
||||
const sender = message.sender === 'persona' ? input.personaName : 'user';
|
||||
return `${sender}@${toIso(message.time)}: ${message.content}`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function conversationInstruction(): string {
|
||||
return [
|
||||
'You are controlling the persona, not a generic assistant.',
|
||||
'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.',
|
||||
'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.',
|
||||
].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;
|
||||
memory: BoxBrainMemoryStore;
|
||||
messages: PersonaMessage[];
|
||||
availability: ScheduledAvailabilitySnapshot;
|
||||
}): Promise<MandatoryConversationContext> {
|
||||
const now = startOfUtcDay(input.now);
|
||||
const from = addUtcDays(now, -1).toISOString();
|
||||
const to = addUtcDays(now, 2).toISOString();
|
||||
const scheduleEntries = await input.memory.listScheduleEntries(input.persona.id, from, to);
|
||||
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 {
|
||||
formattedMessageHistory: formatMessageHistory({ personaName: input.persona.displayName, messages: input.messages }),
|
||||
conversationWindowLabel: `Required conversation window: yesterday/today. Schedule dates: ${dateKeysAround(input.now).join(', ')}.`,
|
||||
memorySummary,
|
||||
personaAndUserFacts,
|
||||
scheduleEntries,
|
||||
availability: input.availability,
|
||||
};
|
||||
}
|
||||
5
src/index.ts
Normal file
5
src/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './types';
|
||||
export * from './memory';
|
||||
export * from './schedule';
|
||||
export * from './conversation';
|
||||
export * from './persona';
|
||||
178
src/memory.ts
Normal file
178
src/memory.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { IdentityDB, type Fact as IdentityFact } from 'identitydb';
|
||||
import type { BoxBrainMemoryStore, FactDraft, MemorySpace, ScheduleEntry, StoredFact } from './types';
|
||||
|
||||
function normalizeTopics(topics: string[]): string[] {
|
||||
return [...new Set(topics.map((topic) => topic.trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
function includesAnyTopic(fact: StoredFact, topics: string[]): boolean {
|
||||
const normalized = new Set(normalizeTopics(topics).map((topic) => topic.toLowerCase()));
|
||||
return fact.topics.some((topic) => normalized.has(topic.toLowerCase()));
|
||||
}
|
||||
|
||||
export class InMemoryMemoryStore implements BoxBrainMemoryStore {
|
||||
readonly spaces = new Map<string, MemorySpace>();
|
||||
readonly facts = new Map<string, StoredFact[]>();
|
||||
readonly schedules = new Map<string, ScheduleEntry[]>();
|
||||
|
||||
async createSpace(input: { displayName: string; seedMessage: string; now: string }): Promise<MemorySpace> {
|
||||
const slug = input.displayName.toLowerCase().replace(/[^a-z0-9가-힣]+/gi, '-').replace(/^-|-$/g, '') || 'persona';
|
||||
const space: MemorySpace = {
|
||||
id: `persona-${slug}-${crypto.randomUUID()}`,
|
||||
displayName: input.displayName,
|
||||
createdAt: input.now,
|
||||
metadata: { seedMessage: input.seedMessage },
|
||||
};
|
||||
this.spaces.set(space.id, space);
|
||||
return space;
|
||||
}
|
||||
|
||||
async getSpace(spaceId: string): Promise<MemorySpace | null> {
|
||||
return this.spaces.get(spaceId) ?? null;
|
||||
}
|
||||
|
||||
async addFact(spaceId: string, fact: FactDraft): Promise<StoredFact> {
|
||||
const stored: StoredFact = {
|
||||
id: crypto.randomUUID(),
|
||||
statement: fact.statement,
|
||||
topics: normalizeTopics(fact.topics),
|
||||
createdAt: new Date().toISOString(),
|
||||
...(fact.confidence === undefined ? {} : { confidence: fact.confidence }),
|
||||
...(fact.source === undefined ? {} : { source: fact.source }),
|
||||
...(fact.metadata === undefined ? {} : { metadata: fact.metadata }),
|
||||
};
|
||||
const existing = this.facts.get(spaceId) ?? [];
|
||||
existing.push(stored);
|
||||
this.facts.set(spaceId, existing);
|
||||
return stored;
|
||||
}
|
||||
|
||||
async findFacts(spaceId: string, topics: string[]): Promise<StoredFact[]> {
|
||||
const facts = this.facts.get(spaceId) ?? [];
|
||||
if (topics.length === 0) return [...facts];
|
||||
return facts.filter((fact) => includesAnyTopic(fact, topics));
|
||||
}
|
||||
|
||||
async saveScheduleEntries(spaceId: string, entries: ScheduleEntry[]): Promise<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[]> {
|
||||
return (this.schedules.get(spaceId) ?? [])
|
||||
.filter((entry) => entry.startAt < toExclusive && entry.endAt > fromInclusive)
|
||||
.sort((a, b) => a.startAt.localeCompare(b.startAt));
|
||||
}
|
||||
|
||||
async deleteScheduleEntriesBefore(spaceId: string, cutoffExclusive: string): Promise<number> {
|
||||
const entries = this.schedules.get(spaceId) ?? [];
|
||||
const kept = entries.filter((entry) => entry.endAt > cutoffExclusive);
|
||||
this.schedules.set(spaceId, kept);
|
||||
return entries.length - kept.length;
|
||||
}
|
||||
}
|
||||
|
||||
export interface IdentityDbMemoryStoreOptions {
|
||||
db: IdentityDB;
|
||||
}
|
||||
|
||||
export class IdentityDbMemoryStore implements BoxBrainMemoryStore {
|
||||
constructor(private readonly options: IdentityDbMemoryStoreOptions) {}
|
||||
|
||||
async createSpace(input: { displayName: string; seedMessage: string; now: string }): Promise<MemorySpace> {
|
||||
const slug = input.displayName.toLowerCase().replace(/[^a-z0-9가-힣]+/gi, '-').replace(/^-|-$/g, '') || 'persona';
|
||||
const spaceName = `persona-${slug}-${crypto.randomUUID()}`;
|
||||
const space = await this.options.db.upsertSpace({
|
||||
name: spaceName,
|
||||
description: `BoxBrain persona space for ${input.displayName}`,
|
||||
metadata: {
|
||||
boxbrainType: 'persona-space',
|
||||
displayName: input.displayName,
|
||||
seedMessage: input.seedMessage,
|
||||
createdAt: input.now,
|
||||
},
|
||||
});
|
||||
return {
|
||||
id: space.name,
|
||||
displayName: input.displayName,
|
||||
createdAt: space.createdAt,
|
||||
metadata: { seedMessage: input.seedMessage, identityDbSpaceId: space.id },
|
||||
};
|
||||
}
|
||||
|
||||
async getSpace(spaceId: string): Promise<MemorySpace | null> {
|
||||
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<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> {
|
||||
const stored = await this.options.db.addFact({
|
||||
spaceName: spaceId,
|
||||
statement: fact.statement,
|
||||
confidence: fact.confidence ?? null,
|
||||
source: fact.source ?? null,
|
||||
metadata: (fact.metadata ?? null) as never,
|
||||
topics: normalizeTopics(fact.topics).map((topic) => ({ name: topic, category: 'entity' as const, granularity: 'concrete' as const })),
|
||||
});
|
||||
return this.fromIdentityFact(stored);
|
||||
}
|
||||
|
||||
async findFacts(spaceId: string, topics: string[]): Promise<StoredFact[]> {
|
||||
const uniqueTopics = normalizeTopics(topics);
|
||||
const collected = new Map<string, StoredFact>();
|
||||
for (const topic of uniqueTopics) {
|
||||
const facts = await this.options.db.getTopicFacts(topic, { spaceName: spaceId });
|
||||
for (const fact of facts) {
|
||||
collected.set(fact.id, this.fromIdentityFact(fact));
|
||||
}
|
||||
}
|
||||
return [...collected.values()].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
||||
}
|
||||
|
||||
async saveScheduleEntries(spaceId: string, entries: ScheduleEntry[]): Promise<void> {
|
||||
for (const entry of entries) {
|
||||
await this.addFact(spaceId, {
|
||||
statement: `${entry.title} from ${entry.startAt} to ${entry.endAt}.`,
|
||||
topics: ['schedule', entry.startAt.slice(0, 10), entry.activity, 'persona'],
|
||||
source: 'boxbrain.schedule',
|
||||
metadata: { ...entry.metadata, scheduleEntry: entry },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async listScheduleEntries(spaceId: string, fromInclusive: string, toExclusive: string): Promise<ScheduleEntry[]> {
|
||||
const facts = await this.findFacts(spaceId, ['schedule']);
|
||||
return facts
|
||||
.map((fact) => fact.metadata?.['scheduleEntry'])
|
||||
.filter((value): value is ScheduleEntry => typeof value === 'object' && value !== null && !Array.isArray(value))
|
||||
.filter((entry) => entry.startAt < toExclusive && entry.endAt > fromInclusive)
|
||||
.sort((a, b) => a.startAt.localeCompare(b.startAt));
|
||||
}
|
||||
|
||||
async deleteScheduleEntriesBefore(_spaceId: string, _cutoffExclusive: string): Promise<number> {
|
||||
// IdentityDB is append-oriented at the public API level. Record schedule deletion as a fact at the Persona layer.
|
||||
return 0;
|
||||
}
|
||||
|
||||
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;
|
||||
return {
|
||||
id: fact.id,
|
||||
statement: fact.statement,
|
||||
topics: fact.topics.map((topic) => topic.name),
|
||||
createdAt: fact.createdAt,
|
||||
...(fact.confidence === null ? {} : { confidence: fact.confidence }),
|
||||
...(fact.source === null ? {} : { source: fact.source }),
|
||||
...(metadata === undefined ? {} : { metadata }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function createSqliteIdentityMemoryStore(filename: string): Promise<IdentityDbMemoryStore> {
|
||||
const db = await IdentityDB.connect({ client: 'sqlite', filename });
|
||||
await db.initialize();
|
||||
return new IdentityDbMemoryStore({ db });
|
||||
}
|
||||
306
src/persona.ts
Normal file
306
src/persona.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
import { InMemoryMemoryStore } from './memory';
|
||||
import {
|
||||
addUtcDays,
|
||||
buildAvailabilitySnapshot,
|
||||
createMonthlyScheduleEntries,
|
||||
createTenMinuteDailySchedule,
|
||||
scheduleTargetDay,
|
||||
startOfUtcDay,
|
||||
toIso,
|
||||
} from './schedule';
|
||||
import {
|
||||
buildMandatoryConversationContext,
|
||||
conversationInstruction,
|
||||
formatMessageHistory,
|
||||
memoryExtractionInstruction,
|
||||
} from './conversation';
|
||||
import type {
|
||||
BoxBrainMemoryStore,
|
||||
DateTimeInput,
|
||||
DebugEvent,
|
||||
FactDraft,
|
||||
MemorySpace,
|
||||
OutgoingMessageDraft,
|
||||
PersonaMessage,
|
||||
PersonaOptions,
|
||||
ScheduleEntry,
|
||||
ScheduledAvailabilitySnapshot,
|
||||
} from './types';
|
||||
|
||||
interface CreateMode {
|
||||
type: 'create';
|
||||
displayName: string;
|
||||
seedMessage: string;
|
||||
}
|
||||
|
||||
interface LoadMode {
|
||||
type: 'load';
|
||||
spaceId: string;
|
||||
}
|
||||
|
||||
type Mode = CreateMode | LoadMode;
|
||||
|
||||
function defaultInitialFact(displayName: string, seedMessage: string): FactDraft {
|
||||
return {
|
||||
statement: `${displayName} is a BoxBrain persona initialized from this seed: ${seedMessage}`,
|
||||
topics: ['persona', displayName],
|
||||
source: 'boxbrain.persona.initialization',
|
||||
confidence: 1,
|
||||
metadata: { boxbrainType: 'persona-initial-fact' },
|
||||
};
|
||||
}
|
||||
|
||||
function ensureDraft(draft: OutgoingMessageDraft): OutgoingMessageDraft {
|
||||
return {
|
||||
messages: draft.messages.map((message) => message.trim()).filter(Boolean),
|
||||
...(draft.reasoning === undefined ? {} : { reasoning: draft.reasoning }),
|
||||
};
|
||||
}
|
||||
|
||||
export class Persona {
|
||||
private readonly memory: BoxBrainMemoryStore;
|
||||
private readonly options: PersonaOptions;
|
||||
private readonly mode: Mode;
|
||||
private readonly readyPromise: Promise<MemorySpace>;
|
||||
private availabilitySnapshot?: ScheduledAvailabilitySnapshot;
|
||||
|
||||
constructor(displayName: string, seedMessage: string, options?: PersonaOptions);
|
||||
constructor(spaceId: string, options?: PersonaOptions);
|
||||
constructor(first: string, second?: string | PersonaOptions, third?: PersonaOptions) {
|
||||
if (typeof second === 'string') {
|
||||
this.mode = { type: 'create', displayName: first, seedMessage: second };
|
||||
this.options = third ?? {};
|
||||
} else {
|
||||
this.mode = { type: 'load', spaceId: first };
|
||||
this.options = second ?? {};
|
||||
}
|
||||
this.memory = this.options.memory ?? new InMemoryMemoryStore();
|
||||
this.readyPromise = this.initialize();
|
||||
}
|
||||
|
||||
async ready(): Promise<MemorySpace> {
|
||||
return this.readyPromise;
|
||||
}
|
||||
|
||||
async createDailySchedule(datetime: DateTimeInput, message: string): Promise<ScheduleEntry[]> {
|
||||
const persona = await this.ready();
|
||||
const targetDay = scheduleTargetDay(datetime);
|
||||
const entries = createTenMinuteDailySchedule({ persona, targetDay, message });
|
||||
await this.emit('persona.schedule.daily.generated', { targetDay: targetDay.toISOString(), count: entries.length, message });
|
||||
await this.memory.saveScheduleEntries(persona.id, entries);
|
||||
await this.refreshAvailability(datetime);
|
||||
return entries;
|
||||
}
|
||||
|
||||
async createMonthlySchedule(datetime: DateTimeInput, message: string): Promise<ScheduleEntry[]> {
|
||||
const persona = await this.ready();
|
||||
const entries = createMonthlyScheduleEntries({ persona, fromDay: datetime, message });
|
||||
await this.emit('persona.schedule.monthly.generated', { count: entries.length, message });
|
||||
await this.memory.saveScheduleEntries(persona.id, entries);
|
||||
await this.refreshAvailability(datetime);
|
||||
return entries;
|
||||
}
|
||||
|
||||
async deleteSchedulesBefore(cutoffExclusive: DateTimeInput): Promise<number> {
|
||||
const persona = await this.ready();
|
||||
const cutoff = toIso(cutoffExclusive);
|
||||
const deleted = await this.memory.deleteScheduleEntriesBefore(persona.id, cutoff);
|
||||
await this.memory.addFact(persona.id, {
|
||||
statement: `Schedules before ${cutoff} were deleted or marked inactive.`,
|
||||
topics: ['persona.schedule.deleted', 'schedule', cutoff.slice(0, 10)],
|
||||
source: 'boxbrain.schedule.prune',
|
||||
metadata: { boxbrainType: 'schedule-deletion', cutoffExclusive: cutoff, deleted },
|
||||
});
|
||||
await this.emit('persona.schedule.deleted', { cutoffExclusive: cutoff, deleted });
|
||||
return deleted;
|
||||
}
|
||||
|
||||
async deleteSchedulesOlderThan(datetime: DateTimeInput): Promise<number> {
|
||||
return this.deleteSchedulesBefore(datetime);
|
||||
}
|
||||
|
||||
async getTodayScheduledAvailability(datetime: DateTimeInput): Promise<ScheduledAvailabilitySnapshot> {
|
||||
if (!this.availabilitySnapshot) {
|
||||
await this.refreshAvailability(datetime);
|
||||
}
|
||||
const snapshot = this.availabilitySnapshot;
|
||||
if (!snapshot) throw new Error('Availability snapshot was not initialized.');
|
||||
|
||||
const today = startOfUtcDay(datetime).toISOString();
|
||||
if (snapshot.windowStartAt !== today) {
|
||||
await this.refreshAvailability(datetime);
|
||||
}
|
||||
|
||||
const refreshed = this.availabilitySnapshot;
|
||||
if (!refreshed) throw new Error('Availability snapshot was not initialized.');
|
||||
return refreshed;
|
||||
}
|
||||
|
||||
async sendMessage(input: {
|
||||
datetime: DateTimeInput;
|
||||
messageHistory: PersonaMessage[];
|
||||
getLatestMessageHistory?: () => Promise<PersonaMessage[]>;
|
||||
}): Promise<OutgoingMessageDraft> {
|
||||
const persona = await this.ready();
|
||||
if (!this.options.models?.conversation) {
|
||||
throw new Error('sendMessage requires options.models.conversation.');
|
||||
}
|
||||
const availability = await this.getTodayScheduledAvailability(input.datetime);
|
||||
const context = await buildMandatoryConversationContext({
|
||||
persona,
|
||||
now: input.datetime,
|
||||
memory: this.memory,
|
||||
messages: input.messageHistory,
|
||||
availability,
|
||||
});
|
||||
await this.emit('persona.conversation.context.loaded', {
|
||||
factCount: context.personaAndUserFacts.length,
|
||||
scheduleEntryCount: context.scheduleEntries.length,
|
||||
});
|
||||
|
||||
const userMessage = [...input.messageHistory].reverse().find((message) => message.sender === 'user')?.content;
|
||||
let draft = ensureDraft(await this.options.models.conversation.generateReply({
|
||||
persona,
|
||||
now: toIso(input.datetime),
|
||||
mode: 'reply',
|
||||
context,
|
||||
...(userMessage === undefined ? {} : { userMessage }),
|
||||
instruction: conversationInstruction(),
|
||||
}));
|
||||
|
||||
if (input.getLatestMessageHistory && this.options.models.rewrite) {
|
||||
const latest = await input.getLatestMessageHistory();
|
||||
if (latest.length > input.messageHistory.length) {
|
||||
const latestContext = await buildMandatoryConversationContext({
|
||||
persona,
|
||||
now: input.datetime,
|
||||
memory: this.memory,
|
||||
messages: latest,
|
||||
availability,
|
||||
});
|
||||
const decision = await this.options.models.rewrite.decide({
|
||||
persona,
|
||||
now: toIso(input.datetime),
|
||||
previousHistory: input.messageHistory,
|
||||
latestHistory: latest,
|
||||
draft,
|
||||
context: latestContext,
|
||||
});
|
||||
await this.emit('persona.conversation.rewrite.checked', { rewrite: decision.rewrite, reason: decision.reason ?? null });
|
||||
if (decision.rewrite) {
|
||||
draft = ensureDraft(decision.draft ?? await this.options.models.conversation.generateReply({
|
||||
persona,
|
||||
now: toIso(input.datetime),
|
||||
mode: 'reply',
|
||||
context: latestContext,
|
||||
instruction: conversationInstruction(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.emit('persona.conversation.reply.generated', { messageCount: draft.messages.length });
|
||||
return draft;
|
||||
}
|
||||
|
||||
async startConversation(input: {
|
||||
datetime: DateTimeInput;
|
||||
messageHistory: PersonaMessage[];
|
||||
}): Promise<OutgoingMessageDraft> {
|
||||
const persona = await this.ready();
|
||||
if (!this.options.models?.conversation) {
|
||||
throw new Error('startConversation requires options.models.conversation.');
|
||||
}
|
||||
const availability = await this.getTodayScheduledAvailability(input.datetime);
|
||||
const context = await buildMandatoryConversationContext({
|
||||
persona,
|
||||
now: input.datetime,
|
||||
memory: this.memory,
|
||||
messages: input.messageHistory,
|
||||
availability,
|
||||
});
|
||||
const draft = ensureDraft(await this.options.models.conversation.generateReply({
|
||||
persona,
|
||||
now: toIso(input.datetime),
|
||||
mode: 'start-conversation',
|
||||
context,
|
||||
instruction: conversationInstruction(),
|
||||
}));
|
||||
await this.emit('persona.conversation.started', { messageCount: draft.messages.length });
|
||||
return draft;
|
||||
}
|
||||
|
||||
async sleepMemory(input: {
|
||||
datetime: DateTimeInput;
|
||||
messageHistory: PersonaMessage[];
|
||||
}): Promise<FactDraft[]> {
|
||||
const persona = await this.ready();
|
||||
if (!this.options.models?.memoryExtraction) {
|
||||
throw new Error('sleepMemory requires options.models.memoryExtraction.');
|
||||
}
|
||||
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({ 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',
|
||||
});
|
||||
}
|
||||
await this.emit('persona.memory.sleep.persisted', { factCount: drafts.length });
|
||||
return drafts;
|
||||
}
|
||||
|
||||
private async initialize(): Promise<MemorySpace> {
|
||||
const now = toIso(this.options.now ?? new Date());
|
||||
if (this.mode.type === 'load') {
|
||||
const existing = await this.memory.getSpace(this.mode.spaceId);
|
||||
if (!existing) throw new Error(`Persona space not found: ${this.mode.spaceId}`);
|
||||
await this.emit('persona.loaded', { displayName: existing.displayName });
|
||||
await this.refreshAvailability(now, existing);
|
||||
return existing;
|
||||
}
|
||||
|
||||
const space = await this.memory.createSpace({ displayName: this.mode.displayName, 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) {
|
||||
await this.memory.addFact(space.id, fact);
|
||||
}
|
||||
await this.emit('persona.initialized', { displayName: space.displayName, factCount: facts.length }, space.id);
|
||||
await this.refreshAvailability(now, space);
|
||||
return space;
|
||||
}
|
||||
|
||||
private async refreshAvailability(datetime: DateTimeInput, knownPersona?: MemorySpace): Promise<void> {
|
||||
const persona = knownPersona ?? await this.ready();
|
||||
const start = startOfUtcDay(datetime);
|
||||
const end = addUtcDays(start, 2);
|
||||
const entries = await this.memory.listScheduleEntries(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> {
|
||||
if (!this.options.debug) return;
|
||||
const event: DebugEvent = {
|
||||
name,
|
||||
time: new Date().toISOString(),
|
||||
...(explicitSpaceId === undefined ? {} : { spaceId: explicitSpaceId }),
|
||||
...(data === undefined ? {} : { data }),
|
||||
};
|
||||
await this.options.debug(event);
|
||||
}
|
||||
}
|
||||
211
src/schedule.ts
Normal file
211
src/schedule.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import type {
|
||||
AvailabilityMode,
|
||||
AvailabilityRange,
|
||||
DateTimeInput,
|
||||
MemorySpace,
|
||||
ScheduleActivity,
|
||||
ScheduleEntry,
|
||||
ScheduledAvailabilitySnapshot,
|
||||
} from './types';
|
||||
|
||||
const TEN_MINUTES_MS = 10 * 60 * 1000;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export function toDate(input: DateTimeInput): Date {
|
||||
const date = input instanceof Date ? new Date(input.getTime()) : new Date(input);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
throw new Error(`Invalid datetime: ${String(input)}`);
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
export function toIso(input: DateTimeInput): string {
|
||||
return toDate(input).toISOString();
|
||||
}
|
||||
|
||||
export function startOfUtcDay(input: DateTimeInput): Date {
|
||||
const date = toDate(input);
|
||||
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
|
||||
}
|
||||
|
||||
export function addUtcDays(input: DateTimeInput, days: number): Date {
|
||||
return new Date(startOfUtcDay(input).getTime() + days * DAY_MS);
|
||||
}
|
||||
|
||||
export function scheduleTargetDay(now: DateTimeInput): Date {
|
||||
return addUtcDays(now, 1);
|
||||
}
|
||||
|
||||
function pick(activity: ScheduleActivity): { title: string; mode: AvailabilityMode } {
|
||||
switch (activity) {
|
||||
case 'sleep':
|
||||
return { title: 'Sleep', mode: 'offline' };
|
||||
case 'work':
|
||||
return { title: 'Work', mode: 'do-not-disturb' };
|
||||
case 'study':
|
||||
return { title: 'Study', mode: 'do-not-disturb' };
|
||||
case 'job-search':
|
||||
return { title: 'Job search', mode: 'do-not-disturb' };
|
||||
case 'travel':
|
||||
return { title: 'Travel', mode: 'do-not-disturb' };
|
||||
case 'commute':
|
||||
return { title: 'Commute', mode: 'do-not-disturb' };
|
||||
case 'exercise':
|
||||
return { title: 'Exercise', mode: 'online' };
|
||||
case 'meal':
|
||||
return { title: 'Meal', mode: 'online' };
|
||||
case 'social':
|
||||
return { title: 'Social time', mode: 'online' };
|
||||
case 'errand':
|
||||
return { title: 'Errand', mode: 'online' };
|
||||
case 'free-time':
|
||||
return { title: 'Free time', mode: 'online' };
|
||||
case 'rest':
|
||||
return { title: 'Rest', mode: 'online' };
|
||||
}
|
||||
}
|
||||
|
||||
function chooseDaytimeActivity(message: string): ScheduleActivity {
|
||||
const lower = message.toLowerCase();
|
||||
if (lower.includes('travel') || lower.includes('trip') || lower.includes('여행')) return 'travel';
|
||||
if (lower.includes('study') || lower.includes('exam') || lower.includes('공부') || lower.includes('시험')) return 'study';
|
||||
if (lower.includes('job') || lower.includes('취업') || lower.includes('구직')) return 'job-search';
|
||||
if (lower.includes('work') || lower.includes('일') || lower.includes('회사')) return 'work';
|
||||
return 'work';
|
||||
}
|
||||
|
||||
function activityForMinute(minuteOfDay: number, message: string): ScheduleActivity {
|
||||
const hour = Math.floor(minuteOfDay / 60);
|
||||
if (hour < 7) return 'sleep';
|
||||
if (hour === 7) return 'meal';
|
||||
if (hour === 8) return 'commute';
|
||||
if (hour >= 9 && hour < 12) return chooseDaytimeActivity(message);
|
||||
if (hour === 12) return 'meal';
|
||||
if (hour >= 13 && hour < 17) return chooseDaytimeActivity(message);
|
||||
if (hour === 17) return 'commute';
|
||||
if (hour === 18) return 'meal';
|
||||
if (hour >= 19 && hour < 21) return message.toLowerCase().includes('study') || message.includes('공부') ? 'study' : 'free-time';
|
||||
if (hour >= 21 && hour < 23) return 'rest';
|
||||
return 'sleep';
|
||||
}
|
||||
|
||||
export function createTenMinuteDailySchedule(input: {
|
||||
persona: MemorySpace;
|
||||
targetDay: DateTimeInput;
|
||||
message: string;
|
||||
}): ScheduleEntry[] {
|
||||
const target = startOfUtcDay(input.targetDay);
|
||||
const entries: ScheduleEntry[] = [];
|
||||
|
||||
for (let offset = 0; offset < DAY_MS; offset += TEN_MINUTES_MS) {
|
||||
const start = new Date(target.getTime() + offset);
|
||||
const end = new Date(start.getTime() + TEN_MINUTES_MS);
|
||||
const minute = offset / (60 * 1000);
|
||||
const activity = activityForMinute(minute, input.message);
|
||||
const picked = pick(activity);
|
||||
entries.push({
|
||||
id: crypto.randomUUID(),
|
||||
spaceId: input.persona.id,
|
||||
startAt: start.toISOString(),
|
||||
endAt: end.toISOString(),
|
||||
activity,
|
||||
title: picked.title,
|
||||
description: `Realistic ${picked.title.toLowerCase()} block for ${input.persona.displayName}.`,
|
||||
granularity: 'ten-minute',
|
||||
sourceMessage: input.message,
|
||||
metadata: {
|
||||
boxbrainType: 'schedule-entry',
|
||||
availabilityMode: picked.mode,
|
||||
targetDate: target.toISOString().slice(0, 10),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function createMonthlyScheduleEntries(input: {
|
||||
persona: MemorySpace;
|
||||
fromDay: DateTimeInput;
|
||||
message: string;
|
||||
days?: number;
|
||||
}): ScheduleEntry[] {
|
||||
const start = scheduleTargetDay(input.fromDay);
|
||||
const count = input.days ?? 30;
|
||||
const entries: ScheduleEntry[] = [];
|
||||
for (let day = 0; day < count; day += 1) {
|
||||
const dayStart = new Date(start.getTime() + day * DAY_MS);
|
||||
const travelHint = day > 0 && day % 90 === 0 ? ' travel' : '';
|
||||
const activity = chooseDaytimeActivity(`${input.message}${travelHint}`);
|
||||
const picked = pick(activity);
|
||||
entries.push({
|
||||
id: crypto.randomUUID(),
|
||||
spaceId: input.persona.id,
|
||||
startAt: dayStart.toISOString(),
|
||||
endAt: new Date(dayStart.getTime() + DAY_MS).toISOString(),
|
||||
activity,
|
||||
title: picked.title,
|
||||
description: `Daily outline for ${input.persona.displayName}.`,
|
||||
granularity: 'day',
|
||||
sourceMessage: input.message,
|
||||
metadata: {
|
||||
boxbrainType: 'schedule-entry',
|
||||
availabilityMode: picked.mode,
|
||||
targetDate: dayStart.toISOString().slice(0, 10),
|
||||
},
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function availabilityModeForEntry(entry: ScheduleEntry): AvailabilityMode {
|
||||
const mode = entry.metadata['availabilityMode'];
|
||||
if (mode === 'online' || mode === 'do-not-disturb' || mode === 'offline') return mode;
|
||||
if (entry.activity === 'sleep') return 'offline';
|
||||
if (entry.activity === 'work' || entry.activity === 'study' || entry.activity === 'job-search' || entry.activity === 'travel' || entry.activity === 'commute') {
|
||||
return 'do-not-disturb';
|
||||
}
|
||||
return 'online';
|
||||
}
|
||||
|
||||
export function buildAvailabilitySnapshot(input: {
|
||||
now: DateTimeInput;
|
||||
generatedAt?: DateTimeInput;
|
||||
entries: ScheduleEntry[];
|
||||
}): ScheduledAvailabilitySnapshot {
|
||||
const windowStart = startOfUtcDay(input.now);
|
||||
const windowEnd = new Date(windowStart.getTime() + 2 * DAY_MS);
|
||||
const sorted = input.entries
|
||||
.filter((entry) => entry.startAt < windowEnd.toISOString() && entry.endAt > windowStart.toISOString())
|
||||
.sort((a, b) => a.startAt.localeCompare(b.startAt));
|
||||
|
||||
const ranges: AvailabilityRange[] = [];
|
||||
for (const entry of sorted) {
|
||||
const mode = availabilityModeForEntry(entry);
|
||||
const previous = ranges.at(-1);
|
||||
if (previous && previous.mode === mode && previous.endAt === entry.startAt) {
|
||||
previous.endAt = entry.endAt;
|
||||
previous.sourceScheduleIds.push(entry.id);
|
||||
continue;
|
||||
}
|
||||
ranges.push({
|
||||
startAt: entry.startAt,
|
||||
endAt: entry.endAt,
|
||||
mode,
|
||||
sourceScheduleIds: [entry.id],
|
||||
reason: entry.title,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
generatedAt: toIso(input.generatedAt ?? input.now),
|
||||
windowStartAt: windowStart.toISOString(),
|
||||
windowEndAt: windowEnd.toISOString(),
|
||||
ranges,
|
||||
};
|
||||
}
|
||||
|
||||
export function dateKeysAround(input: DateTimeInput): string[] {
|
||||
const today = startOfUtcDay(input);
|
||||
return [-1, 0, 1].map((offset) => new Date(today.getTime() + offset * DAY_MS).toISOString().slice(0, 10));
|
||||
}
|
||||
174
src/types.ts
Normal file
174
src/types.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
export type DateTimeInput = Date | string | number;
|
||||
|
||||
export type PersonaConstructorMode = 'create' | 'load';
|
||||
|
||||
export type ScheduleGranularity = 'day' | 'ten-minute';
|
||||
|
||||
export type ScheduleActivity =
|
||||
| 'sleep'
|
||||
| 'rest'
|
||||
| 'meal'
|
||||
| 'commute'
|
||||
| 'work'
|
||||
| 'study'
|
||||
| 'job-search'
|
||||
| 'travel'
|
||||
| 'exercise'
|
||||
| 'social'
|
||||
| 'errand'
|
||||
| 'free-time';
|
||||
|
||||
export type AvailabilityMode = 'online' | 'do-not-disturb' | 'offline';
|
||||
|
||||
export interface MemorySpace {
|
||||
id: string;
|
||||
displayName: string;
|
||||
createdAt: string;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface FactDraft {
|
||||
statement: string;
|
||||
topics: string[];
|
||||
confidence?: number;
|
||||
source?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface StoredFact extends FactDraft {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ScheduleEntry {
|
||||
id: string;
|
||||
spaceId: string;
|
||||
startAt: string;
|
||||
endAt: string;
|
||||
activity: ScheduleActivity;
|
||||
title: string;
|
||||
description?: string;
|
||||
granularity: ScheduleGranularity;
|
||||
sourceMessage?: string;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AvailabilityRange {
|
||||
startAt: string;
|
||||
endAt: string;
|
||||
mode: AvailabilityMode;
|
||||
sourceScheduleIds: string[];
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface ScheduledAvailabilitySnapshot {
|
||||
generatedAt: string;
|
||||
windowStartAt: string;
|
||||
windowEndAt: string;
|
||||
ranges: AvailabilityRange[];
|
||||
}
|
||||
|
||||
export interface PersonaMessage {
|
||||
sender: 'persona' | 'user';
|
||||
time: DateTimeInput;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface DebugEvent {
|
||||
name: string;
|
||||
time: string;
|
||||
spaceId?: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type DebugHook = (event: DebugEvent) => void | Promise<void>;
|
||||
|
||||
export interface MandatoryConversationContext {
|
||||
formattedMessageHistory: string;
|
||||
conversationWindowLabel: string;
|
||||
memorySummary: string;
|
||||
personaAndUserFacts: StoredFact[];
|
||||
scheduleEntries: ScheduleEntry[];
|
||||
availability: ScheduledAvailabilitySnapshot;
|
||||
}
|
||||
|
||||
export interface ReplyGenerationInput {
|
||||
persona: MemorySpace;
|
||||
now: string;
|
||||
mode: 'reply' | 'start-conversation';
|
||||
context: MandatoryConversationContext;
|
||||
userMessage?: string;
|
||||
instruction: string;
|
||||
}
|
||||
|
||||
export interface OutgoingMessageDraft {
|
||||
messages: string[];
|
||||
reasoning?: string;
|
||||
}
|
||||
|
||||
export interface RewriteDecisionInput {
|
||||
persona: MemorySpace;
|
||||
now: string;
|
||||
previousHistory: PersonaMessage[];
|
||||
latestHistory: PersonaMessage[];
|
||||
draft: OutgoingMessageDraft;
|
||||
context: MandatoryConversationContext;
|
||||
}
|
||||
|
||||
export interface RewriteDecision {
|
||||
rewrite: boolean;
|
||||
draft?: OutgoingMessageDraft;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface ConversationModel {
|
||||
generateReply(input: ReplyGenerationInput): Promise<OutgoingMessageDraft>;
|
||||
}
|
||||
|
||||
export interface RewriteModel {
|
||||
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 PersonaInitializationModel {
|
||||
extractInitialFacts(input: {
|
||||
displayName: string;
|
||||
seedMessage: string;
|
||||
now: string;
|
||||
}): Promise<FactDraft[]>;
|
||||
}
|
||||
|
||||
export interface PersonaModels {
|
||||
initialization?: PersonaInitializationModel;
|
||||
conversation?: ConversationModel;
|
||||
rewrite?: RewriteModel;
|
||||
memoryExtraction?: MemoryExtractionModel;
|
||||
}
|
||||
|
||||
export interface PersonaOptions {
|
||||
memory?: BoxBrainMemoryStore;
|
||||
models?: PersonaModels;
|
||||
debug?: DebugHook;
|
||||
now?: DateTimeInput;
|
||||
}
|
||||
|
||||
export interface BoxBrainMemoryStore {
|
||||
createSpace(input: { displayName: string; seedMessage: string; now: string }): Promise<MemorySpace>;
|
||||
getSpace(spaceId: string): Promise<MemorySpace | null>;
|
||||
addFact(spaceId: string, fact: FactDraft): Promise<StoredFact>;
|
||||
findFacts(spaceId: string, topics: string[]): Promise<StoredFact[]>;
|
||||
saveScheduleEntries(spaceId: string, entries: ScheduleEntry[]): Promise<void>;
|
||||
listScheduleEntries(spaceId: string, fromInclusive: string, toExclusive: string): Promise<ScheduleEntry[]>;
|
||||
deleteScheduleEntriesBefore(spaceId: string, cutoffExclusive: string): Promise<number>;
|
||||
}
|
||||
Reference in New Issue
Block a user