@ekai/contexto 0.1.1-rc.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 CHANGED
@@ -1,8 +1,8 @@
1
1
  # @ekai/contexto
2
2
 
3
- OpenClaw plugin that captures all 13 lifecycle events to structured JSONL storage. Built for context, memory, and analytics.
3
+ OpenClaw plugin that provides local-first memory ingest conversation turns and recall relevant context automatically.
4
4
 
5
- Uses [`@ekai/store`](../../store/) for event normalization, safe serialization, and per-session file organization.
5
+ Uses [`@ekai/memory`](../../memory/) for semantic extraction, embedding, and SQLite storage.
6
6
 
7
7
  ## Install
8
8
 
@@ -10,6 +10,11 @@ Uses [`@ekai/store`](../../store/) for event normalization, safe serialization,
10
10
  openclaw plugins install @ekai/contexto
11
11
  ```
12
12
 
13
+ Or from source:
14
+ ```bash
15
+ openclaw plugins install ./integrations/openclaw
16
+ ```
17
+
13
18
  ## Configure
14
19
 
15
20
  In your OpenClaw config:
@@ -17,86 +22,76 @@ In your OpenClaw config:
17
22
  ```json5
18
23
  {
19
24
  plugins: {
20
- allow: ["ekai-contexto"],
25
+ allow: ["@ekai/contexto"],
21
26
  entries: {
22
- "ekai-contexto": {
27
+ "@ekai/contexto": {
23
28
  enabled: true,
24
- config: { "dataDir": "~/.openclaw/ekai/data" }
29
+ config: {
30
+ "dbPath": "~/.openclaw/ekai/memory.db",
31
+ "provider": "openai",
32
+ "apiKey": "sk-..."
33
+ }
25
34
  }
26
35
  }
27
36
  }
28
37
  }
29
38
  ```
30
39
 
31
- `dataDir` defaults to `~/.openclaw/ekai/data` if not set.
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
32
57
 
33
58
  ## Verify
34
59
 
35
60
  ```bash
36
- openclaw plugins list # should show ekai-contexto
37
- openclaw hooks list # should show plugin:ekai-contexto:contexto:* hooks
61
+ openclaw plugins list # should show @ekai/contexto
62
+ openclaw hooks list # should show plugin:@ekai/contexto:* hooks
38
63
  ```
39
64
 
40
- ## Storage Layout
65
+ ## Bootstrap
41
66
 
42
- Events are organized as one JSONL file per session, grouped by agent:
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:
43
68
 
44
69
  ```
45
- {dataDir}/
46
- {agent_id}/
47
- {session_id}.jsonl
70
+ /memory-bootstrap
48
71
  ```
49
72
 
50
- IDs are sanitized for safe file paths (`[a-zA-Z0-9_-]` + 8-char SHA-256 hash suffix). Missing IDs fall back to `_unknown-agent` / `_unknown-session`.
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.
51
74
 
52
- Each line is a JSON object with a versioned schema:
75
+ ## How It Works
53
76
 
54
- ```json
55
- {"id":"...","v":1,"eventTs":1709500000000,"ingestTs":1709500000050,"hook":"llm_output","sessionId":"abc-3f2a1b9c","agentId":"default-8e4c7d1a","event":{...},"ctx":{...}}
56
- ```
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).
57
82
 
58
- ## What It Captures
59
-
60
- All 13 OpenClaw lifecycle hooks:
61
-
62
- | Hook | Description |
63
- |------|-------------|
64
- | `session_start` | Session opened |
65
- | `session_end` | Session closed |
66
- | `message_received` | Inbound message |
67
- | `message_sent` | Outbound message |
68
- | `before_prompt_build` | Pre-prompt state |
69
- | `llm_input` | LLM request |
70
- | `llm_output` | LLM response |
71
- | `before_tool_call` | Pre-tool invocation |
72
- | `after_tool_call` | Tool result |
73
- | `tool_result_persist` | Tool result persistence |
74
- | `agent_end` | Agent completion |
75
- | `before_compaction` | Pre-compaction state |
76
- | `after_compaction` | Post-compaction state |
77
-
78
- Additional fields extracted per event: `sessionId`, `agentId`, `userId`, `conversationId`.
79
-
80
- ## Design
81
-
82
- - **Structured storage** — one JSONL file per session via `@ekai/store` EventWriter
83
- - **Safe serialization** — handles circular refs, BigInt, Error objects (never throws)
84
- - **Never crashes OpenClaw** — every handler wrapped in try/catch
85
- - **Sync writes** — `appendFileSync` for `tool_result_persist` compatibility
86
- - **ID sanitization** — safe file paths with collision-resistant hashing
87
- - **Schema versioned** — every event carries `v: 1` for future migration
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.
88
86
 
89
87
  ## Development
90
88
 
91
89
  ```bash
92
- # Type-check (no build needed OpenClaw loads .ts via jiti)
93
- npm run type-check --workspace=@ekai/contexto
94
-
95
- # Build the store dependency
96
- npm run build --workspace=store
90
+ # Type-check (no build needed -- OpenClaw loads .ts via jiti)
91
+ npm run type-check --workspace=integrations/openclaw
97
92
 
98
- # Run store tests
99
- npm run test --workspace=store
93
+ # Run tests
94
+ npm test --workspace=integrations/openclaw
100
95
 
101
96
  # Local dev install (symlink)
102
97
  openclaw plugins install -l ./integrations/openclaw
@@ -1,16 +1,32 @@
1
1
  {
2
- "id": "ekai-contexto",
2
+ "id": "@ekai/contexto",
3
3
  "configSchema": {
4
4
  "type": "object",
5
5
  "additionalProperties": false,
6
6
  "properties": {
7
- "dataDir": { "type": "string" }
7
+ "dbPath": { "type": "string" },
8
+ "provider": { "type": "string" },
9
+ "apiKey": { "type": "string" },
10
+ "bootstrapDelayMs": { "type": "number" }
8
11
  }
9
12
  },
10
13
  "uiHints": {
11
- "dataDir": {
12
- "label": "Event data directory",
13
- "description": "Directory for structured JSONL event storage (one file per session)"
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.1-rc.0",
4
- "description": "OpenClaw plugin — captures all lifecycle events to JSONL for context, memory, and analytics",
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,23 +18,25 @@
15
18
  "ai",
16
19
  "context",
17
20
  "memory",
18
- "lifecycle",
19
- "events"
21
+ "recall",
22
+ "ingest"
20
23
  ],
21
24
  "repository": {
22
25
  "type": "git",
23
- "url": "https://github.com/ekai-labs/ekai-gateway",
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"
29
33
  },
30
34
  "dependencies": {
31
- "@ekai/store": "*"
35
+ "@ekai/memory": "^0.0.1"
32
36
  },
33
37
  "devDependencies": {
34
- "typescript": "^5.3.2"
38
+ "typescript": "^5.3.2",
39
+ "vitest": "^3.0.0"
35
40
  },
36
41
  "openclaw": {
37
42
  "extensions": ["./src/index.ts"]
@@ -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,108 +1,270 @@
1
- import { EventWriter } from '@ekai/store';
2
-
3
- /** Hooks that store events (10 of 13). */
4
- const STORE_HOOKS = [
5
- { name: 'session_start', description: 'Log session start' },
6
- { name: 'session_end', description: 'Log session end' },
7
- { name: 'message_received', description: 'Log inbound message' },
8
- { name: 'message_sent', description: 'Log outbound message' },
9
- { name: 'llm_input', description: 'Log LLM request' },
10
- { name: 'llm_output', description: 'Log LLM response' },
11
- { name: 'before_tool_call', description: 'Log pre-tool invocation' },
12
- { name: 'after_tool_call', description: 'Log tool result' },
13
- { name: 'tool_result_persist', description: 'Log tool result persistence' },
14
- { name: 'agent_end', description: 'Log agent completion' },
15
- ] as const;
16
-
17
- /** Hooks registered as no-ops keeps OpenClaw aware we're listening. */
18
- const NOOP_HOOKS = [
19
- { name: 'before_prompt_build', description: 'Stub for future memory injection' },
20
- { name: 'before_compaction', description: 'Monitor compaction start' },
21
- { name: 'after_compaction', description: 'Monitor compaction end' },
22
- ] as const;
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 ---
23
118
 
24
119
  export default {
25
- id: 'ekai-contexto',
120
+ id: '@ekai/contexto',
26
121
  name: 'Ekai Contexto',
27
- description: 'Context engine for OpenClaw — captures lifecycle events, extensible to memory injection',
122
+ description: 'Local-first memory for OpenClaw',
28
123
  configSchema: {
29
124
  type: 'object',
30
125
  additionalProperties: false,
31
126
  properties: {
32
- dataDir: { type: 'string' },
127
+ dbPath: { type: 'string' },
128
+ provider: { type: 'string' },
129
+ apiKey: { type: 'string' },
33
130
  },
34
131
  },
35
132
 
36
133
  register(api: any) {
37
- const dataDir = api.resolvePath(api.pluginConfig?.dataDir ?? '~/.openclaw/ekai/data');
38
- const store = new EventWriter(dataDir);
39
-
40
- // Store hooks fire-and-forget with .catch() logging
41
- for (const hook of STORE_HOOKS) {
42
- if (hook.name === 'agent_end') {
43
- // agent_end: store event then flush for clean shutdown
44
- api.registerHook({
45
- name: `contexto:${hook.name}`,
46
- description: hook.description,
47
- hook: hook.name,
48
- handler: (event: any, ctx: any) => {
49
- const sessionId = event?.sessionId ?? ctx?.sessionId ?? ctx?.sessionKey;
50
- const agentId = event?.agentId ?? ctx?.agentId;
51
- const userId = event?.userId ?? ctx?.userId ?? ctx?.user;
52
- const conversationId = event?.conversationId ?? ctx?.conversationId;
53
-
54
- store.append({
55
- hook: hook.name,
56
- sessionId,
57
- agentId,
58
- userId,
59
- conversationId,
60
- event: event ?? {},
61
- ctx,
62
- })
63
- .catch(err => api.logger.warn(`ekai-contexto: append failed: ${String(err)}`))
64
- .finally(() => store.flush()
65
- .catch(err => api.logger.warn(`ekai-contexto: flush failed: ${String(err)}`)));
66
- },
67
- });
68
- continue;
69
- }
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
+ });
70
141
 
71
- api.registerHook({
72
- name: `contexto:${hook.name}`,
73
- description: hook.description,
74
- hook: hook.name,
75
- handler: (event: any, ctx: any) => {
76
- const sessionId = event?.sessionId ?? ctx?.sessionId ?? ctx?.sessionKey;
77
- const agentId = event?.agentId ?? ctx?.agentId;
78
- const userId = event?.userId ?? ctx?.userId ?? ctx?.user;
79
- const conversationId = event?.conversationId ?? ctx?.conversationId;
80
-
81
- store.append({
82
- hook: hook.name,
83
- sessionId,
84
- agentId,
85
- userId,
86
- conversationId,
87
- event: event ?? {},
88
- ctx,
89
- }).catch(err => {
90
- api.logger.warn(`ekai-contexto: store.append failed: ${String(err)}`);
91
- });
92
- },
93
- });
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);
94
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);
95
157
 
96
- // No-op hooks — registered so OpenClaw knows we're listening
97
- for (const hook of NOOP_HOOKS) {
98
- api.registerHook({
99
- name: `contexto:${hook.name}`,
100
- description: hook.description,
101
- hook: hook.name,
102
- handler: () => {},
103
- });
158
+ async function saveProgress() {
159
+ const tmp = progressPath + '.tmp';
160
+ await writeFile(tmp, JSON.stringify(progress), 'utf-8');
161
+ await rename(tmp, progressPath);
104
162
  }
105
163
 
106
- api.logger.info(`ekai-contexto: storing events to ${dataDir}`);
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}`);
107
269
  },
108
270
  };