@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 +44 -71
- package/openclaw.plugin.json +6 -26
- package/package.json +9 -16
- package/src/index.ts +149 -232
- package/src/bootstrap.ts +0 -120
package/README.md
CHANGED
|
@@ -1,35 +1,52 @@
|
|
|
1
|
-
# @ekai/contexto
|
|
1
|
+
# @ekai/contexto (v1)
|
|
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
|
+
## Installation
|
|
8
15
|
|
|
9
16
|
```bash
|
|
10
|
-
|
|
17
|
+
npm install @ekai/contexto
|
|
11
18
|
```
|
|
12
19
|
|
|
13
|
-
|
|
20
|
+
## OpenClaw Setup
|
|
21
|
+
|
|
22
|
+
### 1. Install the plugin in OpenClaw
|
|
23
|
+
|
|
14
24
|
```bash
|
|
15
|
-
openclaw plugins install
|
|
25
|
+
openclaw plugins install @ekai/contexto
|
|
16
26
|
```
|
|
17
27
|
|
|
18
|
-
|
|
28
|
+
### 2. Enable and configure the plugin
|
|
29
|
+
|
|
30
|
+
Set your API key via CLI:
|
|
19
31
|
|
|
20
|
-
|
|
32
|
+
```bash
|
|
33
|
+
openclaw plugins config @ekai/contexto apiKey your-api-key-here
|
|
34
|
+
```
|
|
21
35
|
|
|
22
|
-
|
|
36
|
+
Or add to your OpenClaw config:
|
|
37
|
+
|
|
38
|
+
```json
|
|
23
39
|
{
|
|
24
|
-
plugins: {
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
|
62
|
-
openclaw hooks list # should show plugin:@ekai/contexto:* hooks
|
|
60
|
+
openclaw gateway restart
|
|
63
61
|
```
|
|
64
62
|
|
|
65
|
-
##
|
|
63
|
+
## Configuration
|
|
66
64
|
|
|
67
|
-
|
|
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
|
-
|
|
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
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,32 +1,12 @@
|
|
|
1
1
|
{
|
|
2
|
-
"id": "@ekai/
|
|
2
|
+
"id": "@ekai/mindmap",
|
|
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.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
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
|
40
|
+
}
|
|
112
41
|
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
155
|
-
const progressPath = dbPath.replace(/\.db$/, '') + '.progress.json';
|
|
156
|
-
const progress: BootstrapProgress = loadProgress(progressPath);
|
|
79
|
+
const logger = api.logger;
|
|
157
80
|
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
}
|