diff --git a/README.md b/README.md index 05aad33..3e070d7 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,21 @@ 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`. diff --git a/src/availability.ts b/src/availability/index.ts similarity index 87% rename from src/availability.ts rename to src/availability/index.ts index adbb7f7..a885a29 100644 --- a/src/availability.ts +++ b/src/availability/index.ts @@ -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 { + return setAvailabilityStatusWithDb(this.db, input); + } + + async listEntries(input: ListAvailabilityEntriesInput): Promise { + return listAvailabilityEntriesWithDb(this.db, input); + } + + async getSnapshot(input: GetAvailabilitySnapshotInput): Promise { + return getAvailabilitySnapshotWithDb(this.db, input); + } +} + export async function setAvailabilityStatus(db: IdentityDB, input: SetAvailabilityStatusInput): Promise { + return new AvailabilityService(db).setStatus(input); +} + +async function setAvailabilityStatusWithDb(db: IdentityDB, input: SetAvailabilityStatusInput): Promise { 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 { + return new AvailabilityService(db).listEntries(input); +} + +async function listAvailabilityEntriesWithDb( + db: IdentityDB, + input: ListAvailabilityEntriesInput, ): Promise { 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 { + return new AvailabilityService(db).getSnapshot(input); +} + +async function getAvailabilitySnapshotWithDb( + db: IdentityDB, + input: GetAvailabilitySnapshotInput, ): Promise { const entries = await listAvailabilityEntries(db, { spaceName: input.spaceName }); const current = selectAvailabilityAt(entries, input.at) ?? createDefaultOnlineAvailability(input.at); diff --git a/src/conversation.ts b/src/conversation/index.ts similarity index 93% rename from src/conversation.ts rename to src/conversation/index.ts index 825f263..82da346 100644 --- a/src/conversation.ts +++ b/src/conversation/index.ts @@ -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 { + return replyToConversationWithDb(this.db, input); + } + + async start(input: StartConversationInput): Promise { + return startConversationWithDb(this.db, input); + } + + async listEntries(input: ListConversationEntriesInput): Promise { + return listConversationEntriesWithDb(this.db, input); + } +} + export async function replyToConversation(db: IdentityDB, input: ReplyToConversationInput): Promise { + return new ConversationService(db).reply(input); +} + +async function replyToConversationWithDb(db: IdentityDB, input: ReplyToConversationInput): Promise { 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 { + return new ConversationService(db).start(input); +} + +async function startConversationWithDb(db: IdentityDB, input: StartConversationInput): Promise { 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 { + return new ConversationService(db).listEntries(input); +} + +async function listConversationEntriesWithDb( + db: IdentityDB, + input: ListConversationEntriesInput, ): Promise { const facts = await listFactsInSpace(db, input.spaceName); return facts diff --git a/src/adapters.ts b/src/core/adapters.ts similarity index 100% rename from src/adapters.ts rename to src/core/adapters.ts diff --git a/src/facts.ts b/src/core/facts.ts similarity index 100% rename from src/facts.ts rename to src/core/facts.ts diff --git a/src/types.ts b/src/core/types.ts similarity index 100% rename from src/types.ts rename to src/core/types.ts diff --git a/src/index.ts b/src/index.ts index 9ca629b..6818985 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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'; diff --git a/src/memory.ts b/src/memory/index.ts similarity index 81% rename from src/memory.ts rename to src/memory/index.ts index 4d109f3..be81200 100644 --- a/src/memory.ts +++ b/src/memory/index.ts @@ -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(['entity', 'concept', 'temporal', 'custom']); +export class FactDraftMemoryStore { + constructor(private readonly db: IdentityDB) {} + + async persist(input: PersistFactDraftsInput): Promise { + 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 { - 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 { diff --git a/src/persona.ts b/src/persona/index.ts similarity index 95% rename from src/persona.ts rename to src/persona/index.ts index 596b2ee..46c72dd 100644 --- a/src/persona.ts +++ b/src/persona/index.ts @@ -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 { + return initializePersonaWithDb(this.db, input); + } +} + export async function initializePersona(db: IdentityDB, input: InitializePersonaInput): Promise { + return new PersonaService(db).initialize(input); +} + +async function initializePersonaWithDb(db: IdentityDB, input: InitializePersonaInput): Promise { assertPersonaInitializationInput(input); const id = input.id ?? createPersonaId(input.displayName); diff --git a/src/grok.ts b/src/providers/grok/index.ts similarity index 83% rename from src/grok.ts rename to src/providers/grok/index.ts index 7661744..003649c 100644 --- a/src/grok.ts +++ b/src/providers/grok/index.ts @@ -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) { + 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 { + 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): { - postJson: (path: string, body: JsonObject) => Promise; -} { - 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): GrokApiClient { + return new GrokApiClient(options); } function buildMessages(system: string | undefined, prompt: string): Array<{ role: 'system' | 'user'; content: string }> { diff --git a/src/schedule.ts b/src/schedule/index.ts similarity index 89% rename from src/schedule.ts rename to src/schedule/index.ts index d903a2f..ffa1aa4 100644 --- a/src/schedule.ts +++ b/src/schedule/index.ts @@ -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 { + return listScheduleEventsWithDb(this.db, input); + } + + async pruneExpired(input: PruneExpiredScheduleInput): Promise { + return pruneExpiredScheduleWithDb(this.db, input); + } + + async pruneBefore(input: PruneScheduleBeforeInput): Promise { + 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 { + return new ScheduleService(db).listEvents(input); +} + +async function listScheduleEventsWithDb(db: IdentityDB, input: ListScheduleEventsInput): Promise { 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 { + return new ScheduleService(db).pruneExpired(input); +} + +async function pruneExpiredScheduleWithDb(db: IdentityDB, input: PruneExpiredScheduleInput): Promise { 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 { + return new ScheduleService(db).pruneBefore(input); +} + +async function pruneScheduleBeforeWithDb(db: IdentityDB, input: PruneScheduleBeforeInput): Promise { const cutoffMs = Date.parse(input.before); const events = await listScheduleEvents(db, { spaceName: input.spaceName }); const toDelete = events.filter((event) => Date.parse(event.startAt) < cutoffMs); diff --git a/src/timing.ts b/src/timing.ts deleted file mode 100644 index 14ef531..0000000 --- a/src/timing.ts +++ /dev/null @@ -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; -} diff --git a/src/timing/index.ts b/src/timing/index.ts new file mode 100644 index 0000000..e58dbab --- /dev/null +++ b/src/timing/index.ts @@ -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; +} diff --git a/tests/public-api.test.ts b/tests/public-api.test.ts index 4403d50..c2a2ea6 100644 --- a/tests/public-api.test.ts +++ b/tests/public-api.test.ts @@ -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'); }); });