@ekkos/cli 1.0.36 → 1.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.
@@ -1,6 +1,5 @@
1
1
  # ═══════════════════════════════════════════════════════════════════════════
2
- # ekkOS_ Hook: Stop - Captures turns to BOTH Working (Redis) and Episodic (Supabase)
3
- # NO jq dependency - uses Node.js for all JSON parsing
2
+ # ekkOS_ Hook: Stop - Session cleanup and capture finalization (Windows)
4
3
  # MANAGED BY ekkos-connect - DO NOT EDIT DIRECTLY
5
4
  # EKKOS_MANAGED=1
6
5
  # EKKOS_MANIFEST_SHA256=<computed-at-build>
@@ -10,32 +9,15 @@
10
9
  # - All persisted records MUST include: instanceId, sessionId, sessionName
11
10
  # - Uses EKKOS_INSTANCE_ID env var for multi-session isolation
12
11
  # ═══════════════════════════════════════════════════════════════════════════
13
- # This hook captures every turn to:
14
- # 1. Working Sessions (Redis) - Fast hot cache for /continue
15
- # 2. Episodic Memory (Supabase) - Permanent cold storage
16
- #
17
- # NO compliance checking - skills handle that
18
- # NO PatternGuard validation - skills handle that
19
- # NO verbose output - just capture silently
20
- # ═══════════════════════════════════════════════════════════════════════════
21
12
 
22
13
  $ErrorActionPreference = "SilentlyContinue"
23
14
 
24
15
  # ═══════════════════════════════════════════════════════════════════════════
25
16
  # CONFIG PATHS
26
17
  # ═══════════════════════════════════════════════════════════════════════════
27
- $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
28
- $ProjectRoot = Split-Path -Parent (Split-Path -Parent $ScriptDir)
29
- $StateDir = Join-Path $ProjectRoot ".claude\state"
30
-
31
- if (-not (Test-Path $StateDir)) {
32
- New-Item -ItemType Directory -Path $StateDir -Force | Out-Null
33
- }
34
-
35
18
  $EkkosConfigDir = if ($env:EKKOS_CONFIG_DIR) { $env:EKKOS_CONFIG_DIR } else { "$env:USERPROFILE\.ekkos" }
36
19
  $SessionWordsJson = "$EkkosConfigDir\session-words.json"
37
20
  $SessionWordsDefault = "$EkkosConfigDir\.defaults\session-words.json"
38
- $JsonParseHelper = "$EkkosConfigDir\.helpers\json-parse.cjs"
39
21
 
40
22
  # ═══════════════════════════════════════════════════════════════════════════
41
23
  # INSTANCE ID - Multi-session isolation per v1.2 ADDENDUM
@@ -105,131 +87,65 @@ function Convert-UuidToWords {
105
87
  # ═══════════════════════════════════════════════════════════════════════════
106
88
  $inputJson = [Console]::In.ReadToEnd()
107
89
 
108
- # ═══════════════════════════════════════════════════════════════════════════
109
- # Parse input JSON for session_id, transcript_path, model
110
- # ═══════════════════════════════════════════════════════════════════════════
90
+ # Get session ID from state
91
+ $stateFile = Join-Path $env:USERPROFILE ".claude\state\hook-state.json"
92
+ $sessionFile = Join-Path $env:USERPROFILE ".claude\state\current-session.json"
111
93
  $rawSessionId = "unknown"
112
- $transcriptPath = ""
113
- $modelUsed = "claude-sonnet-4-5"
94
+ $turn = 0
114
95
 
115
- if ($inputJson) {
96
+ if (Test-Path $stateFile) {
116
97
  try {
117
- $stopInput = $inputJson | ConvertFrom-Json
118
- if ($stopInput.session_id) { $rawSessionId = $stopInput.session_id }
119
- if ($stopInput.transcript_path) { $transcriptPath = $stopInput.transcript_path }
120
- if ($stopInput.model) { $modelUsed = $stopInput.model }
98
+ $hookState = Get-Content $stateFile -Raw | ConvertFrom-Json
99
+ $rawSessionId = $hookState.session_id
100
+ $turn = [int]$hookState.turn
121
101
  } catch {}
122
102
  }
123
103
 
124
- # ═══════════════════════════════════════════════════════════════════════════
125
- # Session ID validation
126
- # ═══════════════════════════════════════════════════════════════════════════
127
- if (-not $rawSessionId -or $rawSessionId -eq "unknown" -or $rawSessionId -eq "null") {
128
- exit 0
129
- }
130
-
131
- $isUuid = $rawSessionId -match '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
132
-
133
- if ($isUuid) {
134
- $sessionName = Convert-UuidToWords $rawSessionId
135
- } else {
136
- $sessionName = $rawSessionId
137
- }
138
-
139
- # ═══════════════════════════════════════════════════════════════════════════
140
- # Turn Number - read from turn file written by user-prompt-submit.ps1
141
- # ═══════════════════════════════════════════════════════════════════════════
142
- $sessionsDir = Join-Path $StateDir "sessions"
143
- if (-not (Test-Path $sessionsDir)) {
144
- New-Item -ItemType Directory -Path $sessionsDir -Force | Out-Null
145
- }
146
-
147
- $turnFile = Join-Path $sessionsDir "$rawSessionId.turn"
148
- $turn = 1
149
- if (Test-Path $turnFile) {
150
- try {
151
- $turnContent = Get-Content $turnFile -Raw
152
- $turn = [int]$turnContent.Trim()
153
- } catch {
154
- $turn = 1
155
- }
156
- }
157
-
158
- # ═══════════════════════════════════════════════════════════════════════════
159
- # Load auth - No jq
160
- # ═══════════════════════════════════════════════════════════════════════════
161
- $configFile = Join-Path $EkkosConfigDir "config.json"
162
- $authToken = ""
163
- $userId = ""
164
-
165
- if ((Test-Path $configFile) -and (Test-Path $JsonParseHelper)) {
166
- try {
167
- $authToken = & node $JsonParseHelper $configFile '.hookApiKey' 2>$null
168
- if (-not $authToken) {
169
- $authToken = & node $JsonParseHelper $configFile '.apiKey' 2>$null
170
- }
171
- $userId = & node $JsonParseHelper $configFile '.userId' 2>$null
172
- } catch {}
173
- } elseif (Test-Path $configFile) {
104
+ if ($rawSessionId -eq "unknown" -and (Test-Path $sessionFile)) {
174
105
  try {
175
- $config = Get-Content $configFile -Raw | ConvertFrom-Json
176
- $authToken = if ($config.hookApiKey) { $config.hookApiKey } else { $config.apiKey }
177
- $userId = $config.userId
106
+ $sessionData = Get-Content $sessionFile -Raw | ConvertFrom-Json
107
+ $rawSessionId = $sessionData.session_id
178
108
  } catch {}
179
109
  }
180
110
 
181
- if (-not $authToken) {
182
- $envLocal = Join-Path $ProjectRoot ".env.local"
183
- if (Test-Path $envLocal) {
184
- try {
185
- $envContent = Get-Content $envLocal -Raw
186
- if ($envContent -match 'SUPABASE_SECRET_KEY=["'']?([^"''\r\n]+)') {
187
- $authToken = $Matches[1]
188
- }
189
- } catch {}
190
- }
191
- }
192
-
193
- if (-not $authToken) { exit 0 }
194
-
195
- $memoryApiUrl = "https://api.ekkos.dev"
111
+ $sessionName = Convert-UuidToWords $rawSessionId
196
112
 
197
113
  # ═══════════════════════════════════════════════════════════════════════════
198
114
  # SESSION BINDING: Bridge _pending → real session name for proxy eviction
199
115
  # Windows has no PTY so run.ts can't detect the session name. The stop hook
200
116
  # is the first place we have a confirmed session name, so we bind here.
117
+ # Mac does this in stop.sh (lines 171-179). Logic is identical.
201
118
  # ═══════════════════════════════════════════════════════════════════════════
202
- if ($sessionName -and $sessionName -ne "unknown-session" -and $userId) {
119
+ $configFile = Join-Path $EkkosConfigDir "config.json"
120
+ if ((Test-Path $configFile) -and $sessionName -ne "unknown-session") {
203
121
  try {
204
- $projectPath = ((Get-Location).Path) -replace '\\', '/'
205
- $pendingSession = if ($env:EKKOS_PENDING_SESSION) { $env:EKKOS_PENDING_SESSION } else { "_pending" }
206
-
207
- $bindBody = @{
208
- userId = $userId
209
- realSession = $sessionName
210
- projectPath = $projectPath
211
- pendingSession = $pendingSession
212
- } | ConvertTo-Json -Depth 10 -Compress
122
+ $config = Get-Content $configFile -Raw | ConvertFrom-Json
123
+ $userId = $config.userId
124
+ $authToken = if ($config.hookApiKey) { $config.hookApiKey } else { $config.apiKey }
213
125
 
214
- Start-Job -ScriptBlock {
215
- param($body, $apiUrl)
216
- try {
217
- Invoke-RestMethod -Uri "$apiUrl/proxy/session/bind" `
126
+ if ($userId -and $authToken) {
127
+ $projectPath = (Get-Location).Path
128
+ $pendingSession = if ($env:EKKOS_PENDING_SESSION) { $env:EKKOS_PENDING_SESSION } else { "_pending" }
129
+
130
+ $projectPath = $projectPath -replace '\\', '/'
131
+ $bindBody = @{
132
+ userId = $userId
133
+ realSession = $sessionName
134
+ projectPath = $projectPath
135
+ pendingSession = $pendingSession
136
+ } | ConvertTo-Json -Depth 10
137
+
138
+ Start-Job -ScriptBlock {
139
+ param($body, $token)
140
+ Invoke-RestMethod -Uri "https://mcp.ekkos.dev/proxy/session/bind" `
218
141
  -Method POST `
219
142
  -Headers @{ "Content-Type" = "application/json" } `
220
- -Body ([System.Text.Encoding]::UTF8.GetBytes($body)) `
221
- -TimeoutSec 2 `
222
- -ErrorAction SilentlyContinue | Out-Null
223
- } catch {}
224
- } -ArgumentList $bindBody, $memoryApiUrl | Out-Null
143
+ -Body ([System.Text.Encoding]::UTF8.GetBytes($body)) -ErrorAction SilentlyContinue | Out-Null
144
+ } -ArgumentList $bindBody, $authToken | Out-Null
145
+ }
225
146
  } catch {}
226
147
  }
227
148
 
228
- # ═══════════════════════════════════════════════════════════════════════════
229
- # EVICTION: Handled by IPC (In-Place Progressive Compression) in the proxy.
230
- # No hook-side eviction needed - passthrough is default for cache stability.
231
- # ═══════════════════════════════════════════════════════════════════════════
232
-
233
149
  # ═══════════════════════════════════════════════════════════════════════════
234
150
  # LOCAL CACHE: ACK turn to mark as synced
235
151
  # Per v1.2 ADDENDUM: Pass instanceId for namespacing
@@ -253,13 +169,15 @@ $isInterrupted = $false
253
169
  $stopReason = ""
254
170
  if ($inputJson) {
255
171
  try {
256
- $stopParsed = $inputJson | ConvertFrom-Json
257
- $isInterrupted = $stopParsed.interrupted -eq $true
258
- $stopReason = $stopParsed.stop_reason
172
+ $stopInput = $inputJson | ConvertFrom-Json
173
+ $isInterrupted = $stopInput.interrupted -eq $true
174
+ $stopReason = $stopInput.stop_reason
259
175
  } catch {}
260
176
  }
261
177
 
262
178
  if ($isInterrupted -or $stopReason -eq "user_cancelled" -or $stopReason -eq "interrupted") {
179
+ if (Test-Path $stateFile) { Remove-Item $stateFile -Force }
180
+ Write-Output "ekkOS session ended (interrupted)"
263
181
  exit 0
264
182
  }
265
183
 
@@ -267,6 +185,14 @@ if ($isInterrupted -or $stopReason -eq "user_cancelled" -or $stopReason -eq "int
267
185
  # EXTRACT CONVERSATION FROM TRANSCRIPT
268
186
  # Mirrors stop.sh: Extract last user query, assistant response, file changes
269
187
  # ═══════════════════════════════════════════════════════════════════════════
188
+ $transcriptPath = ""
189
+ if ($inputJson) {
190
+ try {
191
+ $stopInput2 = $inputJson | ConvertFrom-Json
192
+ $transcriptPath = $stopInput2.transcript_path
193
+ } catch {}
194
+ }
195
+
270
196
  $lastUser = ""
271
197
  $lastAssistant = ""
272
198
  $fileChangesJson = "[]"
@@ -302,7 +228,6 @@ for (let i = entries.length - 1; i >= 0; i--) {
302
228
  const parts = content.map(c => {
303
229
  if (c.type === 'text') return c.text;
304
230
  if (c.type === 'tool_use') return '[TOOL: ' + c.name + ']';
305
- if (c.type === 'thinking') return '[THINKING]' + (c.thinking || c.text || '') + '[/THINKING]';
306
231
  return '';
307
232
  }).filter(Boolean);
308
233
  lastAssistant = parts.join('\n'); break;
@@ -332,49 +257,7 @@ console.log(JSON.stringify({
332
257
  $lastUser = $parsed.user
333
258
  $lastAssistant = $parsed.assistant
334
259
  $fileChangesJson = ($parsed.fileChanges | ConvertTo-Json -Depth 10 -Compress)
335
- if (-not $fileChangesJson -or $fileChangesJson -eq "null") { $fileChangesJson = "[]" }
336
- }
337
- } catch {}
338
- }
339
-
340
- if (-not $lastUser -or $lastUser -match '\[Request interrupted') {
341
- exit 0
342
- }
343
-
344
- # ═══════════════════════════════════════════════════════════════════════════
345
- # Extract tools_used and files_referenced from file changes
346
- # ═══════════════════════════════════════════════════════════════════════════
347
- $toolsUsed = @()
348
- $filesReferenced = @()
349
-
350
- if ($parsed -and $parsed.fileChanges) {
351
- try {
352
- $toolsUsed = @($parsed.fileChanges | ForEach-Object { $_.tool } | Where-Object { $_ } | Select-Object -Unique)
353
- $filesReferenced = @($parsed.fileChanges | ForEach-Object { $_.path } | Where-Object { $_ } | Select-Object -Unique)
354
- } catch {
355
- $toolsUsed = @()
356
- $filesReferenced = @()
357
- }
358
- }
359
-
360
- # ═══════════════════════════════════════════════════════════════════════════
361
- # Token breakdown from tokenizer script
362
- # ═══════════════════════════════════════════════════════════════════════════
363
- $totalTokens = 0
364
- $inputTokens = 0
365
- $cacheReadTokens = 0
366
- $cacheCreationTokens = 0
367
-
368
- $tokenizerScript = Join-Path $ScriptDir "lib\count-tokens.cjs"
369
- if ($transcriptPath -and (Test-Path $transcriptPath) -and (Test-Path $tokenizerScript)) {
370
- try {
371
- $tokenJson = & node $tokenizerScript $transcriptPath --json 2>$null
372
- if ($tokenJson) {
373
- $tokenData = $tokenJson | ConvertFrom-Json
374
- if ($tokenData.total_tokens) { $totalTokens = [int]$tokenData.total_tokens }
375
- if ($tokenData.input_tokens) { $inputTokens = [int]$tokenData.input_tokens }
376
- if ($tokenData.cache_read_tokens) { $cacheReadTokens = [int]$tokenData.cache_read_tokens }
377
- if ($tokenData.cache_creation_tokens) { $cacheCreationTokens = [int]$tokenData.cache_creation_tokens }
260
+ if (-not $fileChangesJson) { $fileChangesJson = "[]" }
378
261
  }
379
262
  } catch {}
380
263
  }
@@ -384,98 +267,69 @@ if ($transcriptPath -and (Test-Path $transcriptPath) -and (Test-Path $tokenizerS
384
267
  # Mirrors stop.sh dual-write at lines 271-356
385
268
  # ═══════════════════════════════════════════════════════════════════════════
386
269
  if ($lastUser -and $lastAssistant -and $authToken) {
270
+ $modelUsed = "claude-sonnet-4-5"
271
+ if ($inputJson) {
272
+ try {
273
+ $stopInput3 = $inputJson | ConvertFrom-Json
274
+ if ($stopInput3.model) { $modelUsed = $stopInput3.model }
275
+ } catch {}
276
+ }
277
+
387
278
  $timestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
388
279
  $projectPath = ((Get-Location).Path) -replace '\\', '/'
389
280
 
390
- # Prepare tools_used and files_referenced as JSON strings for passing into jobs
391
- $toolsUsedJson = ($toolsUsed | ConvertTo-Json -Depth 5 -Compress)
392
- if (-not $toolsUsedJson -or $toolsUsedJson -eq "null") { $toolsUsedJson = "[]" }
393
- # Single-element arrays lose their array wrapper in PowerShell ConvertTo-Json
394
- if ($toolsUsed.Count -eq 1) { $toolsUsedJson = "[$toolsUsedJson]" }
395
-
396
- $filesRefJson = ($filesReferenced | ConvertTo-Json -Depth 5 -Compress)
397
- if (-not $filesRefJson -or $filesRefJson -eq "null") { $filesRefJson = "[]" }
398
- if ($filesReferenced.Count -eq 1) { $filesRefJson = "[$filesRefJson]" }
399
-
400
281
  # 1. WORKING SESSIONS (Redis)
401
282
  Start-Job -ScriptBlock {
402
- param($token, $apiUrl, $sessionName, $turnNum, $userQuery, $agentResponse, $model, $toolsJson, $filesJson, $totalTok, $inputTok, $cacheReadTok, $cacheCreateTok)
403
- try {
404
- $truncated = $agentResponse
405
- if ($agentResponse.Length -gt 50000) {
406
- $truncated = $agentResponse.Substring(0, 50000)
407
- }
408
- $body = @{
409
- session_name = $sessionName
410
- turn_number = [int]$turnNum
411
- user_query = $userQuery
412
- agent_response = $truncated
413
- model = $model
414
- tools_used = @(($toolsJson | ConvertFrom-Json))
415
- files_referenced = @(($filesJson | ConvertFrom-Json))
416
- total_context_tokens = [int]$totalTok
417
- token_breakdown = @{
418
- input_tokens = [int]$inputTok
419
- cache_read_tokens = [int]$cacheReadTok
420
- cache_creation_tokens = [int]$cacheCreateTok
421
- }
422
- } | ConvertTo-Json -Depth 10 -Compress
423
-
424
- Invoke-RestMethod -Uri "$apiUrl/api/v1/working/turn" `
425
- -Method POST `
426
- -Headers @{ Authorization = "Bearer $token"; "Content-Type" = "application/json" } `
427
- -Body ([System.Text.Encoding]::UTF8.GetBytes($body)) `
428
- -TimeoutSec 5 `
429
- -ErrorAction SilentlyContinue | Out-Null
430
- } catch {}
431
- } -ArgumentList $authToken, $memoryApiUrl, $sessionName, $turn, $lastUser, $lastAssistant, $modelUsed, $toolsUsedJson, $filesRefJson, $totalTokens, $inputTokens, $cacheReadTokens, $cacheCreationTokens | Out-Null
283
+ param($token, $sessionName, $turnNum, $userQuery, $agentResponse, $model)
284
+ $body = @{
285
+ session_name = $sessionName
286
+ turn_number = $turnNum
287
+ user_query = $userQuery
288
+ agent_response = $agentResponse.Substring(0, [Math]::Min(50000, $agentResponse.Length))
289
+ model = $model
290
+ tools_used = @()
291
+ files_referenced = @()
292
+ } | ConvertTo-Json -Depth 10
293
+
294
+ Invoke-RestMethod -Uri "https://mcp.ekkos.dev/api/v1/working/turn" `
295
+ -Method POST `
296
+ -Headers @{ Authorization = "Bearer $token"; "Content-Type" = "application/json" } `
297
+ -Body ([System.Text.Encoding]::UTF8.GetBytes($body)) `
298
+ -ErrorAction SilentlyContinue | Out-Null
299
+ } -ArgumentList $authToken, $sessionName, $turn, $lastUser, $lastAssistant, $modelUsed | Out-Null
432
300
 
433
301
  # 2. EPISODIC MEMORY (Supabase)
434
302
  Start-Job -ScriptBlock {
435
- param($token, $apiUrl, $userQuery, $agentResponse, $sessionId, $uid, $fileChanges, $model, $ts, $turnNum, $sessionName)
436
- try {
437
- $fcArray = @()
438
- try { $fcArray = @(($fileChanges | ConvertFrom-Json)) } catch { $fcArray = @() }
439
-
440
- $body = @{
441
- user_query = $userQuery
442
- assistant_response = $agentResponse
443
- session_id = $sessionId
444
- user_id = if ($uid) { $uid } else { "system" }
445
- file_changes = $fcArray
446
- metadata = @{
447
- source = "claude-code"
448
- model_used = $model
449
- captured_at = $ts
450
- turn_number = [int]$turnNum
451
- session_name = $sessionName
452
- minimal_hook = $true
453
- }
454
- } | ConvertTo-Json -Depth 10 -Compress
455
-
456
- Invoke-RestMethod -Uri "$apiUrl/api/v1/memory/capture" `
457
- -Method POST `
458
- -Headers @{ Authorization = "Bearer $token"; "Content-Type" = "application/json" } `
459
- -Body ([System.Text.Encoding]::UTF8.GetBytes($body)) `
460
- -TimeoutSec 5 `
461
- -ErrorAction SilentlyContinue | Out-Null
462
- } catch {}
463
- } -ArgumentList $authToken, $memoryApiUrl, $lastUser, $lastAssistant, $rawSessionId, $userId, $fileChangesJson, $modelUsed, $timestamp, $turn, $sessionName | Out-Null
303
+ param($token, $userQuery, $agentResponse, $sessionId, $userId, $fileChanges, $model, $ts, $turnNum, $sessionName)
304
+ $body = @{
305
+ user_query = $userQuery
306
+ assistant_response = $agentResponse
307
+ session_id = $sessionId
308
+ user_id = if ($userId) { $userId } else { "system" }
309
+ file_changes = @()
310
+ metadata = @{
311
+ source = "claude-code"
312
+ model_used = $model
313
+ captured_at = $ts
314
+ turn_number = $turnNum
315
+ session_name = $sessionName
316
+ minimal_hook = $true
317
+ }
318
+ } | ConvertTo-Json -Depth 10
319
+
320
+ Invoke-RestMethod -Uri "https://mcp.ekkos.dev/api/v1/memory/capture" `
321
+ -Method POST `
322
+ -Headers @{ Authorization = "Bearer $token"; "Content-Type" = "application/json" } `
323
+ -Body ([System.Text.Encoding]::UTF8.GetBytes($body)) `
324
+ -ErrorAction SilentlyContinue | Out-Null
325
+ } -ArgumentList $authToken, $lastUser, $lastAssistant, $rawSessionId, $userId, $fileChangesJson, $modelUsed, $timestamp, $turn, $sessionName | Out-Null
464
326
  }
465
327
 
466
328
  # ═══════════════════════════════════════════════════════════════════════════
467
- # Update local .ekkos/current-focus.md (if exists) - SILENT
329
+ # CLEAN UP STATE FILES
468
330
  # ═══════════════════════════════════════════════════════════════════════════
469
- $ekkosDir = Join-Path $ProjectRoot ".ekkos"
470
- if ((Test-Path $ekkosDir) -and $lastUser) {
471
- try {
472
- $focusFile = Join-Path $ekkosDir "current-focus.md"
473
- $taskSummary = $lastUser.Substring(0, [Math]::Min(100, $lastUser.Length))
474
- if ($lastUser.Length -gt 100) { $taskSummary += "..." }
475
- $focusTimestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
476
- $focusContent = "---`nlast_updated: $focusTimestamp`nsession_id: $rawSessionId`n---`n`n# Current Focus`n$taskSummary"
477
- Set-Content -Path $focusFile -Value $focusContent -Force
478
- } catch {}
331
+ if (Test-Path $stateFile) {
332
+ Remove-Item $stateFile -Force
479
333
  }
480
334
 
481
- exit 0
335
+ Write-Output "ekkOS session ended"
@@ -161,7 +161,7 @@ fi
161
161
 
162
162
  [ -z "$AUTH_TOKEN" ] && exit 0
163
163
 
164
- MEMORY_API_URL="https://api.ekkos.dev"
164
+ MEMORY_API_URL="https://mcp.ekkos.dev"
165
165
 
166
166
  # ═══════════════════════════════════════════════════════════════════════════
167
167
  # SESSION BINDING: Bridge _pending → real session name for proxy eviction