@ekkos/mcp-server 2.0.3 → 2.1.0

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/dist/index.d.ts CHANGED
@@ -2,8 +2,11 @@
2
2
  /**
3
3
  * @ekkos/mcp-server
4
4
  *
5
- * Simple stdio proxy to ekkOS cloud MCP gateway.
6
- * This creates a local MCP server that forwards all requests to https://mcp.ekkos.dev
5
+ * Local-first MCP server for ekkOS cloud memory.
6
+ * - Caches patterns + directives locally for <5ms search
7
+ * - Falls back to cloud gateway for full results
8
+ * - Queues writes when offline, flushes on reconnect
9
+ * - Auto-discovers API key from ~/.ekkos/config.json
7
10
  */
8
11
  export {};
9
12
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAEA;;;;;GAKG"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAEA;;;;;;;;GAQG"}
package/dist/index.js CHANGED
@@ -2,8 +2,11 @@
2
2
  /**
3
3
  * @ekkos/mcp-server
4
4
  *
5
- * Simple stdio proxy to ekkOS cloud MCP gateway.
6
- * This creates a local MCP server that forwards all requests to https://mcp.ekkos.dev
5
+ * Local-first MCP server for ekkOS cloud memory.
6
+ * - Caches patterns + directives locally for <5ms search
7
+ * - Falls back to cloud gateway for full results
8
+ * - Queues writes when offline, flushes on reconnect
9
+ * - Auto-discovers API key from ~/.ekkos/config.json
7
10
  */
8
11
  // Sentry must be imported and initialized before other imports
9
12
  import * as Sentry from '@sentry/node';
@@ -19,18 +22,173 @@ if (process.env.SENTRY_DSN) {
19
22
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
20
23
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
21
24
  import { ListToolsRequestSchema, CallToolRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema } from '@modelcontextprotocol/sdk/types.js';
22
- // Configuration
23
- const EKKOS_API_KEY = process.env.EKKOS_API_KEY || '';
25
+ import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'fs';
26
+ import { join } from 'path';
27
+ import { homedir } from 'os';
28
+ import { loadCacheFromDisk, warmCache, searchLocal, getDirectives, invalidateCache, isCacheStale, startPeriodicRefresh, getCacheStats, } from './local-cache.js';
29
+ import { isWriteTool, queueWrite, flushQueue, getPendingCount, } from './offline-queue.js';
30
+ // ═══════════════════════════════════════════════════════════════════════════
31
+ // CONFIGURATION — resolve API key from env, then ~/.ekkos/config.json
32
+ // ═══════════════════════════════════════════════════════════════════════════
33
+ function resolveApiKey() {
34
+ // Priority 1: Environment variable
35
+ if (process.env.EKKOS_API_KEY) {
36
+ return process.env.EKKOS_API_KEY;
37
+ }
38
+ // Priority 2: ~/.ekkos/config.json (from prior `ekkos init`)
39
+ try {
40
+ const configPath = join(homedir(), '.ekkos', 'config.json');
41
+ if (existsSync(configPath)) {
42
+ const config = JSON.parse(readFileSync(configPath, 'utf-8'));
43
+ if (config.apiKey) {
44
+ console.error('[ekkOS] Using API key from ~/.ekkos/config.json');
45
+ return config.apiKey;
46
+ }
47
+ }
48
+ }
49
+ catch {
50
+ // Config unreadable — fall through to degraded mode
51
+ }
52
+ // No key found — caller will enable degraded mode
53
+ return null;
54
+ }
55
+ const EKKOS_API_KEY = resolveApiKey();
56
+ /**
57
+ * Degraded mode: no API key available.
58
+ * Search returns local cache only. Writes are queued to offline queue.
59
+ * All tools still register normally so Claude sees no missing tools.
60
+ */
61
+ const degradedMode = EKKOS_API_KEY === null;
24
62
  const EKKOS_USER_ID = process.env.EKKOS_USER_ID || '';
25
63
  const EKKOS_MCP_URL = process.env.EKKOS_MCP_URL || 'https://mcp.ekkos.dev/api/v1/mcp';
26
64
  const DEBUG = process.env.EKKOS_DEBUG === 'true';
27
- if (!EKKOS_API_KEY) {
28
- console.error('[ekkOS] ERROR: EKKOS_API_KEY environment variable is required');
29
- console.error('[ekkOS] Get your API key at https://platform.ekkos.dev/dashboard/settings/api-keys');
30
- process.exit(1);
65
+ // ═══════════════════════════════════════════════════════════════════════════
66
+ // SESSION METRICS written to ~/.ekkos/session-metrics.json after each tool call
67
+ // ═══════════════════════════════════════════════════════════════════════════
68
+ const METRICS_DIR = join(homedir(), '.ekkos');
69
+ const METRICS_FILE = join(METRICS_DIR, 'session-metrics.json');
70
+ // In-process counters — reset each MCP server process lifetime (= one Claude session)
71
+ const sessionMetrics = {
72
+ session_id: `mcp-${Date.now()}`,
73
+ session_name: '',
74
+ turn: 0,
75
+ started_at: new Date().toISOString(),
76
+ updated_at: new Date().toISOString(),
77
+ tool_calls: 0,
78
+ patterns_recalled: 0,
79
+ patterns_forged: 0,
80
+ directives_active: 0,
81
+ cache_backend: 'json',
82
+ cache_patterns: 0,
83
+ tokens_in: 0,
84
+ tokens_out: 0,
85
+ };
86
+ /** Fire-and-forget write of metrics to disk. Never throws. */
87
+ function writeMetrics() {
88
+ try {
89
+ mkdirSync(METRICS_DIR, { recursive: true });
90
+ const stats = getCacheStats();
91
+ sessionMetrics.updated_at = new Date().toISOString();
92
+ sessionMetrics.cache_backend = stats.backend;
93
+ sessionMetrics.cache_patterns = stats.patterns;
94
+ sessionMetrics.directives_active = stats.directives;
95
+ writeFileSync(METRICS_FILE, JSON.stringify(sessionMetrics, null, 2), 'utf-8');
96
+ }
97
+ catch {
98
+ // best-effort — never block the tool response
99
+ }
100
+ }
101
+ // ═══════════════════════════════════════════════════════════════════════════
102
+ // CIRCUIT BREAKER — fast-fail when cloud is unreachable
103
+ // ═══════════════════════════════════════════════════════════════════════════
104
+ let circuitFailures = 0;
105
+ let circuitOpenUntil = 0;
106
+ const CIRCUIT_THRESHOLD = 3; // failures before opening
107
+ const CIRCUIT_RESET_MS = 60_000; // 60s before half-open retry
108
+ const GATEWAY_TIMEOUT_MS = 8_000; // 8s request timeout
109
+ function isCircuitOpen() {
110
+ if (circuitFailures < CIRCUIT_THRESHOLD)
111
+ return false;
112
+ if (Date.now() > circuitOpenUntil) {
113
+ // Half-open: allow one request through
114
+ return false;
115
+ }
116
+ return true;
117
+ }
118
+ function recordSuccess() {
119
+ circuitFailures = 0;
120
+ circuitOpenUntil = 0;
121
+ }
122
+ function recordFailure() {
123
+ circuitFailures++;
124
+ if (circuitFailures >= CIRCUIT_THRESHOLD) {
125
+ circuitOpenUntil = Date.now() + CIRCUIT_RESET_MS;
126
+ console.error(`[ekkOS:circuit] OPEN — ${circuitFailures} failures, retrying in ${CIRCUIT_RESET_MS / 1000}s`);
127
+ }
128
+ }
129
+ // ═══════════════════════════════════════════════════════════════════════════
130
+ // RESULT SLIMMING — strip verbose _ekkos_* metadata from gateway responses
131
+ // These fields (_ekkos_stream_message, _ekkos_context, _ekkos_pattern_contract,
132
+ // _ekkos_acknowledgment_*) are designed for Cursor's sidebar UI. When returned
133
+ // via MCP stdio to CLI clients (Gemini, Claude Code) the model sees them as
134
+ // raw tool output and parrots them back, producing noisy verbose responses.
135
+ // ═══════════════════════════════════════════════════════════════════════════
136
+ const VERBOSE_EKKOS_KEYS = new Set([
137
+ '_ekkos_stream_message',
138
+ '_ekkos_context',
139
+ '_ekkos_pattern_contract',
140
+ '_ekkos_acknowledgment_required',
141
+ '_ekkos_acknowledgment_instruction',
142
+ '_ekkos_enforcement',
143
+ '_ekkos_session_summary',
144
+ ]);
145
+ /**
146
+ * Strip verbose _ekkos_* fields from a gateway result.
147
+ * Keeps compact fields like _ekkos_patterns and _ekkos_auto_retrieved.
148
+ */
149
+ function slimGatewayResult(data) {
150
+ if (!data || typeof data !== 'object')
151
+ return data;
152
+ // If result has content[].text with JSON, parse → strip → re-serialize
153
+ if (data.result && typeof data.result === 'object') {
154
+ for (const key of VERBOSE_EKKOS_KEYS) {
155
+ delete data.result[key];
156
+ }
157
+ }
158
+ // Also strip from top-level content array (MCP format)
159
+ if (data.content && Array.isArray(data.content)) {
160
+ for (const part of data.content) {
161
+ if (part.type === 'text' && typeof part.text === 'string') {
162
+ try {
163
+ const parsed = JSON.parse(part.text);
164
+ if (parsed && typeof parsed === 'object') {
165
+ let changed = false;
166
+ for (const key of VERBOSE_EKKOS_KEYS) {
167
+ if (key in parsed) {
168
+ delete parsed[key];
169
+ changed = true;
170
+ }
171
+ }
172
+ if (changed) {
173
+ part.text = JSON.stringify(parsed);
174
+ }
175
+ }
176
+ }
177
+ catch {
178
+ // Not JSON — leave as-is
179
+ }
180
+ }
181
+ }
182
+ }
183
+ return data;
31
184
  }
32
- // Simple HTTP client for MCP gateway
185
+ // ═══════════════════════════════════════════════════════════════════════════
186
+ // GATEWAY CLIENT — HTTP calls to cloud MCP gateway
187
+ // ═══════════════════════════════════════════════════════════════════════════
33
188
  async function callGateway(endpoint, method, params) {
189
+ if (degradedMode) {
190
+ throw new Error('ekkOS running in cache-only mode — no API key');
191
+ }
34
192
  const url = `${EKKOS_MCP_URL}/${endpoint}`;
35
193
  const headers = {
36
194
  'Content-Type': 'application/json',
@@ -42,24 +200,155 @@ async function callGateway(endpoint, method, params) {
42
200
  if (DEBUG) {
43
201
  console.error(`[ekkOS] → ${method} ${endpoint}`);
44
202
  }
45
- const response = await fetch(url, {
46
- method: 'POST',
47
- headers,
48
- body: JSON.stringify({ method, params: params || {} }),
49
- });
50
- if (!response.ok) {
51
- throw new Error(`HTTP ${response.status}: ${response.statusText}`);
203
+ const controller = new AbortController();
204
+ const timeout = setTimeout(() => controller.abort(), GATEWAY_TIMEOUT_MS);
205
+ try {
206
+ const response = await fetch(url, {
207
+ method: 'POST',
208
+ headers,
209
+ body: JSON.stringify({ method, params: params || {} }),
210
+ signal: controller.signal,
211
+ });
212
+ if (!response.ok) {
213
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
214
+ }
215
+ const data = await response.json();
216
+ if (DEBUG) {
217
+ console.error(`[ekkOS] ← ${method} OK`);
218
+ }
219
+ recordSuccess();
220
+ return slimGatewayResult(data);
52
221
  }
53
- const data = await response.json();
54
- if (DEBUG) {
55
- console.error(`[ekkOS] ← ${method} OK`);
222
+ catch (err) {
223
+ recordFailure();
224
+ throw err;
225
+ }
226
+ finally {
227
+ clearTimeout(timeout);
56
228
  }
57
- return data;
58
229
  }
59
- // Create MCP server
230
+ /**
231
+ * Call gateway with offline fallback.
232
+ * For write tools: queues when offline.
233
+ * For read tools: returns null when offline (caller handles fallback).
234
+ */
235
+ async function callGatewayWithFallback(endpoint, method, params, toolName) {
236
+ // If circuit is open, don't even try
237
+ if (isCircuitOpen()) {
238
+ if (toolName && isWriteTool(toolName)) {
239
+ queueWrite(toolName, params?.arguments || {});
240
+ return {
241
+ content: [{ type: 'text', text: JSON.stringify({ _offline: true, _queued: true, message: 'Queued for sync when connection restores' }) }],
242
+ };
243
+ }
244
+ return null; // Caller handles fallback
245
+ }
246
+ try {
247
+ const result = await callGateway(endpoint, method, params);
248
+ // Connectivity restored — flush any pending writes in background
249
+ const pendingCount = getPendingCount();
250
+ if (pendingCount > 0) {
251
+ flushQueue(callGateway).catch(() => { });
252
+ }
253
+ return result;
254
+ }
255
+ catch {
256
+ if (toolName && isWriteTool(toolName)) {
257
+ queueWrite(toolName, params?.arguments || {});
258
+ return {
259
+ content: [{ type: 'text', text: JSON.stringify({ _offline: true, _queued: true, message: 'Queued for sync when connection restores' }) }],
260
+ };
261
+ }
262
+ return null; // Caller handles fallback
263
+ }
264
+ }
265
+ // ═══════════════════════════════════════════════════════════════════════════
266
+ // LOCAL-FIRST SEARCH — check cache before cloud
267
+ // ═══════════════════════════════════════════════════════════════════════════
268
+ /**
269
+ * Handle ekkOS_Search with local-first strategy:
270
+ * 1. Search local cache (<5ms)
271
+ * 2. If cache has results, return them immediately
272
+ * 3. Background: also query cloud for freshness
273
+ * 4. If cache miss or stale, fall through to cloud
274
+ */
275
+ async function handleSearchLocally(args) {
276
+ const query = args.query || '';
277
+ const limit = args.limit || 5;
278
+ const start = Date.now();
279
+ // Try local cache first
280
+ const localPatterns = searchLocal(query, limit);
281
+ const localDirectives = getDirectives();
282
+ const localLatency = Date.now() - start;
283
+ if (localPatterns.length > 0 || localDirectives.length > 0) {
284
+ // Build a response that matches the cloud format
285
+ const localResult = {
286
+ content: [{
287
+ type: 'text',
288
+ text: JSON.stringify({
289
+ patterns: localPatterns.map(p => ({
290
+ id: p.id,
291
+ title: p.title,
292
+ similarity: 0.75, // Approximate — local search doesn't compute embeddings
293
+ success_rate: p.success_rate,
294
+ applied_count: p.applied_count,
295
+ tags: p.tags?.slice(0, 3),
296
+ })),
297
+ directives: localDirectives,
298
+ total: localPatterns.length + localDirectives.length,
299
+ _cache: 'local',
300
+ _cache_latency_ms: localLatency,
301
+ }),
302
+ }],
303
+ _ekkos_status: `[ekkOS_RETRIEVE] ⚡ Local: ${localPatterns.length} patterns, ${localDirectives.length} directives (${localLatency}ms)`,
304
+ };
305
+ // If cache is stale, trigger background refresh (don't block)
306
+ if (isCacheStale()) {
307
+ warmCache(callGateway, EKKOS_USER_ID).catch(() => { });
308
+ }
309
+ // Also query cloud in background to update cache (don't await)
310
+ callGatewayWithFallback('tools/call', 'tools/call', {
311
+ name: 'ekkOS_Search',
312
+ arguments: args,
313
+ }).then(cloudResult => {
314
+ if (cloudResult) {
315
+ // Update cache with fresh cloud results
316
+ warmCache(callGateway, EKKOS_USER_ID).catch(() => { });
317
+ }
318
+ }).catch(() => { });
319
+ return localResult;
320
+ }
321
+ // No local results — fall through to cloud
322
+ const cloudResult = await callGatewayWithFallback('tools/call', 'tools/call', {
323
+ name: 'ekkOS_Search',
324
+ arguments: args,
325
+ }, 'ekkOS_Search');
326
+ if (cloudResult) {
327
+ // Trigger cache update with fresh data
328
+ warmCache(callGateway, EKKOS_USER_ID).catch(() => { });
329
+ return cloudResult;
330
+ }
331
+ // Both local and cloud failed
332
+ return {
333
+ content: [{
334
+ type: 'text',
335
+ text: JSON.stringify({
336
+ patterns: [],
337
+ directives: [],
338
+ total: 0,
339
+ _cache: 'miss',
340
+ _offline: true,
341
+ message: 'No cached patterns and cloud unreachable',
342
+ }),
343
+ }],
344
+ };
345
+ }
346
+ // ═══════════════════════════════════════════════════════════════════════════
347
+ // MCP SERVER
348
+ // ═══════════════════════════════════════════════════════════════════════════
60
349
  const server = new Server({
61
350
  name: 'ekkos-memory',
62
- version: '1.0.0',
351
+ version: '1.1.0',
63
352
  }, {
64
353
  capabilities: {
65
354
  tools: {},
@@ -67,26 +356,195 @@ const server = new Server({
67
356
  prompts: {},
68
357
  },
69
358
  });
70
- // List tools - proxy to gateway
359
+ // Cache-invalidating tools after these succeed, refresh local cache
360
+ const CACHE_INVALIDATING_TOOLS = new Set([
361
+ 'ekkOS_Forge',
362
+ 'ekkOS_Directive',
363
+ 'ekkOS_UpdateDirective',
364
+ 'ekkOS_DeleteDirective',
365
+ ]);
366
+ // List tools - proxy to gateway (or return cached list in degraded mode)
71
367
  server.setRequestHandler(ListToolsRequestSchema, async () => {
368
+ // AutoForge tool — always available, handled locally in MCP server
369
+ const autoForgeTool = {
370
+ name: 'ekkOS_AutoForge',
371
+ description: 'Lightweight pattern forge. Call this instead of writing [ekkOS_LEARN]. Accepts a title and context string, uses Gemini Flash to extract a structured pattern, and forges it. Much lighter than ekkOS_Forge — use for quick lessons learned.',
372
+ inputSchema: {
373
+ type: 'object',
374
+ properties: {
375
+ title: { type: 'string', description: 'Pattern title (e.g. "Fix: missing await on async Supabase client")' },
376
+ context: { type: 'string', description: 'Full context: what was the problem, what was the fix, what files were involved. Include code snippets if relevant.' },
377
+ tags: { type: 'array', items: { type: 'string' }, description: 'Optional tags for categorization' },
378
+ },
379
+ required: ['title', 'context'],
380
+ },
381
+ };
382
+ if (degradedMode) {
383
+ return { tools: [autoForgeTool] };
384
+ }
72
385
  const result = await callGateway('tools/list', 'tools/list');
386
+ // Inject AutoForge into the tool list from gateway
387
+ if (result?.tools && Array.isArray(result.tools)) {
388
+ const hasAutoForge = result.tools.some((t) => t.name === 'ekkOS_AutoForge');
389
+ if (!hasAutoForge) {
390
+ result.tools.push(autoForgeTool);
391
+ }
392
+ }
73
393
  return result;
74
394
  });
75
- // Call tool - proxy to gateway
395
+ // Call tool - local-first for search, gateway for everything else
76
396
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
77
- const result = await callGateway('tools/call', 'tools/call', {
78
- name: request.params.name,
79
- arguments: request.params.arguments,
80
- });
81
- return result;
397
+ const toolName = request.params.name;
398
+ const toolArgs = request.params.arguments || {};
399
+ // ── Increment global tool-call counter ──────────────────────────────────
400
+ sessionMetrics.tool_calls++;
401
+ // Extract session_name from ekkOS_Search / ekkOS_Forge args if present
402
+ const argsAny = toolArgs;
403
+ if (argsAny.session_name && typeof argsAny.session_name === 'string' && !sessionMetrics.session_name) {
404
+ sessionMetrics.session_name = argsAny.session_name;
405
+ }
406
+ // LOCAL-FIRST: ekkOS_Search uses local cache
407
+ if (toolName === 'ekkOS_Search') {
408
+ const result = await handleSearchLocally(argsAny);
409
+ // Count patterns returned as "recalled" — parse result text if available
410
+ try {
411
+ const text = result?.content?.[0]?.text;
412
+ if (text) {
413
+ const parsed = JSON.parse(text);
414
+ sessionMetrics.patterns_recalled += (parsed.patterns?.length ?? 0);
415
+ // Infer turn from total search calls (each search ≈ one turn)
416
+ sessionMetrics.turn = sessionMetrics.patterns_recalled > 0
417
+ ? Math.max(sessionMetrics.turn, Math.ceil(sessionMetrics.tool_calls / 2))
418
+ : sessionMetrics.turn;
419
+ }
420
+ }
421
+ catch { /* ignore parse errors */ }
422
+ writeMetrics();
423
+ return result;
424
+ }
425
+ // AUTO-FORGE: lightweight forge via Gemini Flash extraction
426
+ if (toolName === 'ekkOS_AutoForge') {
427
+ const title = argsAny.title || 'Untitled Pattern';
428
+ const context = argsAny.context || '';
429
+ const tags = argsAny.tags || [];
430
+ // Call Gemini Flash to extract structured pattern from the context
431
+ try {
432
+ const extractionPrompt = `Extract a reusable pattern from this context. Return JSON only.
433
+
434
+ Title: ${title}
435
+ Context: ${context}
436
+
437
+ Return: {"problem": "what problem this solves", "solution": "the solution approach", "works_when": ["condition1"], "anti_patterns": ["what not to do"]}`;
438
+ const geminiKey = process.env.GOOGLE_AI_API_KEY || process.env.GEMINI_API_KEY;
439
+ let extraction = { problem: title, solution: context.slice(0, 500), works_when: [], anti_patterns: [] };
440
+ if (geminiKey) {
441
+ const geminiResp = await fetch('https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent', {
442
+ method: 'POST',
443
+ headers: { 'Content-Type': 'application/json', 'x-goog-api-key': geminiKey },
444
+ body: JSON.stringify({
445
+ contents: [{ parts: [{ text: extractionPrompt }] }],
446
+ generationConfig: { temperature: 0.1, maxOutputTokens: 512 },
447
+ }),
448
+ signal: AbortSignal.timeout(5000),
449
+ });
450
+ if (geminiResp.ok) {
451
+ const geminiData = await geminiResp.json();
452
+ const text = geminiData.candidates?.[0]?.content?.parts?.[0]?.text || '';
453
+ const jsonMatch = text.match(/\{[\s\S]*\}/);
454
+ if (jsonMatch) {
455
+ try {
456
+ extraction = { ...extraction, ...JSON.parse(jsonMatch[0]) };
457
+ }
458
+ catch { /* use defaults */ }
459
+ }
460
+ }
461
+ }
462
+ // Forge via gateway (same path as ekkOS_Forge)
463
+ const forgeResult = await callGatewayWithFallback('tools/call', 'tools/call', {
464
+ name: 'ekkOS_Forge',
465
+ arguments: {
466
+ title,
467
+ problem: extraction.problem,
468
+ solution: extraction.solution,
469
+ context,
470
+ tags: [...tags, 'auto-forge', 'gemini-extracted'],
471
+ works_when: extraction.works_when,
472
+ anti_patterns: extraction.anti_patterns,
473
+ },
474
+ }, 'ekkOS_AutoForge');
475
+ sessionMetrics.patterns_forged++;
476
+ writeMetrics();
477
+ return forgeResult || {
478
+ content: [{ type: 'text', text: JSON.stringify({
479
+ success: true,
480
+ _auto_forged: true,
481
+ title,
482
+ message: `Auto-forged: "${title}" (Gemini Flash extracted)`,
483
+ }) }],
484
+ };
485
+ }
486
+ catch (err) {
487
+ return {
488
+ content: [{ type: 'text', text: JSON.stringify({
489
+ success: false,
490
+ error: `AutoForge failed: ${err.message}`,
491
+ _fallback: 'Pattern not forged — try ekkOS_Forge manually',
492
+ }) }],
493
+ };
494
+ }
495
+ }
496
+ // WRITE TOOLS: use fallback with offline queue
497
+ if (isWriteTool(toolName)) {
498
+ const result = await callGatewayWithFallback('tools/call', 'tools/call', {
499
+ name: toolName,
500
+ arguments: toolArgs,
501
+ }, toolName);
502
+ // Invalidate cache after successful writes
503
+ if (result && CACHE_INVALIDATING_TOOLS.has(toolName)) {
504
+ invalidateCache();
505
+ warmCache(callGateway, EKKOS_USER_ID).catch(() => { });
506
+ }
507
+ // Track forged patterns
508
+ if (toolName === 'ekkOS_Forge' && result && !result.content?.[0]?.text?.includes('_offline')) {
509
+ sessionMetrics.patterns_forged++;
510
+ }
511
+ writeMetrics();
512
+ return result || {
513
+ content: [{ type: 'text', text: JSON.stringify({ _offline: true, _queued: true }) }],
514
+ };
515
+ }
516
+ // ALL OTHER TOOLS: proxy to cloud with graceful fallback
517
+ const result = await callGatewayWithFallback('tools/call', 'tools/call', {
518
+ name: toolName,
519
+ arguments: toolArgs,
520
+ }, toolName);
521
+ writeMetrics();
522
+ if (result)
523
+ return result;
524
+ // Offline fallback for read tools
525
+ return {
526
+ content: [{
527
+ type: 'text',
528
+ text: JSON.stringify({
529
+ _offline: true,
530
+ message: `Cloud unreachable — ${toolName} requires connectivity`,
531
+ cache_stats: getCacheStats(),
532
+ }),
533
+ }],
534
+ };
82
535
  });
83
536
  // List resources - proxy to gateway
84
537
  server.setRequestHandler(ListResourcesRequestSchema, async () => {
538
+ if (degradedMode)
539
+ return { resources: [] };
85
540
  const result = await callGateway('resources/list', 'resources/list');
86
541
  return result;
87
542
  });
88
543
  // Read resource - proxy to gateway
89
544
  server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
545
+ if (degradedMode) {
546
+ return { contents: [{ uri: request.params.uri, text: 'ekkOS running in cache-only mode. Run `ekkos init` to connect.' }] };
547
+ }
90
548
  const result = await callGateway('resources/read', 'resources/read', {
91
549
  uri: request.params.uri,
92
550
  });
@@ -94,11 +552,16 @@ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
94
552
  });
95
553
  // List prompts - proxy to gateway
96
554
  server.setRequestHandler(ListPromptsRequestSchema, async () => {
555
+ if (degradedMode)
556
+ return { prompts: [] };
97
557
  const result = await callGateway('prompts/list', 'prompts/list');
98
558
  return result;
99
559
  });
100
560
  // Get prompt - proxy to gateway
101
561
  server.setRequestHandler(GetPromptRequestSchema, async (request) => {
562
+ if (degradedMode) {
563
+ return { description: 'ekkOS cache-only mode', messages: [] };
564
+ }
102
565
  const result = await callGateway('prompts/get', 'prompts/get', {
103
566
  name: request.params.name,
104
567
  arguments: request.params.arguments,
@@ -117,17 +580,42 @@ process.on('SIGINT', async () => {
117
580
  await server.close();
118
581
  process.exit(0);
119
582
  });
120
- // Start server
583
+ // ═══════════════════════════════════════════════════════════════════════════
584
+ // STARTUP
585
+ // ═══════════════════════════════════════════════════════════════════════════
121
586
  async function main() {
587
+ // Step 1: Load cache from disk (instant, <1ms)
588
+ const diskCache = loadCacheFromDisk();
589
+ if (diskCache.patterns > 0 || diskCache.directives > 0) {
590
+ console.error(`[ekkOS] Cache loaded: ${diskCache.patterns} patterns, ${diskCache.directives} directives`);
591
+ }
592
+ // Degraded mode: no API key — run cache-only, skip all cloud steps
593
+ if (degradedMode) {
594
+ console.error('[ekkOS] WARNING: ekkOS running in cache-only mode. Run `ekkos init` to connect.');
595
+ const transport = new StdioServerTransport();
596
+ await server.connect(transport);
597
+ return;
598
+ }
599
+ // Step 2: Flush any pending offline writes (background)
600
+ const pendingCount = getPendingCount();
601
+ if (pendingCount > 0) {
602
+ console.error(`[ekkOS] Flushing ${pendingCount} pending offline writes...`);
603
+ flushQueue(callGateway).catch(() => { });
604
+ }
605
+ // Step 3: Start MCP transport
122
606
  if (DEBUG) {
123
- console.error('[ekkOS] Starting MCP proxy to ekkOS Memory...');
607
+ console.error('[ekkOS] Starting ekkOS Memory MCP Server...');
124
608
  console.error(`[ekkOS] Gateway: ${EKKOS_MCP_URL}`);
125
609
  console.error(`[ekkOS] User ID: ${EKKOS_USER_ID || '(not set)'}`);
126
610
  }
127
611
  const transport = new StdioServerTransport();
128
612
  await server.connect(transport);
613
+ // Step 4: Warm cache from cloud (non-blocking background task)
614
+ warmCache(callGateway, EKKOS_USER_ID).catch(() => { });
615
+ // Step 5: Start periodic cache refresh (every 5 min)
616
+ startPeriodicRefresh(callGateway, EKKOS_USER_ID);
129
617
  if (DEBUG) {
130
- console.error('[ekkOS] ✓ Ready! Your AI can now access ekkOS memory.');
618
+ console.error('[ekkOS] ✓ Ready! Local-first memory with cloud sync.');
131
619
  }
132
620
  }
133
621
  main().catch((error) => {