7 Commits

Author SHA1 Message Date
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
8bd6926a95 v0.3.0
All checks were successful
CI / verify (push) Successful in 10s
Publish / publish (push) Successful in 18s
2026-05-17 15:43:50 +09:00
bedbd01807 ci: add publish workflow 2026-05-17 15:42:48 +09:00
90214cec5c feat: LLM-based schedule generation 2026-05-17 15:40:13 +09:00
864f118a9b v0.2.0
All checks were successful
CI / verify (push) Successful in 11s
2026-05-16 22:36:53 +09:00
5d489bc875 ci: package publish ready 2026-05-16 22:36:46 +09:00
11 changed files with 794 additions and 225 deletions

View File

@@ -0,0 +1,29 @@
name: Publish
on:
push:
tags:
- 'v*'
jobs:
publish:
runs-on: ubuntu-latest
container:
image: oven/bun:1
steps:
- name: Checkout
uses: https://gitea.com/actions/checkout@v6.0.2
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Test
run: bun run test
- name: Typecheck
run: bun run check
- name: Build
run: bun run build
- name: Publish to npm
run: |
echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc
bun publish --access public
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

27
.npmignore Normal file
View File

@@ -0,0 +1,27 @@
# Dependency and build cache directories
node_modules/
.bun/
# Source, tests, and local development files
src/
tests/
scripts/
coverage/
tsconfig.json
bun.lock
# Repository and CI metadata
.git/
.gitea/
.gitignore
# Local runtime data and secrets
.data/
.hermes/
.env
.env.*
!.env.example
*.log
# Package artifacts
*.tgz

View File

@@ -1,21 +1,51 @@
{
"name": "boxbrain",
"version": "0.1.0",
"version": "0.3.1",
"description": "Human-like persona harness framework powered by LLMs and IdentityDB.",
"license": "MIT",
"type": "module",
"main": "dist/index.js",
"module": "dist/index.js",
"types": "src/index.ts",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
},
"./package.json": "./package.json"
},
"files": [
"dist",
"src",
"README.md"
],
"sideEffects": false,
"keywords": [
"llm",
"persona",
"memory",
"identitydb",
"agent"
],
"repository": {
"type": "git",
"url": "git+https://git.psw.kr/p-sw/BoxBrain.git"
},
"bugs": {
"url": "https://git.psw.kr/p-sw/BoxBrain/issues"
},
"homepage": "https://git.psw.kr/p-sw/BoxBrain",
"publishConfig": {
"access": "public"
},
"scripts": {
"test": "vitest run",
"check": "tsc --noEmit",
"build": "tsup src/index.ts --format esm --dts --sourcemap --clean",
"prepare": "bun run build"
"clean": "rm -rf dist",
"pack:check": "bun scripts/check-package.mjs",
"prepack": "bun run build && bun run pack:check",
"prepublishOnly": "bun run check && bun run test && bun run build && bun run pack:check"
},
"dependencies": {
"identitydb": "0.2.1"

41
scripts/check-package.mjs Normal file
View File

@@ -0,0 +1,41 @@
import { existsSync, readFileSync } from "node:fs";
const requiredFiles = [
"README.md",
"dist/index.js",
"dist/index.d.ts",
];
const missingFiles = requiredFiles.filter((path) => !existsSync(path));
if (missingFiles.length > 0) {
console.error(`Missing package artifact(s): ${missingFiles.join(", ")}`);
process.exit(1);
}
const packageJson = JSON.parse(readFileSync("package.json", "utf8"));
const expectedFields = {
main: "dist/index.js",
module: "dist/index.js",
types: "dist/index.d.ts",
};
for (const [field, expected] of Object.entries(expectedFields)) {
if (packageJson[field] !== expected) {
console.error(`package.json ${field} must be ${expected}`);
process.exit(1);
}
}
if (packageJson.exports?.["."]?.types !== "./dist/index.d.ts") {
console.error("package.json exports[\".\"].types must point to ./dist/index.d.ts");
process.exit(1);
}
if (packageJson.exports?.["."]?.import !== "./dist/index.js") {
console.error("package.json exports[\".\"].import must point to ./dist/index.js");
process.exit(1);
}
console.log("Package metadata and artifacts are publish-ready.");

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,19 +1,21 @@
import { InMemoryMemoryStore } from './memory';
import { InMemoryMemoryStore } from "./memory";
import {
addUtcDays,
blocksToDailySchedule,
blocksToMonthlySchedule,
buildAvailabilitySnapshot,
createMonthlyScheduleEntries,
createTenMinuteDailySchedule,
daysInMonth,
scheduleInstruction,
scheduleTargetDay,
startOfUtcDay,
toIso,
} from './schedule';
} from "./schedule";
import {
buildMandatoryConversationContext,
conversationInstruction,
formatMessageHistory,
memoryExtractionInstruction,
} from './conversation';
} from "./conversation";
import type {
BoxBrainMemoryStore,
DateTimeInput,
@@ -25,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" },
};
}
@@ -64,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();
@@ -82,20 +95,70 @@ 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.");
}
const targetDay = scheduleTargetDay(datetime);
const entries = createTenMinuteDailySchedule({ persona, targetDay, message });
await this.emit('persona.schedule.daily.generated', { targetDay: targetDay.toISOString(), count: entries.length, message });
const blocks = await this.options.models.schedule.generateDailySchedule({
persona,
targetDay,
message,
instruction: scheduleInstruction(),
});
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();
const entries = createMonthlyScheduleEntries({ persona, fromDay: datetime, message });
await this.emit('persona.schedule.monthly.generated', { count: entries.length, message });
if (!this.options.models?.schedule) {
throw new Error(
"createMonthlySchedule requires options.models.schedule.",
);
}
const fromDay = scheduleTargetDay(datetime);
const days = daysInMonth(fromDay);
const blocks = await this.options.models.schedule.generateMonthlySchedule({
persona,
fromDay,
message,
days,
instruction: scheduleInstruction(),
});
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;
@@ -104,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;
}
@@ -119,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) {
@@ -132,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;
}
@@ -143,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,
@@ -153,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();
@@ -186,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;
}
@@ -209,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,
@@ -219,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;
}
@@ -236,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,

View File

@@ -3,12 +3,11 @@ import type {
AvailabilityRange,
DateTimeInput,
MemorySpace,
ScheduleActivity,
ScheduleBlock,
ScheduleEntry,
ScheduledAvailabilitySnapshot,
} from './types';
const TEN_MINUTES_MS = 10 * 60 * 1000;
const DAY_MS = 24 * 60 * 60 * 1000;
export function toDate(input: DateTimeInput): Date {
@@ -36,135 +35,87 @@ 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' };
}
export function daysInMonth(date: Date): number {
const year = date.getUTCFullYear();
const month = date.getUTCMonth();
return new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
}
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';
export function scheduleInstruction(): string {
return [
'Generate realistic schedule blocks for the persona based on their profile and the provided message.',
'Activity types should be creative and context-appropriate; do not limit yourself to a fixed list.',
'For each block, choose an availability mode: online (available to chat), do-not-disturb (busy but reachable for urgent matters), or offline (completely unavailable).',
'Return blocks covering the requested time range with startTime and endTime in HH:MM 24-hour format.',
].join('\n');
}
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: {
export function blocksToDailySchedule(input: {
persona: MemorySpace;
targetDay: DateTimeInput;
message: string;
blocks: ScheduleBlock[];
}): 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({
return input.blocks.map((block) => {
const [startHour, startMinute] = block.startTime.split(':').map(Number) as [number, number];
const [endHour, endMinute] = block.endTime.split(':').map(Number) as [number, number];
const start = new Date(target.getTime() + ((startHour * 60 + startMinute) * 60 * 1000));
const end = new Date(target.getTime() + ((endHour * 60 + endMinute) * 60 * 1000));
return {
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}.`,
activity: block.activity,
title: block.title,
description: block.description ?? `Realistic ${block.title.toLowerCase()} block for ${input.persona.displayName}.`,
granularity: 'ten-minute',
sourceMessage: input.message,
metadata: {
boxbrainType: 'schedule-entry',
availabilityMode: picked.mode,
availabilityMode: block.availabilityMode,
targetDate: target.toISOString().slice(0, 10),
},
});
}
return entries;
};
});
}
export function createMonthlyScheduleEntries(input: {
export function blocksToMonthlySchedule(input: {
persona: MemorySpace;
fromDay: DateTimeInput;
message: string;
days?: number;
blocks: ScheduleBlock[];
}): ScheduleEntry[] {
const start = scheduleTargetDay(input.fromDay);
const count = input.days ?? 30;
const entries: ScheduleEntry[] = [];
for (let day = 0; day < count; day += 1) {
const start = startOfUtcDay(input.fromDay);
return input.blocks.map((block, day) => {
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({
const [startHour, startMinute] = block.startTime.split(':').map(Number) as [number, number];
const [endHour, endMinute] = block.endTime.split(':').map(Number) as [number, number];
const entryStart = new Date(dayStart.getTime() + ((startHour * 60 + startMinute) * 60 * 1000));
const entryEnd = new Date(dayStart.getTime() + ((endHour * 60 + endMinute) * 60 * 1000));
return {
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}.`,
startAt: entryStart.toISOString(),
endAt: entryEnd.toISOString(),
activity: block.activity,
title: block.title,
description: block.description ?? `Daily outline for ${input.persona.displayName}.`,
granularity: 'day',
sourceMessage: input.message,
metadata: {
boxbrainType: 'schedule-entry',
availabilityMode: picked.mode,
availabilityMode: block.availabilityMode,
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';
}

View File

@@ -4,19 +4,7 @@ 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 ScheduleActivity = string;
export type AvailabilityMode = 'online' | 'do-not-disturb' | 'offline';
@@ -141,6 +129,35 @@ export interface MemoryExtractionModel {
extract(input: MemoryExtractionInput): Promise<FactDraft[]>;
}
export interface ScheduleBlock {
startTime: string;
endTime: string;
activity: string;
title: string;
description?: string;
availabilityMode: AvailabilityMode;
}
export interface DailyScheduleGenerationInput {
persona: MemorySpace;
targetDay: Date;
message: string;
instruction: string;
}
export interface MonthlyScheduleGenerationInput {
persona: MemorySpace;
fromDay: Date;
message: string;
days: number;
instruction: string;
}
export interface ScheduleModel {
generateDailySchedule(input: DailyScheduleGenerationInput): Promise<ScheduleBlock[]>;
generateMonthlySchedule(input: MonthlyScheduleGenerationInput): Promise<ScheduleBlock[]>;
}
export interface PersonaInitializationModel {
extractInitialFacts(input: {
displayName: string;
@@ -154,6 +171,7 @@ export interface PersonaModels {
conversation?: ConversationModel;
rewrite?: RewriteModel;
memoryExtraction?: MemoryExtractionModel;
schedule?: ScheduleModel;
}
export interface PersonaOptions {

View File

@@ -15,6 +15,16 @@ describe('Conversation API', () => {
return { messages: ['카페에 있었어.', '너는 뭐해?'] };
},
},
schedule: {
async generateDailySchedule() {
return [
{ startTime: '09:00', endTime: '18:00', activity: 'work', title: 'Work', availabilityMode: 'do-not-disturb' as const },
];
},
async generateMonthlySchedule() {
return [];
},
},
},
});
const space = await persona.ready();

View File

@@ -0,0 +1,248 @@
import { describe, expect, it } from 'vitest';
import {
addUtcDays,
availabilityModeForEntry,
blocksToDailySchedule,
blocksToMonthlySchedule,
buildAvailabilitySnapshot,
dateKeysAround,
daysInMonth,
scheduleTargetDay,
startOfUtcDay,
toDate,
toIso,
} from '../src';
describe('toDate', () => {
it('accepts a Date instance', () => {
const original = new Date('2026-05-01T12:34:56.789Z');
const result = toDate(original);
expect(result.toISOString()).toBe('2026-05-01T12:34:56.789Z');
});
it('accepts an ISO string', () => {
const result = toDate('2026-05-01T12:00:00.000Z');
expect(result.toISOString()).toBe('2026-05-01T12:00:00.000Z');
});
it('accepts a timestamp number', () => {
const ts = new Date('2026-05-01T00:00:00.000Z').getTime();
const result = toDate(ts);
expect(result.toISOString()).toBe('2026-05-01T00:00:00.000Z');
});
it('throws on invalid input', () => {
expect(() => toDate('not-a-date')).toThrow('Invalid datetime: not-a-date');
expect(() => toDate(NaN)).toThrow('Invalid datetime: NaN');
});
});
describe('toIso', () => {
it('converts any DateTimeInput to an ISO string', () => {
expect(toIso(new Date('2026-05-01T12:00:00.000Z'))).toBe('2026-05-01T12:00:00.000Z');
expect(toIso('2026-05-01T12:00:00.000Z')).toBe('2026-05-01T12:00:00.000Z');
});
});
describe('startOfUtcDay', () => {
it('strips time to 00:00:00 UTC', () => {
const result = startOfUtcDay('2026-05-01T15:30:45.000Z');
expect(result.toISOString()).toBe('2026-05-01T00:00:00.000Z');
});
it('works across month boundaries', () => {
const result = startOfUtcDay('2026-03-31T23:59:59.999Z');
expect(result.toISOString()).toBe('2026-03-31T00:00:00.000Z');
});
});
describe('addUtcDays', () => {
it('adds days', () => {
const result = addUtcDays('2026-05-01T12:00:00.000Z', 5);
expect(result.toISOString()).toBe('2026-05-06T00:00:00.000Z');
});
it('subtracts days', () => {
const result = addUtcDays('2026-05-01T12:00:00.000Z', -1);
expect(result.toISOString()).toBe('2026-04-30T00:00:00.000Z');
});
});
describe('scheduleTargetDay', () => {
it('returns tomorrow at UTC midnight', () => {
const result = scheduleTargetDay('2026-05-01T10:00:00.000Z');
expect(result.toISOString()).toBe('2026-05-02T00:00:00.000Z');
});
});
describe('daysInMonth', () => {
it('returns 31 for January', () => {
expect(daysInMonth(new Date('2026-01-15T00:00:00.000Z'))).toBe(31);
});
it('returns 28 for February in a non-leap year', () => {
expect(daysInMonth(new Date('2026-02-15T00:00:00.000Z'))).toBe(28);
});
it('returns 29 for February in a leap year', () => {
expect(daysInMonth(new Date('2024-02-15T00:00:00.000Z'))).toBe(29);
});
it('returns 30 for April', () => {
expect(daysInMonth(new Date('2026-04-15T00:00:00.000Z'))).toBe(30);
});
it('returns 31 for December', () => {
expect(daysInMonth(new Date('2026-12-15T00:00:00.000Z'))).toBe(31);
});
});
describe('blocksToDailySchedule', () => {
it('converts schedule blocks to entries for the target day', () => {
const persona = { id: 'space-1', displayName: 'Mina', createdAt: '2026-05-01T00:00:00.000Z', metadata: {} };
const blocks = [
{ startTime: '00:00', endTime: '07:00', activity: 'sleep', title: 'Sleep', availabilityMode: 'offline' as const },
{ startTime: '07:00', endTime: '09:00', activity: 'morning routine', title: 'Morning routine', availabilityMode: 'online' as const },
];
const entries = blocksToDailySchedule({ persona, targetDay: '2026-05-02T10:00:00.000Z', message: 'msg', blocks });
expect(entries).toHaveLength(2);
expect(entries[0]!.startAt).toBe('2026-05-02T00:00:00.000Z');
expect(entries[0]!.endAt).toBe('2026-05-02T07:00:00.000Z');
expect(entries[0]!.activity).toBe('sleep');
expect(entries[0]!.granularity).toBe('ten-minute');
expect(entries[0]!.metadata['availabilityMode']).toBe('offline');
expect(entries[1]!.startAt).toBe('2026-05-02T07:00:00.000Z');
expect(entries[1]!.endAt).toBe('2026-05-02T09:00:00.000Z');
});
it('uses block description when provided', () => {
const persona = { id: 'space-1', displayName: 'Mina', createdAt: '2026-05-01T00:00:00.000Z', metadata: {} };
const blocks = [
{ startTime: '09:00', endTime: '18:00', activity: 'work', title: 'Work', description: 'Custom desc', availabilityMode: 'do-not-disturb' as const },
];
const entries = blocksToDailySchedule({ persona, targetDay: '2026-05-02T00:00:00.000Z', message: 'msg', blocks });
expect(entries[0]!.description).toBe('Custom desc');
});
it('supports 24:00 as end time to reach next midnight', () => {
const persona = { id: 'space-1', displayName: 'Mina', createdAt: '2026-05-01T00:00:00.000Z', metadata: {} };
const blocks = [
{ startTime: '18:00', endTime: '24:00', activity: 'rest', title: 'Rest', availabilityMode: 'online' as const },
];
const entries = blocksToDailySchedule({ persona, targetDay: '2026-05-02T00:00:00.000Z', message: 'msg', blocks });
expect(entries[0]!.endAt).toBe('2026-05-03T00:00:00.000Z');
});
});
describe('blocksToMonthlySchedule', () => {
it('maps each block to consecutive days starting from fromDay', () => {
const persona = { id: 'space-1', displayName: 'Mina', createdAt: '2026-05-01T00:00:00.000Z', metadata: {} };
const blocks = [
{ startTime: '00:00', endTime: '24:00', activity: 'work', title: 'Work', availabilityMode: 'do-not-disturb' as const },
{ startTime: '00:00', endTime: '24:00', activity: 'rest', title: 'Rest', availabilityMode: 'online' as const },
];
const entries = blocksToMonthlySchedule({ persona, fromDay: '2026-05-02T00:00:00.000Z', message: 'msg', blocks });
expect(entries).toHaveLength(2);
expect(entries[0]!.startAt).toBe('2026-05-02T00:00:00.000Z');
expect(entries[0]!.endAt).toBe('2026-05-03T00:00:00.000Z');
expect(entries[1]!.startAt).toBe('2026-05-03T00:00:00.000Z');
expect(entries[1]!.endAt).toBe('2026-05-04T00:00:00.000Z');
expect(entries[0]!.granularity).toBe('day');
});
});
describe('availabilityModeForEntry', () => {
it('reads availabilityMode from metadata', () => {
expect(
availabilityModeForEntry({
id: '1', spaceId: 's', startAt: '', endAt: '', activity: 'anything', title: '', granularity: 'day', metadata: { availabilityMode: 'offline' },
}),
).toBe('offline');
expect(
availabilityModeForEntry({
id: '1', spaceId: 's', startAt: '', endAt: '', activity: 'anything', title: '', granularity: 'day', metadata: { availabilityMode: 'do-not-disturb' },
}),
).toBe('do-not-disturb');
expect(
availabilityModeForEntry({
id: '1', spaceId: 's', startAt: '', endAt: '', activity: 'anything', title: '', granularity: 'day', metadata: { availabilityMode: 'online' },
}),
).toBe('online');
});
it('falls back to online when metadata is missing or invalid', () => {
expect(
availabilityModeForEntry({
id: '1', spaceId: 's', startAt: '', endAt: '', activity: 'work', title: '', granularity: 'day', metadata: {},
}),
).toBe('online');
expect(
availabilityModeForEntry({
id: '1', spaceId: 's', startAt: '', endAt: '', activity: 'sleep', title: '', granularity: 'day', metadata: { availabilityMode: 'invalid' },
}),
).toBe('online');
});
});
describe('buildAvailabilitySnapshot', () => {
it('filters, sorts, and merges contiguous ranges with the same mode', () => {
const entries = [
{
id: 'e1', spaceId: 's', startAt: '2026-05-01T00:00:00.000Z', endAt: '2026-05-01T06:00:00.000Z',
activity: 'sleep', title: 'Sleep', granularity: 'ten-minute' as const, metadata: { availabilityMode: 'offline' },
},
{
id: 'e2', spaceId: 's', startAt: '2026-05-01T06:00:00.000Z', endAt: '2026-05-01T12:00:00.000Z',
activity: 'work', title: 'Work', granularity: 'ten-minute' as const, metadata: { availabilityMode: 'do-not-disturb' },
},
{
id: 'e3', spaceId: 's', startAt: '2026-05-01T12:00:00.000Z', endAt: '2026-05-01T18:00:00.000Z',
activity: 'work', title: 'Work', granularity: 'ten-minute' as const, metadata: { availabilityMode: 'do-not-disturb' },
},
{
id: 'e4', spaceId: 's', startAt: '2026-05-01T18:00:00.000Z', endAt: '2026-05-02T00:00:00.000Z',
activity: 'rest', title: 'Rest', granularity: 'ten-minute' as const, metadata: { availabilityMode: 'online' },
},
];
const snapshot = buildAvailabilitySnapshot({ now: '2026-05-01T12:00:00.000Z', entries });
expect(snapshot.windowStartAt).toBe('2026-05-01T00:00:00.000Z');
expect(snapshot.windowEndAt).toBe('2026-05-03T00:00:00.000Z');
expect(snapshot.ranges).toHaveLength(3);
expect(snapshot.ranges[0]).toMatchObject({ startAt: '2026-05-01T00:00:00.000Z', endAt: '2026-05-01T06:00:00.000Z', mode: 'offline' });
expect(snapshot.ranges[1]).toMatchObject({ startAt: '2026-05-01T06:00:00.000Z', endAt: '2026-05-01T18:00:00.000Z', mode: 'do-not-disturb' });
expect(snapshot.ranges[2]).toMatchObject({ startAt: '2026-05-01T18:00:00.000Z', endAt: '2026-05-02T00:00:00.000Z', mode: 'online' });
});
it('excludes entries outside the 2-day window', () => {
const entries = [
{
id: 'e1', spaceId: 's', startAt: '2026-04-30T00:00:00.000Z', endAt: '2026-04-30T23:59:59.000Z',
activity: 'sleep', title: 'Sleep', granularity: 'day' as const, metadata: { availabilityMode: 'offline' },
},
{
id: 'e2', spaceId: 's', startAt: '2026-05-01T00:00:00.000Z', endAt: '2026-05-02T00:00:00.000Z',
activity: 'work', title: 'Work', granularity: 'day' as const, metadata: { availabilityMode: 'do-not-disturb' },
},
];
const snapshot = buildAvailabilitySnapshot({ now: '2026-05-01T12:00:00.000Z', entries });
expect(snapshot.ranges).toHaveLength(1);
expect(snapshot.ranges[0]!.startAt).toBe('2026-05-01T00:00:00.000Z');
});
});
describe('dateKeysAround', () => {
it('returns yesterday, today, and tomorrow as YYYY-MM-DD strings', () => {
const keys = dateKeysAround('2026-05-01T12:00:00.000Z');
expect(keys).toEqual(['2026-04-30', '2026-05-01', '2026-05-02']);
});
it('handles month boundaries correctly', () => {
const keys = dateKeysAround('2026-03-01T00:00:00.000Z');
expect(keys).toEqual(['2026-02-28', '2026-03-01', '2026-03-02']);
});
});

View File

@@ -4,25 +4,63 @@ import { InMemoryMemoryStore, Persona } from '../src';
describe('Persona schedules and availability', () => {
it('creates tomorrow as a ten-minute daily schedule and persists it in memory', async () => {
const memory = new InMemoryMemoryStore();
const persona = new Persona('Mina', 'Mina works weekdays and studies at night.', { memory, now: '2026-05-01T10:00:00.000Z' });
const persona = new Persona('Mina', 'Mina works weekdays and studies at night.', {
memory,
now: '2026-05-01T10:00:00.000Z',
models: {
schedule: {
async generateDailySchedule() {
return [
{ startTime: '00:00', endTime: '07:00', activity: 'sleep', title: 'Sleep', availabilityMode: 'offline' as const },
{ startTime: '07:00', endTime: '09:00', activity: 'morning routine', title: 'Morning routine', availabilityMode: 'online' as const },
{ startTime: '09:00', endTime: '18:00', activity: 'deep work', title: 'Deep work', availabilityMode: 'do-not-disturb' as const },
{ startTime: '18:00', endTime: '24:00', activity: 'free time', title: 'Free time', availabilityMode: 'online' as const },
];
},
async generateMonthlySchedule() {
return [];
},
},
},
});
const space = await persona.ready();
const entries = await persona.createDailySchedule('2026-05-01T10:00:00.000Z', 'Keep a normal work day.');
expect(entries).toHaveLength(144);
expect(entries).toHaveLength(4);
expect(entries[0]).toMatchObject({
spaceId: space.id,
startAt: '2026-05-02T00:00:00.000Z',
endAt: '2026-05-02T00:10:00.000Z',
endAt: '2026-05-02T07:00:00.000Z',
granularity: 'ten-minute',
activity: 'sleep',
});
expect(entries.at(-1)?.endAt).toBe('2026-05-03T00:00:00.000Z');
await expect(memory.listScheduleEntries(space.id, '2026-05-02T00:00:00.000Z', '2026-05-03T00:00:00.000Z')).resolves.toHaveLength(144);
await expect(
memory.listScheduleEntries(space.id, '2026-05-02T00:00:00.000Z', '2026-05-03T00:00:00.000Z'),
).resolves.toHaveLength(4);
});
it('derives online, do-not-disturb, and offline availability from the in-memory schedule window', async () => {
const memory = new InMemoryMemoryStore();
const persona = new Persona('Mina', 'Mina works weekdays and studies at night.', { memory, now: '2026-05-01T10:00:00.000Z' });
const persona = new Persona('Mina', 'Mina works weekdays and studies at night.', {
memory,
now: '2026-05-01T10:00:00.000Z',
models: {
schedule: {
async generateDailySchedule() {
return [
{ startTime: '00:00', endTime: '07:00', activity: 'sleep', title: 'Sleep', availabilityMode: 'offline' as const },
{ startTime: '07:00', endTime: '09:00', activity: 'morning routine', title: 'Morning routine', availabilityMode: 'online' as const },
{ startTime: '09:00', endTime: '18:00', activity: 'deep work', title: 'Deep work', availabilityMode: 'do-not-disturb' as const },
];
},
async generateMonthlySchedule() {
return [];
},
},
},
});
await persona.ready();
await persona.createDailySchedule('2026-05-01T10:00:00.000Z', 'Keep a normal work day.');
@@ -30,21 +68,46 @@ describe('Persona schedules and availability', () => {
expect(availability.windowStartAt).toBe('2026-05-01T00:00:00.000Z');
expect(availability.windowEndAt).toBe('2026-05-03T00:00:00.000Z');
expect(new Set(availability.ranges.map((range) => range.mode))).toEqual(new Set(['offline', 'online', 'do-not-disturb']));
expect(availability.ranges.find((range) => range.mode === 'offline')?.startAt).toBe('2026-05-02T00:00:00.000Z');
expect(new Set(availability.ranges.map((range) => range.mode))).toEqual(
new Set(['offline', 'online', 'do-not-disturb']),
);
expect(availability.ranges.find((range) => range.mode === 'offline')?.startAt).toBe(
'2026-05-02T00:00:00.000Z',
);
});
it('prunes schedule entries before a caller-provided cutoff', async () => {
const memory = new InMemoryMemoryStore();
const persona = new Persona('Mina', 'Mina works weekdays.', { memory, now: '2026-05-01T10:00:00.000Z' });
const persona = new Persona('Mina', 'Mina works weekdays.', {
memory,
now: '2026-05-01T10:00:00.000Z',
models: {
schedule: {
async generateDailySchedule() {
return [
{ startTime: '00:00', endTime: '06:00', activity: 'sleep', title: 'Sleep', availabilityMode: 'offline' as const },
{ startTime: '06:00', endTime: '12:00', activity: 'work', title: 'Work', availabilityMode: 'do-not-disturb' as const },
{ startTime: '12:00', endTime: '18:00', activity: 'study', title: 'Study', availabilityMode: 'do-not-disturb' as const },
{ startTime: '18:00', endTime: '24:00', activity: 'rest', title: 'Rest', availabilityMode: 'online' as const },
];
},
async generateMonthlySchedule() {
return [];
},
},
},
});
const space = await persona.ready();
await persona.createDailySchedule('2026-05-01T10:00:00.000Z', 'Keep a normal work day.');
const deleted = await persona.deleteSchedulesBefore('2026-05-02T12:00:00.000Z');
expect(deleted).toBe(72);
await expect(memory.listScheduleEntries(space.id, '2026-05-02T00:00:00.000Z', '2026-05-03T00:00:00.000Z')).resolves.toHaveLength(72);
expect(deleted).toBe(2);
await expect(
memory.listScheduleEntries(space.id, '2026-05-02T00:00:00.000Z', '2026-05-03T00:00:00.000Z'),
).resolves.toHaveLength(2);
const deletionFacts = await memory.findFacts(space.id, ['persona.schedule.deleted']);
expect(deletionFacts[0]?.metadata?.['deleted']).toBe(72);
expect(deletionFacts[0]?.metadata?.['deleted']).toBe(2);
});
});