2 Commits

4 changed files with 37 additions and 186 deletions

View File

@@ -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.

View File

@@ -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`.

View File

@@ -236,31 +236,23 @@ export class OpenBrainContextEngine implements ContextEngine {
}): Promise<IngestBatchResult> { }): Promise<IngestBatchResult> {
this.assertNotDisposed(); this.assertNotDisposed();
const maxConcurrency = 5;
let ingestedCount = 0; let ingestedCount = 0;
for (let i = 0; i < params.messages.length; i += maxConcurrency) { for (const message of params.messages) {
const chunk = params.messages.slice(i, i + maxConcurrency); const ingestParams: {
const results = await Promise.all( sessionId: string;
chunk.map((message) => { message: AgentMessage;
const ingestParams: { isHeartbeat?: boolean;
sessionId: string; } = {
message: AgentMessage; sessionId: params.sessionId,
isHeartbeat?: boolean; message,
} = { };
sessionId: params.sessionId, if (params.isHeartbeat !== undefined) {
message, ingestParams.isHeartbeat = params.isHeartbeat;
}; }
if (params.isHeartbeat !== undefined) { const result = await this.ingest(ingestParams);
ingestParams.isHeartbeat = params.isHeartbeat;
}
return this.ingest(ingestParams);
}),
);
for (const result of results) { if (result.ingested) {
if (result.ingested) { ingestedCount += 1;
ingestedCount += 1;
}
} }
} }
@@ -348,22 +340,21 @@ export class OpenBrainContextEngine implements ContextEngine {
}; };
} }
const summarizedThoughts = this.selectSummaryThoughts(recentThoughts);
const summary = this.buildSummary( const summary = this.buildSummary(
params.customInstructions !== undefined params.customInstructions !== undefined
? { ? {
sessionId: params.sessionId, sessionId: params.sessionId,
thoughts: summarizedThoughts, thoughts: recentThoughts,
customInstructions: params.customInstructions, customInstructions: params.customInstructions,
} }
: { : {
sessionId: params.sessionId, sessionId: params.sessionId,
thoughts: summarizedThoughts, thoughts: recentThoughts,
}, },
); );
const summaryTokens = estimateTextTokens(summary); const summaryTokens = estimateTextTokens(summary);
const tokensBefore = this.estimateTokensForThoughts(summarizedThoughts); const tokensBefore = this.estimateTokensForThoughts(recentThoughts);
await client.createThought({ await client.createThought({
content: summary, content: summary,
@@ -376,15 +367,6 @@ export class OpenBrainContextEngine implements ContextEngine {
}, },
}); });
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 { return {
ok: true, ok: true,
compacted: true, compacted: true,
@@ -618,7 +600,7 @@ export class OpenBrainContextEngine implements ContextEngine {
} }
const tokens = estimateTextTokens(serializeContent(message.content)); const tokens = estimateTextTokens(serializeContent(message.content));
if (total + tokens > tokenBudget) { if (total + tokens > tokenBudget) {
break; continue;
} }
total += tokens; total += tokens;
@@ -648,7 +630,12 @@ export class OpenBrainContextEngine implements ContextEngine {
thoughts: OpenBrainThought[]; thoughts: OpenBrainThought[];
customInstructions?: string; customInstructions?: string;
}): string { }): string {
const lines = params.thoughts.map((thought) => { const ordered = [...params.thoughts].sort((a, b) => {
return thoughtTimestamp(a, 0) - thoughtTimestamp(b, 0);
});
const maxLines = Math.min(ordered.length, 10);
const lines = ordered.slice(Math.max(ordered.length - maxLines, 0)).map((thought) => {
const role = normalizeRole(thought.metadata?.role); const role = normalizeRole(thought.metadata?.role);
const content = truncateLine(thought.content.replace(/\s+/g, " ").trim(), 180); const content = truncateLine(thought.content.replace(/\s+/g, " ").trim(), 180);
return `- ${role}: ${content}`; return `- ${role}: ${content}`;
@@ -663,15 +650,6 @@ export class OpenBrainContextEngine implements ContextEngine {
return `${header}\n${instruction}${lines.join("\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: { private buildSubagentSeedContent(params: {
parentSessionKey: string; parentSessionKey: string;
childSessionKey: string; childSessionKey: string;

View File

@@ -131,54 +131,6 @@ describe("OpenBrainContextEngine", () => {
expect(mockClient.createThought).toHaveBeenCalledTimes(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 () => { it("assembles context from recent + semantic search, deduped and budget-aware", async () => {
const mockClient = makeMockClient(); const mockClient = makeMockClient();
vi.mocked(mockClient.listRecent).mockResolvedValue([ vi.mocked(mockClient.listRecent).mockResolvedValue([
@@ -240,19 +192,12 @@ describe("OpenBrainContextEngine", () => {
]); ]);
}); });
it("compact archives a summary thought and deletes summarized inputs", async () => { it("compact archives a summary thought", async () => {
const mockClient = makeMockClient(); const mockClient = makeMockClient();
vi.mocked(mockClient.listRecent).mockResolvedValue( vi.mocked(mockClient.listRecent).mockResolvedValue([
Array.from({ length: 12 }, (_, index) => { makeThought("t1", "first message", sessionId, "user", "2026-03-06T12:00:00.000Z"),
return makeThought( makeThought("t2", "second message", sessionId, "assistant", "2026-03-06T12:01:00.000Z"),
`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( const engine = new OpenBrainContextEngine(
{ {
@@ -281,54 +226,6 @@ describe("OpenBrainContextEngine", () => {
}), }),
}), }),
); );
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 () => { it("prepares subagent spawn and rollback deletes seeded context", async () => {