Compare commits

...

7 Commits

Author SHA1 Message Date
baea23b8b0 refactor: group domain services into folders
All checks were successful
npm release / verify (push) Successful in 14s
npm release / publish to npm (push) Successful in 12s
2026-05-11 19:38:02 +09:00
684b6af5be build: use identitydb 0.2.0 from npm 2026-05-11 19:17:28 +09:00
4bcd80c33d build: fetch identitydb as remote dependency 2026-05-11 19:06:50 +09:00
6eb6024e51 ci: add npm release workflow 2026-05-11 18:42:54 +09:00
202d9316a5 feat: support freeform persona initialization seeds 2026-05-11 17:47:12 +09:00
5be64756ac feat: add xAI Grok adapters 2026-05-11 17:16:48 +09:00
3ee6b233ea feat: add BoxBrain persona runtime APIs 2026-05-11 17:01:19 +09:00
26 changed files with 3010 additions and 169 deletions

View File

@@ -0,0 +1,116 @@
name: npm release
on:
push:
tags:
- 'v*'
- '[0-9]*'
permissions:
contents: read
defaults:
run:
shell: bash
jobs:
verify:
name: verify
runs-on: ubuntu-latest
container:
image: node:20-bookworm
timeout-minutes: 30
steps:
- name: Install release tools
run: |
set -euo pipefail
apt-get update
apt-get install -y git curl ca-certificates
curl -fsSL https://bun.sh/install | bash -s -- bun-v1.3.13
install -m 0755 /root/.bun/bin/bun /usr/local/bin/bun
node --version
npm --version
bun --version
- name: Clone tagged source
run: |
set -euo pipefail
REPO_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
AUTH_HEADER="$(printf '%s' '${{ gitea.actor }}:${{ secrets.GITEA_TOKEN }}' | base64 -w0)"
git -c http.extraHeader="Authorization: Basic $AUTH_HEADER" clone --depth 1 --branch "${{ gitea.ref_name }}" "$REPO_URL" repo
git -C repo rev-parse HEAD
- name: Verify release tag matches package version
working-directory: repo
run: |
set -euo pipefail
TAG_NAME="${{ gitea.ref_name }}"
PACKAGE_VERSION="$(node -p "require('./package.json').version")"
if [ "$TAG_NAME" = "v$PACKAGE_VERSION" ] || [ "$TAG_NAME" = "$PACKAGE_VERSION" ]; then
echo "Release tag $TAG_NAME matches package version $PACKAGE_VERSION"
exit 0
fi
echo "Tag $TAG_NAME does not match package.json version $PACKAGE_VERSION" >&2
exit 1
- name: Run verify pipeline
working-directory: repo
run: |
set -euo pipefail
bun install --frozen-lockfile
bun run test
bun run check
bun run build
release:
name: publish to npm
runs-on: ubuntu-latest
container:
image: node:20-bookworm
timeout-minutes: 30
needs:
- verify
steps:
- name: Install release tools
run: |
set -euo pipefail
apt-get update
apt-get install -y git curl ca-certificates
curl -fsSL https://bun.sh/install | bash -s -- bun-v1.3.13
install -m 0755 /root/.bun/bin/bun /usr/local/bin/bun
node --version
npm --version
bun --version
- name: Clone tagged source
run: |
set -euo pipefail
REPO_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
AUTH_HEADER="$(printf '%s' '${{ gitea.actor }}:${{ secrets.GITEA_TOKEN }}' | base64 -w0)"
git -c http.extraHeader="Authorization: Basic $AUTH_HEADER" clone --depth 1 --branch "${{ gitea.ref_name }}" "$REPO_URL" repo
git -C repo rev-parse HEAD
- name: Install dependencies
working-directory: repo
run: |
set -euo pipefail
bun install --frozen-lockfile
- name: Build package
working-directory: repo
run: |
set -euo pipefail
bun run build
- name: Publish package to npm
working-directory: repo
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
set -euo pipefail
printf '//registry.npmjs.org/:_authToken=%s\n' "$NODE_AUTH_TOKEN" > ~/.npmrc
npm publish

View File

@@ -1,22 +1,29 @@
# BoxBrain
BoxBrain is an IdentityDB-backed TypeScript framework for creating synthetic personas that can behave like human-like DM contacts.
BoxBrain is an IdentityDB-backed TypeScript framework for creating synthetic personas that behave like human-like DM contacts.
The project is framework-first rather than product-first. The current foundation provides:
The project is framework-first rather than product-first. The current core library provides:
- provider-agnostic text, structured-output, and image adapter contracts
- provider-agnostic text, structured-output, image, conversation-memory, and special-date adapter contracts
- ready-made xAI Grok text, structured-output, and image adapters
- one IdentityDB memory space per persona
- persona initialization from personality, history, values, preferences, and relationships
- persona initialization from a long freeform persona seed string, with optional supplemental structured hints
- LLM-generated biography ingestion into IdentityDB fact drafts
- optional profile image generation through an image adapter
- human-like typing and first-reply delay utilities
- schedule generation for day/week/month scopes with optional external special-date context
- schedule persistence, listing, and pruning APIs
- availability state persistence with schedule/manual/tool overrides
- availability snapshots with current + next transition calculation
- DM-style conversation orchestration for inbound replies and proactive openings
- delegated mandatory/contextual memory retrieval pipelines for conversation turns
- human-like first-reply delay and typing delay utilities
- farewell-style refusal flows that can trigger availability-changing tool calls
Planned next APIs include:
Still planned:
- schedule generation and availability state persistence
- inbound DM-style conversation turns with mandatory/contextual memory retrieval
- proactive outbound messages without user input
- HTTP/RPC wrappers around the core library APIs
- ready-made provider adapter packages for additional AI vendors
- production-focused persistence/runtime integrations beyond the in-process core library
## Development
@@ -27,8 +34,29 @@ bun run check
bun run build
```
## Source layout
The library is now grouped by domain under `src/`:
- `src/core/` — shared adapter, type, and IdentityDB helper contracts
- `src/persona/` — persona initialization service
- `src/schedule/` — schedule generation and pruning service
- `src/availability/` — availability state service
- `src/conversation/` — DM turn orchestration service
- `src/memory/` — fact-draft persistence service
- `src/timing/` — typing/reply timing profile helpers
- `src/providers/grok/` — Grok API client and adapter bundle
Each domain now exposes a class-based service API in addition to the existing functional helpers so consumers can organize stateful integrations more cleanly.
## Release
Tagging `vX.Y.Z` or `X.Y.Z` triggers the Gitea npm release workflow under `.gitea/workflows/npm-release.yml`.
BoxBrain now consumes the published `identitydb` package from npm at version `0.2.0`, and `trustedDependencies` keeps Bun lifecycle scripts enabled for `better-sqlite3` and `esbuild` during clean installs.
## Current status
The repository is in foundation development. See the implementation plan:
The repository now contains the framework core for persona initialization, schedule/status management, conversation orchestration, and a ready-made Grok adapter set. See the implementation plan:
- `docs/plans/2026-05-11-boxbrain-foundation.md`

View File

@@ -5,7 +5,7 @@
"": {
"name": "boxbrain",
"dependencies": {
"identitydb": "file:../IdentityDB",
"identitydb": "0.2.0",
},
"devDependencies": {
"@types/node": "^24.0.0",
@@ -15,6 +15,10 @@
},
},
},
"trustedDependencies": [
"esbuild",
"better-sqlite3",
],
"packages": {
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="],
@@ -126,8 +130,6 @@
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.3", "", { "os": "win32", "cpu": "x64" }, "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA=="],
"@types/better-sqlite3": ["@types/better-sqlite3@7.6.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA=="],
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
@@ -136,8 +138,6 @@
"@types/node": ["@types/node@24.12.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-8oljBDGun9cIsZRJR6fkihn0TSXJI0UDOOhncYaERq6M0JMDoPLxyscwruJcb4GKS6dvK/d8xebYBg27h/duaQ=="],
"@types/pg": ["@types/pg@8.20.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow=="],
"@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
"@vitest/mocker": ["@vitest/mocker@3.2.4", "", { "dependencies": { "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ=="],
@@ -228,7 +228,7 @@
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
"identitydb": ["identitydb@file:../IdentityDB", { "dependencies": { "better-sqlite3": "^12.1.1", "kysely": "^0.28.8", "mysql2": "^3.15.3", "pg": "^8.16.0" }, "devDependencies": { "@types/better-sqlite3": "^7.6.13", "@types/node": "^24.0.0", "@types/pg": "^8.20.0", "tsup": "^8.5.0", "typescript": "^5.8.3", "vitest": "^3.2.4" } }],
"identitydb": ["identitydb@0.2.0", "", { "dependencies": { "better-sqlite3": "^12.1.1", "kysely": "^0.28.8", "mysql2": "^3.15.3", "pg": "^8.16.0" } }, "sha512-dXLueo2zx3Lki6R4QJJhMYYGK6jpFecXj8K16AR3Tyq/udH/jw5qAl+s6JPKFPrj24BNl5yAA6CSXS4qFORpQA=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
@@ -417,7 +417,5 @@
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
"estree-walker/@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
}
}

View File

@@ -14,7 +14,7 @@
BoxBrain models one persona as a durable memory space plus a runtime harness:
- `initializePersona(input)` creates a persona, asks an LLM adapter to generate a detailed life story from personality/history/values/likes/dislikes/relationships, asks another extraction path to split that story into IdentityDB facts, stores every fact in the persona's IdentityDB space, and optionally generates a profile image.
- `initializePersona(input)` creates a persona, prefers a long freeform persona seed string as the caller input, asks an LLM adapter to generate a detailed life story from that seed plus any supplemental structured hints, asks another extraction path to split that story into IdentityDB facts, stores every fact in the persona's IdentityDB space, and optionally generates a profile image.
- `generateSchedule(input)` creates a month/week/day schedule for a persona around a date, stores schedule facts keyed by time topics, and derives contact availability windows from the schedule.
- `setAvailability(input)` explicitly sets or updates contact availability: `online`, `do_not_disturb`, or `offline`.
- `sendMessage(input)` handles a user text turn by loading mandatory memories, delegating contextual memory search to an LLM, then asking the persona LLM to emit one or more short DM messages through a tool-like output contract.
@@ -145,7 +145,7 @@ BoxBrain models one persona as a durable memory space plus a runtime harness:
**Behavior to test first:**
- Initialization creates a stable persona object with `id`, `spaceName`, and profile fields.
- Biography adapter is called with personality, history, values, preferences, relationships, and current date context.
- Biography adapter is called with the freeform `seedText` when provided, plus any supplemental structured hints and current date context.
- Fact splitter adapter output is stored in the persona's space.
- Profile image adapter is not called unless requested.
- Profile image result is returned and stored as a fact when requested.

View File

@@ -37,12 +37,16 @@
"simulation"
],
"dependencies": {
"identitydb": "file:../IdentityDB"
"identitydb": "0.2.0"
},
"devDependencies": {
"@types/node": "^24.0.0",
"tsup": "^8.5.0",
"typescript": "^5.8.3",
"vitest": "^3.2.4"
}
},
"trustedDependencies": [
"better-sqlite3",
"esbuild"
]
}

306
src/availability/index.ts Normal file
View File

@@ -0,0 +1,306 @@
import { randomUUID } from 'node:crypto';
import type { Fact, IdentityDB, JsonValue } from 'identitydb';
import { dateOnly, getFactDomain, getJsonObject, jsonObject, listFactsInSpace } from '../core/facts';
import { persistFactDrafts } from '../memory';
import type {
BoxBrainAvailabilityEntry,
BoxBrainAvailabilityMode,
BoxBrainAvailabilitySnapshot,
BoxBrainAvailabilitySourceType,
} from '../core/types';
export interface SetAvailabilityStatusInput {
spaceName: string;
mode: BoxBrainAvailabilityMode;
reason?: string | undefined;
effectiveFrom: string;
until?: string | undefined;
sourceType?: Exclude<BoxBrainAvailabilitySourceType, 'default'> | undefined;
eventId?: string | undefined;
metadata?: JsonValue | null | undefined;
}
export interface GetAvailabilitySnapshotInput {
spaceName: string;
at: string;
}
export interface ListAvailabilityEntriesInput {
spaceName: string;
}
const EXPLICIT_SOURCE_PRIORITY: Record<Exclude<BoxBrainAvailabilitySourceType, 'default'>, number> = {
schedule: 1,
manual: 2,
tool: 3,
};
export class AvailabilityService {
constructor(private readonly db: IdentityDB) {}
async setStatus(input: SetAvailabilityStatusInput): Promise<BoxBrainAvailabilityEntry> {
return setAvailabilityStatusWithDb(this.db, input);
}
async listEntries(input: ListAvailabilityEntriesInput): Promise<BoxBrainAvailabilityEntry[]> {
return listAvailabilityEntriesWithDb(this.db, input);
}
async getSnapshot(input: GetAvailabilitySnapshotInput): Promise<BoxBrainAvailabilitySnapshot> {
return getAvailabilitySnapshotWithDb(this.db, input);
}
}
export async function setAvailabilityStatus(db: IdentityDB, input: SetAvailabilityStatusInput): Promise<BoxBrainAvailabilityEntry> {
return new AvailabilityService(db).setStatus(input);
}
async function setAvailabilityStatusWithDb(db: IdentityDB, input: SetAvailabilityStatusInput): Promise<BoxBrainAvailabilityEntry> {
assertAvailabilityMode(input.mode);
assertChronology(input.effectiveFrom, input.until);
const statusId = randomUUID();
const sourceType = input.sourceType ?? 'manual';
const [fact] = await persistFactDrafts(db, {
spaceName: input.spaceName,
domain: 'persona.availability',
source: `boxbrain:availability.${sourceType}`,
facts: [
{
statement: buildAvailabilityStatement(input.mode, input.reason, input.effectiveFrom, input.until),
metadata: jsonObject({
availabilityStatus: jsonObject({
id: statusId,
mode: input.mode,
reason: input.reason,
effectiveFrom: input.effectiveFrom,
until: input.until,
sourceType,
eventId: input.eventId,
customMetadata: input.metadata ?? null,
}),
}),
topics: [
{ name: dateOnly(input.effectiveFrom), category: 'temporal' },
{ name: input.mode, category: 'concept' },
{ name: 'availability', category: 'concept' },
],
},
],
});
const parsed = fact ? parseAvailabilityFact(fact) : null;
if (!parsed) {
throw new Error('Failed to persist BoxBrain availability status.');
}
return parsed;
}
export async function listAvailabilityEntries(
db: IdentityDB,
input: ListAvailabilityEntriesInput,
): Promise<BoxBrainAvailabilityEntry[]> {
return new AvailabilityService(db).listEntries(input);
}
async function listAvailabilityEntriesWithDb(
db: IdentityDB,
input: ListAvailabilityEntriesInput,
): Promise<BoxBrainAvailabilityEntry[]> {
const facts = await listFactsInSpace(db, input.spaceName);
const deletedScheduleEventIds = collectDeletedScheduleEventIds(facts);
return facts
.map(parseAvailabilityFact)
.filter((entry): entry is BoxBrainAvailabilityEntry => entry !== null)
.filter((entry) => entry.sourceType !== 'schedule' || !entry.eventId || !deletedScheduleEventIds.has(entry.eventId))
.sort(compareAvailabilityEntries);
}
export async function getAvailabilitySnapshot(
db: IdentityDB,
input: GetAvailabilitySnapshotInput,
): Promise<BoxBrainAvailabilitySnapshot> {
return new AvailabilityService(db).getSnapshot(input);
}
async function getAvailabilitySnapshotWithDb(
db: IdentityDB,
input: GetAvailabilitySnapshotInput,
): Promise<BoxBrainAvailabilitySnapshot> {
const entries = await listAvailabilityEntries(db, { spaceName: input.spaceName });
const current = selectAvailabilityAt(entries, input.at) ?? createDefaultOnlineAvailability(input.at);
const next = selectNextAvailabilityTransition(entries, input.at, current);
return { current, next };
}
function selectAvailabilityAt(entries: BoxBrainAvailabilityEntry[], at: string): BoxBrainAvailabilityEntry | null {
const atMs = Date.parse(at);
const applicable = entries.filter((entry) => {
const startMs = Date.parse(entry.effectiveFrom);
const endMs = entry.until ? Date.parse(entry.until) : Number.POSITIVE_INFINITY;
return Number.isFinite(startMs) && atMs >= startMs && atMs < endMs;
});
if (applicable.length === 0) {
return null;
}
applicable.sort((left, right) => {
const priority = priorityOf(right.sourceType) - priorityOf(left.sourceType);
if (priority !== 0) {
return priority;
}
if (right.effectiveFrom !== left.effectiveFrom) {
return right.effectiveFrom.localeCompare(left.effectiveFrom);
}
return (right.createdAt ?? '').localeCompare(left.createdAt ?? '');
});
return applicable[0] ?? null;
}
function selectNextAvailabilityTransition(
entries: BoxBrainAvailabilityEntry[],
at: string,
current: BoxBrainAvailabilityEntry,
): BoxBrainAvailabilityEntry | null {
const atMs = Date.parse(at);
const transitionTimes = Array.from(new Set(
entries.flatMap((entry) => [Date.parse(entry.effectiveFrom), entry.until ? Date.parse(entry.until) : Number.NaN]),
))
.filter((time): time is number => Number.isFinite(time) && time > atMs)
.sort((left, right) => left - right);
for (const transitionTime of transitionTimes) {
const transitionAt = new Date(transitionTime).toISOString();
const probeAt = new Date(transitionTime + 1).toISOString();
const candidate = selectAvailabilityAt(entries, probeAt) ?? createDefaultOnlineAvailability(transitionAt);
if (!sameAvailabilityState(candidate, current)) {
return {
...candidate,
effectiveFrom: transitionAt,
};
}
}
return null;
}
function createDefaultOnlineAvailability(at: string): BoxBrainAvailabilityEntry {
return {
id: 'default-online',
mode: 'online',
effectiveFrom: at,
sourceType: 'default',
};
}
function parseAvailabilityFact(fact: Fact): BoxBrainAvailabilityEntry | null {
if (getFactDomain(fact) !== 'persona.availability') {
return null;
}
const metadata = getJsonObject(fact.metadata);
const payload = getJsonObject(metadata?.availabilityStatus);
if (!payload) {
return null;
}
const id = typeof payload.id === 'string' ? payload.id : fact.id;
const mode = payload.mode;
const effectiveFrom = payload.effectiveFrom;
const sourceType = payload.sourceType;
if (typeof mode !== 'string' || typeof effectiveFrom !== 'string' || typeof sourceType !== 'string') {
return null;
}
assertAvailabilityMode(mode);
if (!isExplicitSourceType(sourceType)) {
return null;
}
return {
id,
mode,
reason: typeof payload.reason === 'string' ? payload.reason : undefined,
effectiveFrom,
until: typeof payload.until === 'string' ? payload.until : undefined,
sourceType,
eventId: typeof payload.eventId === 'string' ? payload.eventId : undefined,
createdAt: fact.createdAt,
metadata: payload.customMetadata ?? null,
};
}
function compareAvailabilityEntries(left: BoxBrainAvailabilityEntry, right: BoxBrainAvailabilityEntry): number {
if (left.effectiveFrom !== right.effectiveFrom) {
return left.effectiveFrom.localeCompare(right.effectiveFrom);
}
return (left.createdAt ?? '').localeCompare(right.createdAt ?? '');
}
function buildAvailabilityStatement(
mode: BoxBrainAvailabilityMode,
reason: string | undefined,
effectiveFrom: string,
until: string | undefined,
): string {
const reasonPart = reason ? ` because ${reason}` : '';
const untilPart = until ? ` until ${until}` : '';
return `Availability is ${mode} from ${effectiveFrom}${untilPart}${reasonPart}.`;
}
function collectDeletedScheduleEventIds(facts: Fact[]): Set<string> {
return new Set(
facts
.filter((fact) => getFactDomain(fact) === 'persona.schedule.deleted')
.map((fact) => getJsonObject(getJsonObject(fact.metadata)?.scheduleDeletion)?.eventId)
.filter((eventId): eventId is string => typeof eventId === 'string'),
);
}
function sameAvailabilityState(left: BoxBrainAvailabilityEntry, right: BoxBrainAvailabilityEntry): boolean {
return left.mode === right.mode
&& left.reason === right.reason
&& left.until === right.until
&& left.sourceType === right.sourceType
&& left.eventId === right.eventId;
}
function assertValidTimestamp(value: string, fieldName: string): void {
if (!Number.isFinite(Date.parse(value))) {
throw new Error(`Availability ${fieldName} must be a valid ISO timestamp.`);
}
}
function assertChronology(startAt: string, endAt: string | undefined): void {
assertValidTimestamp(startAt, 'effectiveFrom');
if (!endAt) {
return;
}
assertValidTimestamp(endAt, 'until');
if (Date.parse(endAt) <= Date.parse(startAt)) {
throw new Error('Availability until must be later than effectiveFrom.');
}
}
function assertAvailabilityMode(mode: string): asserts mode is BoxBrainAvailabilityMode {
if (mode !== 'online' && mode !== 'do_not_disturb' && mode !== 'offline') {
throw new Error(`Unsupported availability mode: ${mode}`);
}
}
function isExplicitSourceType(sourceType: string): sourceType is Exclude<BoxBrainAvailabilitySourceType, 'default'> {
return sourceType === 'schedule' || sourceType === 'manual' || sourceType === 'tool';
}
function priorityOf(sourceType: BoxBrainAvailabilitySourceType): number {
if (sourceType === 'default') {
return 0;
}
return EXPLICIT_SOURCE_PRIORITY[sourceType];
}

574
src/conversation/index.ts Normal file
View File

@@ -0,0 +1,574 @@
import { randomUUID } from 'node:crypto';
import type { Fact, IdentityDB, JsonValue } from 'identitydb';
import type { StructuredModelAdapter } from '../core/adapters';
import { getAvailabilitySnapshot, setAvailabilityStatus } from '../availability';
import { dateOnly, getFactDomain, getJsonObject, jsonObject, listFactsInSpace, resolvePersonaProfile, shiftIsoDate } from '../core/facts';
import { persistFactDrafts } from '../memory';
import { createReplyDelay, createTypingDelay } from '../timing';
import type {
BoxBrainAvailabilityMode,
BoxBrainConversationDirection,
BoxBrainConversationEntry,
BoxBrainMemoryReference,
BoxBrainMessage,
BoxBrainToolCall,
} from '../core/types';
export interface ConversationMemorySelectionResult {
memoryIds: string[];
}
export interface SetAvailabilityToolArguments extends Record<string, JsonValue> {
mode: BoxBrainAvailabilityMode;
reason?: string;
effectiveFrom?: string;
until?: string;
}
export interface ConversationToolCall extends BoxBrainToolCall<SetAvailabilityToolArguments> {
name: 'setAvailabilityStatus';
}
export interface ConversationTurnPlan {
mode: 'reply' | 'refuse';
messages: string[];
toolCalls?: ConversationToolCall[] | undefined;
}
interface BaseConversationInput {
spaceName: string;
counterpartId: string;
counterpartDisplayName?: string | undefined;
currentTime: string;
mandatoryMemoryModel: StructuredModelAdapter;
contextualMemoryModel: StructuredModelAdapter;
responseModel: StructuredModelAdapter;
rng?: (() => number) | undefined;
activeExchangeWindowSeconds?: number | undefined;
isFirstReplyInExchange?: boolean | undefined;
}
export interface ReplyToConversationInput extends BaseConversationInput {
message: string;
}
export interface StartConversationInput extends BaseConversationInput {}
export interface ConversationTurnResult {
blocked: boolean;
blockedReason?: string | undefined;
blockedUntil?: string | undefined;
messages: BoxBrainMessage[];
usedMemories: BoxBrainMemoryReference[];
toolCallsExecuted: ConversationToolCall[];
}
export interface ListConversationEntriesInput {
spaceName: string;
counterpartId?: string | undefined;
since?: string | undefined;
until?: string | undefined;
}
export class ConversationService {
constructor(private readonly db: IdentityDB) {}
async reply(input: ReplyToConversationInput): Promise<ConversationTurnResult> {
return replyToConversationWithDb(this.db, input);
}
async start(input: StartConversationInput): Promise<ConversationTurnResult> {
return startConversationWithDb(this.db, input);
}
async listEntries(input: ListConversationEntriesInput): Promise<BoxBrainConversationEntry[]> {
return listConversationEntriesWithDb(this.db, input);
}
}
export async function replyToConversation(db: IdentityDB, input: ReplyToConversationInput): Promise<ConversationTurnResult> {
return new ConversationService(db).reply(input);
}
async function replyToConversationWithDb(db: IdentityDB, input: ReplyToConversationInput): Promise<ConversationTurnResult> {
const turnId = randomUUID();
await persistConversationEntry(db, {
spaceName: input.spaceName,
counterpartId: input.counterpartId,
counterpartDisplayName: input.counterpartDisplayName,
direction: 'inbound',
text: input.message,
occurredAt: input.currentTime,
proactive: false,
turnId,
source: 'boxbrain:conversation.inbound',
});
return generateConversationTurn(db, {
...input,
proactive: false,
turnId,
inboundMessage: input.message,
});
}
export async function startConversation(db: IdentityDB, input: StartConversationInput): Promise<ConversationTurnResult> {
return new ConversationService(db).start(input);
}
async function startConversationWithDb(db: IdentityDB, input: StartConversationInput): Promise<ConversationTurnResult> {
return generateConversationTurn(db, {
...input,
proactive: true,
turnId: randomUUID(),
});
}
export async function listConversationEntries(
db: IdentityDB,
input: ListConversationEntriesInput,
): Promise<BoxBrainConversationEntry[]> {
return new ConversationService(db).listEntries(input);
}
async function listConversationEntriesWithDb(
db: IdentityDB,
input: ListConversationEntriesInput,
): Promise<BoxBrainConversationEntry[]> {
const facts = await listFactsInSpace(db, input.spaceName);
return facts
.map(parseConversationFact)
.filter((entry): entry is BoxBrainConversationEntry => entry !== null)
.filter((entry) => !input.counterpartId || entry.counterpartId === input.counterpartId)
.filter((entry) => !input.since || entry.occurredAt >= input.since)
.filter((entry) => !input.until || entry.occurredAt <= input.until)
.sort((left, right) => {
if (left.occurredAt !== right.occurredAt) {
return left.occurredAt.localeCompare(right.occurredAt);
}
if ((left.createdAt ?? '') !== (right.createdAt ?? '')) {
return (left.createdAt ?? '').localeCompare(right.createdAt ?? '');
}
return left.id.localeCompare(right.id);
});
}
async function generateConversationTurn(
db: IdentityDB,
input: BaseConversationInput & {
proactive: boolean;
turnId: string;
inboundMessage?: string | undefined;
},
): Promise<ConversationTurnResult> {
const availability = await getAvailabilitySnapshot(db, {
spaceName: input.spaceName,
at: input.currentTime,
});
if (availability.current.mode === 'offline') {
return {
blocked: true,
blockedReason: availability.current.reason,
blockedUntil: availability.current.until,
messages: [],
usedMemories: [],
toolCallsExecuted: [],
};
}
const persona = await resolvePersonaProfile(db, input.spaceName);
const candidateMemories = await buildMemoryCandidates(db, input.spaceName, input.counterpartId, input.counterpartDisplayName, input.currentTime);
const candidateMap = new Map<string, BoxBrainMemoryReference>(
candidateMemories.map((memory, index) => [`m${index + 1}`, memory]),
);
const mandatorySelection = assertConversationMemorySelectionResult(
await input.mandatoryMemoryModel.generateObject<ConversationMemorySelectionResult>({
prompt: buildMandatoryMemoryPrompt(persona.displayName, input, candidateMap),
schema: { type: 'object', required: ['memoryIds'] },
metadata: {
boxbrainTask: 'persona.conversation.select_mandatory_memories',
counterpartId: input.counterpartId,
},
}),
);
const contextualSelection = assertConversationMemorySelectionResult(
await input.contextualMemoryModel.generateObject<ConversationMemorySelectionResult>({
prompt: buildContextualMemoryPrompt(persona.displayName, input, candidateMap),
schema: { type: 'object', required: ['memoryIds'] },
metadata: {
boxbrainTask: 'persona.conversation.select_contextual_memories',
counterpartId: input.counterpartId,
},
}),
);
const usedMemories = uniqueMemoryIds([...mandatorySelection.memoryIds, ...contextualSelection.memoryIds])
.map((id) => candidateMap.get(id))
.filter((memory): memory is BoxBrainMemoryReference => Boolean(memory));
const plan = assertConversationTurnPlan(
await input.responseModel.generateObject<ConversationTurnPlan>({
prompt: buildConversationPrompt(persona.displayName, input, availability, usedMemories),
schema: { type: 'object', required: ['mode', 'messages'] },
metadata: {
boxbrainTask: input.proactive ? 'persona.conversation.initiate' : 'persona.conversation.reply',
counterpartId: input.counterpartId,
},
}),
);
const historyBeforeResponse = await listConversationEntries(db, {
spaceName: input.spaceName,
counterpartId: input.counterpartId,
});
const isFirstReply = input.isFirstReplyInExchange ?? inferIsFirstReply(historyBeforeResponse, input.currentTime, input.activeExchangeWindowSeconds ?? 300);
const replyDelaySeconds = createReplyDelay(availability.current, {
isFirstReplyInExchange: isFirstReply,
rng: input.rng,
});
if (replyDelaySeconds === null) {
return {
blocked: true,
blockedReason: availability.current.reason,
blockedUntil: availability.current.until,
messages: [],
usedMemories,
toolCallsExecuted: [],
};
}
const outboundMessages = plan.messages.map((text, index) => {
const typingDelaySeconds = createTypingDelay(text, { rng: input.rng });
const message: BoxBrainMessage = {
text,
typingDelaySeconds,
replyDelaySeconds: index === 0 ? replyDelaySeconds : 0,
totalDelaySeconds: typingDelaySeconds + (index === 0 ? replyDelaySeconds : 0),
};
return message;
});
for (const message of outboundMessages) {
await persistConversationEntry(db, {
spaceName: input.spaceName,
counterpartId: input.counterpartId,
counterpartDisplayName: input.counterpartDisplayName,
direction: 'outbound',
text: message.text,
occurredAt: input.currentTime,
proactive: input.proactive,
turnId: input.turnId,
source: `${input.responseModel.provider}:${input.responseModel.model}`,
});
}
const toolCallsExecuted = await executeToolCalls(db, input.spaceName, input.currentTime, plan.toolCalls ?? []);
return {
blocked: false,
messages: outboundMessages,
usedMemories,
toolCallsExecuted,
};
}
async function buildMemoryCandidates(
db: IdentityDB,
spaceName: string,
counterpartId: string,
counterpartDisplayName: string | undefined,
currentTime: string,
): Promise<BoxBrainMemoryReference[]> {
const facts = await listFactsInSpace(db, spaceName);
const counterpartName = counterpartDisplayName ?? counterpartId;
const currentDate = dateOnly(currentTime);
const relevantDates = new Set([shiftIsoDate(currentDate, -1), currentDate, shiftIsoDate(currentDate, 1)]);
const conversationWindowMs = 1000 * 60 * 60 * 48;
const nowMs = Date.parse(currentTime);
const scheduleRefs: BoxBrainMemoryReference[] = [];
const recentConversationRefs: BoxBrainMemoryReference[] = [];
const counterpartRefs: BoxBrainMemoryReference[] = [];
const personaRefs: BoxBrainMemoryReference[] = [];
for (const fact of facts) {
const domain = getFactDomain(fact);
if (!domain) {
continue;
}
if (domain === 'persona.schedule') {
const scheduleEvent = getJsonObject(getJsonObject(fact.metadata)?.scheduleEvent);
const startAt = typeof scheduleEvent?.startAt === 'string' ? scheduleEvent.startAt : undefined;
if (startAt && relevantDates.has(dateOnly(startAt))) {
scheduleRefs.push(toMemoryReference(fact, domain, `Schedule memory: ${fact.statement}`, startAt));
}
continue;
}
if (domain === 'persona.conversation') {
const entry = parseConversationFact(fact);
if (entry && entry.counterpartId === counterpartId && nowMs - Date.parse(entry.occurredAt) <= conversationWindowMs) {
recentConversationRefs.push(toMemoryReference(fact, domain, `Recent conversation: ${entry.direction}${entry.text}`, entry.occurredAt));
}
continue;
}
if (domain === 'persona.biography' || domain === 'persona.relationship' || domain === 'persona.profile_image') {
const relevantToCounterpart = fact.statement.includes(counterpartName)
|| fact.topics.some((topic) => topic.name === counterpartName || topic.name === counterpartId);
const reference = toMemoryReference(fact, domain, fact.statement, fact.createdAt);
if (relevantToCounterpart) {
counterpartRefs.push(reference);
} else {
personaRefs.push(reference);
}
}
}
return dedupeMemoryReferences([
...scheduleRefs,
...recentConversationRefs,
...counterpartRefs,
...personaRefs,
]).slice(0, 40);
}
function buildMandatoryMemoryPrompt(
displayName: string,
input: BaseConversationInput & { inboundMessage?: string | undefined; proactive: boolean },
candidateMap: Map<string, BoxBrainMemoryReference>,
): string {
return [
`Select mandatory memories for ${displayName}'s DM turn.`,
'You are the first retrieval model.',
'Always prioritize yesterday, today, and tomorrow schedules when relevant, yesterday and today conversation context, and stable memories about the persona and counterpart.',
`Current time: ${input.currentTime}`,
`Counterpart: ${input.counterpartDisplayName ?? input.counterpartId}`,
input.inboundMessage ? `Inbound message: ${input.inboundMessage}` : 'This is a proactive outbound turn with no inbound user text.',
'Return only memoryIds from the candidate list.',
'Candidate memories:',
renderCandidateMemories(candidateMap),
].join('\n');
}
function buildContextualMemoryPrompt(
displayName: string,
input: BaseConversationInput & { inboundMessage?: string | undefined; proactive: boolean },
candidateMap: Map<string, BoxBrainMemoryReference>,
): string {
return [
`Select additional contextual memories for ${displayName}'s DM turn.`,
'You are the second retrieval model and should only add memories that help the reply feel natural.',
`Current time: ${input.currentTime}`,
input.inboundMessage ? `User message to analyze: ${input.inboundMessage}` : 'No inbound text; choose memories useful for starting a conversation first.',
'Return only memoryIds from the candidate list.',
'Candidate memories:',
renderCandidateMemories(candidateMap),
].join('\n');
}
function buildConversationPrompt(
displayName: string,
input: BaseConversationInput & { inboundMessage?: string | undefined; proactive: boolean },
availability: Awaited<ReturnType<typeof getAvailabilitySnapshot>>,
memories: BoxBrainMemoryReference[],
): string {
return [
`You are writing DM-style messages as ${displayName}.`,
'Send 1 or more very short messages. Each message should be one sentence or less.',
'You may intentionally omit spaces or make tiny typos to feel human.',
'If needed, you may refuse because you need to go soon, and you may use the setAvailabilityStatus tool afterwards.',
`Current time: ${input.currentTime}`,
`Current availability: ${availability.current.mode}${availability.current.reason ? `${availability.current.reason}` : ''}`,
availability.current.until ? `Current availability until: ${availability.current.until}` : undefined,
availability.next ? `next availability transition: ${availability.next.mode} at ${availability.next.effectiveFrom}${availability.next.reason ? `${availability.next.reason}` : ''}` : 'next availability transition: none',
`Counterpart: ${input.counterpartDisplayName ?? input.counterpartId}`,
input.inboundMessage ? `Inbound message: ${input.inboundMessage}` : 'Goal: start the conversation first without waiting for user text.',
'Selected memories:',
memories.length > 0 ? memories.map((memory) => `- ${memory.summary}`).join('\n') : '- none selected',
'Available tool: setAvailabilityStatus({ mode, reason?, effectiveFrom?, until? })',
'Return { mode, messages, toolCalls? }.',
]
.filter((line): line is string => Boolean(line))
.join('\n');
}
function renderCandidateMemories(candidateMap: Map<string, BoxBrainMemoryReference>): string {
return Array.from(candidateMap.entries())
.map(([id, memory]) => `${id}: [${memory.domain}] ${memory.summary}`)
.join('\n');
}
function assertConversationMemorySelectionResult(value: ConversationMemorySelectionResult): ConversationMemorySelectionResult {
if (!value || !Array.isArray(value.memoryIds) || value.memoryIds.some((id) => typeof id !== 'string')) {
throw new Error('Conversation memory selection output must contain a memoryIds string array.');
}
return value;
}
function assertConversationTurnPlan(value: ConversationTurnPlan): ConversationTurnPlan {
if (!value || (value.mode !== 'reply' && value.mode !== 'refuse')) {
throw new Error('Conversation turn plan must include a mode of reply or refuse.');
}
if (!Array.isArray(value.messages) || value.messages.length === 0) {
throw new Error('Conversation turn plan must include at least one message.');
}
for (const message of value.messages) {
if (typeof message !== 'string' || message.trim().length === 0 || message.includes('\n')) {
throw new Error('Conversation turn plan messages must be non-empty single-line strings.');
}
}
if (value.toolCalls && !Array.isArray(value.toolCalls)) {
throw new Error('Conversation turn plan toolCalls must be an array when provided.');
}
return value;
}
async function persistConversationEntry(
db: IdentityDB,
input: {
spaceName: string;
counterpartId: string;
counterpartDisplayName?: string | undefined;
direction: BoxBrainConversationDirection;
text: string;
occurredAt: string;
proactive: boolean;
turnId: string;
source: string;
},
): Promise<void> {
await persistFactDrafts(db, {
spaceName: input.spaceName,
domain: 'persona.conversation',
source: input.source,
facts: [
{
statement: input.text,
metadata: jsonObject({
conversationEntry: jsonObject({
id: randomUUID(),
turnId: input.turnId,
direction: input.direction,
occurredAt: input.occurredAt,
counterpartId: input.counterpartId,
counterpartDisplayName: input.counterpartDisplayName,
proactive: input.proactive,
}),
}),
topics: [
{ name: input.counterpartDisplayName ?? input.counterpartId, category: 'entity' },
{ name: dateOnly(input.occurredAt), category: 'temporal' },
{ name: 'conversation', category: 'concept' },
],
},
],
});
}
function parseConversationFact(fact: Fact): BoxBrainConversationEntry | null {
if (getFactDomain(fact) !== 'persona.conversation') {
return null;
}
const metadata = getJsonObject(fact.metadata);
const payload = getJsonObject(metadata?.conversationEntry);
if (!payload) {
return null;
}
if (
typeof payload.id !== 'string'
|| typeof payload.turnId !== 'string'
|| typeof payload.direction !== 'string'
|| typeof payload.occurredAt !== 'string'
|| typeof payload.counterpartId !== 'string'
|| typeof payload.proactive !== 'boolean'
) {
return null;
}
return {
id: payload.id,
turnId: payload.turnId,
direction: payload.direction as BoxBrainConversationDirection,
text: fact.statement,
occurredAt: payload.occurredAt,
createdAt: fact.createdAt,
counterpartId: payload.counterpartId,
counterpartDisplayName: typeof payload.counterpartDisplayName === 'string' ? payload.counterpartDisplayName : undefined,
proactive: payload.proactive,
metadata: fact.metadata,
};
}
function toMemoryReference(
fact: Fact,
domain: BoxBrainMemoryReference['domain'],
summary: string,
occurredAt: string,
): BoxBrainMemoryReference {
return {
id: fact.id,
domain,
statement: fact.statement,
summary,
occurredAt,
metadata: fact.metadata,
};
}
function dedupeMemoryReferences(memories: BoxBrainMemoryReference[]): BoxBrainMemoryReference[] {
const byId = new Map<string, BoxBrainMemoryReference>();
for (const memory of memories) {
byId.set(memory.id, memory);
}
return Array.from(byId.values());
}
function uniqueMemoryIds(ids: string[]): string[] {
return Array.from(new Set(ids.filter((id) => id.trim().length > 0)));
}
function inferIsFirstReply(history: BoxBrainConversationEntry[], currentTime: string, activeWindowSeconds: number): boolean {
const latestOutbound = history
.slice()
.reverse()
.find((entry) => entry.direction === 'outbound');
if (!latestOutbound) {
return true;
}
return Date.parse(currentTime) - Date.parse(latestOutbound.occurredAt) > activeWindowSeconds * 1000;
}
async function executeToolCalls(
db: IdentityDB,
spaceName: string,
currentTime: string,
toolCalls: ConversationToolCall[],
): Promise<ConversationToolCall[]> {
const executed: ConversationToolCall[] = [];
for (const toolCall of toolCalls) {
if (toolCall.name !== 'setAvailabilityStatus') {
throw new Error(`Unsupported BoxBrain conversation tool: ${toolCall.name}`);
}
await setAvailabilityStatus(db, {
spaceName,
mode: toolCall.arguments.mode,
reason: typeof toolCall.arguments.reason === 'string' ? toolCall.arguments.reason : undefined,
effectiveFrom: typeof toolCall.arguments.effectiveFrom === 'string' ? toolCall.arguments.effectiveFrom : currentTime,
until: typeof toolCall.arguments.until === 'string' ? toolCall.arguments.until : undefined,
sourceType: 'tool',
});
executed.push(toolCall);
}
return executed;
}

View File

@@ -1,4 +1,5 @@
import type { JsonValue } from 'identitydb';
import type { BoxBrainScheduleScope } from './types';
export interface TextGenerationRequest {
prompt: string;
@@ -42,3 +43,19 @@ export interface ImageModelAdapter {
model: string;
generateImage(request: ImageGenerationRequest): Promise<ImageGenerationResult>;
}
export interface SpecialDateContext {
date: string;
title: string;
description?: string | undefined;
}
export interface SpecialDateRequest {
anchorDate: string;
scope: BoxBrainScheduleScope;
timezone?: string | undefined;
}
export interface SpecialDateProvider {
listSpecialDates(request: SpecialDateRequest): Promise<SpecialDateContext[]>;
}

95
src/core/facts.ts Normal file
View File

@@ -0,0 +1,95 @@
import type { Fact, IdentityDB, JsonValue, Space } from 'identitydb';
import type { BoxBrainFactDomain, BoxBrainPersonaProfile } from './types';
export async function listFactsInSpace(db: IdentityDB, spaceName: string): Promise<Fact[]> {
const topics = await db.listTopics({ includeFacts: true, spaceName });
const byId = new Map<string, Fact>();
for (const topic of topics) {
for (const fact of topic.facts) {
byId.set(fact.id, fact);
}
}
return Array.from(byId.values()).sort((left, right) => {
if (left.createdAt !== right.createdAt) {
return left.createdAt.localeCompare(right.createdAt);
}
return left.id.localeCompare(right.id);
});
}
export function getFactDomain(fact: Fact): BoxBrainFactDomain | null {
const metadata = getJsonObject(fact.metadata);
const boxbrain = getJsonObject(metadata?.boxbrain);
const domain = boxbrain?.domain;
return typeof domain === 'string' ? (domain as BoxBrainFactDomain) : null;
}
export function getJsonObject(value: JsonValue | null | undefined): Record<string, JsonValue> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
return value as Record<string, JsonValue>;
}
export function dateOnly(isoLike: string): string {
return isoLike.slice(0, 10);
}
export function shiftIsoDate(date: string, days: number): string {
const parsed = new Date(`${date}T00:00:00.000Z`);
parsed.setUTCDate(parsed.getUTCDate() + days);
return parsed.toISOString().slice(0, 10);
}
export async function resolvePersonaProfile(
db: IdentityDB,
spaceName: string,
fallbackDisplayName?: string,
): Promise<BoxBrainPersonaProfile> {
const space = await db.getSpaceByName(spaceName);
return personaProfileFromSpace(space, spaceName, fallbackDisplayName);
}
export function personaProfileFromSpace(
space: Space | null,
spaceName: string,
fallbackDisplayName?: string,
): BoxBrainPersonaProfile {
const metadata = getJsonObject(space?.metadata);
const boxbrain = getJsonObject(metadata?.boxbrain);
const id = typeof boxbrain?.personaId === 'string'
? boxbrain.personaId
: spaceName.startsWith('persona:')
? spaceName.slice('persona:'.length)
: spaceName;
const displayName = typeof boxbrain?.displayName === 'string'
? boxbrain.displayName
: fallbackDisplayName ?? id;
const profileImageUrl = typeof boxbrain?.profileImageUrl === 'string' ? boxbrain.profileImageUrl : undefined;
return {
id,
spaceName,
displayName,
profileImageUrl,
};
}
export function uniqueStrings(values: string[]): string[] {
return Array.from(new Set(values.filter((value) => value.trim().length > 0)));
}
export function jsonObject(values: Record<string, JsonValue | undefined>): Record<string, JsonValue> {
const result: Record<string, JsonValue> = {};
for (const [key, value] of Object.entries(values)) {
if (value !== undefined) {
result[key] = value;
}
}
return result;
}

112
src/core/types.ts Normal file
View File

@@ -0,0 +1,112 @@
import type { JsonValue, TopicCategory, TopicGranularity } from 'identitydb';
export type BoxBrainFactDomain =
| 'persona.biography'
| 'persona.profile_image'
| 'persona.schedule'
| 'persona.schedule.deleted'
| 'persona.availability'
| 'persona.conversation'
| 'persona.relationship'
| (string & {});
export type BoxBrainAvailabilityMode = 'online' | 'do_not_disturb' | 'offline';
export type BoxBrainAvailabilitySourceType = 'default' | 'schedule' | 'manual' | 'tool';
export type BoxBrainScheduleScope = 'day' | 'week' | 'month';
export type BoxBrainScheduleEventKind = 'routine' | 'special';
export type BoxBrainConversationDirection = 'inbound' | 'outbound';
export interface BoxBrainTopicDraft {
name: string;
category?: TopicCategory | (string & {}) | undefined;
granularity?: TopicGranularity | undefined;
description?: string | null | undefined;
metadata?: JsonValue | null | undefined;
role?: string | null | undefined;
}
export interface BoxBrainFactDraft {
statement: string;
summary?: string | null | undefined;
source?: string | null | undefined;
confidence?: number | null | undefined;
metadata?: JsonValue | null | undefined;
topics: BoxBrainTopicDraft[];
}
export interface BoxBrainAvailability {
mode: BoxBrainAvailabilityMode;
reason?: string | undefined;
until?: string | undefined;
}
export interface BoxBrainMessage {
text: string;
typingDelaySeconds: number;
replyDelaySeconds: number;
totalDelaySeconds: number;
}
export interface BoxBrainPersonaProfile {
id: string;
spaceName: string;
displayName: string;
profileImageUrl?: string | undefined;
}
export interface BoxBrainScheduleEvent {
id: string;
title: string;
description?: string | undefined;
startAt: string;
endAt: string;
availabilityMode: BoxBrainAvailabilityMode;
availabilityReason?: string | undefined;
kind: BoxBrainScheduleEventKind;
topics: BoxBrainTopicDraft[];
metadata?: JsonValue | null | undefined;
}
export interface BoxBrainAvailabilityEntry {
id: string;
mode: BoxBrainAvailabilityMode;
reason?: string | undefined;
effectiveFrom: string;
until?: string | undefined;
sourceType: BoxBrainAvailabilitySourceType;
eventId?: string | undefined;
createdAt?: string | undefined;
metadata?: JsonValue | null | undefined;
}
export interface BoxBrainAvailabilitySnapshot {
current: BoxBrainAvailabilityEntry;
next: BoxBrainAvailabilityEntry | null;
}
export interface BoxBrainConversationEntry {
id: string;
turnId: string;
direction: BoxBrainConversationDirection;
text: string;
occurredAt: string;
createdAt?: string | undefined;
counterpartId: string;
counterpartDisplayName?: string | undefined;
proactive: boolean;
metadata?: JsonValue | null | undefined;
}
export interface BoxBrainMemoryReference {
id: string;
domain: BoxBrainFactDomain;
statement: string;
summary: string;
occurredAt?: string | undefined;
metadata?: JsonValue | null | undefined;
}
export interface BoxBrainToolCall<TArgs extends Record<string, JsonValue> = Record<string, JsonValue>> {
name: string;
arguments: TArgs;
}

View File

@@ -1,5 +1,9 @@
export * from './adapters';
export * from './core/adapters';
export * from './core/types';
export * from './availability';
export * from './conversation';
export * from './memory';
export * from './persona';
export * from './providers/grok';
export * from './schedule';
export * from './timing';
export * from './types';

View File

@@ -1,5 +1,5 @@
import type { AddFactInput, Fact, IdentityDB, JsonValue, TopicCategory } from 'identitydb';
import type { BoxBrainFactDomain, BoxBrainFactDraft, BoxBrainTopicDraft } from './types';
import type { BoxBrainFactDomain, BoxBrainFactDraft, BoxBrainTopicDraft } from '../core/types';
export interface PersistFactDraftsInput {
spaceName: string;
@@ -10,19 +10,27 @@ export interface PersistFactDraftsInput {
const IDENTITYDB_TOPIC_CATEGORIES = new Set<TopicCategory>(['entity', 'concept', 'temporal', 'custom']);
export async function persistFactDrafts(db: IdentityDB, input: PersistFactDraftsInput): Promise<Fact[]> {
export class FactDraftMemoryStore {
constructor(private readonly db: IdentityDB) {}
async persist(input: PersistFactDraftsInput): Promise<Fact[]> {
if (input.facts.length === 0) {
return [];
}
await db.upsertSpace({ name: input.spaceName });
await this.db.upsertSpace({ name: input.spaceName });
const persisted: Fact[] = [];
for (const draft of input.facts) {
persisted.push(await db.addFact(toAddFactInput(draft, input)));
persisted.push(await this.db.addFact(toAddFactInput(draft, input)));
}
return persisted;
}
}
export async function persistFactDrafts(db: IdentityDB, input: PersistFactDraftsInput): Promise<Fact[]> {
return new FactDraftMemoryStore(db).persist(input);
}
function toAddFactInput(draft: BoxBrainFactDraft, input: PersistFactDraftsInput): AddFactInput {

View File

@@ -1,8 +1,8 @@
import { randomUUID } from 'node:crypto';
import type { IdentityDB } from 'identitydb';
import type { ImageModelAdapter, StructuredModelAdapter } from './adapters';
import { persistFactDrafts } from './memory';
import type { BoxBrainFactDraft, BoxBrainPersonaProfile } from './types';
import type { ImageModelAdapter, StructuredModelAdapter } from '../core/adapters';
import { persistFactDrafts } from '../memory';
import type { BoxBrainFactDraft, BoxBrainPersonaProfile } from '../core/types';
export interface PersonaRelationshipInput {
name: string;
@@ -14,7 +14,8 @@ export interface InitializePersonaInput {
id?: string | undefined;
displayName: string;
spaceName?: string | undefined;
personality: string;
seedText?: string | undefined;
personality?: string | undefined;
history?: string | undefined;
values?: string[] | undefined;
likes?: string[] | undefined;
@@ -73,7 +74,21 @@ const PERSONA_FACT_EXTRACTION_SCHEMA = {
},
} as const;
export class PersonaService {
constructor(private readonly db: IdentityDB) {}
async initialize(input: InitializePersonaInput): Promise<InitializedPersona> {
return initializePersonaWithDb(this.db, input);
}
}
export async function initializePersona(db: IdentityDB, input: InitializePersonaInput): Promise<InitializedPersona> {
return new PersonaService(db).initialize(input);
}
async function initializePersonaWithDb(db: IdentityDB, input: InitializePersonaInput): Promise<InitializedPersona> {
assertPersonaInitializationInput(input);
const id = input.id ?? createPersonaId(input.displayName);
const spaceName = input.spaceName ?? `persona:${id}`;
const existingSpace = await db.getSpaceByName(spaceName);
@@ -175,10 +190,11 @@ export async function initializePersona(db: IdentityDB, input: InitializePersona
function buildBiographyPrompt(input: InitializePersonaInput): string {
return [
'Create a concrete, detailed life biography for a synthetic persona from birth to now.',
'The biography must include personality, history, values, preferences, dislikes, and relationships when provided.',
biographySourceInstruction(input),
`Current date: ${input.currentDate ?? new Date().toISOString().slice(0, 10)}`,
`Display name: ${input.displayName}`,
`Personality: ${input.personality}`,
freeformSeedLine(input.seedText),
optionalLine('Personality', input.personality),
optionalLine('History', input.history),
listLine('Values', input.values),
listLine('Likes', input.likes),
@@ -255,6 +271,36 @@ function assertPersonaFactExtractionResult(value: PersonaFactExtractionResult):
return value;
}
function assertPersonaInitializationInput(input: InitializePersonaInput): void {
const hasSeedText = typeof input.seedText === 'string' && input.seedText.trim().length > 0;
const hasStructuredSeed = [
input.personality,
input.history,
...(input.values ?? []),
...(input.likes ?? []),
...(input.dislikes ?? []),
...((input.relationships ?? []).map((relationship) => relationship.name)),
].some((value) => typeof value === 'string' && value.trim().length > 0);
if (!hasSeedText && !hasStructuredSeed) {
throw new Error(
'initializePersona requires either seedText or at least one structured persona hint such as personality, history, values, likes, dislikes, or relationships.',
);
}
}
function biographySourceInstruction(input: InitializePersonaInput): string {
if (input.seedText && input.seedText.trim().length > 0) {
return 'Use the freeform persona seed as the primary source and infer personality, history, values, likes, dislikes, and relationships from it without contradicting the provided details.';
}
return 'The biography must include personality, history, values, preferences, dislikes, and relationships when provided.';
}
function freeformSeedLine(seedText: string | undefined): string | undefined {
return seedText && seedText.trim().length > 0 ? `Freeform persona seed:\n${seedText}` : undefined;
}
function optionalLine(label: string, value: string | undefined): string | undefined {
return value ? `${label}: ${value}` : undefined;
}

280
src/providers/grok/index.ts Normal file
View File

@@ -0,0 +1,280 @@
import type {
ImageGenerationRequest,
ImageModelAdapter,
StructuredGenerationRequest,
StructuredModelAdapter,
TextGenerationRequest,
TextModelAdapter,
} from '../../core/adapters';
type GrokFetch = typeof fetch;
type JsonObject = Record<string, unknown>;
export interface GrokAdapterOptions {
apiKey: string;
model: string;
baseUrl?: string | undefined;
fetch?: GrokFetch | undefined;
extraHeaders?: Record<string, string> | undefined;
}
export interface GrokAdapterBundleOptions {
apiKey: string;
textModel: string;
structuredModel: string;
imageModel: string;
baseUrl?: string | undefined;
fetch?: GrokFetch | undefined;
extraHeaders?: Record<string, string> | undefined;
}
const DEFAULT_BASE_URL = 'https://api.x.ai/v1';
const GROK_PROVIDER = 'xai-grok';
export class GrokApiClient {
private readonly baseUrl: string;
private readonly fetchImpl: GrokFetch;
constructor(private readonly options: Pick<GrokAdapterOptions, 'apiKey' | 'baseUrl' | 'fetch' | 'extraHeaders'>) {
this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, '');
const fetchImpl = options.fetch ?? globalThis.fetch;
if (!fetchImpl) {
throw new Error('Grok adapter requires a fetch implementation.');
}
this.fetchImpl = fetchImpl;
}
async postJson(path: string, body: JsonObject): Promise<JsonObject> {
const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
method: 'POST',
headers: {
authorization: `Bearer ${this.options.apiKey}`,
'content-type': 'application/json',
...(this.options.extraHeaders ?? {}),
},
body: JSON.stringify(removeUndefined(body)),
});
const text = await response.text();
const parsed = text.length > 0 ? tryParseJson(text) : {};
if (!response.ok) {
throw new Error(`Grok API request failed (${response.status}): ${text}`);
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('Grok API response must be a JSON object.');
}
return parsed;
}
}
export function createGrokTextModelAdapter(options: GrokAdapterOptions): TextModelAdapter {
const runtime = createRuntime(options);
return {
provider: GROK_PROVIDER,
model: options.model,
async generateText(request: TextGenerationRequest): Promise<string> {
const response = await runtime.postJson('/chat/completions', {
model: request.model ?? options.model,
messages: buildMessages(request.system, request.prompt),
temperature: request.temperature,
metadata: request.metadata,
});
return extractChatCompletionText(response);
},
};
}
export function createGrokStructuredModelAdapter<TSchema = unknown>(options: GrokAdapterOptions): StructuredModelAdapter<TSchema> {
const runtime = createRuntime(options);
return {
provider: GROK_PROVIDER,
model: options.model,
async generateObject<TOutput>(request: StructuredGenerationRequest<TSchema>): Promise<TOutput> {
const response = await runtime.postJson('/chat/completions', {
model: request.model ?? options.model,
messages: buildMessages(request.system, request.prompt),
temperature: request.temperature,
metadata: request.metadata,
response_format: request.schema
? {
type: 'json_schema',
json_schema: {
name: 'boxbrain_structured_output',
schema: request.schema,
},
}
: { type: 'json_object' },
});
return parseStructuredOutput<TOutput>(extractChatCompletionText(response));
},
};
}
export function createGrokImageModelAdapter(options: GrokAdapterOptions): ImageModelAdapter {
const runtime = createRuntime(options);
return {
provider: GROK_PROVIDER,
model: options.model,
async generateImage(request: ImageGenerationRequest) {
const response = await runtime.postJson('/images/generations', {
model: request.model ?? options.model,
prompt: request.prompt,
aspect_ratio: mapAspectRatio(request.aspectRatio),
metadata: request.metadata,
});
const image = extractFirstImage(response);
return {
url: image.url,
revisedPrompt: image.revised_prompt,
};
},
};
}
export function createGrokAdapters(options: GrokAdapterBundleOptions): {
text: TextModelAdapter;
structured: StructuredModelAdapter;
image: ImageModelAdapter;
} {
return {
text: createGrokTextModelAdapter({
apiKey: options.apiKey,
model: options.textModel,
baseUrl: options.baseUrl,
fetch: options.fetch,
extraHeaders: options.extraHeaders,
}),
structured: createGrokStructuredModelAdapter({
apiKey: options.apiKey,
model: options.structuredModel,
baseUrl: options.baseUrl,
fetch: options.fetch,
extraHeaders: options.extraHeaders,
}),
image: createGrokImageModelAdapter({
apiKey: options.apiKey,
model: options.imageModel,
baseUrl: options.baseUrl,
fetch: options.fetch,
extraHeaders: options.extraHeaders,
}),
};
}
function createRuntime(options: Pick<GrokAdapterOptions, 'apiKey' | 'baseUrl' | 'fetch' | 'extraHeaders'>): GrokApiClient {
return new GrokApiClient(options);
}
function buildMessages(system: string | undefined, prompt: string): Array<{ role: 'system' | 'user'; content: string }> {
const messages: Array<{ role: 'system' | 'user'; content: string }> = [];
if (system) {
messages.push({ role: 'system', content: system });
}
messages.push({ role: 'user', content: prompt });
return messages;
}
function extractChatCompletionText(response: JsonObject): string {
const choices = response.choices;
if (!Array.isArray(choices) || choices.length === 0) {
throw new Error('Grok chat completion response did not include choices.');
}
const firstChoice = choices[0];
if (!firstChoice || typeof firstChoice !== 'object' || Array.isArray(firstChoice)) {
throw new Error('Grok chat completion response choice was invalid.');
}
const message = (firstChoice as JsonObject).message;
if (!message || typeof message !== 'object' || Array.isArray(message)) {
throw new Error('Grok chat completion response did not include a message.');
}
const content = (message as JsonObject).content;
if (typeof content === 'string') {
return content;
}
if (Array.isArray(content)) {
return content
.map((part) => {
if (!part || typeof part !== 'object' || Array.isArray(part)) {
return '';
}
const text = (part as JsonObject).text;
return typeof text === 'string' ? text : '';
})
.join('')
.trim();
}
throw new Error('Grok chat completion response did not include text content.');
}
function parseStructuredOutput<TOutput>(text: string): TOutput {
const parsed = tryParseJson(text);
return parsed as TOutput;
}
function extractFirstImage(response: JsonObject): { url?: string; revised_prompt?: string } {
const data = response.data;
if (!Array.isArray(data) || data.length === 0) {
throw new Error('Grok image generation response did not include image data.');
}
const first = data[0];
if (!first || typeof first !== 'object' || Array.isArray(first)) {
throw new Error('Grok image generation response image entry was invalid.');
}
const url = (first as JsonObject).url;
const revisedPrompt = (first as JsonObject).revised_prompt;
const result: { url?: string; revised_prompt?: string } = {};
if (typeof url === 'string') {
result.url = url;
}
if (typeof revisedPrompt === 'string') {
result.revised_prompt = revisedPrompt;
}
return result;
}
function mapAspectRatio(aspectRatio: ImageGenerationRequest['aspectRatio']): string | undefined {
if (aspectRatio === 'square') {
return '1:1';
}
if (aspectRatio === 'portrait') {
return '3:4';
}
if (aspectRatio === 'landscape') {
return '16:9';
}
return undefined;
}
function removeUndefined(value: JsonObject): JsonObject {
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
}
function tryParseJson(text: string): JsonObject {
try {
const parsed = JSON.parse(text) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('JSON value was not an object.');
}
return parsed as JsonObject;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to parse Grok JSON response: ${message}`);
}
}

463
src/schedule/index.ts Normal file
View File

@@ -0,0 +1,463 @@
import { randomUUID } from 'node:crypto';
import type { Fact, IdentityDB, JsonValue } from 'identitydb';
import type { SpecialDateProvider, StructuredModelAdapter } from '../core/adapters';
import { setAvailabilityStatus } from '../availability';
import { dateOnly, getFactDomain, getJsonObject, jsonObject, listFactsInSpace, uniqueStrings } from '../core/facts';
import { persistFactDrafts } from '../memory';
import type {
BoxBrainAvailabilityMode,
BoxBrainAvailabilityEntry,
BoxBrainScheduleEvent,
BoxBrainScheduleEventKind,
BoxBrainScheduleScope,
BoxBrainTopicDraft,
} from '../core/types';
export interface ScheduleEventDraft {
title: string;
description?: string | undefined;
startAt: string;
endAt: string;
availabilityMode: BoxBrainAvailabilityMode;
availabilityReason?: string | undefined;
kind?: BoxBrainScheduleEventKind | undefined;
topics?: BoxBrainTopicDraft[] | undefined;
metadata?: JsonValue | null | undefined;
}
export interface ScheduleGenerationResult {
events: ScheduleEventDraft[];
}
export interface GenerateScheduleInput {
spaceName: string;
displayName?: string | undefined;
currentDate: string;
scope: BoxBrainScheduleScope;
timezone?: string | undefined;
structuredModel: StructuredModelAdapter;
specialDateProvider?: SpecialDateProvider | undefined;
}
export interface ListScheduleEventsInput {
spaceName: string;
from?: string | undefined;
until?: string | undefined;
}
export interface PruneExpiredScheduleInput {
spaceName: string;
referenceTime: string;
graceSeconds?: number | undefined;
}
export interface PruneScheduleBeforeInput {
spaceName: string;
before: string;
}
export interface SchedulePruneResult {
deletedEventIds: string[];
}
export class ScheduleService {
constructor(private readonly db: IdentityDB) {}
async generate(input: GenerateScheduleInput): Promise<{ events: BoxBrainScheduleEvent[]; availabilityEntries: BoxBrainAvailabilityEntry[] }> {
return generateScheduleWithDb(this.db, input);
}
async listEvents(input: ListScheduleEventsInput): Promise<BoxBrainScheduleEvent[]> {
return listScheduleEventsWithDb(this.db, input);
}
async pruneExpired(input: PruneExpiredScheduleInput): Promise<SchedulePruneResult> {
return pruneExpiredScheduleWithDb(this.db, input);
}
async pruneBefore(input: PruneScheduleBeforeInput): Promise<SchedulePruneResult> {
return pruneScheduleBeforeWithDb(this.db, input);
}
}
export async function generateSchedule(
db: IdentityDB,
input: GenerateScheduleInput,
): Promise<{ events: BoxBrainScheduleEvent[]; availabilityEntries: BoxBrainAvailabilityEntry[] }> {
return new ScheduleService(db).generate(input);
}
async function generateScheduleWithDb(
db: IdentityDB,
input: GenerateScheduleInput,
): Promise<{ events: BoxBrainScheduleEvent[]; availabilityEntries: BoxBrainAvailabilityEntry[] }> {
await ensurePersonaSpace(db, input.spaceName, input.displayName);
const specialDates = input.specialDateProvider
? await input.specialDateProvider.listSpecialDates({
anchorDate: input.currentDate,
scope: input.scope,
timezone: input.timezone,
})
: [];
const generated = assertScheduleGenerationResult(
await input.structuredModel.generateObject<ScheduleGenerationResult>({
prompt: await buildSchedulePrompt(db, input, specialDates),
schema: {
type: 'object',
required: ['events'],
},
metadata: {
boxbrainTask: 'persona.schedule.generate',
scope: input.scope,
currentDate: input.currentDate,
},
}),
);
const events = generated.events.map((event) => toPersistedEvent(event, input.displayName));
await persistFactDrafts(db, {
spaceName: input.spaceName,
domain: 'persona.schedule',
source: `${input.structuredModel.provider}:${input.structuredModel.model}`,
facts: events.map((event) => ({
statement: buildScheduleStatement(event),
metadata: jsonObject({
scheduleEvent: jsonObject({
id: event.id,
title: event.title,
description: event.description,
startAt: event.startAt,
endAt: event.endAt,
availabilityMode: event.availabilityMode,
availabilityReason: event.availabilityReason,
kind: event.kind,
metadata: event.metadata ?? null,
}),
}),
topics: event.topics,
})),
});
const availabilityEntries: BoxBrainAvailabilityEntry[] = [];
for (const event of events) {
availabilityEntries.push(await setAvailabilityStatus(db, {
spaceName: input.spaceName,
mode: event.availabilityMode,
reason: event.availabilityReason ?? event.title,
effectiveFrom: event.startAt,
until: event.endAt,
sourceType: 'schedule',
eventId: event.id,
metadata: {
title: event.title,
kind: event.kind,
},
}));
}
return { events, availabilityEntries };
}
export async function listScheduleEvents(db: IdentityDB, input: ListScheduleEventsInput): Promise<BoxBrainScheduleEvent[]> {
return new ScheduleService(db).listEvents(input);
}
async function listScheduleEventsWithDb(db: IdentityDB, input: ListScheduleEventsInput): Promise<BoxBrainScheduleEvent[]> {
const facts = await listFactsInSpace(db, input.spaceName);
const deletedIds = new Set(
facts
.map(parseScheduleDeletionFact)
.filter((entry): entry is { eventId: string } => entry !== null)
.map((entry) => entry.eventId),
);
return facts
.map(parseScheduleEventFact)
.filter((event): event is BoxBrainScheduleEvent => event !== null)
.filter((event) => !deletedIds.has(event.id))
.filter((event) => overlapsRange(event, input.from, input.until))
.sort((left, right) => {
if (left.startAt !== right.startAt) {
return left.startAt.localeCompare(right.startAt);
}
return left.id.localeCompare(right.id);
});
}
export async function pruneExpiredSchedule(db: IdentityDB, input: PruneExpiredScheduleInput): Promise<SchedulePruneResult> {
return new ScheduleService(db).pruneExpired(input);
}
async function pruneExpiredScheduleWithDb(db: IdentityDB, input: PruneExpiredScheduleInput): Promise<SchedulePruneResult> {
const graceMs = (input.graceSeconds ?? 0) * 1000;
const cutoffMs = Date.parse(input.referenceTime) - graceMs;
const events = await listScheduleEvents(db, { spaceName: input.spaceName });
const toDelete = events.filter((event) => Date.parse(event.endAt) <= cutoffMs);
await writeScheduleDeletionFacts(db, input.spaceName, toDelete, 'expired schedule pruning');
return { deletedEventIds: toDelete.map((event) => event.id) };
}
export async function pruneScheduleBefore(db: IdentityDB, input: PruneScheduleBeforeInput): Promise<SchedulePruneResult> {
return new ScheduleService(db).pruneBefore(input);
}
async function pruneScheduleBeforeWithDb(db: IdentityDB, input: PruneScheduleBeforeInput): Promise<SchedulePruneResult> {
const cutoffMs = Date.parse(input.before);
const events = await listScheduleEvents(db, { spaceName: input.spaceName });
const toDelete = events.filter((event) => Date.parse(event.startAt) < cutoffMs);
await writeScheduleDeletionFacts(db, input.spaceName, toDelete, `schedule pruning before ${input.before}`);
return { deletedEventIds: toDelete.map((event) => event.id) };
}
function assertScheduleGenerationResult(value: ScheduleGenerationResult): ScheduleGenerationResult {
if (!value || !Array.isArray(value.events)) {
throw new Error('Structured schedule generation output must include an events array.');
}
for (let index = 0; index < value.events.length; index += 1) {
const event = value.events[index];
if (!event || typeof event.title !== 'string' || event.title.trim().length === 0) {
throw new Error(`Schedule event at index ${index} requires a non-empty title.`);
}
if (typeof event.startAt !== 'string' || typeof event.endAt !== 'string') {
throw new Error(`Schedule event at index ${index} requires startAt and endAt.`);
}
assertValidIsoTimestamp(event.startAt, `Schedule event at index ${index} startAt`);
assertValidIsoTimestamp(event.endAt, `Schedule event at index ${index} endAt`);
if (Date.parse(event.endAt) <= Date.parse(event.startAt)) {
throw new Error(`Schedule event at index ${index} must end after it starts.`);
}
if (event.availabilityMode !== 'online' && event.availabilityMode !== 'do_not_disturb' && event.availabilityMode !== 'offline') {
throw new Error(`Schedule event at index ${index} has an invalid availabilityMode.`);
}
if (event.kind && event.kind !== 'routine' && event.kind !== 'special') {
throw new Error(`Schedule event at index ${index} has an invalid kind.`);
}
if (event.topics && !Array.isArray(event.topics)) {
throw new Error(`Schedule event at index ${index} has invalid topics.`);
}
}
return value;
}
async function buildSchedulePrompt(
db: IdentityDB,
input: GenerateScheduleInput,
specialDates: Awaited<ReturnType<NonNullable<GenerateScheduleInput['specialDateProvider']>['listSpecialDates']>>,
): Promise<string> {
const facts = await listFactsInSpace(db, input.spaceName);
const personaFacts = facts
.filter((fact) => {
const domain = getFactDomain(fact);
return domain === 'persona.biography' || domain === 'persona.relationship' || domain === 'persona.profile_image';
})
.slice(0, 12)
.map((fact) => `- ${fact.statement}`)
.join('\n');
const existingEvents = (await listScheduleEvents(db, { spaceName: input.spaceName }))
.slice(-8)
.map((event) => `- ${event.startAt} to ${event.endAt}: ${event.title}`)
.join('\n');
const dateContext = specialDates.length > 0
? specialDates.map((entry) => `- ${entry.date}: ${entry.title}${entry.description ? `${entry.description}` : ''}`).join('\n')
: '- none provided';
return [
'Create a realistic personal schedule for a synthetic persona.',
'Most of the time should remain routine. Only introduce special events when justified by the persona history, relationships, assets, or special dates.',
'It is acceptable for long periods to stay ordinary or repetitive.',
`Scope: ${input.scope}`,
`Anchor date: ${input.currentDate}`,
input.timezone ? `Timezone: ${input.timezone}` : undefined,
`Display name: ${input.displayName ?? inferDisplayName(input.spaceName)}`,
'Existing persona facts:',
personaFacts || '- none available',
'Existing schedule continuity:',
existingEvents || '- none available',
'Special dates from external context:',
dateContext,
'Return an events array with ISO timestamps, availability mode, and a routine/special kind.',
]
.filter((line): line is string => Boolean(line))
.join('\n');
}
function toPersistedEvent(event: ScheduleEventDraft, displayName: string | undefined): BoxBrainScheduleEvent {
const id = randomUUID();
const topics = normalizeEventTopics(event, displayName);
return {
id,
title: event.title.trim(),
description: event.description?.trim() || undefined,
startAt: event.startAt,
endAt: event.endAt,
availabilityMode: event.availabilityMode,
availabilityReason: event.availabilityReason?.trim() || undefined,
kind: event.kind ?? 'routine',
topics,
metadata: event.metadata,
};
}
function normalizeEventTopics(event: ScheduleEventDraft, displayName: string | undefined): BoxBrainTopicDraft[] {
const draftTopics = event.topics ?? [];
const names = uniqueStrings([
displayName ?? '',
event.title,
dateOnly(event.startAt),
...draftTopics.map((topic) => topic.name),
]);
return names.map((name) => {
const matchingDraft = draftTopics.find((topic) => topic.name === name);
if (matchingDraft) {
return matchingDraft;
}
if (name === dateOnly(event.startAt)) {
return { name, category: 'temporal' };
}
if (displayName && name === displayName) {
return { name, category: 'entity' };
}
return { name, category: 'concept' };
});
}
function buildScheduleStatement(event: BoxBrainScheduleEvent): string {
const description = event.description ? ` ${event.description}` : '';
return `${event.startAt} to ${event.endAt}: ${event.title}.${description}`.trim();
}
function parseScheduleEventFact(fact: Fact): BoxBrainScheduleEvent | null {
if (getFactDomain(fact) !== 'persona.schedule') {
return null;
}
const metadata = getJsonObject(fact.metadata);
const payload = getJsonObject(metadata?.scheduleEvent);
if (!payload) {
return null;
}
const title = payload.title;
const startAt = payload.startAt;
const endAt = payload.endAt;
const availabilityMode = payload.availabilityMode;
if (typeof title !== 'string' || typeof startAt !== 'string' || typeof endAt !== 'string' || typeof availabilityMode !== 'string') {
return null;
}
return {
id: typeof payload.id === 'string' ? payload.id : fact.id,
title,
description: typeof payload.description === 'string' ? payload.description : undefined,
startAt,
endAt,
availabilityMode: availabilityMode as BoxBrainAvailabilityMode,
availabilityReason: typeof payload.availabilityReason === 'string' ? payload.availabilityReason : undefined,
kind: payload.kind === 'special' ? 'special' : 'routine',
topics: fact.topics.map((topic) => ({
name: topic.name,
category: topic.category,
granularity: topic.granularity,
description: topic.description,
metadata: topic.metadata,
})),
metadata: payload.metadata ?? null,
};
}
function parseScheduleDeletionFact(fact: Fact): { eventId: string } | null {
if (getFactDomain(fact) !== 'persona.schedule.deleted') {
return null;
}
const metadata = getJsonObject(fact.metadata);
const deletion = getJsonObject(metadata?.scheduleDeletion);
if (!deletion || typeof deletion.eventId !== 'string') {
return null;
}
return { eventId: deletion.eventId };
}
async function writeScheduleDeletionFacts(
db: IdentityDB,
spaceName: string,
events: BoxBrainScheduleEvent[],
reason: string,
): Promise<void> {
if (events.length === 0) {
return;
}
await persistFactDrafts(db, {
spaceName,
domain: 'persona.schedule.deleted',
source: 'boxbrain:schedule.prune',
facts: events.map((event) => ({
statement: `Deleted schedule event ${event.title} (${event.id}).`,
metadata: jsonObject({
scheduleDeletion: jsonObject({
eventId: event.id,
deletedAt: new Date().toISOString(),
reason,
}),
}),
topics: [
{ name: dateOnly(event.startAt), category: 'temporal' },
{ name: event.title, category: 'concept' },
],
})),
});
}
function overlapsRange(event: BoxBrainScheduleEvent, from: string | undefined, until: string | undefined): boolean {
if (!from && !until) {
return true;
}
const startMs = Date.parse(event.startAt);
const endMs = Date.parse(event.endAt);
const fromMs = from ? Date.parse(from) : Number.NEGATIVE_INFINITY;
const untilMs = until ? Date.parse(until) : Number.POSITIVE_INFINITY;
return startMs < untilMs && endMs > fromMs;
}
async function ensurePersonaSpace(db: IdentityDB, spaceName: string, displayName: string | undefined): Promise<void> {
const existing = await db.getSpaceByName(spaceName);
const existingMetadata = getJsonObject(existing?.metadata) ?? {};
const existingBoxBrain = getJsonObject(existingMetadata.boxbrain) ?? {};
await db.upsertSpace({
name: spaceName,
metadata: jsonObject({
...existingMetadata,
boxbrain: jsonObject({
...existingBoxBrain,
domain: 'persona.space',
personaId: typeof existingBoxBrain.personaId === 'string' ? existingBoxBrain.personaId : inferDisplayName(spaceName),
displayName: displayName ?? (typeof existingBoxBrain.displayName === 'string' ? existingBoxBrain.displayName : inferDisplayName(spaceName)),
profileImageUrl: typeof existingBoxBrain.profileImageUrl === 'string' ? existingBoxBrain.profileImageUrl : undefined,
}),
}),
});
}
function inferDisplayName(spaceName: string): string {
return spaceName.startsWith('persona:') ? spaceName.slice('persona:'.length) : spaceName;
}
function assertValidIsoTimestamp(value: string, fieldName: string): void {
if (!Number.isFinite(Date.parse(value))) {
throw new Error(`${fieldName} must be a valid ISO timestamp.`);
}
}

View File

@@ -1,78 +0,0 @@
import type { BoxBrainAvailability } from './types';
export type RandomSource = () => number;
export interface TypingDelayOptions {
rng?: RandomSource | undefined;
minSecondsPerCharacter?: number | undefined;
maxSecondsPerCharacter?: number | undefined;
}
export interface ReplyDelayOptions {
isFirstReplyInExchange: boolean;
rng?: RandomSource | undefined;
onlineMinSeconds?: number | undefined;
onlineMaxSeconds?: number | undefined;
dndReplyProbability?: number | undefined;
dndMinSeconds?: number | undefined;
dndMaxSeconds?: number | undefined;
}
export const ONLINE_AVAILABILITY: BoxBrainAvailability = { mode: 'online' };
export const DND_AVAILABILITY: BoxBrainAvailability = { mode: 'do_not_disturb' };
export const OFFLINE_AVAILABILITY: BoxBrainAvailability = { mode: 'offline' };
export function createTypingDelay(message: string, options: TypingDelayOptions = {}): number {
if (message.length === 0) {
return 0;
}
const rng = options.rng ?? Math.random;
const min = options.minSecondsPerCharacter ?? 0.05;
const max = options.maxSecondsPerCharacter ?? 0.08;
const secondsPerCharacter = interpolate(min, max, clampUnit(rng()));
return roundSeconds(message.length * secondsPerCharacter);
}
export function createReplyDelay(
availability: BoxBrainAvailability,
options: ReplyDelayOptions,
): number | null {
if (availability.mode === 'offline') {
return null;
}
if (!options.isFirstReplyInExchange) {
return 0;
}
const rng = options.rng ?? Math.random;
if (availability.mode === 'do_not_disturb') {
const probability = options.dndReplyProbability ?? 0.2;
if (clampUnit(rng()) > probability) {
return null;
}
return roundSeconds(interpolate(options.dndMinSeconds ?? 60, options.dndMaxSeconds ?? 600, clampUnit(rng())));
}
return roundSeconds(interpolate(options.onlineMinSeconds ?? 1, options.onlineMaxSeconds ?? 12, clampUnit(rng())));
}
function interpolate(min: number, max: number, ratio: number): number {
return min + (max - min) * ratio;
}
function clampUnit(value: number): number {
if (Number.isNaN(value)) {
return 0;
}
return Math.min(1, Math.max(0, value));
}
function roundSeconds(value: number): number {
return Math.round(value * 1_000_000) / 1_000_000;
}

87
src/timing/index.ts Normal file
View File

@@ -0,0 +1,87 @@
import type { BoxBrainAvailability } from '../core/types';
export type RandomSource = () => number;
export interface TypingDelayOptions {
rng?: RandomSource | undefined;
minSecondsPerCharacter?: number | undefined;
maxSecondsPerCharacter?: number | undefined;
}
export interface ReplyDelayOptions {
isFirstReplyInExchange: boolean;
rng?: RandomSource | undefined;
onlineMinSeconds?: number | undefined;
onlineMaxSeconds?: number | undefined;
dndReplyProbability?: number | undefined;
dndMinSeconds?: number | undefined;
dndMaxSeconds?: number | undefined;
}
export const ONLINE_AVAILABILITY: BoxBrainAvailability = { mode: 'online' };
export const DND_AVAILABILITY: BoxBrainAvailability = { mode: 'do_not_disturb' };
export const OFFLINE_AVAILABILITY: BoxBrainAvailability = { mode: 'offline' };
export class TimingProfile {
createTypingDelay(message: string, options: TypingDelayOptions = {}): number {
if (message.length === 0) {
return 0;
}
const rng = options.rng ?? Math.random;
const min = options.minSecondsPerCharacter ?? 0.05;
const max = options.maxSecondsPerCharacter ?? 0.08;
const secondsPerCharacter = interpolate(min, max, clampUnit(rng()));
return roundSeconds(message.length * secondsPerCharacter);
}
createReplyDelay(availability: BoxBrainAvailability, options: ReplyDelayOptions): number | null {
if (availability.mode === 'offline') {
return null;
}
if (!options.isFirstReplyInExchange) {
return 0;
}
const rng = options.rng ?? Math.random;
if (availability.mode === 'do_not_disturb') {
const probability = options.dndReplyProbability ?? 0.2;
if (clampUnit(rng()) > probability) {
return null;
}
return roundSeconds(interpolate(options.dndMinSeconds ?? 60, options.dndMaxSeconds ?? 600, clampUnit(rng())));
}
return roundSeconds(interpolate(options.onlineMinSeconds ?? 1, options.onlineMaxSeconds ?? 12, clampUnit(rng())));
}
}
const DEFAULT_TIMING_PROFILE = new TimingProfile();
export function createTypingDelay(message: string, options: TypingDelayOptions = {}): number {
return DEFAULT_TIMING_PROFILE.createTypingDelay(message, options);
}
export function createReplyDelay(availability: BoxBrainAvailability, options: ReplyDelayOptions): number | null {
return DEFAULT_TIMING_PROFILE.createReplyDelay(availability, options);
}
function interpolate(min: number, max: number, ratio: number): number {
return min + (max - min) * ratio;
}
function clampUnit(value: number): number {
if (Number.isNaN(value)) {
return 0;
}
return Math.min(1, Math.max(0, value));
}
function roundSeconds(value: number): number {
return Math.round(value * 1_000_000) / 1_000_000;
}

View File

@@ -1,48 +0,0 @@
import type { JsonValue, TopicCategory, TopicGranularity } from 'identitydb';
export type BoxBrainFactDomain =
| 'persona.biography'
| 'persona.profile_image'
| 'persona.schedule'
| 'persona.availability'
| 'persona.conversation'
| 'persona.relationship'
| (string & {});
export type BoxBrainAvailabilityMode = 'online' | 'do_not_disturb' | 'offline';
export interface BoxBrainTopicDraft {
name: string;
category?: TopicCategory | (string & {}) | undefined;
granularity?: TopicGranularity | undefined;
description?: string | null | undefined;
metadata?: JsonValue | null | undefined;
role?: string | null | undefined;
}
export interface BoxBrainFactDraft {
statement: string;
summary?: string | null | undefined;
source?: string | null | undefined;
confidence?: number | null | undefined;
metadata?: JsonValue | null | undefined;
topics: BoxBrainTopicDraft[];
}
export interface BoxBrainAvailability {
mode: BoxBrainAvailabilityMode;
reason?: string | undefined;
until?: string | undefined;
}
export interface BoxBrainMessage {
text: string;
typingDelaySeconds: number;
}
export interface BoxBrainPersonaProfile {
id: string;
spaceName: string;
displayName: string;
profileImageUrl?: string | undefined;
}

143
tests/availability.test.ts Normal file
View File

@@ -0,0 +1,143 @@
import { afterEach, describe, expect, it } from 'vitest';
import {
generateSchedule,
getAvailabilitySnapshot,
setAvailabilityStatus,
type ScheduleGenerationResult,
type StructuredModelAdapter,
} from '../src';
import { closeOpenDbs, createDb } from './helpers';
afterEach(closeOpenDbs);
describe('availability status APIs', () => {
it('defaults to online when no explicit status exists', async () => {
const db = await createDb();
const snapshot = await getAvailabilitySnapshot(db, {
spaceName: 'persona:minji',
at: '2026-05-12T08:00:00.000Z',
});
expect(snapshot.current.mode).toBe('online');
expect(snapshot.current.sourceType).toBe('default');
});
it('derives current and next availability from the stored schedule', async () => {
const db = await createDb();
await seedSchedule(db);
const classTime = await getAvailabilitySnapshot(db, {
spaceName: 'persona:minji',
at: '2026-05-12T09:15:00.000Z',
});
expect(classTime.current.mode).toBe('do_not_disturb');
expect(classTime.current.reason).toBe('In class');
expect(classTime.next?.mode).toBe('online');
expect(classTime.next?.effectiveFrom).toBe('2026-05-12T10:30:00.000Z');
const afterDinner = await getAvailabilitySnapshot(db, {
spaceName: 'persona:minji',
at: '2026-05-12T21:30:00.000Z',
});
expect(afterDinner.current.mode).toBe('offline');
expect(afterDinner.current.until).toBe('2026-05-12T22:00:00.000Z');
});
it('allows explicit status updates that override schedule-derived status', async () => {
const db = await createDb();
await seedSchedule(db);
await setAvailabilityStatus(db, {
spaceName: 'persona:minji',
mode: 'online',
reason: 'Taking a quick break before class starts again.',
effectiveFrom: '2026-05-12T09:10:00.000Z',
until: '2026-05-12T09:40:00.000Z',
sourceType: 'tool',
});
const snapshot = await getAvailabilitySnapshot(db, {
spaceName: 'persona:minji',
at: '2026-05-12T09:20:00.000Z',
});
expect(snapshot.current.mode).toBe('online');
expect(snapshot.current.sourceType).toBe('tool');
expect(snapshot.current.reason).toContain('quick break');
expect(snapshot.next?.mode).toBe('do_not_disturb');
expect(snapshot.next?.effectiveFrom).toBe('2026-05-12T09:40:00.000Z');
});
it('keeps caller metadata separate from reserved availability fields', async () => {
const db = await createDb();
const entry = await setAvailabilityStatus(db, {
spaceName: 'persona:minji',
mode: 'online',
effectiveFrom: '2026-05-12T08:00:00.000Z',
metadata: {
id: 'caller-id',
mode: 'offline',
note: 'still just metadata',
},
});
expect(entry.id).not.toBe('caller-id');
expect(entry.mode).toBe('online');
expect(entry.metadata).toEqual({
id: 'caller-id',
mode: 'offline',
note: 'still just metadata',
});
});
it('rejects invalid availability timestamps', async () => {
const db = await createDb();
await expect(setAvailabilityStatus(db, {
spaceName: 'persona:minji',
mode: 'online',
effectiveFrom: 'not-a-date',
})).rejects.toThrow('Availability effectiveFrom must be a valid ISO timestamp.');
});
});
async function seedSchedule(db: Awaited<ReturnType<typeof createDb>>) {
const structured: StructuredModelAdapter = {
provider: 'fake-structured',
model: 'schedule-model',
async generateObject<TOutput>(): Promise<TOutput> {
return {
events: [
{
title: 'Morning lecture',
description: 'Regular major lecture on campus.',
startAt: '2026-05-12T09:00:00.000Z',
endAt: '2026-05-12T10:30:00.000Z',
availabilityMode: 'do_not_disturb',
availabilityReason: 'In class',
kind: 'routine',
},
{
title: 'Family dinner',
description: 'Dinner with family.',
startAt: '2026-05-12T18:00:00.000Z',
endAt: '2026-05-12T22:00:00.000Z',
availabilityMode: 'offline',
availabilityReason: 'Family dinner',
kind: 'special',
},
],
} satisfies ScheduleGenerationResult as TOutput;
},
};
await generateSchedule(db, {
spaceName: 'persona:minji',
displayName: 'Minji',
currentDate: '2026-05-12',
scope: 'day',
structuredModel: structured,
});
}

258
tests/conversation.test.ts Normal file
View File

@@ -0,0 +1,258 @@
import { afterEach, describe, expect, it } from 'vitest';
import {
generateSchedule,
getAvailabilitySnapshot,
listConversationEntries,
replyToConversation,
setAvailabilityStatus,
startConversation,
type ConversationMemorySelectionResult,
type ConversationTurnPlan,
type ScheduleGenerationResult,
type StructuredModelAdapter,
} from '../src';
import { persistFactDrafts } from '../src/memory';
import { closeOpenDbs, createDb } from './helpers';
afterEach(closeOpenDbs);
describe('conversation APIs', () => {
it('blocks replies while the persona is offline', async () => {
const db = await createDb();
await db.upsertSpace({
name: 'persona:minji',
metadata: { boxbrain: { domain: 'persona.space', personaId: 'minji', displayName: 'Minji' } },
});
await setAvailabilityStatus(db, {
spaceName: 'persona:minji',
mode: 'offline',
reason: 'Sleeping',
effectiveFrom: '2026-05-12T00:00:00.000Z',
until: '2026-05-12T08:00:00.000Z',
sourceType: 'manual',
});
let responseCalls = 0;
const result = await replyToConversation(db, {
spaceName: 'persona:minji',
counterpartId: 'user:shinwoo',
counterpartDisplayName: 'Shinwoo',
message: '지금뭐해',
currentTime: '2026-05-12T07:00:00.000Z',
mandatoryMemoryModel: createSelectionModel([]),
contextualMemoryModel: createSelectionModel([]),
responseModel: {
provider: 'fake-structured',
model: 'response-model',
async generateObject<TOutput>(): Promise<TOutput> {
responseCalls += 1;
return { mode: 'reply', messages: ['안자'] } as TOutput;
},
},
});
expect(result.blocked).toBe(true);
expect(result.messages).toEqual([]);
expect(responseCalls).toBe(0);
});
it('retrieves mandatory/contextual memories, generates DM-style replies, and stores the turn', async () => {
const db = await createDb();
await seedPersonaMemory(db);
const mandatoryPrompts: string[] = [];
const contextualPrompts: string[] = [];
const responsePrompts: string[] = [];
const result = await replyToConversation(db, {
spaceName: 'persona:minji',
counterpartId: 'user:shinwoo',
counterpartDisplayName: 'Shinwoo',
message: '오늘저녁뭐해?',
currentTime: '2026-05-12T12:00:00.000Z',
mandatoryMemoryModel: createSelectionModel(['m1', 'm2'], mandatoryPrompts),
contextualMemoryModel: createSelectionModel(['m3'], contextualPrompts),
responseModel: createResponseModel(
{
mode: 'reply',
messages: ['저녁엔가족이랑먹어', '왜궁금해'],
},
responsePrompts,
),
rng: () => 0,
});
expect(mandatoryPrompts[0]).toContain('yesterday, today, and tomorrow schedules');
expect(contextualPrompts[0]).toContain('오늘저녁뭐해?');
expect(responsePrompts[0]).toContain('family dinner');
expect(result.messages).toHaveLength(2);
expect(result.messages[0]?.replyDelaySeconds).toBe(1);
expect(result.messages[0]?.typingDelaySeconds).toBeGreaterThan(0);
expect(result.messages[1]?.replyDelaySeconds).toBe(0);
expect(result.usedMemories).toHaveLength(3);
const history = await listConversationEntries(db, { spaceName: 'persona:minji', counterpartId: 'user:shinwoo' });
expect(history.map((entry) => `${entry.direction}:${entry.text}`)).toEqual([
'inbound:오늘저녁뭐해?',
'outbound:저녁엔가족이랑먹어',
'outbound:왜궁금해',
]);
});
it('can proactively start a conversation without inbound user text', async () => {
const db = await createDb();
await seedPersonaMemory(db);
const result = await startConversation(db, {
spaceName: 'persona:minji',
counterpartId: 'user:shinwoo',
counterpartDisplayName: 'Shinwoo',
currentTime: '2026-05-12T15:00:00.000Z',
mandatoryMemoryModel: createSelectionModel(['m1']),
contextualMemoryModel: createSelectionModel([]),
responseModel: createResponseModel({ mode: 'reply', messages: ['지금뭐해'] }),
rng: () => 0,
});
expect(result.messages.map((message) => message.text)).toEqual(['지금뭐해']);
const history = await listConversationEntries(db, { spaceName: 'persona:minji', counterpartId: 'user:shinwoo' });
expect(history[0]?.proactive).toBe(true);
});
it('executes availability tool calls after farewell-style refusal messages', async () => {
const db = await createDb();
await seedPersonaMemory(db);
await generateSchedule(db, {
spaceName: 'persona:minji',
displayName: 'Minji',
currentDate: '2026-05-12',
scope: 'day',
structuredModel: {
provider: 'fake-structured',
model: 'schedule-model',
async generateObject<TOutput>(): Promise<TOutput> {
return {
events: [
{
title: 'Exam',
startAt: '2026-05-12T10:00:00.000Z',
endAt: '2026-05-12T12:00:00.000Z',
availabilityMode: 'offline',
availabilityReason: 'Exam starting',
kind: 'special',
},
],
} satisfies ScheduleGenerationResult as TOutput;
},
},
});
const prompts: string[] = [];
const result = await startConversation(db, {
spaceName: 'persona:minji',
counterpartId: 'user:shinwoo',
counterpartDisplayName: 'Shinwoo',
currentTime: '2026-05-12T09:55:00.000Z',
mandatoryMemoryModel: createSelectionModel(['m1']),
contextualMemoryModel: createSelectionModel([]),
responseModel: createResponseModel(
{
mode: 'refuse',
messages: ['나이제가봐야해', '이따연락해'],
toolCalls: [
{
name: 'setAvailabilityStatus',
arguments: {
mode: 'offline',
reason: 'Exam starting',
effectiveFrom: '2026-05-12T09:58:00.000Z',
until: '2026-05-12T12:00:00.000Z',
},
},
],
},
prompts,
),
});
expect(prompts[0]).toContain('next availability transition');
expect(result.messages.map((message) => message.text)).toEqual(['나이제가봐야해', '이따연락해']);
const snapshot = await getAvailabilitySnapshot(db, {
spaceName: 'persona:minji',
at: '2026-05-12T10:00:00.000Z',
});
expect(snapshot.current.mode).toBe('offline');
expect(snapshot.current.reason).toBe('Exam starting');
});
});
function createSelectionModel(memoryIds: string[], prompts: string[] = []): StructuredModelAdapter {
return {
provider: 'fake-structured',
model: 'selection-model',
async generateObject<TOutput>(request: { prompt: string }): Promise<TOutput> {
prompts.push(request.prompt);
return { memoryIds } satisfies ConversationMemorySelectionResult as TOutput;
},
};
}
function createResponseModel(plan: ConversationTurnPlan, prompts: string[] = []): StructuredModelAdapter {
return {
provider: 'fake-structured',
model: 'response-model',
async generateObject<TOutput>(request: { prompt: string }): Promise<TOutput> {
prompts.push(request.prompt);
return plan as TOutput;
},
};
}
async function seedPersonaMemory(db: Awaited<ReturnType<typeof createDb>>) {
await db.upsertSpace({
name: 'persona:minji',
metadata: { boxbrain: { domain: 'persona.space', personaId: 'minji', displayName: 'Minji' } },
});
await persistFactDrafts(db, {
spaceName: 'persona:minji',
domain: 'persona.biography',
source: 'boxbrain:test',
facts: [
{
statement: 'Minji prefers quiet evenings and close family dinners.',
topics: [{ name: 'Minji' }, { name: 'family dinner' }],
},
{
statement: 'Minji knows Shinwoo as a close contact she messages casually.',
topics: [{ name: 'Minji' }, { name: 'Shinwoo' }],
},
],
});
await generateSchedule(db, {
spaceName: 'persona:minji',
displayName: 'Minji',
currentDate: '2026-05-12',
scope: 'day',
structuredModel: {
provider: 'fake-structured',
model: 'schedule-model',
async generateObject<TOutput>(): Promise<TOutput> {
return {
events: [
{
title: 'Family dinner',
description: 'Dinner at home with family.',
startAt: '2026-05-12T18:00:00.000Z',
endAt: '2026-05-12T20:00:00.000Z',
availabilityMode: 'offline',
availabilityReason: 'family dinner',
kind: 'special',
},
],
} satisfies ScheduleGenerationResult as TOutput;
},
},
});
}

147
tests/grok.test.ts Normal file
View File

@@ -0,0 +1,147 @@
import { describe, expect, it } from 'vitest';
import {
createGrokAdapters,
createGrokImageModelAdapter,
createGrokStructuredModelAdapter,
createGrokTextModelAdapter,
} from '../src';
describe('Grok adapters', () => {
it('creates a text adapter that calls xAI chat completions', async () => {
const calls: Array<{ url: string; init?: RequestInit }> = [];
const adapter = createGrokTextModelAdapter({
apiKey: 'test-key',
model: 'grok-4.3-mini',
fetch: async (url, init) => {
calls.push({ url: String(url), ...(init ? { init } : {}) });
return new Response(JSON.stringify({
choices: [
{
message: {
content: 'hello from grok',
},
},
],
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
},
});
await expect(adapter.generateText({
system: 'You are a helpful persona engine.',
prompt: 'Say hi.',
temperature: 0.3,
})).resolves.toBe('hello from grok');
expect(calls).toHaveLength(1);
expect(calls[0]?.url).toBe('https://api.x.ai/v1/chat/completions');
expect(calls[0]?.init?.method).toBe('POST');
expect(calls[0]?.init?.headers).toMatchObject({
authorization: 'Bearer test-key',
'content-type': 'application/json',
});
const body = JSON.parse(String(calls[0]?.init?.body));
expect(body.model).toBe('grok-4.3-mini');
expect(body.temperature).toBe(0.3);
expect(body.messages).toEqual([
{ role: 'system', content: 'You are a helpful persona engine.' },
{ role: 'user', content: 'Say hi.' },
]);
});
it('creates a structured adapter that sends json_schema response_format and parses JSON', async () => {
const calls: Array<{ url: string; init?: RequestInit }> = [];
const adapter = createGrokStructuredModelAdapter({
apiKey: 'test-key',
model: 'grok-4.3',
fetch: async (url, init) => {
calls.push({ url: String(url), ...(init ? { init } : {}) });
return new Response(JSON.stringify({
choices: [
{
message: {
content: JSON.stringify({ biography: 'Born in Busan.' }),
},
},
],
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
},
});
const schema = {
type: 'object',
required: ['biography'],
properties: { biography: { type: 'string' } },
};
await expect(adapter.generateObject<{ biography: string }>({
prompt: 'Write a biography.',
system: 'Return only JSON.',
schema,
})).resolves.toEqual({ biography: 'Born in Busan.' });
const body = JSON.parse(String(calls[0]?.init?.body));
expect(body.response_format).toEqual({
type: 'json_schema',
json_schema: {
name: 'boxbrain_structured_output',
schema,
},
});
});
it('creates an image adapter that calls xAI image generation with mapped aspect ratios', async () => {
const calls: Array<{ url: string; init?: RequestInit }> = [];
const adapter = createGrokImageModelAdapter({
apiKey: 'test-key',
model: 'grok-imagine-image-quality',
fetch: async (url, init) => {
calls.push({ url: String(url), ...(init ? { init } : {}) });
return new Response(JSON.stringify({
data: [{ url: 'https://cdn.x.ai/generated.png', revised_prompt: 'revised prompt' }],
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
},
});
await expect(adapter.generateImage({
prompt: 'A quiet portrait photo.',
aspectRatio: 'portrait',
})).resolves.toEqual({
url: 'https://cdn.x.ai/generated.png',
revisedPrompt: 'revised prompt',
});
expect(calls[0]?.url).toBe('https://api.x.ai/v1/images/generations');
const body = JSON.parse(String(calls[0]?.init?.body));
expect(body.aspect_ratio).toBe('3:4');
expect(body.model).toBe('grok-imagine-image-quality');
});
it('can create a bundled Grok adapter set with shared defaults', async () => {
const adapters = createGrokAdapters({
apiKey: 'bundle-key',
textModel: 'grok-4.3-mini',
structuredModel: 'grok-4.3',
imageModel: 'grok-imagine-image-quality',
fetch: async (_url, _init) => new Response(JSON.stringify({
choices: [{ message: { content: 'ok' } }],
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
});
expect(adapters.text.provider).toBe('xai-grok');
expect(adapters.structured.model).toBe('grok-4.3');
expect(adapters.image.model).toBe('grok-imagine-image-quality');
});
});

16
tests/helpers.ts Normal file
View File

@@ -0,0 +1,16 @@
import { IdentityDB } from 'identitydb';
const openDbs: IdentityDB[] = [];
export async function createDb() {
const db = await IdentityDB.connect({ client: 'sqlite', filename: ':memory:' });
await db.initialize();
openDbs.push(db);
return db;
}
export async function closeOpenDbs() {
while (openDbs.length > 0) {
await openDbs.pop()!.close();
}
}

View File

@@ -53,6 +53,32 @@ describe('initializePersona', () => {
expect(facts.map((fact) => fact.statement)).toContain('Minji grew up near the sea.');
});
it('accepts a single freeform persona seed string and forwards it to the biography generation step', async () => {
const db = await createDb();
const prompts: string[] = [];
const structured = createStructuredAdapter(prompts);
const seedText =
'Mina is 29, grew up in Busan, moved to Seoul for product design work, values loyalty and quiet consistency, loves indie music and late-night walks, dislikes loud restaurants, and is very close to her older brother Jisoo.';
const persona = await initializePersona(db, {
id: 'mina',
displayName: 'Mina',
seedText,
currentDate: '2026-05-11',
structuredModel: structured,
});
expect(persona).toMatchObject({
id: 'mina',
displayName: 'Mina',
spaceName: 'persona:mina',
});
expect(prompts[0]).toContain('Freeform persona seed');
expect(prompts[0]).toContain(seedText);
expect(prompts[0]).toContain('infer personality, history, values, likes, dislikes, and relationships');
expect(prompts[0]).toContain('Current date: 2026-05-11');
});
it('does not call the image adapter unless a profile image is requested', async () => {
const db = await createDb();
const structured = createStructuredAdapter([]);

View File

@@ -1,11 +1,22 @@
import { describe, expect, it } from 'vitest';
import {
createGrokAdapters,
createReplyDelay,
createTypingDelay,
generateSchedule,
ONLINE_AVAILABILITY,
replyToConversation,
type BoxBrainFactDraft,
type SpecialDateProvider,
type TextModelAdapter,
} from '../src';
import { AvailabilityService } from '../src/availability';
import { ConversationService } from '../src/conversation';
import { FactDraftMemoryStore } from '../src/memory';
import { PersonaService } from '../src/persona';
import { GrokApiClient } from '../src/providers/grok';
import { ScheduleService } from '../src/schedule';
import { TimingProfile } from '../src/timing';
describe('public API', () => {
it('exports timing helpers and runtime availability constants', () => {
@@ -34,4 +45,25 @@ describe('public API', () => {
expect(fact.topics.map((topic) => topic.name)).toEqual(['Mina', 'quiet cafés']);
});
it('exports grouped service classes and provider runtime helpers', () => {
const specialDateProvider: SpecialDateProvider = {
async listSpecialDates() {
return [{ date: '2026-05-08', title: 'Parents Day' }];
},
};
expect(typeof generateSchedule).toBe('function');
expect(typeof replyToConversation).toBe('function');
expect(typeof createGrokAdapters).toBe('function');
expect(specialDateProvider.listSpecialDates).toBeTypeOf('function');
expect(AvailabilityService).toBeTypeOf('function');
expect(ConversationService).toBeTypeOf('function');
expect(FactDraftMemoryStore).toBeTypeOf('function');
expect(PersonaService).toBeTypeOf('function');
expect(GrokApiClient).toBeTypeOf('function');
expect(ScheduleService).toBeTypeOf('function');
expect(TimingProfile).toBeTypeOf('function');
});
});

View File

@@ -0,0 +1,29 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
describe('release config', () => {
it('depends on the published identitydb 0.2.0 npm package', () => {
const packageJson = JSON.parse(
readFileSync(join(process.cwd(), 'package.json'), 'utf8'),
) as {
dependencies?: Record<string, string>;
trustedDependencies?: string[];
};
expect(packageJson.dependencies?.identitydb).toBe('0.2.0');
expect(packageJson.trustedDependencies).toEqual(
expect.arrayContaining(['better-sqlite3', 'esbuild']),
);
});
it('publishes without cloning a sibling IdentityDB repository first', () => {
const workflow = readFileSync(
join(process.cwd(), '.gitea/workflows/npm-release.yml'),
'utf8',
);
expect(workflow).not.toContain('Clone IdentityDB dependency');
expect(workflow).not.toContain('IDENTITYDB_URL');
});
});

178
tests/schedule.test.ts Normal file
View File

@@ -0,0 +1,178 @@
import { afterEach, describe, expect, it } from 'vitest';
import {
generateSchedule,
getAvailabilitySnapshot,
listScheduleEvents,
pruneExpiredSchedule,
pruneScheduleBefore,
type ScheduleGenerationResult,
type SpecialDateProvider,
type StructuredModelAdapter,
} from '../src';
import { closeOpenDbs, createDb } from './helpers';
afterEach(closeOpenDbs);
describe('generateSchedule', () => {
it('generates schedule events, stores them, and includes special date context in the prompt', async () => {
const db = await createDb();
const prompts: string[] = [];
const structured: StructuredModelAdapter = {
provider: 'fake-structured',
model: 'schedule-model',
async generateObject<TOutput>(request: { prompt: string }): Promise<TOutput> {
prompts.push(request.prompt);
return {
events: [
{
title: 'Morning lecture',
description: 'Regular major lecture on campus.',
startAt: '2026-05-12T09:00:00.000Z',
endAt: '2026-05-12T10:30:00.000Z',
availabilityMode: 'do_not_disturb',
availabilityReason: 'In class',
kind: 'routine',
topics: [{ name: 'lecture', category: 'concept' }],
},
{
title: 'Birthday dinner',
description: 'Family dinner for a parent birthday.',
startAt: '2026-05-12T18:00:00.000Z',
endAt: '2026-05-12T20:00:00.000Z',
availabilityMode: 'offline',
availabilityReason: 'Family dinner',
kind: 'special',
topics: [{ name: 'birthday dinner', category: 'concept' }],
},
],
} satisfies ScheduleGenerationResult as TOutput;
},
};
const specialDateProvider: SpecialDateProvider = {
async listSpecialDates() {
return [
{
date: '2026-05-12',
title: 'Parents Day',
description: 'A family-focused observance in Korea.',
},
];
},
};
const result = await generateSchedule(db, {
spaceName: 'persona:minji',
displayName: 'Minji',
currentDate: '2026-05-12',
scope: 'day',
timezone: 'Asia/Seoul',
structuredModel: structured,
specialDateProvider,
});
expect(prompts[0]).toContain('Parents Day');
expect(prompts[0]).toContain('Most of the time should remain routine');
expect(result.events.map((event) => event.title)).toEqual(['Morning lecture', 'Birthday dinner']);
expect(result.availabilityEntries.map((entry) => entry.mode)).toEqual(['do_not_disturb', 'offline']);
const stored = await listScheduleEvents(db, { spaceName: 'persona:minji' });
expect(stored.map((event) => event.title)).toEqual(['Morning lecture', 'Birthday dinner']);
expect(stored[0]?.topics.map((topic) => topic.name)).toContain('2026-05-12');
});
it('can prune events that ended before a reference time', async () => {
const db = await createDb();
await seedSchedule(db);
const pruned = await pruneExpiredSchedule(db, {
spaceName: 'persona:minji',
referenceTime: '2026-05-12T12:00:00.000Z',
graceSeconds: 0,
});
expect(pruned.deletedEventIds).toHaveLength(1);
expect((await listScheduleEvents(db, { spaceName: 'persona:minji' })).map((event) => event.title)).toEqual(['Evening shift']);
const snapshot = await getAvailabilitySnapshot(db, {
spaceName: 'persona:minji',
at: '2026-05-12T09:30:00.000Z',
});
expect(snapshot.current.mode).toBe('online');
expect(snapshot.current.sourceType).toBe('default');
});
it('can prune events scheduled before a cutoff date', async () => {
const db = await createDb();
await seedSchedule(db);
const pruned = await pruneScheduleBefore(db, {
spaceName: 'persona:minji',
before: '2026-05-12T18:00:00.000Z',
});
expect(pruned.deletedEventIds).toHaveLength(1);
expect((await listScheduleEvents(db, { spaceName: 'persona:minji' })).map((event) => event.title)).toEqual(['Evening shift']);
});
it('rejects invalid schedule timestamps from the structured model', async () => {
const db = await createDb();
const structured: StructuredModelAdapter = {
provider: 'fake-structured',
model: 'schedule-model',
async generateObject<TOutput>(): Promise<TOutput> {
return {
events: [
{
title: 'Broken event',
startAt: 'not-a-date',
endAt: '2026-05-12T10:00:00.000Z',
availabilityMode: 'online',
},
],
} satisfies ScheduleGenerationResult as TOutput;
},
};
await expect(generateSchedule(db, {
spaceName: 'persona:minji',
displayName: 'Minji',
currentDate: '2026-05-12',
scope: 'day',
structuredModel: structured,
})).rejects.toThrow('Schedule event at index 0 startAt must be a valid ISO timestamp.');
});
});
async function seedSchedule(db: Awaited<ReturnType<typeof createDb>>) {
const structured: StructuredModelAdapter = {
provider: 'fake-structured',
model: 'schedule-model',
async generateObject<TOutput>(): Promise<TOutput> {
return {
events: [
{
title: 'Morning lecture',
startAt: '2026-05-12T09:00:00.000Z',
endAt: '2026-05-12T10:30:00.000Z',
availabilityMode: 'do_not_disturb',
kind: 'routine',
},
{
title: 'Evening shift',
startAt: '2026-05-12T18:00:00.000Z',
endAt: '2026-05-12T22:00:00.000Z',
availabilityMode: 'offline',
kind: 'routine',
},
],
} satisfies ScheduleGenerationResult as TOutput;
},
};
await generateSchedule(db, {
spaceName: 'persona:minji',
displayName: 'Minji',
currentDate: '2026-05-12',
scope: 'day',
structuredModel: structured,
});
}