Skip to content
Merged
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
10 changes: 10 additions & 0 deletions packages/contexto/openclaw.plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,19 @@
"default": "default",
"description": "Compaction Strategy in the context engine"
},
"contextEnabled": {
"type": "boolean",
"default": true,
"description": "Enable context retrieval and injection (default: true)"
},
"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
7 changes: 4 additions & 3 deletions packages/contexto/src/engine/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,13 @@ 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}`);
const contextEnabled = this.config.contextEnabled ?? true;
this.logger.info(`[contexto] assemble() called — ${messages?.length} messages, tokenBudget: ${tokenBudget}, contextEnabled: ${contextEnabled}`);
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 || !contextEnabled) {
this.logger.info(`[contexto] assemble() skipping — apiKey: ${!!this.config.apiKey}, contextEnabled: ${contextEnabled}`);
return { messages, estimatedTokens: 0 };
}

Expand Down
50 changes: 45 additions & 5 deletions packages/contexto/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import type { PluginConfig } from './types.js';
import { RemoteBackend } from './client.js';
import { LocalBackend } from './local/index.js';
import type { ResolvedCredentials } 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, ResolvedCredentials, EpisodeSummary } from './local/index.js';

/** OpenClaw plugin definition. */
export default {
Expand All @@ -20,15 +23,20 @@ export default {
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 logger = api.logger;

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 @@ -39,19 +47,51 @@ export default {
compactThreshold: api.pluginConfig?.compactThreshold ?? 0.50,
};

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

// Defer API key resolution to first ingest/search so registerContextEngine
// is called synchronously here (OpenClaw does not wait for async work in register()).
const resolveCredentials = async (): Promise<ResolvedCredentials | null> => {
try {
const openrouterAuth = await modelAuth.resolveApiKeyForProvider({ provider: 'openrouter', cfg: api.config });
if (openrouterAuth?.apiKey) {
return { provider: 'openrouter', apiKey: openrouterAuth.apiKey };
}
const openaiAuth = await modelAuth.resolveApiKeyForProvider({ provider: 'openai', cfg: api.config });
if (openaiAuth?.apiKey) {
return { provider: 'openai', apiKey: openaiAuth.apiKey };
}
logger.warn('[contexto] Local mode requires an OpenRouter or OpenAI API key configured in OpenClaw');
return null;
} catch (err: any) {
logger.warn(`[contexto] Failed to resolve API key: ${err?.message ?? err}`);
return null;
}
};

// Sentinel apiKey so assemble()/afterTurn() guards pass; real credentials live in the backend.
config.apiKey = 'local';
const backend = new LocalBackend({ credentials: resolveCredentials }, logger);
const engine = createContextEngine(config, backend, logger);
api.registerContextEngine('contexto', () => engine);
logger.info('[contexto] Plugin registered with local backend');
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})`);
},
};
169 changes: 169 additions & 0 deletions packages/contexto/src/local/backend.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
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, ResolvedCredentials } 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 config: LocalBackendConfig;
private logger: Logger;
private mindmapPromise: Promise<Mindmap | null> | null = null;

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

/** Resolve credentials and build the Mindmap on first use, caching the result. */
private async getMindmap(): Promise<Mindmap | null> {
if (!this.mindmapPromise) {
this.mindmapPromise = this.initMindmap();
}
return this.mindmapPromise;
}

private async initMindmap(): Promise<Mindmap | null> {
let creds: ResolvedCredentials | null;
try {
creds = typeof this.config.credentials === 'function'
? await this.config.credentials()
: this.config.credentials;
} catch (err) {
this.logger.warn(`[contexto:local] Credential resolution failed: ${err instanceof Error ? err.message : String(err)}`);
return null;
}

if (!creds?.apiKey) {
this.logger.warn('[contexto:local] No API key available — local backend disabled');
return null;
}

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

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

private async getCredentials(): Promise<ResolvedCredentials | null> {
try {
return typeof this.config.credentials === 'function'
? await this.config.credentials()
: this.config.credentials;
} catch {
return null;
}
}

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

const mindmap = await this.getMindmap();
if (!mindmap) return;

const creds = await this.getCredentials();
if (!creds) 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: creds.provider,
apiKey: creds.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 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> {
const mindmap = await this.getMindmap();
if (!mindmap) return null;

try {
const result = await 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, ResolvedCredentials, EpisodeSummary, EvidenceRef, EvidenceRefType, LLMProviderConfig } from './types.js';
export { extractEpisodeText, summarizeEpisode } from './summarizer.js';
Loading
Loading