diff --git a/plugins/openclaw-context/README.md b/plugins/openclaw-context/README.md new file mode 100644 index 0000000..f90caeb --- /dev/null +++ b/plugins/openclaw-context/README.md @@ -0,0 +1,97 @@ +# @mosaic/openclaw-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/plugins/openclaw-context/openclaw.plugin.json b/plugins/openclaw-context/openclaw.plugin.json new file mode 100644 index 0000000..38c8fdd --- /dev/null +++ b/plugins/openclaw-context/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/plugins/openclaw-context/package.json b/plugins/openclaw-context/package.json new file mode 100644 index 0000000..c0ca13f --- /dev/null +++ b/plugins/openclaw-context/package.json @@ -0,0 +1,38 @@ +{ + "name": "@mosaic/openclaw-context", + "version": "0.1.0", + "type": "module", + "description": "OpenClaw \u2192 OpenBrain context engine plugin", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "files": [ + "dist", + "openclaw.plugin.json" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc --noEmit", + "lint": "eslint src/", + "test": "vitest run" + }, + "dependencies": { + "@mosaic/types": "workspace:*" + }, + "devDependencies": { + "typescript": "^5", + "vitest": "^2", + "@types/node": "^22" + }, + "keywords": [ + "openclaw", + "openbrain", + "context-engine", + "plugin" + ] +} \ No newline at end of file diff --git a/plugins/openclaw-context/src/constants.ts b/plugins/openclaw-context/src/constants.ts new file mode 100644 index 0000000..6403b77 --- /dev/null +++ b/plugins/openclaw-context/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/plugins/openclaw-context/src/engine.ts b/plugins/openclaw-context/src/engine.ts new file mode 100644 index 0000000..1dc7faf --- /dev/null +++ b/plugins/openclaw-context/src/engine.ts @@ -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 { + 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(); + + 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 { + 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 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 { + 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) { + 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"); + } + } +} diff --git a/plugins/openclaw-context/src/errors.ts b/plugins/openclaw-context/src/errors.ts new file mode 100644 index 0000000..e357d21 --- /dev/null +++ b/plugins/openclaw-context/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/plugins/openclaw-context/src/index.ts b/plugins/openclaw-context/src/index.ts new file mode 100644 index 0000000..86e4075 --- /dev/null +++ b/plugins/openclaw-context/src/index.ts @@ -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; diff --git a/plugins/openclaw-context/src/openbrain-client.ts b/plugins/openclaw-context/src/openbrain-client.ts new file mode 100644 index 0000000..f679c55 --- /dev/null +++ b/plugins/openclaw-context/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/plugins/openclaw-context/src/openclaw-types.ts b/plugins/openclaw-context/src/openclaw-types.ts new file mode 100644 index 0000000..c72f367 --- /dev/null +++ b/plugins/openclaw-context/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/plugins/openclaw-context/tests/engine.test.ts b/plugins/openclaw-context/tests/engine.test.ts new file mode 100644 index 0000000..bdbd195 --- /dev/null +++ b/plugins/openclaw-context/tests/engine.test.ts @@ -0,0 +1,414 @@ +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("ingests batches in parallel chunks of five", async () => { + const mockClient = makeMockClient(); + let inFlight = 0; + let maxInFlight = 0; + let createdCount = 0; + + vi.mocked(mockClient.createThought).mockImplementation(async (input: OpenBrainThoughtInput) => { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + await new Promise((resolve) => { + setTimeout(resolve, 20); + }); + inFlight -= 1; + createdCount += 1; + return { + id: `thought-${createdCount}`, + content: input.content, + source: input.source, + metadata: input.metadata, + createdAt: new Date().toISOString(), + 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 result = await engine.ingestBatch({ + sessionId, + messages: Array.from({ length: 10 }, (_, index) => ({ + role: index % 2 === 0 ? "user" : "assistant", + content: `message-${index + 1}`, + timestamp: index + 1, + })), + }); + + expect(result.ingestedCount).toBe(10); + expect(maxInFlight).toBe(5); + expect(mockClient.createThought).toHaveBeenCalledTimes(10); + }); + + 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 and deletes summarized inputs", async () => { + const mockClient = makeMockClient(); + vi.mocked(mockClient.listRecent).mockResolvedValue( + Array.from({ length: 12 }, (_, index) => { + return makeThought( + `t${index + 1}`, + `message ${index + 1}`, + sessionId, + index % 2 === 0 ? "user" : "assistant", + `2026-03-06T12:${String(index).padStart(2, "0")}: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", + }), + }), + ); + const deletedIds = vi + .mocked(mockClient.deleteThought) + .mock.calls.map(([id]) => id) + .sort((left, right) => left.localeCompare(right)); + expect(deletedIds).toEqual([ + "t10", + "t11", + "t12", + "t3", + "t4", + "t5", + "t6", + "t7", + "t8", + "t9", + ]); + }); + + it("stops trimming once the newest message exceeds budget", async () => { + const mockClient = makeMockClient(); + const oversizedNewest = "z".repeat(400); + vi.mocked(mockClient.listRecent).mockResolvedValue([ + makeThought("t1", "small older message", sessionId, "assistant", "2026-03-06T12:00:00.000Z"), + makeThought("t2", oversizedNewest, 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.assemble({ + sessionId, + messages: [ + { + role: "user", + content: "query", + timestamp: Date.now(), + }, + ], + tokenBudget: 12, + }); + + expect(result.messages.map((message) => String(message.content))).toEqual([oversizedNewest]); + }); + + 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/plugins/openclaw-context/tests/openbrain-client.test.ts b/plugins/openclaw-context/tests/openbrain-client.test.ts new file mode 100644 index 0000000..cc76c23 --- /dev/null +++ b/plugins/openclaw-context/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/plugins/openclaw-context/tests/register.test.ts b/plugins/openclaw-context/tests/register.test.ts new file mode 100644 index 0000000..df0fb30 --- /dev/null +++ b/plugins/openclaw-context/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); + }); +}); diff --git a/plugins/openclaw-context/tests/smoke.test.ts b/plugins/openclaw-context/tests/smoke.test.ts new file mode 100644 index 0000000..7c894d6 --- /dev/null +++ b/plugins/openclaw-context/tests/smoke.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "vitest"; + +import { OPENBRAIN_CONTEXT_ENGINE_ID } from "../src/index.js"; + +describe("project scaffold", () => { + it("exports openbrain context engine id", () => { + expect(OPENBRAIN_CONTEXT_ENGINE_ID).toBe("openbrain"); + }); +}); diff --git a/plugins/openclaw-context/tsconfig.json b/plugins/openclaw-context/tsconfig.json new file mode 100644 index 0000000..9d91dc3 --- /dev/null +++ b/plugins/openclaw-context/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "isolatedModules": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": ".", + "types": ["node", "vitest/globals"], + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts", "tests/**/*.ts"], + "exclude": ["dist", "node_modules"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 04a339a..734123c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,7 +10,7 @@ importers: devDependencies: '@changesets/cli': specifier: ^2 - version: 2.30.0 + version: 2.30.0(@types/node@22.19.15) prettier: specifier: ^3 version: 3.8.1 @@ -41,7 +41,7 @@ importers: devDependencies: vitest: specifier: ^3 - version: 3.2.4 + version: 3.2.4(@types/node@22.19.15) packages/types: devDependencies: @@ -49,6 +49,22 @@ importers: specifier: ^5 version: 5.9.3 + plugins/openclaw-context: + dependencies: + '@mosaic/types': + specifier: workspace:* + version: link:../../packages/types + devDependencies: + '@types/node': + specifier: ^22 + version: 22.19.15 + typescript: + specifier: ^5 + version: 5.9.3 + vitest: + specifier: ^2 + version: 2.1.9(@types/node@22.19.15) + packages: '@babel/runtime@7.28.6': @@ -110,102 +126,204 @@ packages: '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.27.3': resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.27.3': resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.27.3': resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.27.3': resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.27.3': resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.27.3': resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.27.3': resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.27.3': resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.27.3': resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.27.3': resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.27.3': resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.27.3': resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.27.3': resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.27.3': resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.27.3': resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.27.3': resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.27.3': resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} engines: {node: '>=18'} @@ -218,6 +336,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.27.3': resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} engines: {node: '>=18'} @@ -230,6 +354,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.27.3': resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} engines: {node: '>=18'} @@ -242,24 +372,48 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.27.3': resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.27.3': resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.27.3': resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.27.3': resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} engines: {node: '>=18'} @@ -452,9 +606,26 @@ packages: '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} + '@types/node@22.19.15': + resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==} + + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/mocker@3.2.4': resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} peerDependencies: @@ -466,18 +637,33 @@ packages: vite: optional: true + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + '@vitest/pretty-format@3.2.4': resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + '@vitest/runner@3.2.4': resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + '@vitest/snapshot@3.2.4': resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + '@vitest/spy@3.2.4': resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} @@ -648,6 +834,11 @@ packages: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + esbuild@0.27.3: resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} engines: {node: '>=18'} @@ -989,6 +1180,9 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -1193,10 +1387,18 @@ packages: resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} engines: {node: ^18.0.0 || >=20.0.0} + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + tinyrainbow@2.0.0: resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} engines: {node: '>=14.0.0'} + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + tinyspy@4.0.4: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} @@ -1252,6 +1454,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} @@ -1264,11 +1469,47 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + vite-node@3.2.4: resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + vite@7.3.1: resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1309,6 +1550,31 @@ packages: yaml: optional: true + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vitest@3.2.4: resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -1391,7 +1657,7 @@ snapshots: dependencies: '@changesets/types': 6.1.0 - '@changesets/cli@2.30.0': + '@changesets/cli@2.30.0(@types/node@22.19.15)': dependencies: '@changesets/apply-release-plan': 7.1.0 '@changesets/assemble-release-plan': 6.0.9 @@ -1407,7 +1673,7 @@ snapshots: '@changesets/should-skip-package': 0.1.2 '@changesets/types': 6.1.0 '@changesets/write': 0.4.0 - '@inquirer/external-editor': 1.0.3 + '@inquirer/external-editor': 1.0.3(@types/node@22.19.15) '@manypkg/get-packages': 1.1.3 ansi-colors: 4.1.3 enquirer: 2.4.1 @@ -1505,81 +1771,150 @@ snapshots: human-id: 4.1.3 prettier: 2.8.8 + '@esbuild/aix-ppc64@0.21.5': + optional: true + '@esbuild/aix-ppc64@0.27.3': optional: true + '@esbuild/android-arm64@0.21.5': + optional: true + '@esbuild/android-arm64@0.27.3': optional: true + '@esbuild/android-arm@0.21.5': + optional: true + '@esbuild/android-arm@0.27.3': optional: true + '@esbuild/android-x64@0.21.5': + optional: true + '@esbuild/android-x64@0.27.3': optional: true + '@esbuild/darwin-arm64@0.21.5': + optional: true + '@esbuild/darwin-arm64@0.27.3': optional: true + '@esbuild/darwin-x64@0.21.5': + optional: true + '@esbuild/darwin-x64@0.27.3': optional: true + '@esbuild/freebsd-arm64@0.21.5': + optional: true + '@esbuild/freebsd-arm64@0.27.3': optional: true + '@esbuild/freebsd-x64@0.21.5': + optional: true + '@esbuild/freebsd-x64@0.27.3': optional: true + '@esbuild/linux-arm64@0.21.5': + optional: true + '@esbuild/linux-arm64@0.27.3': optional: true + '@esbuild/linux-arm@0.21.5': + optional: true + '@esbuild/linux-arm@0.27.3': optional: true + '@esbuild/linux-ia32@0.21.5': + optional: true + '@esbuild/linux-ia32@0.27.3': optional: true + '@esbuild/linux-loong64@0.21.5': + optional: true + '@esbuild/linux-loong64@0.27.3': optional: true + '@esbuild/linux-mips64el@0.21.5': + optional: true + '@esbuild/linux-mips64el@0.27.3': optional: true + '@esbuild/linux-ppc64@0.21.5': + optional: true + '@esbuild/linux-ppc64@0.27.3': optional: true + '@esbuild/linux-riscv64@0.21.5': + optional: true + '@esbuild/linux-riscv64@0.27.3': optional: true + '@esbuild/linux-s390x@0.21.5': + optional: true + '@esbuild/linux-s390x@0.27.3': optional: true + '@esbuild/linux-x64@0.21.5': + optional: true + '@esbuild/linux-x64@0.27.3': optional: true '@esbuild/netbsd-arm64@0.27.3': optional: true + '@esbuild/netbsd-x64@0.21.5': + optional: true + '@esbuild/netbsd-x64@0.27.3': optional: true '@esbuild/openbsd-arm64@0.27.3': optional: true + '@esbuild/openbsd-x64@0.21.5': + optional: true + '@esbuild/openbsd-x64@0.27.3': optional: true '@esbuild/openharmony-arm64@0.27.3': optional: true + '@esbuild/sunos-x64@0.21.5': + optional: true + '@esbuild/sunos-x64@0.27.3': optional: true + '@esbuild/win32-arm64@0.21.5': + optional: true + '@esbuild/win32-arm64@0.27.3': optional: true + '@esbuild/win32-ia32@0.21.5': + optional: true + '@esbuild/win32-ia32@0.27.3': optional: true + '@esbuild/win32-x64@0.21.5': + optional: true + '@esbuild/win32-x64@0.27.3': optional: true @@ -1587,10 +1922,12 @@ snapshots: dependencies: hono: 4.12.5 - '@inquirer/external-editor@1.0.3': + '@inquirer/external-editor@1.0.3(@types/node@22.19.15)': dependencies: chardet: 2.1.1 iconv-lite: 0.7.2 + optionalDependencies: + '@types/node': 22.19.15 '@ioredis/commands@1.5.1': {} @@ -1732,6 +2069,17 @@ snapshots: '@types/node@12.20.55': {} + '@types/node@22.19.15': + dependencies: + undici-types: 6.21.0 + + '@vitest/expect@2.1.9': + dependencies: + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 + '@vitest/expect@3.2.4': dependencies: '@types/chai': 5.2.3 @@ -1740,34 +2088,67 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.3.1)': + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@22.19.15))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@22.19.15) + + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@22.19.15))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1 + vite: 7.3.1(@types/node@22.19.15) + + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 '@vitest/pretty-format@3.2.4': dependencies: tinyrainbow: 2.0.0 + '@vitest/runner@2.1.9': + dependencies: + '@vitest/utils': 2.1.9 + pathe: 1.1.2 + '@vitest/runner@3.2.4': dependencies: '@vitest/utils': 3.2.4 pathe: 2.0.3 strip-literal: 3.1.0 + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 + '@vitest/snapshot@3.2.4': dependencies: '@vitest/pretty-format': 3.2.4 magic-string: 0.30.21 pathe: 2.0.3 + '@vitest/spy@2.1.9': + dependencies: + tinyspy: 3.0.2 + '@vitest/spy@3.2.4': dependencies: tinyspy: 4.0.4 + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 + '@vitest/utils@3.2.4': dependencies: '@vitest/pretty-format': 3.2.4 @@ -1916,6 +2297,32 @@ snapshots: dependencies: es-errors: 1.3.0 + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + esbuild@0.27.3: optionalDependencies: '@esbuild/aix-ppc64': 0.27.3 @@ -2269,6 +2676,8 @@ snapshots: path-type@4.0.0: {} + pathe@1.1.2: {} + pathe@2.0.3: {} pathval@2.0.1: {} @@ -2490,8 +2899,12 @@ snapshots: tinypool@1.1.1: {} + tinyrainbow@1.2.0: {} + tinyrainbow@2.0.0: {} + tinyspy@3.0.2: {} + tinyspy@4.0.4: {} to-regex-range@5.0.1: @@ -2535,19 +2948,39 @@ snapshots: typescript@5.9.3: {} + undici-types@6.21.0: {} + universalify@0.1.2: {} unpipe@1.0.0: {} vary@1.1.2: {} - vite-node@3.2.4: + vite-node@2.1.9(@types/node@22.19.15): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21(@types/node@22.19.15) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite-node@3.2.4(@types/node@22.19.15): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.1 + vite: 7.3.1(@types/node@22.19.15) transitivePeerDependencies: - '@types/node' - jiti @@ -2562,7 +2995,16 @@ snapshots: - tsx - yaml - vite@7.3.1: + vite@5.4.21(@types/node@22.19.15): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.8 + rollup: 4.59.0 + optionalDependencies: + '@types/node': 22.19.15 + fsevents: 2.3.3 + + vite@7.3.1(@types/node@22.19.15): dependencies: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) @@ -2571,13 +3013,49 @@ snapshots: rollup: 4.59.0 tinyglobby: 0.2.15 optionalDependencies: + '@types/node': 22.19.15 fsevents: 2.3.3 - vitest@3.2.4: + vitest@2.1.9(@types/node@22.19.15): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@22.19.15)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21(@types/node@22.19.15) + vite-node: 2.1.9(@types/node@22.19.15) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.15 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vitest@3.2.4(@types/node@22.19.15): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.1) + '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@22.19.15)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -2595,9 +3073,11 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.1 - vite-node: 3.2.4 + vite: 7.3.1(@types/node@22.19.15) + vite-node: 3.2.4(@types/node@22.19.15) why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.15 transitivePeerDependencies: - jiti - less