@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 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
@@ -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
- "logPath": { "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
- "logPath": {
12
- "label": "Event log path",
13
- "description": "Path to the JSONL event log file"
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.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,20 +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"
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"]
@@ -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 { EventLog } from './store.js';
2
-
3
- const HOOKS = [
4
- { name: 'session_start', description: 'Log session start' },
5
- { name: 'session_end', description: 'Log session end' },
6
- { name: 'message_received', description: 'Log inbound message' },
7
- { name: 'message_sent', description: 'Log outbound message' },
8
- { name: 'before_prompt_build', description: 'Log pre-prompt state' },
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
- { name: 'before_compaction', description: 'Log pre-compaction state' },
16
- { name: 'after_compaction', description: 'Log post-compaction state' },
17
- ] 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 ---
18
118
 
19
119
  export default {
20
- id: 'ekai-contexto',
120
+ id: '@ekai/contexto',
21
121
  name: 'Ekai Contexto',
22
- description: 'Context engine for OpenClaw — captures lifecycle events, extensible to memory injection',
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 logPath = api.resolvePath(api.pluginConfig?.logPath ?? '~/.openclaw/ekai/events.jsonl');
27
- const log = new EventLog(logPath);
28
-
29
- for (const hook of HOOKS) {
30
- api.registerHook({
31
- name: `contexto:${hook.name}`,
32
- description: hook.description,
33
- hook: hook.name,
34
- handler: (event: unknown, ctx: unknown) => {
35
- try {
36
- log.append(hook.name, event, ctx);
37
- } catch (err) {
38
- // Never crash OpenClaw log the failure and move on
39
- api.logger.warn(`ekai-contexto: failed to log ${hook.name}: ${String(err)}`);
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
- api.logger.info(`ekai-contexto: logging to ${logPath}`);
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
- }