feat: add spaceId getter
Some checks failed
CI / verify (push) Successful in 11s
Publish / publish (push) Failing after 18s

This commit is contained in:
2026-05-17 23:06:23 +09:00
parent 8bd6926a95
commit 239d63dff7
2 changed files with 234 additions and 105 deletions

View File

@@ -5,36 +5,39 @@ import type {
MemorySpace,
PersonaMessage,
ScheduledAvailabilitySnapshot,
} from './types';
import { addUtcDays, buildAvailabilitySnapshot, dateKeysAround, startOfUtcDay, toIso } from './schedule';
} from "./types";
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
.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}`;
})
.join('\n');
.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.',
"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');
].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.',
"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');
"Prefer facts about the persona, the user, their relationship, preferences, history, schedule-relevant events, and stable traits.",
].join("\n");
}
export async function buildMandatoryConversationContext(input: {
@@ -47,15 +50,27 @@ export async function buildMandatoryConversationContext(input: {
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');
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(', ')}.`,
formattedMessageHistory: formatMessageHistory({
personaName: input.persona.displayName,
messages: input.messages,
}),
conversationWindowLabel: `Required conversation window: yesterday/today. Schedule dates: ${dateKeysAround(input.now).join(", ")}.`,
memorySummary,
personaAndUserFacts,
scheduleEntries,

View File

@@ -1,4 +1,4 @@
import { InMemoryMemoryStore } from './memory';
import { InMemoryMemoryStore } from "./memory";
import {
addUtcDays,
blocksToDailySchedule,
@@ -9,13 +9,13 @@ import {
scheduleTargetDay,
startOfUtcDay,
toIso,
} from './schedule';
} from "./schedule";
import {
buildMandatoryConversationContext,
conversationInstruction,
formatMessageHistory,
memoryExtractionInstruction,
} from './conversation';
} from "./conversation";
import type {
BoxBrainMemoryStore,
DateTimeInput,
@@ -27,28 +27,31 @@ import type {
PersonaOptions,
ScheduleEntry,
ScheduledAvailabilitySnapshot,
} from './types';
} from "./types";
interface CreateMode {
type: 'create';
type: "create";
displayName: string;
seedMessage: string;
}
interface LoadMode {
type: 'load';
type: "load";
spaceId: string;
}
type Mode = CreateMode | LoadMode;
function defaultInitialFact(displayName: string, seedMessage: string): FactDraft {
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',
topics: ["persona", displayName],
source: "boxbrain.persona.initialization",
confidence: 1,
metadata: { boxbrainType: 'persona-initial-fact' },
metadata: { boxbrainType: "persona-initial-fact" },
};
}
@@ -66,14 +69,22 @@ export class Persona {
private readonly readyPromise: Promise<MemorySpace>;
private availabilitySnapshot?: ScheduledAvailabilitySnapshot;
constructor(displayName: string, seedMessage: string, options?: PersonaOptions);
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 };
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.mode = { type: "load", spaceId: first };
this.options = second ?? {};
}
this.memory = this.options.memory ?? new InMemoryMemoryStore();
@@ -84,10 +95,17 @@ export class Persona {
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();
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 blocks = await this.options.models.schedule.generateDailySchedule({
@@ -96,17 +114,31 @@ export class Persona {
message,
instruction: scheduleInstruction(),
});
const entries = blocksToDailySchedule({ persona, targetDay, message, blocks });
await this.emit('persona.schedule.daily.generated', { targetDay: targetDay.toISOString(), count: entries.length, message });
const entries = blocksToDailySchedule({
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.refreshAvailability(datetime);
return entries;
}
async createMonthlySchedule(datetime: DateTimeInput, message: string): Promise<ScheduleEntry[]> {
async createMonthlySchedule(
datetime: DateTimeInput,
message: string,
): Promise<ScheduleEntry[]> {
const persona = await this.ready();
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 days = daysInMonth(fromDay);
@@ -117,8 +149,16 @@ export class Persona {
days,
instruction: scheduleInstruction(),
});
const entries = blocksToMonthlySchedule({ persona, fromDay, message, blocks });
await this.emit('persona.schedule.monthly.generated', { count: entries.length, message });
const entries = blocksToMonthlySchedule({
persona,
fromDay,
message,
blocks,
});
await this.emit("persona.schedule.monthly.generated", {
count: entries.length,
message,
});
await this.memory.saveScheduleEntries(persona.id, entries);
await this.refreshAvailability(datetime);
return entries;
@@ -127,14 +167,24 @@ export class Persona {
async deleteSchedulesBefore(cutoffExclusive: DateTimeInput): Promise<number> {
const persona = await this.ready();
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, {
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 },
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,
});
await this.emit('persona.schedule.deleted', { cutoffExclusive: cutoff, deleted });
return deleted;
}
@@ -142,12 +192,15 @@ export class Persona {
return this.deleteSchedulesBefore(datetime);
}
async getTodayScheduledAvailability(datetime: DateTimeInput): Promise<ScheduledAvailabilitySnapshot> {
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.');
if (!snapshot)
throw new Error("Availability snapshot was not initialized.");
const today = startOfUtcDay(datetime).toISOString();
if (snapshot.windowStartAt !== today) {
@@ -155,7 +208,8 @@ export class Persona {
}
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;
}
@@ -166,9 +220,11 @@ export class Persona {
}): Promise<OutgoingMessageDraft> {
const persona = await this.ready();
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({
persona,
now: input.datetime,
@@ -176,20 +232,24 @@ export class Persona {
messages: input.messageHistory,
availability,
});
await this.emit('persona.conversation.context.loaded', {
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(),
}));
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();
@@ -209,20 +269,28 @@ export class Persona {
draft,
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) {
draft = ensureDraft(decision.draft ?? await this.options.models.conversation.generateReply({
persona,
now: toIso(input.datetime),
mode: 'reply',
context: latestContext,
instruction: conversationInstruction(),
}));
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 });
await this.emit("persona.conversation.reply.generated", {
messageCount: draft.messages.length,
});
return draft;
}
@@ -232,9 +300,13 @@ export class Persona {
}): Promise<OutgoingMessageDraft> {
const persona = await this.ready();
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({
persona,
now: input.datetime,
@@ -242,14 +314,18 @@ export class Persona {
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 });
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;
}
@@ -259,64 +335,102 @@ export class Persona {
}): Promise<FactDraft[]> {
const persona = await this.ready();
if (!this.options.models?.memoryExtraction) {
throw new Error('sleepMemory requires options.models.memoryExtraction.');
throw new Error("sleepMemory requires options.models.memoryExtraction.");
}
const contextFacts = await this.memory.findFacts(persona.id, ['persona', persona.displayName, 'user']);
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 }),
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',
topics: [...draft.topics, "sleepMemory"],
source: draft.source ?? "boxbrain.sleepMemory",
});
}
await this.emit('persona.memory.sleep.persisted', { factCount: drafts.length });
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') {
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 });
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 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,
})
displayName: this.mode.displayName,
seedMessage: this.mode.seedMessage,
now,
})
: undefined;
const facts = modelFacts ?? [defaultInitialFact(this.mode.displayName, this.mode.seedMessage)];
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.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();
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);
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> {
private async emit(
name: string,
data?: Record<string, unknown>,
explicitSpaceId?: string,
): Promise<void> {
if (!this.options.debug) return;
const event: DebugEvent = {
name,