Compare commits
1 Commits
chore/arch
...
docs/boots
| Author | SHA1 | Date | |
|---|---|---|---|
| 0cdfec7557 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,4 +0,0 @@
|
|||||||
node_modules
|
|
||||||
dist
|
|
||||||
coverage
|
|
||||||
.DS_Store
|
|
||||||
12
ARCHIVED.md
12
ARCHIVED.md
@@ -1,12 +0,0 @@
|
|||||||
# ⚠️ This repo has been archived
|
|
||||||
|
|
||||||
**Migrated to:** [`mosaic/mosaic`](https://git.mosaicstack.dev/mosaic/mosaic) — `plugins/openclaw-context/`
|
|
||||||
**Package:** `@mosaic/openclaw-context`
|
|
||||||
**Date:** 2026-03-06
|
|
||||||
|
|
||||||
Install via:
|
|
||||||
```bash
|
|
||||||
npm install @mosaic/openclaw-context --registry https://git.mosaicstack.dev/api/packages/mosaic/npm
|
|
||||||
```
|
|
||||||
|
|
||||||
All future development happens in the monorepo. This repo is read-only.
|
|
||||||
95
README.md
95
README.md
@@ -1,97 +1,2 @@
|
|||||||
# openclaw-openbrain-context
|
# openclaw-openbrain-context
|
||||||
|
|
||||||
OpenBrain-backed `ContextEngine` plugin for OpenClaw.
|
|
||||||
|
|
||||||
This plugin stores session context in OpenBrain over REST so context can be reassembled from recent history plus semantic matches instead of relying only on in-session compaction state.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- Registers context engine id: `openbrain`
|
|
||||||
- Typed OpenBrain REST client with Bearer auth
|
|
||||||
- Session-aware ingest + batch ingest
|
|
||||||
- Context assembly from recent + semantic search under token budget
|
|
||||||
- Compaction summaries archived to OpenBrain
|
|
||||||
- Subagent seed/result handoff helpers
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
- OpenClaw with plugin/context-engine support (`openclaw >= 2026.3.2`)
|
|
||||||
- Reachable OpenBrain REST API
|
|
||||||
- OpenBrain API key
|
|
||||||
|
|
||||||
## Install (local workspace plugin)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm install
|
|
||||||
pnpm build
|
|
||||||
```
|
|
||||||
|
|
||||||
Then reference this plugin in your OpenClaw config.
|
|
||||||
|
|
||||||
## OpenBrain Setup (self-host or hosted)
|
|
||||||
|
|
||||||
You must provide both of these in plugin config:
|
|
||||||
|
|
||||||
- `baseUrl`: your OpenBrain API root (example: `https://brain.your-domain.com`)
|
|
||||||
- `apiKey`: Bearer token for your OpenBrain instance
|
|
||||||
|
|
||||||
No host or key fallback is built in. Missing `baseUrl` or `apiKey` throws `OpenBrainConfigError` at `bootstrap()`.
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
Plugin entry id: `openclaw-openbrain-context`
|
|
||||||
Context engine slot id: `openbrain`
|
|
||||||
|
|
||||||
### Config fields
|
|
||||||
|
|
||||||
- `baseUrl` (required, string): OpenBrain API base URL
|
|
||||||
- `apiKey` (required, string): OpenBrain Bearer token
|
|
||||||
- `source` (optional, string, default `openclaw`): source prefix; engine stores thoughts under `<source>:<sessionId>`
|
|
||||||
- `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
|
|
||||||
```
|
|
||||||
|
|||||||
26
docs/PRD.md
26
docs/PRD.md
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
## Problem Statement
|
## Problem Statement
|
||||||
|
|
||||||
OpenClaw compacts context when sessions grow long, causing information loss. The new `ContextEngine` plugin interface (merged in PR #22201) allows replacing the default compaction strategy with a persistent, lossless alternative. OpenBrain is a self-hosted pgvector + REST API service that can store and semantically retrieve conversation context indefinitely.
|
OpenClaw compacts context when sessions grow long, causing information loss. The new `ContextEngine` plugin interface (merged in PR #22201) allows replacing the default compaction strategy with a persistent, lossless alternative. OpenBrain (brain.woltje.com) is a live pgvector + REST API service that can store and semantically retrieve conversation context indefinitely.
|
||||||
|
|
||||||
## Solution
|
## Solution
|
||||||
|
|
||||||
@@ -20,8 +20,8 @@ lossless-claw (https://github.com/Martian-Engineering/lossless-claw) is the prim
|
|||||||
## OpenBrain API
|
## OpenBrain API
|
||||||
|
|
||||||
```
|
```
|
||||||
Base: <user-configured — OPENBRAIN_URL env var or plugin config.baseUrl — NO DEFAULT>
|
Base: https://brain.woltje.com
|
||||||
Auth: Bearer <OPENBRAIN_API_KEY env var or plugin config.apiKey — NO DEFAULT>
|
Auth: Bearer <OPENBRAIN_API_KEY>
|
||||||
|
|
||||||
POST /v1/thoughts { content, source, metadata }
|
POST /v1/thoughts { content, source, metadata }
|
||||||
POST /v1/search { query, limit }
|
POST /v1/search { query, limit }
|
||||||
@@ -85,8 +85,8 @@ export function register(api: OpenClawPluginApi) {
|
|||||||
"openclaw-openbrain-context": {
|
"openclaw-openbrain-context": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"config": {
|
"config": {
|
||||||
"baseUrl": "https://your-openbrain-instance.example.com",
|
"baseUrl": "https://brain.woltje.com",
|
||||||
"apiKey": "your-api-key",
|
"apiKey": "${OPENBRAIN_API_KEY}",
|
||||||
"recentMessages": 20,
|
"recentMessages": 20,
|
||||||
"semanticSearchLimit": 10,
|
"semanticSearchLimit": 10,
|
||||||
"source": "openclaw"
|
"source": "openclaw"
|
||||||
@@ -103,27 +103,15 @@ export function register(api: OpenClawPluginApi) {
|
|||||||
- openclaw/plugin-sdk for ContextEngine interface
|
- openclaw/plugin-sdk for ContextEngine interface
|
||||||
- pnpm, vitest, ESLint
|
- pnpm, vitest, ESLint
|
||||||
|
|
||||||
## ⚠️ Hard Rule: No Hardcoded Instance Values
|
|
||||||
|
|
||||||
This plugin will be used by anyone running their own OpenBrain instance. The following are STRICTLY FORBIDDEN in source code, defaults, or fallback logic:
|
|
||||||
- Any hardcoded URL (no `brain.woltje.com` or any other specific domain)
|
|
||||||
- Any hardcoded API key
|
|
||||||
- Any `process.env.OPENBRAIN_URL || 'https://brain.woltje.com'` fallback patterns
|
|
||||||
|
|
||||||
Required behavior:
|
|
||||||
- `baseUrl` and `apiKey` MUST be provided via plugin config or env vars
|
|
||||||
- If either is missing, throw `OpenBrainConfigError` at `bootstrap()` time with a clear message: "openclaw-openbrain-context: baseUrl and apiKey are required. Set them in your openclaw.json plugin config."
|
|
||||||
- No silent degradation, no defaults to any specific host
|
|
||||||
|
|
||||||
## Acceptance Criteria (v0.0.1)
|
## Acceptance Criteria (v0.0.1)
|
||||||
1. Plugin registers as a ContextEngine with id `openbrain`
|
1. Plugin registers as a ContextEngine with id `openbrain`
|
||||||
2. `ingest()` stores messages to OpenBrain with correct metadata
|
2. `ingest()` stores messages to OpenBrain with correct metadata
|
||||||
3. `assemble()` retrieves recent + semantically relevant context within token budget
|
3. `assemble()` retrieves recent + semantically relevant context within token budget
|
||||||
4. `compact()` archives turn summary, returns minimal prompt-injection
|
4. `compact()` archives turn summary, returns minimal prompt-injection
|
||||||
5. `bootstrap()` loads prior session context on restart; throws `OpenBrainConfigError` if config missing
|
5. `bootstrap()` loads prior session context on restart
|
||||||
6. Tests pass, TypeScript strict, ESLint clean
|
6. Tests pass, TypeScript strict, ESLint clean
|
||||||
7. openclaw.plugin.json with correct manifest
|
7. openclaw.plugin.json with correct manifest
|
||||||
8. README documents: self-host OpenBrain setup, all config options with types/defaults, env var pattern, example config
|
8. README: install + config + usage
|
||||||
|
|
||||||
## ASSUMPTION: ContextEngine interface shape
|
## ASSUMPTION: ContextEngine interface shape
|
||||||
Based on lossless-claw source and PR #22201. Plugin SDK import path: `openclaw/plugin-sdk`.
|
Based on lossless-claw source and PR #22201. Plugin SDK import path: `openclaw/plugin-sdk`.
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
| id | status | description | issue | repo | branch | depends_on | blocks | agent | started_at | completed_at | estimate | used | notes |
|
| id | status | description | issue | repo | branch | depends_on | blocks | agent | started_at | completed_at | estimate | used | notes |
|
||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
| OBC-001 | done | Init: pnpm, TypeScript strict, ESLint, vitest, openclaw plugin-sdk dep | TASKS:P1 | openclaw-openbrain-context | feat/engine | | OBC-002 | | | | 8K | | |
|
| OBC-001 | not-started | Init: pnpm, TypeScript strict, ESLint, vitest, openclaw plugin-sdk dep | TASKS:P1 | openclaw-openbrain-context | feat/engine | | OBC-002 | | | | 8K | | |
|
||||||
| OBC-002 | done | OpenBrain REST client module (typed, auth, error handling) | TASKS:P1 | openclaw-openbrain-context | feat/engine | OBC-001 | OBC-003 | | | | 8K | | |
|
| OBC-002 | not-started | OpenBrain REST client module (typed, auth, error handling) | TASKS:P1 | openclaw-openbrain-context | feat/engine | OBC-001 | OBC-003 | | | | 8K | | |
|
||||||
| OBC-003 | done | ContextEngine implementation: bootstrap + ingest + ingestBatch | TASKS:P1 | openclaw-openbrain-context | feat/engine | OBC-002 | OBC-004 | | | | 15K | | |
|
| OBC-003 | not-started | ContextEngine implementation: bootstrap + ingest + ingestBatch | TASKS:P1 | openclaw-openbrain-context | feat/engine | OBC-002 | OBC-004 | | | | 15K | | |
|
||||||
| OBC-004 | done | ContextEngine implementation: assemble (recent + semantic merge) | TASKS:P1 | openclaw-openbrain-context | feat/engine | OBC-003 | OBC-005 | | | | 15K | | |
|
| OBC-004 | not-started | ContextEngine implementation: assemble (recent + semantic merge) | TASKS:P1 | openclaw-openbrain-context | feat/engine | OBC-003 | OBC-005 | | | | 15K | | |
|
||||||
| OBC-005 | done | ContextEngine implementation: compact + prepareSubagentSpawn + onSubagentEnded + dispose | TASKS:P1 | openclaw-openbrain-context | feat/engine | OBC-004 | OBC-006 | | | | 12K | | |
|
| OBC-005 | not-started | ContextEngine implementation: compact + prepareSubagentSpawn + onSubagentEnded + dispose | TASKS:P1 | openclaw-openbrain-context | feat/engine | OBC-004 | OBC-006 | | | | 12K | | |
|
||||||
| OBC-006 | done | Plugin entrypoint: register(), openclaw.plugin.json manifest | TASKS:P1 | openclaw-openbrain-context | feat/engine | OBC-005 | OBC-007 | | | | 5K | | |
|
| OBC-006 | not-started | Plugin entrypoint: register(), openclaw.plugin.json manifest | TASKS:P1 | openclaw-openbrain-context | feat/engine | OBC-005 | OBC-007 | | | | 5K | | |
|
||||||
| OBC-007 | done | Unit tests: ingest/assemble/compact + mock OpenBrain responses | TASKS:P1 | openclaw-openbrain-context | feat/engine | OBC-006 | OBC-008 | | | | 15K | | |
|
| OBC-007 | not-started | Unit tests: ingest/assemble/compact + mock OpenBrain responses | TASKS:P1 | openclaw-openbrain-context | feat/engine | OBC-006 | OBC-008 | | | | 15K | | |
|
||||||
| OBC-008 | done | README + install instructions + openclaw.json config example | TASKS:P1 | openclaw-openbrain-context | feat/engine | OBC-007 | | | | | 5K | | |
|
| OBC-008 | not-started | README + install instructions + openclaw.json config example | TASKS:P1 | openclaw-openbrain-context | feat/engine | OBC-007 | | | | | 5K | | |
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
import js from "@eslint/js";
|
|
||||||
import tseslint from "typescript-eslint";
|
|
||||||
|
|
||||||
export default [
|
|
||||||
{
|
|
||||||
ignores: ["dist/**", "coverage/**"],
|
|
||||||
},
|
|
||||||
js.configs.recommended,
|
|
||||||
...tseslint.configs.recommended,
|
|
||||||
{
|
|
||||||
files: ["**/*.ts"],
|
|
||||||
languageOptions: {
|
|
||||||
ecmaVersion: "latest",
|
|
||||||
sourceType: "module",
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
"@typescript-eslint/consistent-type-imports": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
prefer: "type-imports",
|
|
||||||
fixStyle: "inline-type-imports",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
29
package.json
29
package.json
@@ -1,29 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "openclaw-openbrain-context",
|
|
||||||
"version": "0.0.1",
|
|
||||||
"description": "OpenBrain-backed ContextEngine plugin for OpenClaw",
|
|
||||||
"type": "module",
|
|
||||||
"main": "dist/index.js",
|
|
||||||
"types": "dist/index.d.ts",
|
|
||||||
"files": [
|
|
||||||
"dist",
|
|
||||||
"openclaw.plugin.json",
|
|
||||||
"README.md"
|
|
||||||
],
|
|
||||||
"scripts": {
|
|
||||||
"lint": "eslint . --max-warnings=0",
|
|
||||||
"build": "tsc -p tsconfig.json",
|
|
||||||
"test": "vitest run"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"openclaw": ">=2026.3.2"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@eslint/js": "^9.25.1",
|
|
||||||
"@types/node": "^22.15.3",
|
|
||||||
"eslint": "^9.25.1",
|
|
||||||
"typescript": "^5.8.3",
|
|
||||||
"typescript-eslint": "^8.30.1",
|
|
||||||
"vitest": "^2.1.8"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
8341
pnpm-lock.yaml
generated
8341
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,3 +0,0 @@
|
|||||||
export const OPENBRAIN_CONTEXT_ENGINE_ID = "openbrain";
|
|
||||||
export const OPENBRAIN_PLUGIN_ID = "openclaw-openbrain-context";
|
|
||||||
export const OPENBRAIN_PLUGIN_VERSION = "0.0.1";
|
|
||||||
774
src/engine.ts
774
src/engine.ts
@@ -1,774 +0,0 @@
|
|||||||
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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
31
src/index.ts
31
src/index.ts
@@ -1,31 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,333 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
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;
|
|
||||||
};
|
|
||||||
@@ -1,414 +0,0 @@
|
|||||||
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,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
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> | unknown];
|
|
||||||
expect(id).toBe(OPENBRAIN_CONTEXT_ENGINE_ID);
|
|
||||||
|
|
||||||
const engine = await factory();
|
|
||||||
expect(engine).toHaveProperty("info.id", OPENBRAIN_CONTEXT_ENGINE_ID);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
{
|
|
||||||
"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,
|
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"openclaw/plugin-sdk": ["../openclaw-fork/src/plugin-sdk/index.ts"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"include": ["src/**/*.ts", "tests/**/*.ts"],
|
|
||||||
"exclude": ["dist", "node_modules"]
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import { defineConfig } from "vitest/config";
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
test: {
|
|
||||||
environment: "node",
|
|
||||||
include: ["tests/**/*.test.ts"],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user