@ekai/contexto 0.1.0 → 0.1.1
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 +102 -0
- package/openclaw.plugin.json +21 -5
- package/package.json +15 -7
- package/src/bootstrap.ts +120 -0
- package/src/index.ts +261 -38
- package/src/store.ts +0 -56
package/README.md
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# @ekai/contexto
|
|
2
|
+
|
|
3
|
+
OpenClaw plugin that provides local-first memory — ingest conversation turns and recall relevant context automatically.
|
|
4
|
+
|
|
5
|
+
Uses [`@ekai/memory`](../../memory/) for semantic extraction, embedding, and SQLite storage.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
openclaw plugins install @ekai/contexto
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or from source:
|
|
14
|
+
```bash
|
|
15
|
+
openclaw plugins install ./integrations/openclaw
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Configure
|
|
19
|
+
|
|
20
|
+
In your OpenClaw config:
|
|
21
|
+
|
|
22
|
+
```json5
|
|
23
|
+
{
|
|
24
|
+
plugins: {
|
|
25
|
+
allow: ["@ekai/contexto"],
|
|
26
|
+
entries: {
|
|
27
|
+
"@ekai/contexto": {
|
|
28
|
+
enabled: true,
|
|
29
|
+
config: {
|
|
30
|
+
"dbPath": "~/.openclaw/ekai/memory.db",
|
|
31
|
+
"provider": "openai",
|
|
32
|
+
"apiKey": "sk-..."
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
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
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
openclaw plugins list # should show @ekai/contexto
|
|
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
|
|
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).
|
|
82
|
+
|
|
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
|
+
```
|
|
99
|
+
|
|
100
|
+
## License
|
|
101
|
+
|
|
102
|
+
MIT
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,16 +1,32 @@
|
|
|
1
1
|
{
|
|
2
|
-
"id": "ekai
|
|
2
|
+
"id": "@ekai/contexto",
|
|
3
3
|
"configSchema": {
|
|
4
4
|
"type": "object",
|
|
5
5
|
"additionalProperties": false,
|
|
6
6
|
"properties": {
|
|
7
|
-
"
|
|
7
|
+
"dbPath": { "type": "string" },
|
|
8
|
+
"provider": { "type": "string" },
|
|
9
|
+
"apiKey": { "type": "string" },
|
|
10
|
+
"bootstrapDelayMs": { "type": "number" }
|
|
8
11
|
}
|
|
9
12
|
},
|
|
10
13
|
"uiHints": {
|
|
11
|
-
"
|
|
12
|
-
"label": "
|
|
13
|
-
"description": "Path to
|
|
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
|
|
14
30
|
}
|
|
15
31
|
}
|
|
16
32
|
}
|
package/package.json
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ekai/contexto",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "OpenClaw plugin —
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "OpenClaw plugin — local-first memory for context recall and ingest",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "public"
|
|
9
|
+
},
|
|
7
10
|
"main": "src/index.ts",
|
|
8
11
|
"files": [
|
|
9
12
|
"src/",
|
|
@@ -15,20 +18,25 @@
|
|
|
15
18
|
"ai",
|
|
16
19
|
"context",
|
|
17
20
|
"memory",
|
|
18
|
-
"
|
|
19
|
-
"
|
|
21
|
+
"recall",
|
|
22
|
+
"ingest"
|
|
20
23
|
],
|
|
21
24
|
"repository": {
|
|
22
25
|
"type": "git",
|
|
23
|
-
"url": "https://github.com/
|
|
26
|
+
"url": "https://github.com/ekailabs/contexto",
|
|
24
27
|
"directory": "integrations/openclaw"
|
|
25
28
|
},
|
|
26
29
|
"author": "Ekai Labs",
|
|
27
30
|
"scripts": {
|
|
28
|
-
"type-check": "tsc --noEmit"
|
|
31
|
+
"type-check": "tsc --noEmit",
|
|
32
|
+
"test": "vitest run"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@ekai/memory": "^0.0.1"
|
|
29
36
|
},
|
|
30
37
|
"devDependencies": {
|
|
31
|
-
"typescript": "^5.3.2"
|
|
38
|
+
"typescript": "^5.3.2",
|
|
39
|
+
"vitest": "^3.0.0"
|
|
32
40
|
},
|
|
33
41
|
"openclaw": {
|
|
34
42
|
"extensions": ["./src/index.ts"]
|
package/src/bootstrap.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
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
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,47 +1,270 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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';
|
|
7
|
+
|
|
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);
|
|
26
|
+
}
|
|
27
|
+
|
|
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;
|
|
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
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function loadProgress(path: string): BootstrapProgress {
|
|
51
|
+
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
|
+
}
|
|
104
|
+
|
|
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' };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Case 6: nothing found, let core handle
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// --- Plugin definition ---
|
|
18
118
|
|
|
19
119
|
export default {
|
|
20
|
-
id: 'ekai
|
|
120
|
+
id: '@ekai/contexto',
|
|
21
121
|
name: 'Ekai Contexto',
|
|
22
|
-
description: '
|
|
23
|
-
configSchema: {
|
|
122
|
+
description: 'Local-first memory for OpenClaw',
|
|
123
|
+
configSchema: {
|
|
124
|
+
type: 'object',
|
|
125
|
+
additionalProperties: false,
|
|
126
|
+
properties: {
|
|
127
|
+
dbPath: { type: 'string' },
|
|
128
|
+
provider: { type: 'string' },
|
|
129
|
+
apiKey: { type: 'string' },
|
|
130
|
+
},
|
|
131
|
+
},
|
|
24
132
|
|
|
25
133
|
register(api: any) {
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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');
|
|
153
|
+
|
|
154
|
+
// --- Delta tracking (persisted) ---
|
|
155
|
+
const progressPath = dbPath.replace(/\.db$/, '') + '.progress.json';
|
|
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);
|
|
43
162
|
}
|
|
44
163
|
|
|
45
|
-
|
|
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;
|
|
187
|
+
}
|
|
188
|
+
|
|
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
|
+
}
|
|
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.' };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const stateDir = api.runtime?.state?.resolveStateDir?.(process.env, os.homedir());
|
|
240
|
+
if (!stateDir) {
|
|
241
|
+
return { text: 'Could not resolve state directory.' };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
progress.__bootstrap = { status: 'running', startedAt: Date.now() };
|
|
245
|
+
await saveProgress();
|
|
246
|
+
|
|
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
|
+
});
|
|
262
|
+
|
|
263
|
+
return { text: 'Memory bootstrap started. Check logs for progress.' };
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
const providerInfo = resolved ? ` (${resolved.provider} via ${resolved.source})` : '';
|
|
268
|
+
api.logger.info(`@ekai/contexto: memory at ${dbPath}${providerInfo}`);
|
|
46
269
|
},
|
|
47
270
|
};
|
package/src/store.ts
DELETED
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
2
|
-
import { dirname } from 'node:path';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Safe JSON replacer — handles circular refs, BigInt, undefined, errors.
|
|
6
|
-
* No external deps needed.
|
|
7
|
-
*/
|
|
8
|
-
function safeReplacer() {
|
|
9
|
-
const seen = new WeakSet();
|
|
10
|
-
return (_key: string, value: unknown): unknown => {
|
|
11
|
-
if (typeof value === 'bigint') return value.toString();
|
|
12
|
-
if (value instanceof Error) return { message: value.message, stack: value.stack };
|
|
13
|
-
if (value !== null && typeof value === 'object') {
|
|
14
|
-
if (seen.has(value)) return '[Circular]';
|
|
15
|
-
seen.add(value);
|
|
16
|
-
}
|
|
17
|
-
return value;
|
|
18
|
-
};
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function safeStringify(obj: unknown): string {
|
|
22
|
-
try {
|
|
23
|
-
return JSON.stringify(obj, safeReplacer());
|
|
24
|
-
} catch {
|
|
25
|
-
return JSON.stringify({ _error: 'serialization failed' });
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Extract sessionId and agentId from event or ctx (whichever has them).
|
|
31
|
-
*/
|
|
32
|
-
function extractIds(event: any, ctx: any): { sessionId?: string; agentId?: string } {
|
|
33
|
-
return {
|
|
34
|
-
sessionId: event?.sessionId ?? ctx?.sessionId ?? ctx?.sessionKey ?? undefined,
|
|
35
|
-
agentId: event?.agentId ?? ctx?.agentId ?? undefined,
|
|
36
|
-
};
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export class EventLog {
|
|
40
|
-
constructor(private path: string) {
|
|
41
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Append a hook event to the JSONL log.
|
|
46
|
-
* Uses appendFileSync for tool_result_persist sync compatibility.
|
|
47
|
-
*
|
|
48
|
-
* NOTE (v0): appendFileSync blocks the event loop. Acceptable at low volume.
|
|
49
|
-
* Future: async write with buffering for high-throughput hooks.
|
|
50
|
-
*/
|
|
51
|
-
append(hook: string, event: unknown, ctx: unknown): void {
|
|
52
|
-
const { sessionId, agentId } = extractIds(event, ctx);
|
|
53
|
-
const line = safeStringify({ ts: Date.now(), hook, sessionId, agentId, event, ctx });
|
|
54
|
-
appendFileSync(this.path, line + '\n');
|
|
55
|
-
}
|
|
56
|
-
}
|