Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion packages/contexto/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,24 @@ For the deeper technical reasoning:

| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `apiKey` | string | Yes | Your Contexto API key |
| `apiKey` | string | Yes (remote) | Your Contexto API key |
| `mode` | string | No | `remote` (default) or `local` |

### Remote mode (default)

Uses the hosted Contexto API. Get an API key at [getcontexto.com](https://getcontexto.com/).

```bash
openclaw config set plugins.entries.contexto.config.apiKey YOUR_KEY
```

### Local mode

Runs the full pipeline locally: summarize via LLM, embed, cluster (AGNES), and persist to `~/.openclaw/data/contexto/mindmap.json`. Uses your OpenClaw provider and API key — no extra config needed.

```bash
openclaw config set plugins.entries.contexto.config.mode local
```

## Community

Expand Down
5 changes: 5 additions & 0 deletions packages/contexto/openclaw.plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@
"maxContextChars": {
"type": "number",
"description": "Maximum characters of context to inject (default: 2000)"
},
"mode": {
"type": "string",
"default": "remote",
"description": "'remote' (hosted API) or 'local' (local pipeline with LLM summarization + mindmap)"
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions packages/contexto/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@
"scripts": {
"build": "tsc --noEmit"
},
"dependencies": {
"@ekai/mindmap": "^0.1.8"
},
"peerDependencies": {
"openclaw": "*"
},
Expand Down
6 changes: 3 additions & 3 deletions packages/contexto/src/engine/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,12 @@ export abstract class AbstractContextEngine implements ContextEngine {
if (tokenBudget != null) this.state.cachedTokenBudget = tokenBudget;

const lastMsg = messages?.[messages.length - 1];
this.logger.info(`[contexto] assemble() called — ${messages?.length} messages, tokenBudget: ${tokenBudget}, contextEnabled: ${this.config.contextEnabled}, hasApiKey: ${!!this.config.apiKey}`);
this.logger.info(`[contexto] assemble() called — ${messages?.length} messages, tokenBudget: ${tokenBudget}`);
const lastMsgContent = lastMsg && 'content' in lastMsg ? lastMsg.content : undefined;
this.logger.debug(`[contexto] last message — role: ${lastMsg?.role}, content type: ${typeof lastMsgContent}, isArray: ${Array.isArray(lastMsgContent)}, sample: ${JSON.stringify(lastMsgContent)?.slice(0, 200)}`);

if (!this.config.apiKey || !this.config.contextEnabled) {
this.logger.info(`[contexto] assemble() skipping — apiKey: ${!!this.config.apiKey}, contextEnabled: ${this.config.contextEnabled}`);
if (!this.config.apiKey) {
this.logger.info(`[contexto] assemble() skipping — not configured`);
return { messages, estimatedTokens: 0 };
}

Expand Down
55 changes: 48 additions & 7 deletions packages/contexto/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import type { PluginConfig } from './types.js';
import { RemoteBackend } from './client.js';
import { LocalBackend } from './local/index.js';
import { createContextEngine } from './engine/index.js';

// Public API — use ContextoBackend to implement a custom (e.g. local) backend
export type { ContextoBackend, SearchResult, WebhookPayload, Logger } from './types.js';
export { RemoteBackend } from './client.js';
export { LocalBackend } from './local/index.js';
export type { LocalBackendConfig, EpisodeSummary } from './local/index.js';

/** OpenClaw plugin definition. */
export default {
Expand All @@ -16,19 +18,22 @@ export default {
type: 'object',
properties: {
apiKey: { type: 'string' },
contextEnabled: { type: 'boolean', default: true },

maxContextChars: { type: 'number' },
compactThreshold: { type: 'number', default: 0.50 },
compactionStrategy: { type: 'string', default: 'default' },
mode: { type: 'string', default: 'remote' },
},
},

register(api: any) {
const strategy = api.pluginConfig?.compactionStrategy ?? 'default';
const backendMode = api.pluginConfig?.mode ?? 'remote';

const base = {
apiKey: api.pluginConfig?.apiKey,
contextEnabled: api.pluginConfig?.contextEnabled ?? true,
maxContextChars: api.pluginConfig?.maxContextChars,
mode: backendMode as 'remote' | 'local',
};

const config: PluginConfig = strategy === 'default'
Expand All @@ -41,17 +46,53 @@ export default {

const logger = api.logger;

if (backendMode === 'local') {
const modelAuth = api.runtime?.modelAuth;
if (!modelAuth?.resolveApiKeyForProvider) {
logger.warn('[contexto] Local mode requires modelAuth — not available');
return;
}

// Resolve API key via .then() since register() must be synchronous
modelAuth.resolveApiKeyForProvider({ provider: 'openrouter', cfg: api.config })
.then((openrouterAuth: any) => {
if (openrouterAuth?.apiKey) {
return { provider: 'openrouter' as const, apiKey: openrouterAuth.apiKey };
}
return modelAuth.resolveApiKeyForProvider({ provider: 'openai', cfg: api.config })
.then((openaiAuth: any) => {
if (openaiAuth?.apiKey) {
return { provider: 'openai' as const, apiKey: openaiAuth.apiKey };
}
return null;
});
})
.then((result: { provider: 'openrouter' | 'openai'; apiKey: string } | null) => {
if (!result) {
logger.warn('[contexto] Local mode requires an OpenRouter or OpenAI API key configured in OpenClaw');
return;
}
config.apiKey = 'local';
const backend = new LocalBackend({ provider: result.provider, apiKey: result.apiKey }, logger);
const engine = createContextEngine(config, backend, logger);
api.registerContextEngine('contexto', () => engine);
logger.info(`[contexto] Plugin registered with local backend (provider: ${result.provider})`);
})
.catch((err: any) => {
logger.warn(`[contexto] Failed to resolve API key: ${err?.message ?? err}`);
});
return;
}

// Remote backend (default)
if (!config.apiKey) {
logger.warn('[contexto] Missing apiKey — ingestion and retrieval will be disabled');
return;
}

const backend = new RemoteBackend(config, logger);

const engine = createContextEngine(config, backend, logger);

api.registerContextEngine('contexto', () => engine);

logger.info(`[contexto] Plugin registered (contextEnabled: ${config.contextEnabled})`);
logger.info('[contexto] Plugin registered');
},
};
125 changes: 125 additions & 0 deletions packages/contexto/src/local/backend.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { homedir } from 'node:os';
import { join } from 'node:path';
import { Mindmap, jsonFileStorage } from '@ekai/mindmap';
import type { ContextoBackend, Logger, SearchResult, WebhookPayload } from '../types.js';
import type { LocalBackendConfig } from './types.js';
import { extractEpisodeText, summarizeEpisode } from './summarizer.js';

const STORAGE_PATH = join(homedir(), '.openclaw', 'data', 'contexto', 'mindmap.json');

/** ContextoBackend implementation that runs the full pipeline locally. */
export class LocalBackend implements ContextoBackend {
private mindmap: Mindmap;
private config: LocalBackendConfig;
private logger: Logger;

constructor(config: LocalBackendConfig, logger: Logger) {
this.config = config;
this.logger = logger;

const storage = config.storage ?? jsonFileStorage(STORAGE_PATH);

this.mindmap = new Mindmap({
provider: config.provider,
apiKey: config.apiKey,
embedModel: config.embedModel,
storage,
config: config.mindmapConfig,
});
}

async ingest(payload: WebhookPayload | WebhookPayload[]): Promise<void> {
const payloads = Array.isArray(payload) ? payload : [payload];
if (payloads.length === 0) return;

// Filter to episode/combined events only
const episodes = payloads.filter(
(p) => p.event.type === 'episode' && p.event.action === 'combined',
);

if (episodes.length === 0) {
this.logger.debug('[contexto:local] No episode/combined events to ingest');
return;
}

try {
const items: Array<{ id: string; role: string; content: string; timestamp?: string; metadata?: Record<string, unknown> }> = [];

for (const ep of episodes) {
const text = extractEpisodeText(ep);
if (!text) {
this.logger.debug('[contexto:local] Empty episode text, skipping');
continue;
}

const traceRef = crypto.randomUUID();
const summary = await summarizeEpisode(text, {
provider: this.config.provider,
apiKey: this.config.apiKey,
model: this.config.llmModel,
}, this.logger);

// Compose content: summary + key findings as bullets (matches remote API format)
const contentParts = [summary.summary];
if (summary.key_findings.length > 0) {
contentParts.push(`\nKey findings:\n${summary.key_findings.map((f) => `- ${f}`).join('\n')}`);
}

const episodeData = ep.data as Record<string, any> | undefined;

items.push({
id: crypto.randomUUID(),
role: 'assistant',
content: contentParts.join('\n'),
timestamp: ep.timestamp ?? new Date().toISOString(),
metadata: {
source: 'summary',
status: summary.status,
evidence_refs: summary.evidence_refs,
open_questions: summary.open_questions,
confidence: summary.confidence,
trace_ref: traceRef,
sessionKey: ep.sessionKey,
episode: {
userMessage: episodeData?.userMessage,
assistantMessages: episodeData?.assistantMessages ?? [],
toolMessages: episodeData?.toolMessages ?? [],
},
},
});
}

if (items.length > 0) {
await this.mindmap.add(items);
this.logger.info(`[contexto:local] Ingested ${items.length} episode(s) into mindmap`);
}
} catch (err) {
this.logger.warn(`[contexto:local] Ingest failed: ${err instanceof Error ? err.message : String(err)}`);
}
}

async search(
query: string,
maxResults: number,
filter?: Record<string, unknown>,
minScore?: number,
): Promise<SearchResult | null> {
try {
const result = await this.mindmap.search(query, {
maxResults,
filter,
minScore,
});

if (!result.items.length) return null;

return {
items: result.items,
paths: result.paths,
};
} catch (err) {
this.logger.warn(`[contexto:local] Search failed: ${err instanceof Error ? err.message : String(err)}`);
return null;
}
}
}
3 changes: 3 additions & 0 deletions packages/contexto/src/local/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { LocalBackend } from './backend.js';
export type { LocalBackendConfig, EpisodeSummary, EvidenceRef, EvidenceRefType, LLMProviderConfig } from './types.js';
export { extractEpisodeText, summarizeEpisode } from './summarizer.js';
Loading
Loading