feat(wave2): @mosaic/openclaw-context plugin migrated to monorepo (#3)

Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #3.
This commit is contained in:
2026-03-07 00:33:20 +00:00
committed by jason.woltje
parent 2828a83b66
commit d7f200edd6
15 changed files with 2554 additions and 14 deletions

View File

@@ -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";

View File

@@ -0,0 +1,774 @@
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<string, unknown> {
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<string, number>();
private readonly subagentState = new Map<string, SubagentState>();
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<BootstrapResult> {
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<IngestResult> {
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<IngestBatchResult> {
this.assertNotDisposed();
const maxConcurrency = 5;
let ingestedCount = 0;
for (let i = 0; i < params.messages.length; i += maxConcurrency) {
const chunk = params.messages.slice(i, i + maxConcurrency);
const results = await Promise.all(
chunk.map((message) => {
const ingestParams: {
sessionId: string;
message: AgentMessage;
isHeartbeat?: boolean;
} = {
sessionId: params.sessionId,
message,
};
if (params.isHeartbeat !== undefined) {
ingestParams.isHeartbeat = params.isHeartbeat;
}
return this.ingest(ingestParams);
}),
);
for (const result of results) {
if (result.ingested) {
ingestedCount += 1;
}
}
}
return { ingestedCount };
}
async assemble(params: {
sessionId: string;
messages: AgentMessage[];
tokenBudget?: number;
}): Promise<AssembleResult> {
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<string, unknown>;
}): Promise<CompactResult> {
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 summarizedThoughts = this.selectSummaryThoughts(recentThoughts);
const summary = this.buildSummary(
params.customInstructions !== undefined
? {
sessionId: params.sessionId,
thoughts: summarizedThoughts,
customInstructions: params.customInstructions,
}
: {
sessionId: params.sessionId,
thoughts: summarizedThoughts,
},
);
const summaryTokens = estimateTextTokens(summary);
const tokensBefore = this.estimateTokensForThoughts(summarizedThoughts);
await client.createThought({
content: summary,
source,
metadata: {
sessionId: params.sessionId,
turn: this.nextTurn(params.sessionId),
role: "assistant",
type: "summary",
},
});
const summaryThoughtIds = Array.from(
new Set(
summarizedThoughts
.map((thought) => thought.id.trim())
.filter((id) => id.length > 0),
),
);
await Promise.all(summaryThoughtIds.map((thoughtId) => client.deleteThought(thoughtId)));
return {
ok: true,
compacted: true,
reason: "summary-archived",
result: {
summary,
tokensBefore,
tokensAfter: summaryTokens,
},
};
}
async prepareSubagentSpawn(params: {
parentSessionKey: string;
childSessionKey: string;
ttlMs?: number;
}): Promise<SubagentSpawnPreparation | undefined> {
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<void> {
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<void> {
this.sessionTurns.clear();
this.subagentState.clear();
this.disposed = true;
}
private searchSemanticThoughts(params: {
client: OpenBrainClientLike;
source: string;
config: ResolvedOpenBrainContextEngineConfig;
sessionId: string;
messages: AgentMessage[];
}): Promise<OpenBrainThought[]> {
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<string>();
const seenFingerprints = new Set<string>();
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) {
break;
}
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 lines = params.thoughts.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 selectSummaryThoughts(thoughts: OpenBrainThought[]): OpenBrainThought[] {
const ordered = [...thoughts].sort((a, b) => {
return thoughtTimestamp(a, 0) - thoughtTimestamp(b, 0);
});
const maxLines = Math.min(ordered.length, 10);
return ordered.slice(Math.max(ordered.length - maxLines, 0));
}
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");
}
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,31 @@
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;

View File

@@ -0,0 +1,333 @@
import { OpenBrainConfigError, OpenBrainHttpError, OpenBrainRequestError } from "./errors.js";
export type OpenBrainThoughtMetadata = Record<string, unknown> & {
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<OpenBrainThought>;
search(input: OpenBrainSearchInput): Promise<OpenBrainThought[]>;
listRecent(input: { limit: number; source?: string }): Promise<OpenBrainThought[]>;
updateThought(
id: string,
payload: { content?: string; metadata?: OpenBrainThoughtMetadata },
): Promise<OpenBrainThought>;
deleteThought(id: string): Promise<void>;
deleteThoughts(params: { source?: string; metadataId?: string }): Promise<void>;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function readString(record: Record<string, unknown>, key: string): string | undefined {
const value = record[key];
return typeof value === "string" ? value : undefined;
}
function readNumber(record: Record<string, unknown>, 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<string, string> {
if (headers === undefined) {
return {};
}
if (Array.isArray(headers)) {
const normalized: Record<string, string> = {};
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<string, string> = {};
for (const [key, value] of headers.entries()) {
normalized[key] = value;
}
return normalized;
}
if (!isRecord(headers)) {
return {};
}
const normalized: Record<string, string> = {};
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<string | undefined> {
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<OpenBrainThought> {
const payload = await this.request<unknown>("/v1/thoughts", {
method: "POST",
body: JSON.stringify(input),
});
return this.extractThought(payload);
}
async search(input: OpenBrainSearchInput): Promise<OpenBrainThought[]> {
const payload = await this.request<unknown>("/v1/search", {
method: "POST",
body: JSON.stringify(input),
});
return this.extractThoughtArray(payload);
}
async listRecent(input: { limit: number; source?: string }): Promise<OpenBrainThought[]> {
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<unknown>(`/v1/thoughts/recent?${params.toString()}`, {
method: "GET",
});
return this.extractThoughtArray(payload);
}
async updateThought(
id: string,
payload: { content?: string; metadata?: OpenBrainThoughtMetadata },
): Promise<OpenBrainThought> {
const responsePayload = await this.request<unknown>(`/v1/thoughts/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify(payload),
});
return this.extractThought(responsePayload);
}
async deleteThought(id: string): Promise<void> {
await this.request<unknown>(`/v1/thoughts/${encodeURIComponent(id)}`, {
method: "DELETE",
});
}
async deleteThoughts(params: { source?: string; metadataId?: string }): Promise<void> {
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<unknown>(`/v1/thoughts${suffix}`, {
method: "DELETE",
});
}
private async request<T>(endpoint: string, init: RequestInit): Promise<T> {
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 };

View File

@@ -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<void>;
};
export type SubagentEndReason = "deleted" | "completed" | "swept" | "released";
export interface ContextEngine {
readonly info: ContextEngineInfo;
bootstrap?(params: { sessionId: string; sessionFile: string }): Promise<BootstrapResult>;
ingest(params: {
sessionId: string;
message: AgentMessage;
isHeartbeat?: boolean;
}): Promise<IngestResult>;
ingestBatch?(params: {
sessionId: string;
messages: AgentMessage[];
isHeartbeat?: boolean;
}): Promise<IngestBatchResult>;
afterTurn?(params: {
sessionId: string;
sessionFile: string;
messages: AgentMessage[];
prePromptMessageCount: number;
autoCompactionSummary?: string;
isHeartbeat?: boolean;
tokenBudget?: number;
legacyCompactionParams?: Record<string, unknown>;
}): Promise<void>;
assemble(params: {
sessionId: string;
messages: AgentMessage[];
tokenBudget?: number;
}): Promise<AssembleResult>;
compact(params: {
sessionId: string;
sessionFile: string;
tokenBudget?: number;
force?: boolean;
currentTokenCount?: number;
compactionTarget?: "budget" | "threshold";
customInstructions?: string;
legacyParams?: Record<string, unknown>;
}): Promise<CompactResult>;
prepareSubagentSpawn?(params: {
parentSessionKey: string;
childSessionKey: string;
ttlMs?: number;
}): Promise<SubagentSpawnPreparation | undefined>;
onSubagentEnded?(params: {
childSessionKey: string;
reason: SubagentEndReason;
}): Promise<void>;
dispose?(): Promise<void>;
}
export type ContextEngineFactory = () => ContextEngine | Promise<ContextEngine>;
export type PluginLogger = {
debug?: (...args: unknown[]) => void;
info?: (...args: unknown[]) => void;
warn?: (...args: unknown[]) => void;
error?: (...args: unknown[]) => void;
};
export type OpenClawPluginApi = {
pluginConfig?: Record<string, unknown>;
logger?: PluginLogger;
registerContextEngine: (id: string, factory: ContextEngineFactory) => void;
};