diff --git a/README.md b/README.md index ceb4b16..840e59a 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,97 @@ # openclaw-openbrain-context +OpenBrain-backed `ContextEngine` plugin for OpenClaw. + +This plugin stores session context in OpenBrain over REST so context can be reassembled from recent history plus semantic matches instead of relying only on in-session compaction state. + +## Features + +- Registers context engine id: `openbrain` +- Typed OpenBrain REST client with Bearer auth +- Session-aware ingest + batch ingest +- Context assembly from recent + semantic search under token budget +- Compaction summaries archived to OpenBrain +- Subagent seed/result handoff helpers + +## Requirements + +- OpenClaw with plugin/context-engine support (`openclaw >= 2026.3.2`) +- Reachable OpenBrain REST API +- OpenBrain API key + +## Install (local workspace plugin) + +```bash +pnpm install +pnpm build +``` + +Then reference this plugin in your OpenClaw config. + +## OpenBrain Setup (self-host or hosted) + +You must provide both of these in plugin config: + +- `baseUrl`: your OpenBrain API root (example: `https://brain.your-domain.com`) +- `apiKey`: Bearer token for your OpenBrain instance + +No host or key fallback is built in. Missing `baseUrl` or `apiKey` throws `OpenBrainConfigError` at `bootstrap()`. + +## Configuration + +Plugin entry id: `openclaw-openbrain-context` +Context engine slot id: `openbrain` + +### Config fields + +- `baseUrl` (required, string): OpenBrain API base URL +- `apiKey` (required, string): OpenBrain Bearer token +- `source` (optional, string, default `openclaw`): source prefix; engine stores thoughts under `:` +- `recentMessages` (optional, integer, default `20`): recent thoughts to fetch for bootstrap/assemble +- `semanticSearchLimit` (optional, integer, default `10`): semantic matches fetched in assemble +- `subagentRecentMessages` (optional, integer, default `8`): context lines used for subagent seed/result exchange + +## Environment Variable Pattern + +Use OpenClaw variable interpolation in `openclaw.json`: + +```json +{ + "apiKey": "${OPENBRAIN_API_KEY}" +} +``` + +Then set it in your shell/runtime environment before starting OpenClaw. + +## Example `openclaw.json` + +```json +{ + "plugins": { + "slots": { + "contextEngine": "openbrain" + }, + "entries": { + "openclaw-openbrain-context": { + "enabled": true, + "config": { + "baseUrl": "https://brain.example.com", + "apiKey": "${OPENBRAIN_API_KEY}", + "source": "openclaw", + "recentMessages": 20, + "semanticSearchLimit": 10, + "subagentRecentMessages": 8 + } + } + } + } +} +``` + +## Development + +```bash +pnpm lint +pnpm build +pnpm test +``` diff --git a/docs/TASKS.md b/docs/TASKS.md index 0f5bce8..2f976c7 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -2,11 +2,11 @@ | id | status | description | issue | repo | branch | depends_on | blocks | agent | started_at | completed_at | estimate | used | notes | |---|---|---|---|---|---|---|---|---|---|---|---|---|---| -| OBC-001 | not-started | Init: pnpm, TypeScript strict, ESLint, vitest, openclaw plugin-sdk dep | TASKS:P1 | openclaw-openbrain-context | feat/engine | | OBC-002 | | | | 8K | | | -| OBC-002 | not-started | OpenBrain REST client module (typed, auth, error handling) | TASKS:P1 | openclaw-openbrain-context | feat/engine | OBC-001 | OBC-003 | | | | 8K | | | -| OBC-003 | not-started | ContextEngine implementation: bootstrap + ingest + ingestBatch | TASKS:P1 | openclaw-openbrain-context | feat/engine | OBC-002 | OBC-004 | | | | 15K | | | -| OBC-004 | not-started | ContextEngine implementation: assemble (recent + semantic merge) | TASKS:P1 | openclaw-openbrain-context | feat/engine | OBC-003 | OBC-005 | | | | 15K | | | -| OBC-005 | not-started | ContextEngine implementation: compact + prepareSubagentSpawn + onSubagentEnded + dispose | TASKS:P1 | openclaw-openbrain-context | feat/engine | OBC-004 | OBC-006 | | | | 12K | | | -| OBC-006 | not-started | Plugin entrypoint: register(), openclaw.plugin.json manifest | TASKS:P1 | openclaw-openbrain-context | feat/engine | OBC-005 | OBC-007 | | | | 5K | | | -| OBC-007 | not-started | Unit tests: ingest/assemble/compact + mock OpenBrain responses | TASKS:P1 | openclaw-openbrain-context | feat/engine | OBC-006 | OBC-008 | | | | 15K | | | -| OBC-008 | not-started | README + install instructions + openclaw.json config example | TASKS:P1 | openclaw-openbrain-context | feat/engine | OBC-007 | | | | | 5K | | | +| OBC-001 | done | Init: pnpm, TypeScript strict, ESLint, vitest, openclaw plugin-sdk dep | TASKS:P1 | openclaw-openbrain-context | feat/engine | | OBC-002 | | | | 8K | | | +| OBC-002 | done | OpenBrain REST client module (typed, auth, error handling) | TASKS:P1 | openclaw-openbrain-context | feat/engine | OBC-001 | OBC-003 | | | | 8K | | | +| OBC-003 | done | ContextEngine implementation: bootstrap + ingest + ingestBatch | TASKS:P1 | openclaw-openbrain-context | feat/engine | OBC-002 | OBC-004 | | | | 15K | | | +| OBC-004 | done | ContextEngine implementation: assemble (recent + semantic merge) | TASKS:P1 | openclaw-openbrain-context | feat/engine | OBC-003 | OBC-005 | | | | 15K | | | +| OBC-005 | done | ContextEngine implementation: compact + prepareSubagentSpawn + onSubagentEnded + dispose | TASKS:P1 | openclaw-openbrain-context | feat/engine | OBC-004 | OBC-006 | | | | 12K | | | +| OBC-006 | done | Plugin entrypoint: register(), openclaw.plugin.json manifest | TASKS:P1 | openclaw-openbrain-context | feat/engine | OBC-005 | OBC-007 | | | | 5K | | | +| OBC-007 | done | Unit tests: ingest/assemble/compact + mock OpenBrain responses | TASKS:P1 | openclaw-openbrain-context | feat/engine | OBC-006 | OBC-008 | | | | 15K | | | +| OBC-008 | done | README + install instructions + openclaw.json config example | TASKS:P1 | openclaw-openbrain-context | feat/engine | OBC-007 | | | | | 5K | | | diff --git a/openclaw.plugin.json b/openclaw.plugin.json new file mode 100644 index 0000000..38c8fdd --- /dev/null +++ b/openclaw.plugin.json @@ -0,0 +1,58 @@ +{ + "id": "openclaw-openbrain-context", + "name": "OpenBrain Context Engine", + "description": "OpenBrain-backed ContextEngine plugin for OpenClaw", + "version": "0.0.1", + "kind": "context-engine", + "configSchema": { + "type": "object", + "additionalProperties": false, + "required": ["baseUrl", "apiKey"], + "properties": { + "baseUrl": { + "type": "string", + "minLength": 1, + "description": "Base URL of your OpenBrain REST API" + }, + "apiKey": { + "type": "string", + "minLength": 1, + "description": "Bearer token used to authenticate against OpenBrain" + }, + "source": { + "type": "string", + "minLength": 1, + "default": "openclaw", + "description": "Source prefix stored in OpenBrain (session id is appended)" + }, + "recentMessages": { + "type": "integer", + "minimum": 1, + "default": 20, + "description": "How many recent thoughts to fetch during assemble/bootstrap" + }, + "semanticSearchLimit": { + "type": "integer", + "minimum": 1, + "default": 10, + "description": "How many semantic matches to request during assemble" + }, + "subagentRecentMessages": { + "type": "integer", + "minimum": 1, + "default": 8, + "description": "How many thoughts to use when seeding/summarizing subagents" + } + } + }, + "uiHints": { + "baseUrl": { + "label": "OpenBrain Base URL", + "placeholder": "https://brain.example.com" + }, + "apiKey": { + "label": "OpenBrain API Key", + "sensitive": true + } + } +} diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..6403b77 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,3 @@ +export const OPENBRAIN_CONTEXT_ENGINE_ID = "openbrain"; +export const OPENBRAIN_PLUGIN_ID = "openclaw-openbrain-context"; +export const OPENBRAIN_PLUGIN_VERSION = "0.0.1"; diff --git a/src/engine.ts b/src/engine.ts new file mode 100644 index 0000000..08a1c07 --- /dev/null +++ b/src/engine.ts @@ -0,0 +1,752 @@ +import { OPENBRAIN_CONTEXT_ENGINE_ID, OPENBRAIN_PLUGIN_VERSION } from "./constants.js"; +import { OpenBrainConfigError } from "./errors.js"; +import type { + AgentMessage, + AssembleResult, + BootstrapResult, + CompactResult, + ContextEngine, + ContextEngineInfo, + IngestBatchResult, + IngestResult, + PluginLogger, + SubagentEndReason, + SubagentSpawnPreparation, +} from "./openclaw-types.js"; +import { + OpenBrainClient, + type OpenBrainClientLike, + type OpenBrainSearchInput, + type OpenBrainThought, + type OpenBrainThoughtMetadata, +} from "./openbrain-client.js"; + +export type OpenBrainContextEngineConfig = { + baseUrl?: string; + apiKey?: string; + recentMessages?: number; + semanticSearchLimit?: number; + source?: string; + subagentRecentMessages?: number; +}; + +type ResolvedOpenBrainContextEngineConfig = { + baseUrl: string; + apiKey: string; + recentMessages: number; + semanticSearchLimit: number; + source: string; + subagentRecentMessages: number; +}; + +export type OpenBrainContextEngineDeps = { + createClient?: (config: ResolvedOpenBrainContextEngineConfig) => OpenBrainClientLike; + now?: () => number; + logger?: PluginLogger; +}; + +type SubagentState = { + parentSessionKey: string; + seedThoughtId?: string; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function parsePositiveInteger(value: unknown, fallback: number): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + return fallback; + } + + const rounded = Math.floor(value); + return rounded > 0 ? rounded : fallback; +} + +function normalizeRole(role: unknown): string { + if (typeof role !== "string" || role.length === 0) { + return "assistant"; + } + + if (role === "user" || role === "assistant" || role === "tool" || role === "system") { + return role; + } + + return "assistant"; +} + +function serializeContent(value: unknown): string { + if (typeof value === "string") { + return value; + } + + if (Array.isArray(value)) { + return value + .map((part) => serializeContent(part)) + .filter((part) => part.length > 0) + .join("\n") + .trim(); + } + + if (isRecord(value) && typeof value.text === "string") { + return value.text; + } + + if (value === undefined || value === null) { + return ""; + } + + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +function estimateTextTokens(text: string): number { + const normalized = text.trim(); + if (normalized.length === 0) { + return 1; + } + + return Math.max(1, Math.ceil(normalized.length / 4) + 4); +} + +function thoughtTimestamp(thought: OpenBrainThought, fallbackTimestamp: number): number { + const createdAt = + thought.createdAt ?? + (typeof thought.created_at === "string" ? thought.created_at : undefined); + + if (createdAt === undefined) { + return fallbackTimestamp; + } + + const parsed = Date.parse(createdAt); + return Number.isFinite(parsed) ? parsed : fallbackTimestamp; +} + +function thoughtFingerprint(thought: OpenBrainThought): string { + const role = typeof thought.metadata?.role === "string" ? thought.metadata.role : "assistant"; + return `${role}\n${thought.content}`; +} + +function truncateLine(value: string, maxLength: number): string { + if (value.length <= maxLength) { + return value; + } + + return `${value.slice(0, maxLength - 3)}...`; +} + +export class OpenBrainContextEngine implements ContextEngine { + readonly info: ContextEngineInfo = { + id: OPENBRAIN_CONTEXT_ENGINE_ID, + name: "OpenBrain Context Engine", + version: OPENBRAIN_PLUGIN_VERSION, + ownsCompaction: true, + }; + + private readonly rawConfig: unknown; + private readonly createClientFn: + | ((config: ResolvedOpenBrainContextEngineConfig) => OpenBrainClientLike) + | undefined; + private readonly now: () => number; + private readonly logger: PluginLogger | undefined; + + private config: ResolvedOpenBrainContextEngineConfig | undefined; + private client: OpenBrainClientLike | undefined; + private readonly sessionTurns = new Map(); + private readonly subagentState = new Map(); + private disposed = false; + + constructor(rawConfig: unknown, deps?: OpenBrainContextEngineDeps) { + this.rawConfig = rawConfig; + this.createClientFn = deps?.createClient; + this.now = deps?.now ?? (() => Date.now()); + this.logger = deps?.logger; + } + + async bootstrap(params: { sessionId: string; sessionFile: string }): Promise { + this.assertNotDisposed(); + + const config = this.getConfig(); + const client = this.getClient(); + const source = this.sourceForSession(params.sessionId); + + const recentThoughts = await client.listRecent({ + limit: config.recentMessages, + source, + }); + + const sessionThoughts = this.filterSessionThoughts(recentThoughts, params.sessionId); + + let maxTurn = -1; + for (const thought of sessionThoughts) { + const turn = thought.metadata?.turn; + if (typeof turn === "number" && Number.isFinite(turn) && turn > maxTurn) { + maxTurn = turn; + } + } + + this.sessionTurns.set(params.sessionId, maxTurn + 1); + + return { + bootstrapped: true, + importedMessages: sessionThoughts.length, + }; + } + + async ingest(params: { + sessionId: string; + message: AgentMessage; + isHeartbeat?: boolean; + }): Promise { + this.assertNotDisposed(); + + const client = this.getClient(); + const content = serializeContent(params.message.content).trim(); + if (content.length === 0) { + return { ingested: false }; + } + + const metadata: OpenBrainThoughtMetadata = { + sessionId: params.sessionId, + turn: this.nextTurn(params.sessionId), + role: normalizeRole(params.message.role), + type: "message", + }; + + if (params.isHeartbeat === true) { + metadata.isHeartbeat = true; + } + + await client.createThought({ + content, + source: this.sourceForSession(params.sessionId), + metadata, + }); + + return { ingested: true }; + } + + async ingestBatch(params: { + sessionId: string; + messages: AgentMessage[]; + isHeartbeat?: boolean; + }): Promise { + this.assertNotDisposed(); + + let ingestedCount = 0; + for (const message of params.messages) { + const ingestParams: { + sessionId: string; + message: AgentMessage; + isHeartbeat?: boolean; + } = { + sessionId: params.sessionId, + message, + }; + if (params.isHeartbeat !== undefined) { + ingestParams.isHeartbeat = params.isHeartbeat; + } + const result = await this.ingest(ingestParams); + + if (result.ingested) { + ingestedCount += 1; + } + } + + return { ingestedCount }; + } + + async assemble(params: { + sessionId: string; + messages: AgentMessage[]; + tokenBudget?: number; + }): Promise { + this.assertNotDisposed(); + + const config = this.getConfig(); + const client = this.getClient(); + const source = this.sourceForSession(params.sessionId); + + const recentThoughts = this.filterSessionThoughts( + await client.listRecent({ + limit: config.recentMessages, + source, + }), + params.sessionId, + ); + + const semanticThoughts = await this.searchSemanticThoughts({ + client, + source, + config, + sessionId: params.sessionId, + messages: params.messages, + }); + + const mergedThoughts = this.mergeThoughts(recentThoughts, semanticThoughts); + const mergedMessages = + mergedThoughts.length > 0 + ? mergedThoughts.map((thought, index) => this.toAgentMessage(thought, index)) + : params.messages; + + const tokenBudget = params.tokenBudget; + const budgetedMessages = + typeof tokenBudget === "number" && tokenBudget > 0 + ? this.trimToBudget(mergedMessages, tokenBudget) + : mergedMessages; + + return { + messages: budgetedMessages, + estimatedTokens: this.estimateTokensForMessages(budgetedMessages), + }; + } + + async compact(params: { + sessionId: string; + sessionFile: string; + tokenBudget?: number; + force?: boolean; + currentTokenCount?: number; + compactionTarget?: "budget" | "threshold"; + customInstructions?: string; + legacyParams?: Record; + }): Promise { + this.assertNotDisposed(); + + const config = this.getConfig(); + const client = this.getClient(); + const source = this.sourceForSession(params.sessionId); + + const recentThoughts = this.filterSessionThoughts( + await client.listRecent({ + limit: Math.max(config.recentMessages, config.subagentRecentMessages), + source, + }), + params.sessionId, + ); + + if (recentThoughts.length === 0) { + return { + ok: true, + compacted: false, + reason: "no-session-context", + result: { + tokensBefore: 0, + tokensAfter: 0, + }, + }; + } + + const summary = this.buildSummary( + params.customInstructions !== undefined + ? { + sessionId: params.sessionId, + thoughts: recentThoughts, + customInstructions: params.customInstructions, + } + : { + sessionId: params.sessionId, + thoughts: recentThoughts, + }, + ); + + const summaryTokens = estimateTextTokens(summary); + const tokensBefore = this.estimateTokensForThoughts(recentThoughts); + + await client.createThought({ + content: summary, + source, + metadata: { + sessionId: params.sessionId, + turn: this.nextTurn(params.sessionId), + role: "assistant", + type: "summary", + }, + }); + + return { + ok: true, + compacted: true, + reason: "summary-archived", + result: { + summary, + tokensBefore, + tokensAfter: summaryTokens, + }, + }; + } + + async prepareSubagentSpawn(params: { + parentSessionKey: string; + childSessionKey: string; + ttlMs?: number; + }): Promise { + this.assertNotDisposed(); + + const config = this.getConfig(); + const client = this.getClient(); + + const parentThoughts = this.filterSessionThoughts( + await client.listRecent({ + limit: config.subagentRecentMessages, + source: this.sourceForSession(params.parentSessionKey), + }), + params.parentSessionKey, + ); + + const seedContent = this.buildSubagentSeedContent({ + parentSessionKey: params.parentSessionKey, + childSessionKey: params.childSessionKey, + thoughts: parentThoughts, + }); + + const createdThought = await client.createThought({ + content: seedContent, + source: this.sourceForSession(params.childSessionKey), + metadata: { + sessionId: params.childSessionKey, + role: "assistant", + type: "summary", + parentSessionId: params.parentSessionKey, + ttlMs: params.ttlMs, + }, + }); + + this.subagentState.set(params.childSessionKey, { + parentSessionKey: params.parentSessionKey, + seedThoughtId: createdThought.id, + }); + + return { + rollback: async () => { + const state = this.subagentState.get(params.childSessionKey); + this.subagentState.delete(params.childSessionKey); + + if (state?.seedThoughtId !== undefined && state.seedThoughtId.length > 0) { + await client.deleteThought(state.seedThoughtId); + } + }, + }; + } + + async onSubagentEnded(params: { + childSessionKey: string; + reason: SubagentEndReason; + }): Promise { + this.assertNotDisposed(); + + const state = this.subagentState.get(params.childSessionKey); + if (state === undefined) { + return; + } + + const client = this.getClient(); + const config = this.getConfig(); + + const childThoughts = this.filterSessionThoughts( + await client.listRecent({ + limit: config.subagentRecentMessages, + source: this.sourceForSession(params.childSessionKey), + }), + params.childSessionKey, + ); + + const summary = this.buildSubagentResultSummary({ + childSessionKey: params.childSessionKey, + reason: params.reason, + thoughts: childThoughts, + }); + + await client.createThought({ + content: summary, + source: this.sourceForSession(state.parentSessionKey), + metadata: { + sessionId: state.parentSessionKey, + turn: this.nextTurn(state.parentSessionKey), + role: "tool", + type: "subagent-result", + childSessionId: params.childSessionKey, + reason: params.reason, + }, + }); + + this.subagentState.delete(params.childSessionKey); + } + + async dispose(): Promise { + this.sessionTurns.clear(); + this.subagentState.clear(); + this.disposed = true; + } + + private searchSemanticThoughts(params: { + client: OpenBrainClientLike; + source: string; + config: ResolvedOpenBrainContextEngineConfig; + sessionId: string; + messages: AgentMessage[]; + }): Promise { + const query = this.pickSemanticQuery(params.messages); + if (query === undefined || query.length === 0 || params.config.semanticSearchLimit <= 0) { + return Promise.resolve([]); + } + + const request: OpenBrainSearchInput = { + query, + limit: params.config.semanticSearchLimit, + source: params.source, + }; + + return params.client + .search(request) + .then((results) => this.filterSessionThoughts(results, params.sessionId)) + .catch((error) => { + this.logger?.warn?.("OpenBrain semantic search failed", error); + return []; + }); + } + + private pickSemanticQuery(messages: AgentMessage[]): string | undefined { + for (let i = messages.length - 1; i >= 0; i -= 1) { + const message = messages[i]; + if (message === undefined) { + continue; + } + if (normalizeRole(message.role) !== "user") { + continue; + } + + const content = serializeContent(message.content).trim(); + if (content.length > 0) { + return content; + } + } + + for (let i = messages.length - 1; i >= 0; i -= 1) { + const message = messages[i]; + if (message === undefined) { + continue; + } + const content = serializeContent(message.content).trim(); + if (content.length > 0) { + return content; + } + } + + return undefined; + } + + private mergeThoughts(recentThoughts: OpenBrainThought[], semanticThoughts: OpenBrainThought[]): OpenBrainThought[] { + const merged: OpenBrainThought[] = []; + const seenIds = new Set(); + const seenFingerprints = new Set(); + + for (const thought of [...recentThoughts, ...semanticThoughts]) { + const id = thought.id.trim(); + const fingerprint = thoughtFingerprint(thought); + + if (id.length > 0 && seenIds.has(id)) { + continue; + } + + if (seenFingerprints.has(fingerprint)) { + continue; + } + + if (id.length > 0) { + seenIds.add(id); + } + seenFingerprints.add(fingerprint); + merged.push(thought); + } + + return merged; + } + + private filterSessionThoughts(thoughts: OpenBrainThought[], sessionId: string): OpenBrainThought[] { + return thoughts.filter((thought) => { + const thoughtSessionId = thought.metadata?.sessionId; + if (typeof thoughtSessionId === "string" && thoughtSessionId.length > 0) { + return thoughtSessionId === sessionId; + } + + return thought.source === this.sourceForSession(sessionId); + }); + } + + private toAgentMessage(thought: OpenBrainThought, index: number): AgentMessage { + return { + role: normalizeRole(thought.metadata?.role), + content: thought.content, + timestamp: thoughtTimestamp(thought, this.now() + index), + }; + } + + private trimToBudget(messages: AgentMessage[], tokenBudget: number): AgentMessage[] { + if (messages.length === 0 || tokenBudget <= 0) { + return []; + } + + let total = 0; + const budgeted: AgentMessage[] = []; + + for (let i = messages.length - 1; i >= 0; i -= 1) { + const message = messages[i]; + if (message === undefined) { + continue; + } + const tokens = estimateTextTokens(serializeContent(message.content)); + if (total + tokens > tokenBudget) { + continue; + } + + total += tokens; + budgeted.unshift(message); + } + + if (budgeted.length === 0) { + const lastMessage = messages[messages.length - 1]; + return lastMessage === undefined ? [] : [lastMessage]; + } + + return budgeted; + } + + private estimateTokensForMessages(messages: AgentMessage[]): number { + return messages.reduce((total, message) => { + return total + estimateTextTokens(serializeContent(message.content)); + }, 0); + } + + private estimateTokensForThoughts(thoughts: OpenBrainThought[]): number { + return thoughts.reduce((total, thought) => total + estimateTextTokens(thought.content), 0); + } + + private buildSummary(params: { + sessionId: string; + thoughts: OpenBrainThought[]; + customInstructions?: string; + }): string { + const ordered = [...params.thoughts].sort((a, b) => { + return thoughtTimestamp(a, 0) - thoughtTimestamp(b, 0); + }); + + const maxLines = Math.min(ordered.length, 10); + const lines = ordered.slice(Math.max(ordered.length - maxLines, 0)).map((thought) => { + const role = normalizeRole(thought.metadata?.role); + const content = truncateLine(thought.content.replace(/\s+/g, " ").trim(), 180); + return `- ${role}: ${content}`; + }); + + const header = `Context summary for session ${params.sessionId}`; + const instruction = + params.customInstructions !== undefined && params.customInstructions.trim().length > 0 + ? `Custom instructions: ${params.customInstructions.trim()}\n` + : ""; + + return `${header}\n${instruction}${lines.join("\n")}`; + } + + private buildSubagentSeedContent(params: { + parentSessionKey: string; + childSessionKey: string; + thoughts: OpenBrainThought[]; + }): string { + const lines = params.thoughts.slice(-5).map((thought) => { + const role = normalizeRole(thought.metadata?.role); + return `- ${role}: ${truncateLine(thought.content.replace(/\s+/g, " ").trim(), 160)}`; + }); + + const contextBlock = lines.length > 0 ? lines.join("\n") : "- (no prior context found)"; + + return [ + `Subagent context seed`, + `Parent session: ${params.parentSessionKey}`, + `Child session: ${params.childSessionKey}`, + contextBlock, + ].join("\n"); + } + + private buildSubagentResultSummary(params: { + childSessionKey: string; + reason: SubagentEndReason; + thoughts: OpenBrainThought[]; + }): string { + const lines = params.thoughts.slice(-5).map((thought) => { + const role = normalizeRole(thought.metadata?.role); + return `- ${role}: ${truncateLine(thought.content.replace(/\s+/g, " ").trim(), 160)}`; + }); + + const contextBlock = lines.length > 0 ? lines.join("\n") : "- (no child messages found)"; + + return [ + `Subagent ended (${params.reason})`, + `Child session: ${params.childSessionKey}`, + contextBlock, + ].join("\n"); + } + + private sourceForSession(sessionId: string): string { + return `${this.getConfig().source}:${sessionId}`; + } + + private nextTurn(sessionId: string): number { + const next = this.sessionTurns.get(sessionId) ?? 0; + this.sessionTurns.set(sessionId, next + 1); + return next; + } + + private getClient(): OpenBrainClientLike { + if (this.client !== undefined) { + return this.client; + } + + const config = this.getConfig(); + this.client = + this.createClientFn?.(config) ?? + new OpenBrainClient({ + baseUrl: config.baseUrl, + apiKey: config.apiKey, + }); + + return this.client; + } + + private getConfig(): ResolvedOpenBrainContextEngineConfig { + if (this.config !== undefined) { + return this.config; + } + + const raw = isRecord(this.rawConfig) ? this.rawConfig : {}; + + const baseUrl = typeof raw.baseUrl === "string" ? raw.baseUrl.trim() : ""; + if (baseUrl.length === 0) { + throw new OpenBrainConfigError("Missing required OpenBrain config: baseUrl"); + } + + const apiKey = typeof raw.apiKey === "string" ? raw.apiKey.trim() : ""; + if (apiKey.length === 0) { + throw new OpenBrainConfigError("Missing required OpenBrain config: apiKey"); + } + + this.config = { + baseUrl, + apiKey, + recentMessages: parsePositiveInteger(raw.recentMessages, 20), + semanticSearchLimit: parsePositiveInteger(raw.semanticSearchLimit, 10), + source: typeof raw.source === "string" && raw.source.trim().length > 0 ? raw.source.trim() : "openclaw", + subagentRecentMessages: parsePositiveInteger(raw.subagentRecentMessages, 8), + }; + + return this.config; + } + + private assertNotDisposed(): void { + if (this.disposed) { + throw new Error("OpenBrainContextEngine has already been disposed"); + } + } +} diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..e357d21 --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,40 @@ +export class OpenBrainError extends Error { + constructor(message: string, cause?: unknown) { + super(message); + this.name = "OpenBrainError"; + if (cause !== undefined) { + (this as Error & { cause?: unknown }).cause = cause; + } + } +} + +export class OpenBrainConfigError extends OpenBrainError { + constructor(message: string) { + super(message); + this.name = "OpenBrainConfigError"; + } +} + +export class OpenBrainHttpError extends OpenBrainError { + readonly status: number; + readonly endpoint: string; + readonly responseBody: string | undefined; + + constructor(params: { endpoint: string; status: number; responseBody: string | undefined }) { + super(`OpenBrain request failed (${params.status}) for ${params.endpoint}`); + this.name = "OpenBrainHttpError"; + this.status = params.status; + this.endpoint = params.endpoint; + this.responseBody = params.responseBody; + } +} + +export class OpenBrainRequestError extends OpenBrainError { + readonly endpoint: string; + + constructor(params: { endpoint: string; cause: unknown }) { + super(`OpenBrain request failed for ${params.endpoint}`, params.cause); + this.name = "OpenBrainRequestError"; + this.endpoint = params.endpoint; + } +} diff --git a/src/index.ts b/src/index.ts index fd630d9..86e4075 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,31 @@ -export const OPENBRAIN_CONTEXT_ENGINE_ID = "openbrain"; +import { + OPENBRAIN_CONTEXT_ENGINE_ID, + OPENBRAIN_PLUGIN_ID, + OPENBRAIN_PLUGIN_VERSION, +} from "./constants.js"; +import { OpenBrainContextEngine } from "./engine.js"; +import type { OpenClawPluginApi } from "./openclaw-types.js"; + +export { OPENBRAIN_CONTEXT_ENGINE_ID } from "./constants.js"; +export { OpenBrainContextEngine } from "./engine.js"; +export { OpenBrainConfigError, OpenBrainHttpError, OpenBrainRequestError } from "./errors.js"; +export { OpenBrainClient } from "./openbrain-client.js"; +export type { OpenBrainContextEngineConfig } from "./engine.js"; +export type { OpenClawPluginApi } from "./openclaw-types.js"; + +export function register(api: OpenClawPluginApi): void { + api.registerContextEngine(OPENBRAIN_CONTEXT_ENGINE_ID, () => { + const deps = api.logger !== undefined ? { logger: api.logger } : undefined; + return new OpenBrainContextEngine(api.pluginConfig, deps); + }); +} + +const plugin = { + id: OPENBRAIN_PLUGIN_ID, + name: "OpenBrain Context Engine", + version: OPENBRAIN_PLUGIN_VERSION, + kind: "context-engine", + register, +}; + +export default plugin; diff --git a/src/openbrain-client.ts b/src/openbrain-client.ts new file mode 100644 index 0000000..f679c55 --- /dev/null +++ b/src/openbrain-client.ts @@ -0,0 +1,333 @@ +import { OpenBrainConfigError, OpenBrainHttpError, OpenBrainRequestError } from "./errors.js"; + +export type OpenBrainThoughtMetadata = Record & { + sessionId?: string; + turn?: number; + role?: string; + type?: string; +}; + +export type OpenBrainThought = { + id: string; + content: string; + source: string; + metadata: OpenBrainThoughtMetadata | undefined; + createdAt: string | undefined; + updatedAt: string | undefined; + score: number | undefined; + [key: string]: unknown; +}; + +export type OpenBrainThoughtInput = { + content: string; + source: string; + metadata?: OpenBrainThoughtMetadata; +}; + +export type OpenBrainSearchInput = { + query: string; + limit: number; + source?: string; +}; + +export type OpenBrainClientOptions = { + baseUrl: string; + apiKey: string; + fetchImpl?: typeof fetch; +}; + +export interface OpenBrainClientLike { + createThought(input: OpenBrainThoughtInput): Promise; + search(input: OpenBrainSearchInput): Promise; + listRecent(input: { limit: number; source?: string }): Promise; + updateThought( + id: string, + payload: { content?: string; metadata?: OpenBrainThoughtMetadata }, + ): Promise; + deleteThought(id: string): Promise; + deleteThoughts(params: { source?: string; metadataId?: string }): Promise; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function readString(record: Record, key: string): string | undefined { + const value = record[key]; + return typeof value === "string" ? value : undefined; +} + +function readNumber(record: Record, key: string): number | undefined { + const value = record[key]; + return typeof value === "number" ? value : undefined; +} + +function normalizeBaseUrl(baseUrl: string): string { + const normalized = baseUrl.trim().replace(/\/+$/, ""); + if (normalized.length === 0) { + throw new OpenBrainConfigError("Missing required OpenBrain config: baseUrl"); + } + return normalized; +} + +function normalizeApiKey(apiKey: string): string { + const normalized = apiKey.trim(); + if (normalized.length === 0) { + throw new OpenBrainConfigError("Missing required OpenBrain config: apiKey"); + } + return normalized; +} + +function normalizeHeaders(headers: unknown): Record { + if (headers === undefined) { + return {}; + } + + if (Array.isArray(headers)) { + const normalized: Record = {}; + for (const pair of headers) { + if (!Array.isArray(pair) || pair.length < 2) { + continue; + } + + const key = pair[0]; + const value = pair[1]; + if (typeof key !== "string" || typeof value !== "string") { + continue; + } + + normalized[key] = value; + } + return normalized; + } + + if (headers instanceof Headers) { + const normalized: Record = {}; + for (const [key, value] of headers.entries()) { + normalized[key] = value; + } + return normalized; + } + + if (!isRecord(headers)) { + return {}; + } + + const normalized: Record = {}; + for (const [key, value] of Object.entries(headers)) { + if (typeof value === "string") { + normalized[key] = value; + continue; + } + + if (Array.isArray(value)) { + normalized[key] = value.join(", "); + } + } + + return normalized; +} + +async function readResponseBody(response: Response): Promise { + try { + const body = await response.text(); + return body.length > 0 ? body : undefined; + } catch { + return undefined; + } +} + +export class OpenBrainClient implements OpenBrainClientLike { + private readonly baseUrl: string; + private readonly apiKey: string; + private readonly fetchImpl: typeof fetch; + + constructor(options: OpenBrainClientOptions) { + this.baseUrl = normalizeBaseUrl(options.baseUrl); + this.apiKey = normalizeApiKey(options.apiKey); + this.fetchImpl = options.fetchImpl ?? fetch; + } + + async createThought(input: OpenBrainThoughtInput): Promise { + const payload = await this.request("/v1/thoughts", { + method: "POST", + body: JSON.stringify(input), + }); + return this.extractThought(payload); + } + + async search(input: OpenBrainSearchInput): Promise { + const payload = await this.request("/v1/search", { + method: "POST", + body: JSON.stringify(input), + }); + return this.extractThoughtArray(payload); + } + + async listRecent(input: { limit: number; source?: string }): Promise { + const params = new URLSearchParams({ + limit: String(input.limit), + }); + + if (input.source !== undefined && input.source.length > 0) { + params.set("source", input.source); + } + + const payload = await this.request(`/v1/thoughts/recent?${params.toString()}`, { + method: "GET", + }); + + return this.extractThoughtArray(payload); + } + + async updateThought( + id: string, + payload: { content?: string; metadata?: OpenBrainThoughtMetadata }, + ): Promise { + const responsePayload = await this.request(`/v1/thoughts/${encodeURIComponent(id)}`, { + method: "PATCH", + body: JSON.stringify(payload), + }); + + return this.extractThought(responsePayload); + } + + async deleteThought(id: string): Promise { + await this.request(`/v1/thoughts/${encodeURIComponent(id)}`, { + method: "DELETE", + }); + } + + async deleteThoughts(params: { source?: string; metadataId?: string }): Promise { + const query = new URLSearchParams(); + if (params.source !== undefined && params.source.length > 0) { + query.set("source", params.source); + } + if (params.metadataId !== undefined && params.metadataId.length > 0) { + query.set("metadata_id", params.metadataId); + } + + const suffix = query.size > 0 ? `?${query.toString()}` : ""; + await this.request(`/v1/thoughts${suffix}`, { + method: "DELETE", + }); + } + + private async request(endpoint: string, init: RequestInit): Promise { + const headers = normalizeHeaders(init.headers); + headers.Authorization = `Bearer ${this.apiKey}`; + + if (init.body !== undefined && headers["Content-Type"] === undefined) { + headers["Content-Type"] = "application/json"; + } + + const url = `${this.baseUrl}${endpoint}`; + + let response: Response; + try { + response = await this.fetchImpl(url, { + ...init, + headers, + }); + } catch (error) { + throw new OpenBrainRequestError({ endpoint, cause: error }); + } + + if (!response.ok) { + throw new OpenBrainHttpError({ + endpoint, + status: response.status, + responseBody: await readResponseBody(response), + }); + } + + if (response.status === 204) { + return undefined as T; + } + + const contentType = response.headers.get("content-type") ?? ""; + if (!contentType.toLowerCase().includes("application/json")) { + return undefined as T; + } + + return (await response.json()) as T; + } + + private extractThoughtArray(payload: unknown): OpenBrainThought[] { + if (Array.isArray(payload)) { + return payload.map((item) => this.normalizeThought(item)); + } + + if (!isRecord(payload)) { + return []; + } + + const candidates = [payload.thoughts, payload.data, payload.results, payload.items]; + for (const candidate of candidates) { + if (Array.isArray(candidate)) { + return candidate.map((item) => this.normalizeThought(item)); + } + } + + return []; + } + + private extractThought(payload: unknown): OpenBrainThought { + if (isRecord(payload)) { + const nested = payload.thought; + if (nested !== undefined) { + return this.normalizeThought(nested); + } + + const data = payload.data; + if (data !== undefined && !Array.isArray(data)) { + return this.normalizeThought(data); + } + } + + return this.normalizeThought(payload); + } + + private normalizeThought(value: unknown): OpenBrainThought { + if (!isRecord(value)) { + return { + id: "", + content: "", + source: "", + metadata: undefined, + createdAt: undefined, + updatedAt: undefined, + score: undefined, + }; + } + + const metadataValue = value.metadata; + const metadata = isRecord(metadataValue) + ? ({ ...metadataValue } as OpenBrainThoughtMetadata) + : undefined; + + const id = readString(value, "id") ?? readString(value, "thought_id") ?? ""; + const content = + readString(value, "content") ?? + readString(value, "text") ?? + (value.content === undefined ? "" : String(value.content)); + const source = readString(value, "source") ?? ""; + + const createdAt = readString(value, "createdAt") ?? readString(value, "created_at"); + const updatedAt = readString(value, "updatedAt") ?? readString(value, "updated_at"); + const score = readNumber(value, "score"); + + return { + ...value, + id, + content, + source, + metadata, + createdAt, + updatedAt, + score, + }; + } +} + +export { normalizeApiKey, normalizeBaseUrl }; diff --git a/src/openclaw-types.ts b/src/openclaw-types.ts new file mode 100644 index 0000000..c72f367 --- /dev/null +++ b/src/openclaw-types.ts @@ -0,0 +1,128 @@ +export type AgentMessageRole = "user" | "assistant" | "tool" | "system" | string; + +export type AgentMessage = { + role: AgentMessageRole; + content: unknown; + timestamp?: number; + [key: string]: unknown; +}; + +export type AssembleResult = { + messages: AgentMessage[]; + estimatedTokens: number; + systemPromptAddition?: string; +}; + +export type CompactResult = { + ok: boolean; + compacted: boolean; + reason?: string; + result?: { + summary?: string; + firstKeptEntryId?: string; + tokensBefore: number; + tokensAfter?: number; + details?: unknown; + }; +}; + +export type IngestResult = { + ingested: boolean; +}; + +export type IngestBatchResult = { + ingestedCount: number; +}; + +export type BootstrapResult = { + bootstrapped: boolean; + importedMessages?: number; + reason?: string; +}; + +export type ContextEngineInfo = { + id: string; + name: string; + version?: string; + ownsCompaction?: boolean; +}; + +export type SubagentSpawnPreparation = { + rollback: () => void | Promise; +}; + +export type SubagentEndReason = "deleted" | "completed" | "swept" | "released"; + +export interface ContextEngine { + readonly info: ContextEngineInfo; + + bootstrap?(params: { sessionId: string; sessionFile: string }): Promise; + + ingest(params: { + sessionId: string; + message: AgentMessage; + isHeartbeat?: boolean; + }): Promise; + + ingestBatch?(params: { + sessionId: string; + messages: AgentMessage[]; + isHeartbeat?: boolean; + }): Promise; + + afterTurn?(params: { + sessionId: string; + sessionFile: string; + messages: AgentMessage[]; + prePromptMessageCount: number; + autoCompactionSummary?: string; + isHeartbeat?: boolean; + tokenBudget?: number; + legacyCompactionParams?: Record; + }): Promise; + + assemble(params: { + sessionId: string; + messages: AgentMessage[]; + tokenBudget?: number; + }): Promise; + + compact(params: { + sessionId: string; + sessionFile: string; + tokenBudget?: number; + force?: boolean; + currentTokenCount?: number; + compactionTarget?: "budget" | "threshold"; + customInstructions?: string; + legacyParams?: Record; + }): Promise; + + prepareSubagentSpawn?(params: { + parentSessionKey: string; + childSessionKey: string; + ttlMs?: number; + }): Promise; + + onSubagentEnded?(params: { + childSessionKey: string; + reason: SubagentEndReason; + }): Promise; + + dispose?(): Promise; +} + +export type ContextEngineFactory = () => ContextEngine | Promise; + +export type PluginLogger = { + debug?: (...args: unknown[]) => void; + info?: (...args: unknown[]) => void; + warn?: (...args: unknown[]) => void; + error?: (...args: unknown[]) => void; +}; + +export type OpenClawPluginApi = { + pluginConfig?: Record; + logger?: PluginLogger; + registerContextEngine: (id: string, factory: ContextEngineFactory) => void; +}; diff --git a/tests/engine.test.ts b/tests/engine.test.ts new file mode 100644 index 0000000..6327921 --- /dev/null +++ b/tests/engine.test.ts @@ -0,0 +1,311 @@ +import { describe, expect, it, vi } from "vitest"; + +import { OpenBrainConfigError } from "../src/errors.js"; +import { OpenBrainContextEngine } from "../src/engine.js"; +import type { AgentMessage } from "../src/openclaw-types.js"; +import type { + OpenBrainClientLike, + OpenBrainThought, + OpenBrainThoughtInput, +} from "../src/openbrain-client.js"; + +function makeThought( + id: string, + content: string, + sessionId: string, + role: string, + createdAt: string, +): OpenBrainThought { + return { + id, + content, + source: `openclaw:${sessionId}`, + metadata: { + sessionId, + role, + type: "message", + }, + createdAt, + updatedAt: undefined, + score: undefined, + }; +} + +function makeMockClient(): OpenBrainClientLike { + return { + createThought: vi.fn(async (input: OpenBrainThoughtInput) => ({ + id: `thought-${Math.random().toString(36).slice(2)}`, + content: input.content, + source: input.source, + metadata: input.metadata, + createdAt: new Date().toISOString(), + updatedAt: undefined, + score: undefined, + })), + search: vi.fn(async () => []), + listRecent: vi.fn(async () => []), + updateThought: vi.fn(async (id, payload) => ({ + id, + content: payload.content ?? "", + source: "openclaw:session", + metadata: payload.metadata, + createdAt: new Date().toISOString(), + updatedAt: undefined, + score: undefined, + })), + deleteThought: vi.fn(async () => undefined), + deleteThoughts: vi.fn(async () => undefined), + }; +} + +const sessionId = "session-main"; + +const userMessage: AgentMessage = { + role: "user", + content: "What did we decide yesterday?", + timestamp: Date.now(), +}; + +describe("OpenBrainContextEngine", () => { + it("throws OpenBrainConfigError at bootstrap when baseUrl/apiKey are missing", async () => { + const engine = new OpenBrainContextEngine({}); + + await expect( + engine.bootstrap({ + sessionId, + sessionFile: "/tmp/session.json", + }), + ).rejects.toBeInstanceOf(OpenBrainConfigError); + }); + + it("ingests messages with session metadata", async () => { + const mockClient = makeMockClient(); + const engine = new OpenBrainContextEngine( + { + baseUrl: "https://brain.example.com", + apiKey: "secret", + source: "openclaw", + }, + { + createClient: () => mockClient, + }, + ); + + await engine.bootstrap({ sessionId, sessionFile: "/tmp/session.json" }); + const result = await engine.ingest({ sessionId, message: userMessage }); + + expect(result.ingested).toBe(true); + expect(mockClient.createThought).toHaveBeenCalledWith( + expect.objectContaining({ + source: "openclaw:session-main", + metadata: expect.objectContaining({ + sessionId, + role: "user", + type: "message", + turn: 0, + }), + }), + ); + }); + + it("ingests batches and returns ingested count", async () => { + const mockClient = makeMockClient(); + const engine = new OpenBrainContextEngine( + { + baseUrl: "https://brain.example.com", + apiKey: "secret", + }, + { createClient: () => mockClient }, + ); + + await engine.bootstrap({ sessionId, sessionFile: "/tmp/session.json" }); + const result = await engine.ingestBatch({ + sessionId, + messages: [ + { role: "user", content: "one", timestamp: 1 }, + { role: "assistant", content: "two", timestamp: 2 }, + ], + }); + + expect(result.ingestedCount).toBe(2); + expect(mockClient.createThought).toHaveBeenCalledTimes(2); + }); + + it("assembles context from recent + semantic search, deduped and budget-aware", async () => { + const mockClient = makeMockClient(); + vi.mocked(mockClient.listRecent).mockResolvedValue([ + makeThought("t1", "recent user context", sessionId, "user", "2026-03-06T12:00:00.000Z"), + makeThought( + "t2", + "recent assistant context", + sessionId, + "assistant", + "2026-03-06T12:01:00.000Z", + ), + ]); + vi.mocked(mockClient.search).mockResolvedValue([ + makeThought( + "t2", + "recent assistant context", + sessionId, + "assistant", + "2026-03-06T12:01:00.000Z", + ), + makeThought("t3", "semantic match", sessionId, "assistant", "2026-03-06T12:02:00.000Z"), + ]); + + const engine = new OpenBrainContextEngine( + { + baseUrl: "https://brain.example.com", + apiKey: "secret", + recentMessages: 10, + semanticSearchLimit: 10, + }, + { createClient: () => mockClient }, + ); + + await engine.bootstrap({ sessionId, sessionFile: "/tmp/session.json" }); + + const result = await engine.assemble({ + sessionId, + messages: [ + { + role: "user", + content: "Find the semantic context", + timestamp: Date.now(), + }, + ], + tokenBudget: 40, + }); + + expect(mockClient.search).toHaveBeenCalledWith( + expect.objectContaining({ + query: "Find the semantic context", + limit: 10, + }), + ); + expect(result.estimatedTokens).toBeLessThanOrEqual(40); + expect(result.messages.map((message) => String(message.content))).toEqual([ + "recent user context", + "recent assistant context", + "semantic match", + ]); + }); + + it("compact archives a summary thought", async () => { + const mockClient = makeMockClient(); + vi.mocked(mockClient.listRecent).mockResolvedValue([ + makeThought("t1", "first message", sessionId, "user", "2026-03-06T12:00:00.000Z"), + makeThought("t2", "second message", sessionId, "assistant", "2026-03-06T12:01:00.000Z"), + ]); + + const engine = new OpenBrainContextEngine( + { + baseUrl: "https://brain.example.com", + apiKey: "secret", + }, + { createClient: () => mockClient }, + ); + + await engine.bootstrap({ sessionId, sessionFile: "/tmp/session.json" }); + + const result = await engine.compact({ + sessionId, + sessionFile: "/tmp/session.json", + tokenBudget: 128, + }); + + expect(result.ok).toBe(true); + expect(result.compacted).toBe(true); + expect(mockClient.createThought).toHaveBeenCalledWith( + expect.objectContaining({ + source: "openclaw:session-main", + metadata: expect.objectContaining({ + sessionId, + type: "summary", + }), + }), + ); + }); + + it("prepares subagent spawn and rollback deletes seeded context", async () => { + const mockClient = makeMockClient(); + vi.mocked(mockClient.listRecent).mockResolvedValue([ + makeThought("t1", "parent context", sessionId, "assistant", "2026-03-06T12:00:00.000Z"), + ]); + vi.mocked(mockClient.createThought).mockResolvedValue({ + id: "seed-thought", + content: "seed", + source: "openclaw:child", + metadata: undefined, + createdAt: "2026-03-06T12:01:00.000Z", + updatedAt: undefined, + score: undefined, + }); + + const engine = new OpenBrainContextEngine( + { + baseUrl: "https://brain.example.com", + apiKey: "secret", + }, + { createClient: () => mockClient }, + ); + + await engine.bootstrap({ sessionId, sessionFile: "/tmp/session.json" }); + + const prep = await engine.prepareSubagentSpawn({ + parentSessionKey: sessionId, + childSessionKey: "child-session", + }); + + expect(prep).toBeDefined(); + expect(mockClient.createThought).toHaveBeenCalledWith( + expect.objectContaining({ + source: "openclaw:child-session", + }), + ); + + await prep?.rollback(); + expect(mockClient.deleteThought).toHaveBeenCalledWith("seed-thought"); + }); + + it("stores child outcome back into parent on subagent end", async () => { + const mockClient = makeMockClient(); + vi.mocked(mockClient.listRecent) + .mockResolvedValueOnce([ + makeThought("p1", "parent context", sessionId, "assistant", "2026-03-06T12:00:00.000Z"), + ]) + .mockResolvedValueOnce([ + makeThought("c1", "child result detail", "child-session", "assistant", "2026-03-06T12:05:00.000Z"), + ]); + + const engine = new OpenBrainContextEngine( + { + baseUrl: "https://brain.example.com", + apiKey: "secret", + }, + { createClient: () => mockClient }, + ); + + await engine.bootstrap({ sessionId, sessionFile: "/tmp/session.json" }); + await engine.prepareSubagentSpawn({ + parentSessionKey: sessionId, + childSessionKey: "child-session", + }); + + await engine.onSubagentEnded({ + childSessionKey: "child-session", + reason: "completed", + }); + + expect(mockClient.createThought).toHaveBeenLastCalledWith( + expect.objectContaining({ + source: "openclaw:session-main", + metadata: expect.objectContaining({ + type: "subagent-result", + sessionId, + }), + }), + ); + }); +}); diff --git a/tests/openbrain-client.test.ts b/tests/openbrain-client.test.ts new file mode 100644 index 0000000..cc76c23 --- /dev/null +++ b/tests/openbrain-client.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from "vitest"; + +import { OpenBrainConfigError, OpenBrainHttpError } from "../src/errors.js"; +import { OpenBrainClient } from "../src/openbrain-client.js"; + +function jsonResponse(body: unknown, init?: ResponseInit): Response { + return new Response(JSON.stringify(body), { + status: init?.status ?? 200, + headers: { + "content-type": "application/json", + ...(init?.headers ?? {}), + }, + }); +} + +describe("OpenBrainClient", () => { + it("sends bearer auth and normalized URL for createThought", async () => { + const fetchMock = vi.fn(async () => + jsonResponse({ + id: "thought-1", + content: "hello", + source: "openclaw:main", + }), + ); + + const client = new OpenBrainClient({ + baseUrl: "https://brain.example.com/", + apiKey: "secret", + fetchImpl: fetchMock as unknown as typeof fetch, + }); + + await client.createThought({ + content: "hello", + source: "openclaw:main", + metadata: { sessionId: "session-1" }, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const firstCall = fetchMock.mock.calls[0]; + expect(firstCall).toBeDefined(); + if (firstCall === undefined) { + throw new Error("Expected fetch call arguments"); + } + const [url, init] = firstCall as unknown as [string, RequestInit]; + expect(url).toBe("https://brain.example.com/v1/thoughts"); + expect(init.method).toBe("POST"); + expect(init.headers).toMatchObject({ + Authorization: "Bearer secret", + "Content-Type": "application/json", + }); + }); + + it("throws OpenBrainHttpError on non-2xx responses", async () => { + const fetchMock = vi.fn(async () => + jsonResponse({ error: "unauthorized" }, { status: 401 }), + ); + + const client = new OpenBrainClient({ + baseUrl: "https://brain.example.com", + apiKey: "secret", + fetchImpl: fetchMock as unknown as typeof fetch, + }); + + await expect(client.listRecent({ limit: 5, source: "openclaw:main" })).rejects.toBeInstanceOf( + OpenBrainHttpError, + ); + + await expect(client.listRecent({ limit: 5, source: "openclaw:main" })).rejects.toMatchObject({ + status: 401, + }); + }); + + it("throws OpenBrainConfigError when initialized without baseUrl or apiKey", () => { + expect( + () => new OpenBrainClient({ baseUrl: "", apiKey: "secret", fetchImpl: fetch }), + ).toThrow(OpenBrainConfigError); + expect( + () => new OpenBrainClient({ baseUrl: "https://brain.example.com", apiKey: "", fetchImpl: fetch }), + ).toThrow(OpenBrainConfigError); + }); +}); diff --git a/tests/register.test.ts b/tests/register.test.ts new file mode 100644 index 0000000..df0fb30 --- /dev/null +++ b/tests/register.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from "vitest"; + +import { OPENBRAIN_CONTEXT_ENGINE_ID, register } from "../src/index.js"; + +describe("plugin register()", () => { + it("registers the openbrain context engine factory", async () => { + const registerContextEngine = vi.fn(); + + register({ + registerContextEngine, + pluginConfig: { + baseUrl: "https://brain.example.com", + apiKey: "secret", + }, + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + }); + + expect(registerContextEngine).toHaveBeenCalledTimes(1); + const [id, factory] = registerContextEngine.mock.calls[0] as [string, () => Promise | unknown]; + expect(id).toBe(OPENBRAIN_CONTEXT_ENGINE_ID); + + const engine = await factory(); + expect(engine).toHaveProperty("info.id", OPENBRAIN_CONTEXT_ENGINE_ID); + }); +});