refactor: group domain services into folders
This commit is contained in:
15
README.md
15
README.md
@@ -34,6 +34,21 @@ bun run check
|
|||||||
bun run build
|
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
|
## Release
|
||||||
|
|
||||||
Tagging `vX.Y.Z` or `X.Y.Z` triggers the Gitea npm release workflow under `.gitea/workflows/npm-release.yml`.
|
Tagging `vX.Y.Z` or `X.Y.Z` triggers the Gitea npm release workflow under `.gitea/workflows/npm-release.yml`.
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import type { Fact, IdentityDB, JsonValue } from 'identitydb';
|
import type { Fact, IdentityDB, JsonValue } from 'identitydb';
|
||||||
import { dateOnly, getFactDomain, getJsonObject, jsonObject, listFactsInSpace } from './facts';
|
import { dateOnly, getFactDomain, getJsonObject, jsonObject, listFactsInSpace } from '../core/facts';
|
||||||
import { persistFactDrafts } from './memory';
|
import { persistFactDrafts } from '../memory';
|
||||||
import type {
|
import type {
|
||||||
BoxBrainAvailabilityEntry,
|
BoxBrainAvailabilityEntry,
|
||||||
BoxBrainAvailabilityMode,
|
BoxBrainAvailabilityMode,
|
||||||
BoxBrainAvailabilitySnapshot,
|
BoxBrainAvailabilitySnapshot,
|
||||||
BoxBrainAvailabilitySourceType,
|
BoxBrainAvailabilitySourceType,
|
||||||
} from './types';
|
} from '../core/types';
|
||||||
|
|
||||||
export interface SetAvailabilityStatusInput {
|
export interface SetAvailabilityStatusInput {
|
||||||
spaceName: string;
|
spaceName: string;
|
||||||
@@ -35,7 +35,27 @@ const EXPLICIT_SOURCE_PRIORITY: Record<Exclude<BoxBrainAvailabilitySourceType, '
|
|||||||
tool: 3,
|
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> {
|
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);
|
assertAvailabilityMode(input.mode);
|
||||||
assertChronology(input.effectiveFrom, input.until);
|
assertChronology(input.effectiveFrom, input.until);
|
||||||
|
|
||||||
@@ -80,6 +100,13 @@ export async function setAvailabilityStatus(db: IdentityDB, input: SetAvailabili
|
|||||||
export async function listAvailabilityEntries(
|
export async function listAvailabilityEntries(
|
||||||
db: IdentityDB,
|
db: IdentityDB,
|
||||||
input: ListAvailabilityEntriesInput,
|
input: ListAvailabilityEntriesInput,
|
||||||
|
): Promise<BoxBrainAvailabilityEntry[]> {
|
||||||
|
return new AvailabilityService(db).listEntries(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listAvailabilityEntriesWithDb(
|
||||||
|
db: IdentityDB,
|
||||||
|
input: ListAvailabilityEntriesInput,
|
||||||
): Promise<BoxBrainAvailabilityEntry[]> {
|
): Promise<BoxBrainAvailabilityEntry[]> {
|
||||||
const facts = await listFactsInSpace(db, input.spaceName);
|
const facts = await listFactsInSpace(db, input.spaceName);
|
||||||
const deletedScheduleEventIds = collectDeletedScheduleEventIds(facts);
|
const deletedScheduleEventIds = collectDeletedScheduleEventIds(facts);
|
||||||
@@ -94,6 +121,13 @@ export async function listAvailabilityEntries(
|
|||||||
export async function getAvailabilitySnapshot(
|
export async function getAvailabilitySnapshot(
|
||||||
db: IdentityDB,
|
db: IdentityDB,
|
||||||
input: GetAvailabilitySnapshotInput,
|
input: GetAvailabilitySnapshotInput,
|
||||||
|
): Promise<BoxBrainAvailabilitySnapshot> {
|
||||||
|
return new AvailabilityService(db).getSnapshot(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAvailabilitySnapshotWithDb(
|
||||||
|
db: IdentityDB,
|
||||||
|
input: GetAvailabilitySnapshotInput,
|
||||||
): Promise<BoxBrainAvailabilitySnapshot> {
|
): Promise<BoxBrainAvailabilitySnapshot> {
|
||||||
const entries = await listAvailabilityEntries(db, { spaceName: input.spaceName });
|
const entries = await listAvailabilityEntries(db, { spaceName: input.spaceName });
|
||||||
const current = selectAvailabilityAt(entries, input.at) ?? createDefaultOnlineAvailability(input.at);
|
const current = selectAvailabilityAt(entries, input.at) ?? createDefaultOnlineAvailability(input.at);
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import type { Fact, IdentityDB, JsonValue } from 'identitydb';
|
import type { Fact, IdentityDB, JsonValue } from 'identitydb';
|
||||||
import type { StructuredModelAdapter } from './adapters';
|
import type { StructuredModelAdapter } from '../core/adapters';
|
||||||
import { getAvailabilitySnapshot, setAvailabilityStatus } from './availability';
|
import { getAvailabilitySnapshot, setAvailabilityStatus } from '../availability';
|
||||||
import { dateOnly, getFactDomain, getJsonObject, jsonObject, listFactsInSpace, resolvePersonaProfile, shiftIsoDate } from './facts';
|
import { dateOnly, getFactDomain, getJsonObject, jsonObject, listFactsInSpace, resolvePersonaProfile, shiftIsoDate } from '../core/facts';
|
||||||
import { persistFactDrafts } from './memory';
|
import { persistFactDrafts } from '../memory';
|
||||||
import { createReplyDelay, createTypingDelay } from './timing';
|
import { createReplyDelay, createTypingDelay } from '../timing';
|
||||||
import type {
|
import type {
|
||||||
BoxBrainAvailabilityMode,
|
BoxBrainAvailabilityMode,
|
||||||
BoxBrainConversationDirection,
|
BoxBrainConversationDirection,
|
||||||
@@ -12,7 +12,7 @@ import type {
|
|||||||
BoxBrainMemoryReference,
|
BoxBrainMemoryReference,
|
||||||
BoxBrainMessage,
|
BoxBrainMessage,
|
||||||
BoxBrainToolCall,
|
BoxBrainToolCall,
|
||||||
} from './types';
|
} from '../core/types';
|
||||||
|
|
||||||
export interface ConversationMemorySelectionResult {
|
export interface ConversationMemorySelectionResult {
|
||||||
memoryIds: string[];
|
memoryIds: string[];
|
||||||
@@ -70,7 +70,27 @@ export interface ListConversationEntriesInput {
|
|||||||
until?: 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> {
|
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();
|
const turnId = randomUUID();
|
||||||
await persistConversationEntry(db, {
|
await persistConversationEntry(db, {
|
||||||
spaceName: input.spaceName,
|
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> {
|
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, {
|
return generateConversationTurn(db, {
|
||||||
...input,
|
...input,
|
||||||
proactive: true,
|
proactive: true,
|
||||||
@@ -103,6 +127,13 @@ export async function startConversation(db: IdentityDB, input: StartConversation
|
|||||||
export async function listConversationEntries(
|
export async function listConversationEntries(
|
||||||
db: IdentityDB,
|
db: IdentityDB,
|
||||||
input: ListConversationEntriesInput,
|
input: ListConversationEntriesInput,
|
||||||
|
): Promise<BoxBrainConversationEntry[]> {
|
||||||
|
return new ConversationService(db).listEntries(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listConversationEntriesWithDb(
|
||||||
|
db: IdentityDB,
|
||||||
|
input: ListConversationEntriesInput,
|
||||||
): Promise<BoxBrainConversationEntry[]> {
|
): Promise<BoxBrainConversationEntry[]> {
|
||||||
const facts = await listFactsInSpace(db, input.spaceName);
|
const facts = await listFactsInSpace(db, input.spaceName);
|
||||||
return facts
|
return facts
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
export * from './adapters';
|
export * from './core/adapters';
|
||||||
|
export * from './core/types';
|
||||||
export * from './availability';
|
export * from './availability';
|
||||||
export * from './conversation';
|
export * from './conversation';
|
||||||
export * from './grok';
|
|
||||||
export * from './memory';
|
export * from './memory';
|
||||||
export * from './persona';
|
export * from './persona';
|
||||||
|
export * from './providers/grok';
|
||||||
export * from './schedule';
|
export * from './schedule';
|
||||||
export * from './timing';
|
export * from './timing';
|
||||||
export * from './types';
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { AddFactInput, Fact, IdentityDB, JsonValue, TopicCategory } from 'identitydb';
|
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 {
|
export interface PersistFactDraftsInput {
|
||||||
spaceName: string;
|
spaceName: string;
|
||||||
@@ -10,20 +10,28 @@ export interface PersistFactDraftsInput {
|
|||||||
|
|
||||||
const IDENTITYDB_TOPIC_CATEGORIES = new Set<TopicCategory>(['entity', 'concept', 'temporal', 'custom']);
|
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) {
|
if (input.facts.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.upsertSpace({ name: input.spaceName });
|
await this.db.upsertSpace({ name: input.spaceName });
|
||||||
|
|
||||||
const persisted: Fact[] = [];
|
const persisted: Fact[] = [];
|
||||||
for (const draft of input.facts) {
|
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;
|
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 {
|
function toAddFactInput(draft: BoxBrainFactDraft, input: PersistFactDraftsInput): AddFactInput {
|
||||||
const source = draft.source ?? input.source;
|
const source = draft.source ?? input.source;
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import type { IdentityDB } from 'identitydb';
|
import type { IdentityDB } from 'identitydb';
|
||||||
import type { ImageModelAdapter, StructuredModelAdapter } from './adapters';
|
import type { ImageModelAdapter, StructuredModelAdapter } from '../core/adapters';
|
||||||
import { persistFactDrafts } from './memory';
|
import { persistFactDrafts } from '../memory';
|
||||||
import type { BoxBrainFactDraft, BoxBrainPersonaProfile } from './types';
|
import type { BoxBrainFactDraft, BoxBrainPersonaProfile } from '../core/types';
|
||||||
|
|
||||||
export interface PersonaRelationshipInput {
|
export interface PersonaRelationshipInput {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -74,7 +74,19 @@ const PERSONA_FACT_EXTRACTION_SCHEMA = {
|
|||||||
},
|
},
|
||||||
} as const;
|
} 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> {
|
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);
|
assertPersonaInitializationInput(input);
|
||||||
|
|
||||||
const id = input.id ?? createPersonaId(input.displayName);
|
const id = input.id ?? createPersonaId(input.displayName);
|
||||||
@@ -5,7 +5,7 @@ import type {
|
|||||||
StructuredModelAdapter,
|
StructuredModelAdapter,
|
||||||
TextGenerationRequest,
|
TextGenerationRequest,
|
||||||
TextModelAdapter,
|
TextModelAdapter,
|
||||||
} from './adapters';
|
} from '../../core/adapters';
|
||||||
|
|
||||||
type GrokFetch = typeof fetch;
|
type GrokFetch = typeof fetch;
|
||||||
|
|
||||||
@@ -32,6 +32,43 @@ export interface GrokAdapterBundleOptions {
|
|||||||
const DEFAULT_BASE_URL = 'https://api.x.ai/v1';
|
const DEFAULT_BASE_URL = 'https://api.x.ai/v1';
|
||||||
const GROK_PROVIDER = 'xai-grok';
|
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 {
|
export function createGrokTextModelAdapter(options: GrokAdapterOptions): TextModelAdapter {
|
||||||
const runtime = createRuntime(options);
|
const runtime = createRuntime(options);
|
||||||
|
|
||||||
@@ -132,39 +169,8 @@ export function createGrokAdapters(options: GrokAdapterBundleOptions): {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createRuntime(options: Pick<GrokAdapterOptions, 'apiKey' | 'baseUrl' | 'fetch' | 'extraHeaders'>): {
|
function createRuntime(options: Pick<GrokAdapterOptions, 'apiKey' | 'baseUrl' | 'fetch' | 'extraHeaders'>): GrokApiClient {
|
||||||
postJson: (path: string, body: JsonObject) => Promise<JsonObject>;
|
return new GrokApiClient(options);
|
||||||
} {
|
|
||||||
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 buildMessages(system: string | undefined, prompt: string): Array<{ role: 'system' | 'user'; content: string }> {
|
function buildMessages(system: string | undefined, prompt: string): Array<{ role: 'system' | 'user'; content: string }> {
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import type { Fact, IdentityDB, JsonValue } from 'identitydb';
|
import type { Fact, IdentityDB, JsonValue } from 'identitydb';
|
||||||
import type { SpecialDateProvider, StructuredModelAdapter } from './adapters';
|
import type { SpecialDateProvider, StructuredModelAdapter } from '../core/adapters';
|
||||||
import { setAvailabilityStatus } from './availability';
|
import { setAvailabilityStatus } from '../availability';
|
||||||
import { dateOnly, getFactDomain, getJsonObject, jsonObject, listFactsInSpace, uniqueStrings } from './facts';
|
import { dateOnly, getFactDomain, getJsonObject, jsonObject, listFactsInSpace, uniqueStrings } from '../core/facts';
|
||||||
import { persistFactDrafts } from './memory';
|
import { persistFactDrafts } from '../memory';
|
||||||
import type {
|
import type {
|
||||||
BoxBrainAvailabilityMode,
|
BoxBrainAvailabilityMode,
|
||||||
BoxBrainAvailabilityEntry,
|
BoxBrainAvailabilityEntry,
|
||||||
@@ -11,7 +11,7 @@ import type {
|
|||||||
BoxBrainScheduleEventKind,
|
BoxBrainScheduleEventKind,
|
||||||
BoxBrainScheduleScope,
|
BoxBrainScheduleScope,
|
||||||
BoxBrainTopicDraft,
|
BoxBrainTopicDraft,
|
||||||
} from './types';
|
} from '../core/types';
|
||||||
|
|
||||||
export interface ScheduleEventDraft {
|
export interface ScheduleEventDraft {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -60,9 +60,36 @@ export interface SchedulePruneResult {
|
|||||||
deletedEventIds: string[];
|
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(
|
export async function generateSchedule(
|
||||||
db: IdentityDB,
|
db: IdentityDB,
|
||||||
input: GenerateScheduleInput,
|
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[] }> {
|
): Promise<{ events: BoxBrainScheduleEvent[]; availabilityEntries: BoxBrainAvailabilityEntry[] }> {
|
||||||
await ensurePersonaSpace(db, input.spaceName, input.displayName);
|
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[]> {
|
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 facts = await listFactsInSpace(db, input.spaceName);
|
||||||
const deletedIds = new Set(
|
const deletedIds = new Set(
|
||||||
facts
|
facts
|
||||||
@@ -156,6 +187,10 @@ export async function listScheduleEvents(db: IdentityDB, input: ListScheduleEven
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function pruneExpiredSchedule(db: IdentityDB, input: PruneExpiredScheduleInput): Promise<SchedulePruneResult> {
|
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 graceMs = (input.graceSeconds ?? 0) * 1000;
|
||||||
const cutoffMs = Date.parse(input.referenceTime) - graceMs;
|
const cutoffMs = Date.parse(input.referenceTime) - graceMs;
|
||||||
const events = await listScheduleEvents(db, { spaceName: input.spaceName });
|
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> {
|
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 cutoffMs = Date.parse(input.before);
|
||||||
const events = await listScheduleEvents(db, { spaceName: input.spaceName });
|
const events = await listScheduleEvents(db, { spaceName: input.spaceName });
|
||||||
const toDelete = events.filter((event) => Date.parse(event.startAt) < cutoffMs);
|
const toDelete = events.filter((event) => Date.parse(event.startAt) < cutoffMs);
|
||||||
@@ -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
87
src/timing/index.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -10,6 +10,13 @@ import {
|
|||||||
type SpecialDateProvider,
|
type SpecialDateProvider,
|
||||||
type TextModelAdapter,
|
type TextModelAdapter,
|
||||||
} from '../src';
|
} 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', () => {
|
describe('public API', () => {
|
||||||
it('exports timing helpers and runtime availability constants', () => {
|
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']);
|
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 = {
|
const specialDateProvider: SpecialDateProvider = {
|
||||||
async listSpecialDates() {
|
async listSpecialDates() {
|
||||||
return [{ date: '2026-05-08', title: 'Parents Day' }];
|
return [{ date: '2026-05-08', title: 'Parents Day' }];
|
||||||
@@ -50,5 +57,13 @@ describe('public API', () => {
|
|||||||
expect(typeof replyToConversation).toBe('function');
|
expect(typeof replyToConversation).toBe('function');
|
||||||
expect(typeof createGrokAdapters).toBe('function');
|
expect(typeof createGrokAdapters).toBe('function');
|
||||||
expect(specialDateProvider.listSpecialDates).toBeTypeOf('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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user