@ekai/contexto 0.1.1 → 0.1.3
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 +37 -70
- package/openclaw.plugin.json +5 -25
- package/package.json +9 -16
- package/src/index.ts +149 -232
- package/src/bootstrap.ts +0 -120
package/README.md
CHANGED
|
@@ -1,35 +1,46 @@
|
|
|
1
1
|
# @ekai/contexto
|
|
2
2
|
|
|
3
|
-
OpenClaw plugin
|
|
3
|
+
OpenClaw plugin — Context graph engine that prevents context rot by visualizing and organizing conversation context.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Purpose
|
|
6
6
|
|
|
7
|
-
|
|
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
|
+
## OpenClaw Setup
|
|
15
|
+
|
|
16
|
+
### 1. Install the plugin in OpenClaw
|
|
8
17
|
|
|
9
18
|
```bash
|
|
10
19
|
openclaw plugins install @ekai/contexto
|
|
11
20
|
```
|
|
12
21
|
|
|
13
|
-
|
|
22
|
+
### 2. Enable and configure the plugin
|
|
23
|
+
|
|
24
|
+
Set your API key via CLI:
|
|
25
|
+
|
|
14
26
|
```bash
|
|
15
|
-
openclaw plugins
|
|
27
|
+
openclaw plugins config @ekai/contexto apiKey your-api-key-here
|
|
16
28
|
```
|
|
17
29
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
In your OpenClaw config:
|
|
30
|
+
Or add to your OpenClaw config:
|
|
21
31
|
|
|
22
|
-
```
|
|
32
|
+
```json
|
|
23
33
|
{
|
|
24
|
-
plugins: {
|
|
25
|
-
|
|
26
|
-
|
|
34
|
+
"plugins": {
|
|
35
|
+
"slots": {
|
|
36
|
+
"contextEngine": "@ekai/contexto"
|
|
37
|
+
},
|
|
38
|
+
"allow": ["@ekai/contexto"],
|
|
39
|
+
"entries": {
|
|
27
40
|
"@ekai/contexto": {
|
|
28
|
-
enabled: true,
|
|
29
|
-
config: {
|
|
30
|
-
"
|
|
31
|
-
"provider": "openai",
|
|
32
|
-
"apiKey": "sk-..."
|
|
41
|
+
"enabled": true,
|
|
42
|
+
"config": {
|
|
43
|
+
"apiKey": "your-api-key-here"
|
|
33
44
|
}
|
|
34
45
|
}
|
|
35
46
|
}
|
|
@@ -37,66 +48,22 @@ In your OpenClaw config:
|
|
|
37
48
|
}
|
|
38
49
|
```
|
|
39
50
|
|
|
40
|
-
|
|
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
|
|
51
|
+
### 3. Restart OpenClaw
|
|
59
52
|
|
|
60
53
|
```bash
|
|
61
|
-
openclaw
|
|
62
|
-
openclaw hooks list # should show plugin:@ekai/contexto:* hooks
|
|
63
|
-
```
|
|
64
|
-
|
|
65
|
-
## Bootstrap
|
|
66
|
-
|
|
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:
|
|
68
|
-
|
|
69
|
-
```
|
|
70
|
-
/memory-bootstrap
|
|
54
|
+
openclaw gateway restart
|
|
71
55
|
```
|
|
72
56
|
|
|
73
|
-
|
|
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.
|
|
57
|
+
## Configuration
|
|
80
58
|
|
|
81
|
-
|
|
59
|
+
| Property | Type | Required | Description |
|
|
60
|
+
| --- | --- | --- | --- |
|
|
61
|
+
| `apiKey` | string | Yes | Your Contexto API key |
|
|
82
62
|
|
|
83
|
-
|
|
63
|
+
## Version
|
|
84
64
|
|
|
85
|
-
|
|
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
|
-
```
|
|
65
|
+
This is **v1** of @ekai/contexto. For the legacy version (v0), see [`../v0`](../v0).
|
|
99
66
|
|
|
100
67
|
## License
|
|
101
68
|
|
|
102
|
-
MIT
|
|
69
|
+
MIT
|
package/openclaw.plugin.json
CHANGED
|
@@ -2,31 +2,11 @@
|
|
|
2
2
|
"id": "@ekai/contexto",
|
|
3
3
|
"configSchema": {
|
|
4
4
|
"type": "object",
|
|
5
|
-
"additionalProperties": false,
|
|
6
5
|
"properties": {
|
|
7
|
-
"
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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.
|
|
4
|
-
"description": "OpenClaw plugin —
|
|
3
|
+
"version": "0.1.3",
|
|
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
|
-
"
|
|
19
|
-
"
|
|
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": "
|
|
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
|
-
"
|
|
39
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
|
|
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
|
-
}
|
|
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>;
|
|
48
18
|
}
|
|
49
19
|
|
|
50
|
-
|
|
20
|
+
async function sendWebhook(config: WebhookConfig, payload: WebhookPayload, logger: any): Promise<void> {
|
|
51
21
|
try {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
}
|
|
112
|
-
|
|
113
|
-
// Case 6: nothing found, let core handle
|
|
114
|
-
return undefined;
|
|
115
40
|
}
|
|
116
41
|
|
|
117
|
-
|
|
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
|
+
};
|
|
58
|
+
}
|
|
118
59
|
|
|
119
|
-
|
|
60
|
+
const webhookPlugin = {
|
|
120
61
|
id: '@ekai/contexto',
|
|
121
|
-
name: '
|
|
122
|
-
description: '
|
|
62
|
+
name: 'Mind Map',
|
|
63
|
+
description: 'Sends OpenClaw conversation events to a webhook API',
|
|
64
|
+
|
|
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
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
...(resolved ? { provider: resolved.provider, apiKey: resolved.apiKey } : {}),
|
|
139
|
-
dbPath,
|
|
140
|
-
});
|
|
74
|
+
const config: WebhookConfig = {
|
|
75
|
+
apiKey: api.pluginConfig?.apiKey,
|
|
76
|
+
contextEnabled: api.pluginConfig?.contextEnabled ?? false,
|
|
77
|
+
};
|
|
141
78
|
|
|
142
|
-
|
|
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');
|
|
79
|
+
const logger = api.logger;
|
|
153
80
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
const progress: BootstrapProgress = loadProgress(progressPath);
|
|
157
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
// ---
|
|
167
|
-
api.on(
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
const
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
116
|
+
sendWebhook(config, payload, logger);
|
|
198
117
|
});
|
|
199
118
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
}
|
|
223
|
-
});
|
|
224
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
245
|
-
|
|
162
|
+
sendWebhook(config, payload, logger);
|
|
163
|
+
});
|
|
246
164
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
264
|
-
},
|
|
180
|
+
sendWebhook(config, payload, logger);
|
|
265
181
|
});
|
|
266
182
|
|
|
267
|
-
|
|
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
|
-
}
|