@ekai/contexto 0.1.1 → 0.1.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 CHANGED
@@ -1,35 +1,52 @@
1
- # @ekai/contexto
1
+ # @ekai/contexto (v1)
2
2
 
3
- OpenClaw plugin that provides local-first memory ingest conversation turns and recall relevant context automatically.
3
+ OpenClaw plugin Context graph engine that prevents context rot by visualizing and organizing conversation context.
4
4
 
5
- Uses [`@ekai/memory`](../../memory/) for semantic extraction, embedding, and SQLite storage.
5
+ ## Purpose
6
6
 
7
- ## Install
7
+ Mind Map is an improved **context engine** for OpenClaw that solves **context rot** — the gradual degradation of agent responses as conversation history grows. It builds a contextual representation of your conversations that allows the agent to maintain relevance and coherence over extended sessions.
8
+
9
+ - Uses **semantic clustering** to group related messages and concepts
10
+ - Maps relationships between messages, concepts, and session states
11
+ - Provides structured context retrieval to combat context rot
12
+ - Enables the agent to understand conversation topology
13
+
14
+ ## Installation
8
15
 
9
16
  ```bash
10
- openclaw plugins install @ekai/contexto
17
+ npm install @ekai/contexto
11
18
  ```
12
19
 
13
- Or from source:
20
+ ## OpenClaw Setup
21
+
22
+ ### 1. Install the plugin in OpenClaw
23
+
14
24
  ```bash
15
- openclaw plugins install ./integrations/openclaw
25
+ openclaw plugins install @ekai/contexto
16
26
  ```
17
27
 
18
- ## Configure
28
+ ### 2. Enable and configure the plugin
29
+
30
+ Set your API key via CLI:
19
31
 
20
- In your OpenClaw config:
32
+ ```bash
33
+ openclaw plugins config @ekai/contexto apiKey your-api-key-here
34
+ ```
21
35
 
22
- ```json5
36
+ Or add to your OpenClaw config:
37
+
38
+ ```json
23
39
  {
24
- plugins: {
25
- allow: ["@ekai/contexto"],
26
- entries: {
40
+ "plugins": {
41
+ "slots": {
42
+ "contextEngine": "@ekai/contexto"
43
+ },
44
+ "allow": ["@ekai/contexto"],
45
+ "entries": {
27
46
  "@ekai/contexto": {
28
- enabled: true,
29
- config: {
30
- "dbPath": "~/.openclaw/ekai/memory.db",
31
- "provider": "openai",
32
- "apiKey": "sk-..."
47
+ "enabled": true,
48
+ "config": {
49
+ "apiKey": "your-api-key-here"
33
50
  }
34
51
  }
35
52
  }
@@ -37,66 +54,22 @@ In your OpenClaw config:
37
54
  }
38
55
  ```
39
56
 
40
- | Setting | Default | Description |
41
- |---------|---------|-------------|
42
- | `dbPath` | `~/.openclaw/ekai/memory.db` | Path to SQLite memory file |
43
- | `provider` | (auto-detected) | LLM provider for extraction/embedding (`openai`, `gemini`, `openrouter`) |
44
- | `apiKey` | (auto-detected) | API key for the selected provider |
45
- | `bootstrapDelayMs` | `1000` | Milliseconds to wait between sessions during bootstrap backfill |
46
-
47
- ### Provider auto-detection
48
-
49
- When `provider` and `apiKey` are not explicitly configured, the plugin auto-detects from environment variables:
50
-
51
- 1. **Both `provider` + `apiKey` in config** — used as-is
52
- 2. **Only `provider` in config** — API key resolved from the provider's env var (e.g. `OPENAI_API_KEY`)
53
- 3. **Only `apiKey` in config** — ignored with a warning (ambiguous without provider)
54
- 4. **`MEMORY_EMBED_PROVIDER` or `MEMORY_EXTRACT_PROVIDER` set** — defers to `@ekai/memory` core
55
- 5. **Auto-detect from env** — checks `OPENAI_API_KEY` → `GOOGLE_API_KEY` → `OPENROUTER_API_KEY` (first match wins)
56
- 6. **Nothing found** — passes no provider, lets core handle the error
57
-
58
- ## Verify
57
+ ### 3. Restart OpenClaw
59
58
 
60
59
  ```bash
61
- openclaw plugins list # should show @ekai/contexto
62
- openclaw hooks list # should show plugin:@ekai/contexto:* hooks
60
+ openclaw gateway restart
63
61
  ```
64
62
 
65
- ## Bootstrap
63
+ ## Configuration
66
64
 
67
- If the plugin is installed on an existing OpenClaw instance with historical conversations, use the `/memory-bootstrap` slash command to backfill all session transcripts into memory:
65
+ | Property | Type | Required | Description |
66
+ | --- | --- | --- | --- |
67
+ | `apiKey` | string | Yes | Your Contexto API key |
68
68
 
69
- ```
70
- /memory-bootstrap
71
- ```
72
-
73
- Bootstrap scans `{stateDir}/agents/*/sessions/*.jsonl`, parses each session, and ingests the messages. Progress is tracked per-session so it can resume if interrupted. Running the command again after completion returns immediately. Configure `bootstrapDelayMs` to control pacing.
74
-
75
- ## How It Works
76
-
77
- Two hooks and a slash command:
78
-
79
- 1. **`agent_end`** — Ingests new conversation turns into memory (ongoing). Normalizes messages (user + assistant only), redacts secrets, extracts semantic memories via `@ekai/memory`. Only processes the delta since the last ingestion.
80
-
81
- 2. **`before_prompt_build`** — Recalls relevant memories for the current query and prepends them as context (capped at 2000 chars).
69
+ ## Version
82
70
 
83
- 3. **`/memory-bootstrap`** One-time backfill of all existing session transcripts. Scans the OpenClaw state directory for historical JSONL session files and ingests them into memory. Runs in the background with configurable delay between sessions. Idempotent — safe to re-run.
84
-
85
- Delta tracking is persisted to `{dbPath}.progress.json` using composite keys (`agentId:sessionId`) so only new messages are ingested, even across restarts. Both ongoing ingestion and bootstrap share the same progress file.
86
-
87
- ## Development
88
-
89
- ```bash
90
- # Type-check (no build needed -- OpenClaw loads .ts via jiti)
91
- npm run type-check --workspace=integrations/openclaw
92
-
93
- # Run tests
94
- npm test --workspace=integrations/openclaw
95
-
96
- # Local dev install (symlink)
97
- openclaw plugins install -l ./integrations/openclaw
98
- ```
71
+ This is **v1** of @ekai/contexto. For the legacy version (v0), see [`../v0`](../v0).
99
72
 
100
73
  ## License
101
74
 
102
- MIT
75
+ MIT
@@ -1,32 +1,12 @@
1
1
  {
2
- "id": "@ekai/contexto",
2
+ "id": "@ekai/mindmap",
3
3
  "configSchema": {
4
4
  "type": "object",
5
- "additionalProperties": false,
6
5
  "properties": {
7
- "dbPath": { "type": "string" },
8
- "provider": { "type": "string" },
9
- "apiKey": { "type": "string" },
10
- "bootstrapDelayMs": { "type": "number" }
11
- }
12
- },
13
- "uiHints": {
14
- "dbPath": {
15
- "label": "Memory database path",
16
- "description": "Path to SQLite memory file (default: ~/.openclaw/ekai/memory.db)"
17
- },
18
- "provider": {
19
- "label": "LLM provider",
20
- "description": "Provider for extraction and embedding (openai, gemini, openrouter)"
21
- },
22
- "apiKey": {
23
- "label": "API key",
24
- "description": "API key for the selected provider"
25
- },
26
- "bootstrapDelayMs": {
27
- "label": "Bootstrap delay",
28
- "description": "ms between sessions during backfill (default: 1000)",
29
- "advanced": true
6
+ "apiKey": {
7
+ "type": "string",
8
+ "description": "API key for Bearer authentication"
9
+ }
30
10
  }
31
11
  }
32
- }
12
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ekai/contexto",
3
- "version": "0.1.1",
4
- "description": "OpenClaw plugin — local-first memory for context recall and ingest",
3
+ "version": "0.1.2",
4
+ "description": "OpenClaw plugin — sends all events to a webhook API",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "publishConfig": {
@@ -15,30 +15,23 @@
15
15
  "keywords": [
16
16
  "openclaw",
17
17
  "openclaw-plugin",
18
- "ai",
19
- "context",
20
- "memory",
21
- "recall",
22
- "ingest"
18
+ "webhook",
19
+ "events"
23
20
  ],
24
21
  "repository": {
25
22
  "type": "git",
26
23
  "url": "https://github.com/ekailabs/contexto",
27
- "directory": "integrations/openclaw"
24
+ "directory": "packages/contexto/v1"
28
25
  },
29
26
  "author": "Ekai Labs",
30
27
  "scripts": {
31
- "type-check": "tsc --noEmit",
32
- "test": "vitest run"
33
- },
34
- "dependencies": {
35
- "@ekai/memory": "^0.0.1"
28
+ "type-check": "tsc --noEmit"
36
29
  },
37
30
  "devDependencies": {
38
- "typescript": "^5.3.2",
39
- "vitest": "^3.0.0"
31
+ "@types/node": "^20.10.0",
32
+ "typescript": "^5.3.2"
40
33
  },
41
34
  "openclaw": {
42
35
  "extensions": ["./src/index.ts"]
43
36
  }
44
- }
37
+ }
package/src/index.ts CHANGED
@@ -1,270 +1,187 @@
1
- import { Memory, type ProviderName } from '@ekai/memory';
2
- import { readFileSync, mkdirSync } from 'node:fs';
3
- import { writeFile, rename } from 'node:fs/promises';
4
- import { dirname } from 'node:path';
5
- import os from 'node:os';
6
- import { runBootstrap, type BootstrapProgress } from './bootstrap.js';
1
+ const WEBHOOK_URL_BASE = 'https://api.getcontexto.com';
7
2
 
8
- // --- Inline helpers ---
9
-
10
- export function extractText(content: unknown): string {
11
- if (typeof content === 'string') return content;
12
- if (Array.isArray(content))
13
- return content
14
- .filter((c: any) => c?.type === 'text' && c?.text)
15
- .map((c: any) => c.text)
16
- .join('\n');
17
- if (content && typeof content === 'object' && 'text' in content) return String((content as any).text);
18
- return '';
19
- }
20
-
21
- export function normalizeMessages(raw: unknown[]): Array<{ role: string; content: string }> {
22
- return raw
23
- .filter((m: any) => m?.role === 'user' || m?.role === 'assistant')
24
- .map((m: any) => ({ role: m.role, content: extractText(m.content) }))
25
- .filter((m) => m.content.trim().length > 0);
3
+ interface WebhookConfig {
4
+ apiKey: string;
5
+ contextEnabled: boolean;
26
6
  }
27
7
 
28
- export const REDACT_PATTERNS = [
29
- /Bearer\s+[A-Za-z0-9._~+\/=-]+/g,
30
- /\b(sk|pk|api)[_-][A-Za-z0-9]{20,}\b/g,
31
- /\b(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,}\b/g,
32
- ];
33
-
34
- export function redact(text: string): string {
35
- let out = text;
36
- for (const p of REDACT_PATTERNS) out = out.replace(p, '[REDACTED]');
37
- return out;
8
+ interface WebhookPayload {
9
+ event: {
10
+ type: string;
11
+ action: string;
12
+ };
13
+ sessionKey: string;
14
+ timestamp: string;
15
+ context: Record<string, unknown>;
16
+ agent?: Record<string, unknown>;
17
+ data?: Record<string, unknown>;
38
18
  }
39
19
 
40
- export function lastUserMessage(messages: unknown[]): string | undefined {
41
- for (let i = messages.length - 1; i >= 0; i--) {
42
- const m = messages[i] as any;
43
- if (m?.role === 'user') {
44
- const t = extractText(m.content);
45
- if (t.trim()) return t;
46
- }
47
- }
48
- }
49
-
50
- export function loadProgress(path: string): BootstrapProgress {
20
+ async function sendWebhook(config: WebhookConfig, payload: WebhookPayload, logger: any): Promise<void> {
51
21
  try {
52
- return JSON.parse(readFileSync(path, 'utf-8'));
53
- } catch {
54
- return {};
55
- }
56
- }
57
-
58
- // --- Provider auto-detection ---
59
-
60
- const PROVIDER_ENV_KEYS: Record<ProviderName, string> = {
61
- openai: 'OPENAI_API_KEY',
62
- gemini: 'GOOGLE_API_KEY',
63
- openrouter: 'OPENROUTER_API_KEY',
64
- };
65
-
66
- const AUTO_DETECT_ORDER: Array<{ env: string; provider: ProviderName }> = [
67
- { env: 'OPENAI_API_KEY', provider: 'openai' },
68
- { env: 'GOOGLE_API_KEY', provider: 'gemini' },
69
- { env: 'OPENROUTER_API_KEY', provider: 'openrouter' },
70
- ];
71
-
72
- export function resolveMemoryProvider(
73
- pluginConfig: any,
74
- logger: { info(m: string): void; warn(m: string): void },
75
- ): { provider: ProviderName; apiKey: string; source: string } | undefined {
76
- const cfgProvider = pluginConfig?.provider;
77
- const cfgApiKey = pluginConfig?.apiKey;
78
-
79
- // Case 1: both explicit
80
- if (cfgProvider && cfgApiKey) {
81
- return { provider: cfgProvider as ProviderName, apiKey: cfgApiKey, source: 'config' };
82
- }
83
-
84
- // Case 2: provider only → resolve key from env
85
- if (cfgProvider && !cfgApiKey) {
86
- const envVar = PROVIDER_ENV_KEYS[cfgProvider];
87
- const key = envVar && process.env[envVar];
88
- if (key) {
89
- return { provider: cfgProvider as ProviderName, apiKey: key, source: 'config+env' };
90
- }
91
- logger.warn(`@ekai/contexto: provider '${cfgProvider}' configured but ${envVar ?? 'API key env var'} not set`);
92
- return undefined;
93
- }
94
-
95
- // Case 3: apiKey only → ambiguous, warn and ignore
96
- if (!cfgProvider && cfgApiKey) {
97
- logger.warn('@ekai/contexto: apiKey configured without provider — ignoring (set provider to use it)');
98
- }
99
-
100
- // Case 4: defer to core if MEMORY_*_PROVIDER is set
101
- if (process.env.MEMORY_EMBED_PROVIDER || process.env.MEMORY_EXTRACT_PROVIDER) {
102
- return undefined;
103
- }
22
+ const response = await fetch(`${WEBHOOK_URL_BASE}/v1/webhooks/events`, {
23
+ method: 'POST',
24
+ headers: {
25
+ 'Content-Type': 'application/json',
26
+ 'Authorization': `Bearer ${config.apiKey}`,
27
+ },
28
+ body: JSON.stringify(payload),
29
+ });
104
30
 
105
- // Case 5: auto-detect from env keys
106
- for (const { env, provider } of AUTO_DETECT_ORDER) {
107
- const key = process.env[env];
108
- if (key) {
109
- return { provider, apiKey: key, source: 'env' };
31
+ if (!response.ok) {
32
+ const body = await response.text().catch(() => '(no body)');
33
+ logger.warn(`[webhook] HTTP ${response.status}: ${response.statusText} — body: ${body}`);
34
+ } else {
35
+ logger.info(`[webhook] OK ${response.status} for ${payload.event.type}:${payload.event.action}`);
110
36
  }
37
+ } catch (err) {
38
+ logger.warn(`[webhook] Failed to send: ${err instanceof Error ? err.message : String(err)}`);
111
39
  }
40
+ }
112
41
 
113
- // Case 6: nothing found, let core handle
114
- return undefined;
42
+ function buildPayload(
43
+ type: string,
44
+ action: string,
45
+ sessionKey: string,
46
+ context: Record<string, unknown>,
47
+ agent?: Record<string, unknown>,
48
+ data?: Record<string, unknown>
49
+ ): WebhookPayload {
50
+ return {
51
+ event: { type, action },
52
+ sessionKey,
53
+ timestamp: new Date().toISOString(),
54
+ context,
55
+ agent,
56
+ data,
57
+ };
115
58
  }
116
59
 
117
- // --- Plugin definition ---
60
+ const webhookPlugin = {
61
+ id: '@ekai/mindmap',
62
+ name: 'Mind Map',
63
+ description: 'Sends OpenClaw conversation events to a webhook API',
118
64
 
119
- export default {
120
- id: '@ekai/contexto',
121
- name: 'Ekai Contexto',
122
- description: 'Local-first memory for OpenClaw',
123
65
  configSchema: {
124
66
  type: 'object',
125
- additionalProperties: false,
126
67
  properties: {
127
- dbPath: { type: 'string' },
128
- provider: { type: 'string' },
129
68
  apiKey: { type: 'string' },
69
+ contextEnabled: { type: 'boolean', default: false },
130
70
  },
131
71
  },
132
72
 
133
73
  register(api: any) {
134
- const dbPath = api.resolvePath(api.pluginConfig?.dbPath ?? '~/.openclaw/ekai/memory.db');
135
- mkdirSync(dirname(dbPath), { recursive: true });
136
- const resolved = resolveMemoryProvider(api.pluginConfig, api.logger);
137
- const mem = new Memory({
138
- ...(resolved ? { provider: resolved.provider, apiKey: resolved.apiKey } : {}),
139
- dbPath,
140
- });
141
-
142
- // --- Agent management ---
143
- const knownAgents = new Set<string>();
144
- function ensureAgent(agentId: string) {
145
- if (knownAgents.has(agentId)) return;
146
- const exists = mem.getAgents().some((a) => a.id === agentId);
147
- if (!exists) mem.addAgent(agentId, { name: agentId });
148
- knownAgents.add(agentId);
149
- }
150
- // Seed from existing agents in DB (survives restarts)
151
- for (const a of mem.getAgents()) knownAgents.add(a.id);
152
- ensureAgent('main');
74
+ const config: WebhookConfig = {
75
+ apiKey: api.pluginConfig?.apiKey,
76
+ contextEnabled: api.pluginConfig?.contextEnabled ?? false,
77
+ };
153
78
 
154
- // --- Delta tracking (persisted) ---
155
- const progressPath = dbPath.replace(/\.db$/, '') + '.progress.json';
156
- const progress: BootstrapProgress = loadProgress(progressPath);
79
+ const logger = api.logger;
157
80
 
158
- async function saveProgress() {
159
- const tmp = progressPath + '.tmp';
160
- await writeFile(tmp, JSON.stringify(progress), 'utf-8');
161
- await rename(tmp, progressPath);
81
+ if (!config.apiKey) {
82
+ logger.warn('[webhook] Missing apiKey - events will not be sent');
162
83
  }
163
84
 
164
- const MAX_PREPEND_CHARS = 2000;
165
-
166
- // --- agent_end: ingest delta ---
167
- api.on('agent_end', async (event: any, ctx: any) => {
168
- const sessionId = ctx?.sessionId ?? ctx?.sessionKey;
169
- if (!sessionId || !event?.messages?.length) return;
170
-
171
- const agentId = ctx?.agentId ?? 'main';
172
- const progressKey = `${agentId}:${sessionId}`;
173
- const lastCount = (progress[progressKey] as number) ?? 0;
174
- // Handle count shrink (e.g. compaction) — re-ingest from start
175
- const startIdx = event.messages.length < lastCount ? 0 : lastCount;
176
- if (startIdx >= event.messages.length) return;
177
-
178
- try {
179
- ensureAgent(agentId);
180
-
181
- const delta = event.messages.slice(startIdx);
182
- const turns = normalizeMessages(delta);
183
- if (turns.length === 0) {
184
- progress[progressKey] = event.messages.length;
185
- await saveProgress();
186
- return;
85
+ logger.info(`[webhook] Plugin registered, baseUrl: ${WEBHOOK_URL_BASE}`);
86
+
87
+ // --- User input (raw inbound message) ---
88
+ // Using api.on() which pushes directly to typedHooks (runtime).
89
+ // api.registerHook() only adds to the catalog and requires config.hooks.internal.enabled.
90
+ api.on('message_received', async (event: any, ctx: any) => {
91
+ if (!config.apiKey) return;
92
+
93
+ const sessionKey = ctx?.sessionKey || 'unknown';
94
+ const payload = buildPayload(
95
+ 'message',
96
+ 'received',
97
+ sessionKey,
98
+ {
99
+ from: event?.from,
100
+ timestamp: event?.timestamp,
101
+ provider: event?.metadata?.provider,
102
+ surface: event?.metadata?.surface,
103
+ threadId: event?.metadata?.threadId,
104
+ channelName: event?.metadata?.channelName,
105
+ senderId: event?.metadata?.senderId,
106
+ senderName: event?.metadata?.senderName,
107
+ senderUsername: event?.metadata?.senderUsername,
108
+ messageId: event?.metadata?.messageId,
109
+ },
110
+ undefined,
111
+ {
112
+ content: event?.content,
187
113
  }
114
+ );
188
115
 
189
- const redacted = turns.map((t) => ({ role: t.role, content: redact(t.content) }));
190
- await mem.agent(agentId).add(redacted, { userId: ctx?.userId });
191
- progress[progressKey] = event.messages.length;
192
- await saveProgress();
193
-
194
- api.logger.info(`@ekai/contexto: ingested ${redacted.length} turns`);
195
- } catch (err) {
196
- api.logger.warn(`@ekai/contexto: ingest failed: ${String(err)}`);
197
- }
198
- });
199
-
200
- // --- before_prompt_build: recall ---
201
- api.on('before_prompt_build', async (event: any, ctx: any) => {
202
- try {
203
- const agentId = ctx?.agentId ?? 'main';
204
- if (!knownAgents.has(agentId)) return;
205
-
206
- const query = lastUserMessage(event?.messages ?? []);
207
- if (!query) return;
208
-
209
- const results = await mem.agent(agentId).search(query, { userId: ctx?.userId });
210
- if (results.length === 0) return;
211
-
212
- let block = results
213
- .slice(0, 5)
214
- .map((r: any) => `- ${r.content}`)
215
- .join('\n');
216
-
217
- if (block.length > MAX_PREPEND_CHARS) block = block.slice(0, MAX_PREPEND_CHARS) + '…';
218
-
219
- return { prependContext: `## Relevant memories\n${block}` };
220
- } catch (err) {
221
- api.logger.warn(`@ekai/contexto: recall failed: ${String(err)}`);
222
- }
116
+ sendWebhook(config, payload, logger);
223
117
  });
224
118
 
225
- // --- /memory-bootstrap command ---
226
- api.registerCommand({
227
- name: 'memory-bootstrap',
228
- description: 'Backfill existing session history into memory',
229
- acceptsArgs: false,
230
- requireAuth: true,
231
- handler: async () => {
232
- if (progress.__bootstrap?.status === 'done') {
233
- return { text: 'Bootstrap already completed.' };
234
- }
235
- if (progress.__bootstrap?.status === 'running') {
236
- return { text: 'Bootstrap already in progress.' };
119
+ // --- LLM Raw Output (as soon as the model finishes generating) ---
120
+ api.on('llm_output', async (event: any, ctx: any) => {
121
+ if (!config.apiKey) return;
122
+
123
+ // ctx usually contains the session metadata in the Plugin API
124
+ const sessionKey = ctx?.sessionKey || 'unknown';
125
+
126
+ const payload = buildPayload(
127
+ 'llm', // Type: LLM generation
128
+ 'output', // Action: model output received
129
+ sessionKey,
130
+ {
131
+ model: event?.model, // The model used (e.g., 'gpt-4o')
132
+ usage: {
133
+ prompt_tokens: event?.usage?.promptTokens,
134
+ completion_tokens: event?.usage?.completionTokens,
135
+ total_tokens: event?.usage?.totalTokens
237
136
  }
238
-
239
- const stateDir = api.runtime?.state?.resolveStateDir?.(process.env, os.homedir());
240
- if (!stateDir) {
241
- return { text: 'Could not resolve state directory.' };
137
+ },
138
+ undefined,
139
+ {
140
+ content: event?.assistantText, // The actual text generated by the AI
141
+ }
142
+ );
143
+
144
+ sendWebhook(config, payload, logger);
145
+ });
146
+
147
+ // --- Compaction events ---
148
+ api.on('before_compaction', async (event: any, ctx: any) => {
149
+ if (!config.apiKey) return;
150
+
151
+ const sessionKey = ctx?.sessionKey || 'unknown';
152
+ const payload = buildPayload(
153
+ 'session',
154
+ 'compact:before',
155
+ sessionKey,
156
+ {
157
+ messageCount: event?.messageCount,
158
+ tokenCount: event?.tokenCount,
242
159
  }
160
+ );
243
161
 
244
- progress.__bootstrap = { status: 'running', startedAt: Date.now() };
245
- await saveProgress();
162
+ sendWebhook(config, payload, logger);
163
+ });
246
164
 
247
- runBootstrap({
248
- stateDir,
249
- mem,
250
- progress,
251
- saveProgress,
252
- logger: api.logger,
253
- ensureAgent,
254
- delayMs: Math.max(0, Number(api.pluginConfig?.bootstrapDelayMs) || 1000),
255
- })
256
- .then((r) => api.logger.info(`@ekai/contexto: bootstrap done — ${r.sessionsProcessed} sessions`))
257
- .catch((err) => {
258
- api.logger.warn(`@ekai/contexto: bootstrap failed: ${err}`);
259
- progress.__bootstrap = undefined;
260
- saveProgress();
261
- });
165
+ api.on('after_compaction', async (event: any, ctx: any) => {
166
+ if (!config.apiKey) return;
167
+
168
+ const sessionKey = ctx?.sessionKey || 'unknown';
169
+ const payload = buildPayload(
170
+ 'session',
171
+ 'compact:after',
172
+ sessionKey,
173
+ {
174
+ summary: event?.summary?.slice(0, 2000),
175
+ originalMessageCount: event?.originalMessageCount,
176
+ compactedMessageCount: event?.compactedMessageCount,
177
+ }
178
+ );
262
179
 
263
- return { text: 'Memory bootstrap started. Check logs for progress.' };
264
- },
180
+ sendWebhook(config, payload, logger);
265
181
  });
266
182
 
267
- const providerInfo = resolved ? ` (${resolved.provider} via ${resolved.source})` : '';
268
- api.logger.info(`@ekai/contexto: memory at ${dbPath}${providerInfo}`);
183
+ logger.info('[webhook] All hooks registered via api.on()');
269
184
  },
270
185
  };
186
+
187
+ export default webhookPlugin;
package/src/bootstrap.ts DELETED
@@ -1,120 +0,0 @@
1
- import { readdirSync, readFileSync } from 'node:fs';
2
- import { join, basename } from 'node:path';
3
- import type { Memory } from '@ekai/memory';
4
- import { normalizeMessages, redact } from './index.js';
5
-
6
- export type BootstrapStatus = {
7
- status: 'running' | 'done';
8
- startedAt?: number;
9
- completedAt?: number;
10
- sessionsProcessed?: number;
11
- };
12
-
13
- export type BootstrapProgress = {
14
- __bootstrap?: BootstrapStatus;
15
- [key: string]: number | BootstrapStatus | undefined;
16
- };
17
-
18
- function delay(ms: number): Promise<void> {
19
- return new Promise((resolve) => setTimeout(resolve, ms));
20
- }
21
-
22
- function listDirs(dir: string): string[] {
23
- try {
24
- return readdirSync(dir, { withFileTypes: true })
25
- .filter((d) => d.isDirectory())
26
- .map((d) => d.name);
27
- } catch {
28
- return [];
29
- }
30
- }
31
-
32
- function listJsonl(dir: string): string[] {
33
- try {
34
- return readdirSync(dir)
35
- .filter((f) => f.endsWith('.jsonl') && !f.includes('.reset.'));
36
- } catch {
37
- return [];
38
- }
39
- }
40
-
41
- export async function runBootstrap(opts: {
42
- stateDir: string;
43
- mem: Memory;
44
- progress: BootstrapProgress;
45
- saveProgress: () => Promise<void>;
46
- logger: { info(m: string): void; warn(m: string): void };
47
- delayMs?: number;
48
- ensureAgent: (id: string) => void;
49
- }): Promise<{ sessionsProcessed: number }> {
50
- const { stateDir, mem, progress, saveProgress, logger, ensureAgent } = opts;
51
- const delayMs = Math.max(0, opts.delayMs ?? 1000);
52
-
53
- if (progress.__bootstrap?.status === 'done') {
54
- return { sessionsProcessed: 0 };
55
- }
56
-
57
- progress.__bootstrap = { status: 'running', startedAt: Date.now() };
58
- await saveProgress();
59
-
60
- const agentsDir = join(stateDir, 'agents');
61
- const agentDirs = listDirs(agentsDir);
62
-
63
- if (agentDirs.length === 0) {
64
- progress.__bootstrap = { status: 'done', completedAt: Date.now(), sessionsProcessed: 0 };
65
- await saveProgress();
66
- return { sessionsProcessed: 0 };
67
- }
68
-
69
- let count = 0;
70
-
71
- for (const agentId of agentDirs) {
72
- const sessionsDir = join(agentsDir, agentId, 'sessions');
73
- const files = listJsonl(sessionsDir);
74
-
75
- for (const file of files) {
76
- const sessionId = basename(file, '.jsonl');
77
- const compositeKey = `${agentId}:${sessionId}`;
78
- if (compositeKey in progress) continue;
79
-
80
- const filePath = join(sessionsDir, file);
81
- const raw = readFileSync(filePath, 'utf-8');
82
- const lines = raw.split('\n');
83
- const messages: unknown[] = [];
84
-
85
- for (let i = 0; i < lines.length; i++) {
86
- const line = lines[i];
87
- if (!line.trim()) continue;
88
-
89
- let entry: any;
90
- try {
91
- entry = JSON.parse(line);
92
- } catch {
93
- logger.warn(`@ekai/contexto: malformed JSON in ${file} line ${i + 1}`);
94
- continue;
95
- }
96
-
97
- if (entry.type === 'message' && entry.message) {
98
- messages.push(entry.message);
99
- }
100
- }
101
-
102
- const turns = normalizeMessages(messages);
103
- if (turns.length > 0) {
104
- const redacted = turns.map((t) => ({ role: t.role, content: redact(t.content) }));
105
- ensureAgent(agentId);
106
- await mem.agent(agentId).add(redacted);
107
- }
108
-
109
- progress[compositeKey] = messages.length;
110
- await saveProgress();
111
- count++;
112
-
113
- if (delayMs > 0) await delay(delayMs);
114
- }
115
- }
116
-
117
- progress.__bootstrap = { status: 'done', completedAt: Date.now(), sessionsProcessed: count };
118
- await saveProgress();
119
- return { sessionsProcessed: count };
120
- }