@baselayer-id/baselayer-openclaw 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,56 @@
1
+ # baselayer-openclaw
2
+
3
+ OpenClaw plugin that captures your AI conversations and ingests them into your [BaseLayer](https://baselayer.id) knowledge vault.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ openclaw plugins install @baselayer-id/baselayer-openclaw
9
+ ```
10
+
11
+ ## Configure
12
+
13
+ Add your credentials to your OpenClaw config (`~/.openclaw/openclaw.json`):
14
+
15
+ ```json
16
+ {
17
+ "plugins": {
18
+ "entries": {
19
+ "baselayer-openclaw": {
20
+ "enabled": true,
21
+ "config": {
22
+ "apiKey": "bl_your_api_key_here"
23
+ }
24
+ }
25
+ }
26
+ }
27
+ }
28
+ ```
29
+
30
+ **Where to get your API Key:**
31
+
32
+ - **API Key** — [baselayer.id/profile](https://baselayer.id/profile) (starts with `bl_`)
33
+
34
+ ## How it works
35
+
36
+ After each conversation turn, the plugin:
37
+
38
+ 1. Captures the user message and assistant response
39
+ 2. Sends the payload to your BaseLayer vault
40
+
41
+ Conversations accumulate under a single session ID, so an ongoing chat appears as one conversation in your vault rather than scattered fragments.
42
+
43
+ ## Requirements
44
+
45
+ - [BaseLayer Desktop](https://baselayer.id/download) installed and signed in
46
+ - An active BaseLayer account with API key
47
+
48
+ ## Links
49
+
50
+ - [BaseLayer](https://baselayer.id)
51
+ - [Documentation](https://docs.baselayer.id)
52
+ - [Chrome Extension](https://chromewebstore.google.com/detail/baselayer-sovereign-memory/bhpchdhfjgecpnphpmpgmkeajefnhmdn)
53
+
54
+ ## License
55
+
56
+ MIT
package/index.ts ADDED
@@ -0,0 +1,356 @@
1
+ /**
2
+ * BaseLayer OpenClaw Plugin
3
+ *
4
+ * Captures conversation turns from your OpenClaw agent and ingests them
5
+ * into your BaseLayer vault.
6
+ *
7
+ * @version 0.6.0
8
+ * @see https://baselayer.id/credentials
9
+ */
10
+
11
+ import { appendFileSync } from 'node:fs';
12
+
13
+ // ============================================================================
14
+ // Debug Logging — writes to stdout AND a dedicated log file
15
+ // ============================================================================
16
+
17
+ const LOG_FILE = `${process.env.HOME}/.openclaw/logs/baselayer-plugin.log`;
18
+
19
+ function pluginLog(level: string, msg: string) {
20
+ const ts = new Date().toISOString();
21
+ const line = `${ts} [baselayer:${level}] ${msg}`;
22
+ console.log(line);
23
+ try { appendFileSync(LOG_FILE, line + '\n'); } catch {}
24
+ }
25
+
26
+ // ============================================================================
27
+ // Types
28
+ // ============================================================================
29
+
30
+ interface PluginConfig {
31
+ apiKey: string;
32
+ realm?: 'local' | 'dev' | 'prod';
33
+ }
34
+
35
+ interface ConversationTurn {
36
+ role: 'user' | 'assistant';
37
+ content: string;
38
+ timestamp: number;
39
+ }
40
+
41
+ interface IngestMessage {
42
+ role: 'user' | 'assistant';
43
+ content: string;
44
+ timestamp: number;
45
+ }
46
+
47
+ interface IngestPayload {
48
+ schema_version: number;
49
+ conversation_id: string;
50
+ provider: string;
51
+ new_messages: IngestMessage[];
52
+ metadata: {
53
+ title: string;
54
+ url: string;
55
+ message_count: number;
56
+ };
57
+ }
58
+
59
+ // ============================================================================
60
+ // Session Filtering — skip automated/non-human flows
61
+ // ============================================================================
62
+
63
+ /**
64
+ * Returns true if this session should be SKIPPED (not captured).
65
+ * We only want human-initiated conversations.
66
+ *
67
+ * Excluded:
68
+ * - Sub-agent sessions (sessionKind === 'isolated' or session key matches agent:*:subagent:*)
69
+ * - Cron/heartbeat jobs (source === 'cron' or 'heartbeat')
70
+ * - System events without a real channel
71
+ */
72
+ function shouldSkipSession(event: any, ctx: any): { skip: boolean; reason: string } {
73
+ // Sub-agent sessions
74
+ if (event?.sessionKind === 'isolated') {
75
+ return { skip: true, reason: 'isolated sub-agent session' };
76
+ }
77
+ const sessionKey = ctx?.sessionKey || event?.sessionKey || '';
78
+ if (sessionKey.includes(':subagent:')) {
79
+ return { skip: true, reason: `sub-agent session: ${sessionKey}` };
80
+ }
81
+
82
+ // Cron and heartbeat
83
+ const source = event?.source || ctx?.source || '';
84
+ if (source === 'cron' || source === 'heartbeat') {
85
+ return { skip: true, reason: `automated source: ${source}` };
86
+ }
87
+ if (sessionKey.includes(':cron:') || sessionKey.includes(':heartbeat:')) {
88
+ return { skip: true, reason: `automated session key: ${sessionKey}` };
89
+ }
90
+
91
+ return { skip: false, reason: 'human-initiated' };
92
+ }
93
+
94
+ // ============================================================================
95
+ // Ingest
96
+ // ============================================================================
97
+
98
+ async function flush(
99
+ config: PluginConfig,
100
+ turns: ConversationTurn[],
101
+ title: string,
102
+ conversationId: string,
103
+ ): Promise<void> {
104
+ if (turns.length === 0) return;
105
+
106
+ let endpoint = 'https://api.baselayer.id/api/ingest/event';
107
+ if (config.realm === 'local') {
108
+ endpoint = 'http://localhost:8080/api/ingest/event';
109
+ } else if (config.realm === 'dev') {
110
+ endpoint = 'https://api-dev.baselayer.id/api/ingest/event';
111
+ }
112
+
113
+ pluginLog('info', `Flushing ${turns.length} turns to ${endpoint} (convId=${conversationId})`);
114
+
115
+ const plainMessages: IngestMessage[] = turns.map((turn) => ({
116
+ role: turn.role,
117
+ content: turn.content,
118
+ timestamp: turn.timestamp,
119
+ }));
120
+
121
+ const payload: IngestPayload = {
122
+ schema_version: 2,
123
+ conversation_id: conversationId,
124
+ provider: 'openclaw',
125
+ new_messages: plainMessages,
126
+ metadata: {
127
+ title: title || 'OpenClaw Conversation',
128
+ url: 'openclaw://conversation',
129
+ message_count: plainMessages.length,
130
+ },
131
+ };
132
+
133
+ pluginLog('debug', `Payload: convId=${conversationId}, msgs=${plainMessages.length}, title="${title}"`);
134
+
135
+ try {
136
+ const response = await fetch(endpoint, {
137
+ method: 'POST',
138
+ headers: {
139
+ 'Content-Type': 'application/json',
140
+ Authorization: `Bearer ${config.apiKey}`,
141
+ },
142
+ body: JSON.stringify(payload),
143
+ });
144
+
145
+ const body = await response.text().catch(() => '');
146
+
147
+ if (response.status === 200 || response.status === 202) {
148
+ let id = 'unknown';
149
+ try { id = JSON.parse(body)?.id || body.slice(0, 80); } catch {}
150
+ pluginLog('info', `✅ Captured ${plainMessages.length} messages (id=${id}, status=${response.status})`);
151
+ } else if (response.status === 401) {
152
+ pluginLog('error', `❌ API key rejected (401). Check https://baselayer.id/profile`);
153
+ } else {
154
+ pluginLog('error', `❌ Ingest failed: HTTP ${response.status} — ${body.slice(0, 500)}`);
155
+ }
156
+ } catch (err) {
157
+ const msg = err instanceof Error ? err.message : String(err);
158
+ pluginLog('error', `❌ Fetch error: ${msg}`);
159
+ }
160
+ }
161
+
162
+ // ============================================================================
163
+ // BaseLayer MCP Helper
164
+ // ============================================================================
165
+
166
+ async function callBaseLayerMCP(tool: string, args: Record<string, any>): Promise<string | null> {
167
+ try {
168
+ const resp = await fetch('http://localhost:44044', {
169
+ method: 'POST',
170
+ headers: { 'Content-Type': 'application/json' },
171
+ body: JSON.stringify({
172
+ jsonrpc: '2.0',
173
+ method: 'tools/call',
174
+ params: { name: tool, arguments: args },
175
+ id: `plugin-${Date.now()}`,
176
+ }),
177
+ signal: AbortSignal.timeout(5000),
178
+ });
179
+ const data = await resp.json() as any;
180
+ return data?.result?.content?.[0]?.text ?? null;
181
+ } catch {
182
+ return null;
183
+ }
184
+ }
185
+
186
+ // ============================================================================
187
+ // Plugin
188
+ // ============================================================================
189
+
190
+ export default function register(api: any) {
191
+ const config = (api.pluginConfig ?? {}) as PluginConfig;
192
+
193
+ // ── Validate config ──────────────────────────────────────────────────
194
+ if (!config?.apiKey) {
195
+ pluginLog('error', 'Missing apiKey. Get one at https://baselayer.id/profile');
196
+ return;
197
+ }
198
+ if (!config?.apiKey.startsWith('bl_')) {
199
+ pluginLog('error', 'apiKey should start with "bl_". Check https://baselayer.id/profile');
200
+ return;
201
+ }
202
+
203
+ pluginLog('info', `Plugin active. apiKey=bl_***...`);
204
+
205
+ // ── Context Injection ─────────────────────────────────────────────────
206
+ let turnCount = 0;
207
+ const CONTEXT_REFRESH_INTERVAL = 10;
208
+
209
+ api.on('before_prompt_build', async (event: any) => {
210
+ turnCount++;
211
+ if (turnCount > 1 && turnCount % CONTEXT_REFRESH_INTERVAL !== 0) return;
212
+
213
+ const recentContext = await callBaseLayerMCP('recent', {
214
+ hours_back: 24,
215
+ max_raw: 3,
216
+ max_dreamed: 10,
217
+ include_raw: true,
218
+ });
219
+
220
+ if (!recentContext) return;
221
+
222
+ const contextBlock = [
223
+ '## BaseLayer Memory Context (auto-injected)',
224
+ 'You have access to a sovereign knowledge vault via MCP at localhost:44044.',
225
+ 'Use `mcporter call baselayer.memory_search query="NAME"` to search before answering.',
226
+ 'Use `mcporter call baselayer.record_memory content="..."` to store new knowledge.',
227
+ '',
228
+ recentContext,
229
+ ].join('\n');
230
+
231
+ return { prependContext: contextBlock };
232
+ });
233
+
234
+ // ── State ────────────────────────────────────────────────────────────
235
+ let pendingUser: ConversationTurn | null = null;
236
+ let pendingAssistant: ConversationTurn | null = null;
237
+ let activeChannelId: string | null = null;
238
+ let activeChatId: string | null = null;
239
+
240
+ // ── Hooks ────────────────────────────────────────────────────────────
241
+
242
+ api.on('message_received', async (event: any, ctx: any) => {
243
+ pluginLog('debug', `>>> message_received fired. content=${(event?.content || '').slice(0, 80)}...`);
244
+ pluginLog('debug', ` ctx.channelId=${ctx?.channelId}, event.from=${event?.from}, ctx.sessionKey=${ctx?.sessionKey}`);
245
+ pluginLog('debug', ` event keys: ${Object.keys(event || {}).join(', ')}`);
246
+ pluginLog('debug', ` ctx keys: ${Object.keys(ctx || {}).join(', ')}`);
247
+
248
+ // Check session filter
249
+ const filter = shouldSkipSession(event, ctx);
250
+ if (filter.skip) {
251
+ pluginLog('info', `⏭️ Skipping message_received: ${filter.reason}`);
252
+ return;
253
+ }
254
+
255
+ if (!event.content) {
256
+ pluginLog('debug', ' No content in event, skipping');
257
+ return;
258
+ }
259
+
260
+ if (ctx?.channelId) activeChannelId = ctx.channelId;
261
+ if (event.from) activeChatId = event.from;
262
+
263
+ pendingUser = { role: 'user', content: event.content, timestamp: Date.now() };
264
+ pluginLog('info', `📩 User turn captured (channel=${activeChannelId}, chat=${activeChatId})`);
265
+ });
266
+
267
+ api.on('llm_output', async (event: any, ctx: any) => {
268
+ pluginLog('debug', `>>> llm_output fired. assistantTexts count=${event?.assistantTexts?.length || 0}`);
269
+
270
+ // Check session filter
271
+ const filter = shouldSkipSession(event, ctx);
272
+ if (filter.skip) {
273
+ pluginLog('info', `⏭️ Skipping llm_output: ${filter.reason}`);
274
+ return;
275
+ }
276
+
277
+ const texts: string[] = event?.assistantTexts || [];
278
+ const combined = texts.join('\n\n').trim();
279
+ if (!combined) {
280
+ pluginLog('debug', ' No assistant text in llm_output');
281
+ return;
282
+ }
283
+ pendingAssistant = { role: 'assistant', content: combined, timestamp: Date.now() };
284
+ pluginLog('info', `🤖 Assistant turn captured (${combined.length} chars)`);
285
+ });
286
+
287
+ api.on('agent_end', async (event: any, ctx: any) => {
288
+ pluginLog('debug', `>>> agent_end fired. messages count=${event?.messages?.length || 0}`);
289
+ pluginLog('debug', ` ctx keys: ${Object.keys(ctx || {}).join(', ')}`);
290
+
291
+ // Check session filter
292
+ const filter = shouldSkipSession(event, ctx);
293
+ if (filter.skip) {
294
+ pluginLog('info', `⏭️ Skipping agent_end: ${filter.reason}`);
295
+ pendingUser = null;
296
+ pendingAssistant = null;
297
+ return;
298
+ }
299
+
300
+ // Brief yield for llm_output to complete
301
+ await new Promise((r) => setTimeout(r, 200));
302
+
303
+ // Fallback: extract assistant text from agent_end messages if llm_output missed
304
+ if (!pendingAssistant && event?.messages?.length) {
305
+ const last = [...event.messages].reverse().find((m: any) => m.role === 'assistant');
306
+ if (last) {
307
+ const text =
308
+ typeof last.content === 'string'
309
+ ? last.content
310
+ : Array.isArray(last.content)
311
+ ? last.content
312
+ .filter((c: any) => c.type === 'text')
313
+ .map((c: any) => c.text)
314
+ .join('\n\n')
315
+ : '';
316
+ if (text.trim()) {
317
+ pendingAssistant = { role: 'assistant', content: text.trim(), timestamp: Date.now() };
318
+ pluginLog('debug', ` Extracted assistant text from agent_end messages (${text.length} chars)`);
319
+ }
320
+ }
321
+ }
322
+
323
+ const turns: ConversationTurn[] = [];
324
+ if (pendingUser) { turns.push(pendingUser); pendingUser = null; }
325
+ if (pendingAssistant) { turns.push(pendingAssistant); pendingAssistant = null; }
326
+
327
+ if (turns.length === 0) {
328
+ pluginLog('debug', ' No turns to flush');
329
+ return;
330
+ }
331
+
332
+ // Build stable conversation ID from channel context.
333
+ // Plaintext format (e.g., "discord-1475712146506776628" or "telegram-6502483662")
334
+ const conversationId = (activeChannelId && activeChatId)
335
+ ? `${activeChannelId}-${activeChatId}`
336
+ : 'main';
337
+
338
+ // Build title from channel/provider name (not first message).
339
+ // Format: "Discord #baselayer-dev" or "Telegram James" or "Google Chat project-xyz"
340
+ const providerName = activeChannelId
341
+ ? activeChannelId.charAt(0).toUpperCase() + activeChannelId.slice(1)
342
+ : 'OpenClaw';
343
+ const flushTitle = activeChatId
344
+ ? `${providerName} ${activeChatId.split(':').slice(-1)[0]}`
345
+ : `${providerName} Conversation`;
346
+
347
+ pluginLog('info', `🚀 Flushing ${turns.length} turns (convId=${conversationId}, channel=${activeChannelId})`);
348
+
349
+ try {
350
+ await flush(config, turns, flushTitle, conversationId);
351
+ } catch (err) {
352
+ const msg = err instanceof Error ? err.message : String(err);
353
+ pluginLog('error', `Flush error: ${msg}`);
354
+ }
355
+ });
356
+ }
@@ -0,0 +1,34 @@
1
+ {
2
+ "id": "baselayer-openclaw",
3
+ "name": "BaseLayer OpenClaw",
4
+ "description": "Captures conversations and ingests them into your BaseLayer vault",
5
+ "version": "0.5.0",
6
+ "configSchema": {
7
+ "type": "object",
8
+ "required": [
9
+ "apiKey"
10
+ ],
11
+ "additionalProperties": false,
12
+ "properties": {
13
+ "apiKey": {
14
+ "type": "string",
15
+ "description": "BaseLayer API key (starts with bl_)"
16
+ },
17
+ "ingestEndpoint": {
18
+ "type": "string",
19
+ "description": "Override ingest endpoint (advanced, defaults to https://api.baselayer.id/ingest)"
20
+ }
21
+ }
22
+ },
23
+ "uiHints": {
24
+ "apiKey": {
25
+ "label": "API Key",
26
+ "placeholder": "bl_...",
27
+ "sensitive": true
28
+ },
29
+ "ingestEndpoint": {
30
+ "label": "Ingest Endpoint (Advanced)",
31
+ "placeholder": "https://api.baselayer.id/ingest"
32
+ }
33
+ }
34
+ }
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@baselayer-id/baselayer-openclaw",
3
+ "version": "0.5.2",
4
+ "description": "OpenClaw plugin for BaseLayer — captures AI conversations into your knowledge vault",
5
+ "main": "index.ts",
6
+ "type": "module",
7
+ "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/baselayer-id/baselayer-openclaw.git"
11
+ },
12
+ "homepage": "https://baselayer.id",
13
+ "keywords": [
14
+ "openclaw",
15
+ "plugin",
16
+ "baselayer",
17
+ "ai",
18
+ "memory",
19
+ "mcp",
20
+ "knowledge-graph"
21
+ ],
22
+ "files": [
23
+ "index.ts",
24
+ "openclaw.plugin.json",
25
+ "README.md"
26
+ ],
27
+ "engines": {
28
+ "node": ">=18.0.0"
29
+ },
30
+ "devDependencies": {
31
+ "@types/node": "^25.5.0"
32
+ }
33
+ }