Compare commits

...

4 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
18 changed files with 478 additions and 156 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

@@ -34,6 +34,27 @@ 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 now contains the framework core for persona initialization, schedule/status management, conversation orchestration, and a ready-made Grok adapter set. See the implementation plan:

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

@@ -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"
]
}

View File

@@ -1,13 +1,13 @@
import { randomUUID } from 'node:crypto';
import type { Fact, IdentityDB, JsonValue } from 'identitydb';
import { dateOnly, getFactDomain, getJsonObject, jsonObject, listFactsInSpace } from './facts';
import { persistFactDrafts } from './memory';
import { dateOnly, getFactDomain, getJsonObject, jsonObject, listFactsInSpace } from '../core/facts';
import { persistFactDrafts } from '../memory';
import type {
BoxBrainAvailabilityEntry,
BoxBrainAvailabilityMode,
BoxBrainAvailabilitySnapshot,
BoxBrainAvailabilitySourceType,
} from './types';
} from '../core/types';
export interface SetAvailabilityStatusInput {
spaceName: string;
@@ -35,7 +35,27 @@ const EXPLICIT_SOURCE_PRIORITY: Record<Exclude<BoxBrainAvailabilitySourceType, '
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);
@@ -80,6 +100,13 @@ export async function setAvailabilityStatus(db: IdentityDB, input: SetAvailabili
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);
@@ -94,6 +121,13 @@ export async function listAvailabilityEntries(
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);

View File

@@ -1,10 +1,10 @@
import { randomUUID } from 'node:crypto';
import type { Fact, IdentityDB, JsonValue } from 'identitydb';
import type { StructuredModelAdapter } from './adapters';
import { getAvailabilitySnapshot, setAvailabilityStatus } from './availability';
import { dateOnly, getFactDomain, getJsonObject, jsonObject, listFactsInSpace, resolvePersonaProfile, shiftIsoDate } from './facts';
import { persistFactDrafts } from './memory';
import { createReplyDelay, createTypingDelay } from './timing';
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,
@@ -12,7 +12,7 @@ import type {
BoxBrainMemoryReference,
BoxBrainMessage,
BoxBrainToolCall,
} from './types';
} from '../core/types';
export interface ConversationMemorySelectionResult {
memoryIds: string[];
@@ -70,7 +70,27 @@ export interface ListConversationEntriesInput {
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,
@@ -93,6 +113,10 @@ export async function replyToConversation(db: IdentityDB, input: ReplyToConversa
}
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,
@@ -103,6 +127,13 @@ export async function startConversation(db: IdentityDB, input: StartConversation
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

View File

@@ -1,9 +1,9 @@
export * from './adapters';
export * from './core/adapters';
export * from './core/types';
export * from './availability';
export * from './conversation';
export * from './grok';
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 class FactDraftMemoryStore {
constructor(private readonly db: IdentityDB) {}
async persist(input: PersistFactDraftsInput): Promise<Fact[]> {
if (input.facts.length === 0) {
return [];
}
await this.db.upsertSpace({ name: input.spaceName });
const persisted: Fact[] = [];
for (const draft of input.facts) {
persisted.push(await this.db.addFact(toAddFactInput(draft, input)));
}
return persisted;
}
}
export async function persistFactDrafts(db: IdentityDB, input: PersistFactDraftsInput): Promise<Fact[]> {
if (input.facts.length === 0) {
return [];
}
await db.upsertSpace({ name: input.spaceName });
const persisted: Fact[] = [];
for (const draft of input.facts) {
persisted.push(await db.addFact(toAddFactInput(draft, input)));
}
return persisted;
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;
@@ -74,7 +74,19 @@ 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);

View File

@@ -5,7 +5,7 @@ import type {
StructuredModelAdapter,
TextGenerationRequest,
TextModelAdapter,
} from './adapters';
} from '../../core/adapters';
type GrokFetch = typeof fetch;
@@ -32,6 +32,43 @@ export interface GrokAdapterBundleOptions {
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);
@@ -132,39 +169,8 @@ export function createGrokAdapters(options: GrokAdapterBundleOptions): {
};
}
function createRuntime(options: Pick<GrokAdapterOptions, 'apiKey' | 'baseUrl' | 'fetch' | 'extraHeaders'>): {
postJson: (path: string, body: JsonObject) => Promise<JsonObject>;
} {
const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, '');
const fetchImpl = options.fetch ?? globalThis.fetch;
if (!fetchImpl) {
throw new Error('Grok adapter requires a fetch implementation.');
}
return {
async postJson(path, body) {
const response = await fetchImpl(`${baseUrl}${path}`, {
method: 'POST',
headers: {
authorization: `Bearer ${options.apiKey}`,
'content-type': 'application/json',
...(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;
},
};
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 }> {

View File

@@ -1,9 +1,9 @@
import { randomUUID } from 'node:crypto';
import type { Fact, IdentityDB, JsonValue } from 'identitydb';
import type { SpecialDateProvider, StructuredModelAdapter } from './adapters';
import { setAvailabilityStatus } from './availability';
import { dateOnly, getFactDomain, getJsonObject, jsonObject, listFactsInSpace, uniqueStrings } from './facts';
import { persistFactDrafts } from './memory';
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,
@@ -11,7 +11,7 @@ import type {
BoxBrainScheduleEventKind,
BoxBrainScheduleScope,
BoxBrainTopicDraft,
} from './types';
} from '../core/types';
export interface ScheduleEventDraft {
title: string;
@@ -60,9 +60,36 @@ 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);
@@ -134,6 +161,10 @@ export async function generateSchedule(
}
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
@@ -156,6 +187,10 @@ export async function listScheduleEvents(db: IdentityDB, input: ListScheduleEven
}
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 });
@@ -166,6 +201,10 @@ export async function pruneExpiredSchedule(db: IdentityDB, input: PruneExpiredSc
}
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);

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

@@ -10,6 +10,13 @@ import {
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', () => {
@@ -39,7 +46,7 @@ describe('public API', () => {
expect(fact.topics.map((topic) => topic.name)).toEqual(['Mina', 'quiet cafés']);
});
it('exports schedule, conversation, Grok, and external special-date adapter contracts', () => {
it('exports grouped service classes and provider runtime helpers', () => {
const specialDateProvider: SpecialDateProvider = {
async listSpecialDates() {
return [{ date: '2026-05-08', title: 'Parents Day' }];
@@ -50,5 +57,13 @@ describe('public API', () => {
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');
});
});