@ekkos/cli 1.0.34 → 1.0.36

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.
Files changed (44) hide show
  1. package/dist/capture/jsonl-rewriter.js +72 -7
  2. package/dist/commands/dashboard.js +186 -557
  3. package/dist/commands/init.js +3 -15
  4. package/dist/commands/run.js +222 -256
  5. package/dist/commands/setup.js +0 -47
  6. package/dist/commands/swarm-dashboard.js +4 -13
  7. package/dist/deploy/instructions.d.ts +2 -5
  8. package/dist/deploy/instructions.js +8 -11
  9. package/dist/deploy/settings.js +21 -15
  10. package/dist/deploy/skills.d.ts +0 -8
  11. package/dist/deploy/skills.js +0 -26
  12. package/dist/index.js +2 -2
  13. package/dist/lib/usage-parser.js +1 -2
  14. package/dist/utils/platform.d.ts +0 -3
  15. package/dist/utils/platform.js +1 -4
  16. package/dist/utils/session-binding.d.ts +1 -1
  17. package/dist/utils/session-binding.js +2 -3
  18. package/package.json +1 -1
  19. package/templates/agents/README.md +182 -0
  20. package/templates/agents/code-reviewer.md +166 -0
  21. package/templates/agents/debug-detective.md +169 -0
  22. package/templates/agents/ekkOS_Vercel.md +99 -0
  23. package/templates/agents/extension-manager.md +229 -0
  24. package/templates/agents/git-companion.md +185 -0
  25. package/templates/agents/github-test-agent.md +321 -0
  26. package/templates/agents/railway-manager.md +179 -0
  27. package/templates/hooks/assistant-response.ps1 +26 -94
  28. package/templates/hooks/lib/count-tokens.cjs +0 -0
  29. package/templates/hooks/lib/ekkos-reminders.sh +0 -0
  30. package/templates/hooks/session-start.ps1 +224 -61
  31. package/templates/hooks/session-start.sh +1 -1
  32. package/templates/hooks/stop.ps1 +249 -103
  33. package/templates/hooks/stop.sh +1 -1
  34. package/templates/hooks/user-prompt-submit.ps1 +519 -129
  35. package/templates/hooks/user-prompt-submit.sh +2 -2
  36. package/templates/plan-template.md +0 -0
  37. package/templates/spec-template.md +0 -0
  38. package/templates/windsurf-hooks/before-submit-prompt.sh +238 -0
  39. package/templates/windsurf-hooks/install.sh +0 -0
  40. package/templates/windsurf-hooks/lib/contract.sh +0 -0
  41. package/templates/windsurf-hooks/post-cascade-response.sh +0 -0
  42. package/templates/windsurf-hooks/pre-user-prompt.sh +0 -0
  43. package/templates/windsurf-skills/ekkos-memory/SKILL.md +219 -0
  44. package/README.md +0 -57
@@ -3,7 +3,12 @@
3
3
  # MANAGED BY ekkos-connect - DO NOT EDIT DIRECTLY
4
4
  # EKKOS_MANAGED=1
5
5
  # EKKOS_MANIFEST_SHA256=<computed-at-build>
6
- # EKKOS_TEMPLATE_VERSION=1.0.0
6
+ # EKKOS_TEMPLATE_VERSION=2.0.0
7
+ #
8
+ # ZERO USER ACTION NEEDED:
9
+ # 1. Tracks turn number and context size
10
+ # 2. Detects when compaction happened (context dropped from high to low)
11
+ # 3. AUTO-INJECTS restored context - user just keeps working
7
12
  #
8
13
  # Per ekkOS Onboarding Spec v1.2 FINAL + ADDENDUM:
9
14
  # - All persisted records MUST include: instanceId, sessionId, sessionName
@@ -12,18 +17,27 @@
12
17
 
13
18
  $ErrorActionPreference = "SilentlyContinue"
14
19
 
20
+ $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
21
+ $ProjectRoot = Split-Path -Parent (Split-Path -Parent $ScriptDir)
22
+
15
23
  # ═══════════════════════════════════════════════════════════════════════════
16
24
  # CONFIG PATHS - No hardcoded word arrays per spec v1.2 Addendum
17
25
  # ═══════════════════════════════════════════════════════════════════════════
18
26
  $EkkosConfigDir = if ($env:EKKOS_CONFIG_DIR) { $env:EKKOS_CONFIG_DIR } else { "$env:USERPROFILE\.ekkos" }
19
27
  $SessionWordsJson = "$EkkosConfigDir\session-words.json"
20
28
  $SessionWordsDefault = "$EkkosConfigDir\.defaults\session-words.json"
29
+ $JsonParseHelper = "$EkkosConfigDir\.helpers\json-parse.cjs"
21
30
 
22
31
  # ═══════════════════════════════════════════════════════════════════════════
23
32
  # INSTANCE ID - Multi-session isolation per v1.2 ADDENDUM
24
33
  # ═══════════════════════════════════════════════════════════════════════════
25
34
  $EkkosInstanceId = if ($env:EKKOS_INSTANCE_ID) { $env:EKKOS_INSTANCE_ID } else { "default" }
26
35
 
36
+ # ═══════════════════════════════════════════════════════════════════════════
37
+ # API URL
38
+ # ═══════════════════════════════════════════════════════════════════════════
39
+ $MemoryApiUrl = "https://api.ekkos.dev"
40
+
27
41
  # ═══════════════════════════════════════════════════════════════════════════
28
42
  # Load session words from JSON file - NO HARDCODED ARRAYS
29
43
  # ═══════════════════════════════════════════════════════════════════════════
@@ -48,7 +62,9 @@ function Load-SessionWords {
48
62
  }
49
63
  }
50
64
 
65
+ # ═══════════════════════════════════════════════════════════════════════════
51
66
  # Read input from stdin
67
+ # ═══════════════════════════════════════════════════════════════════════════
52
68
  $inputJson = [Console]::In.ReadToEnd()
53
69
  if (-not $inputJson) { exit 0 }
54
70
 
@@ -65,15 +81,24 @@ if (-not $userQuery) { exit 0 }
65
81
 
66
82
  $rawSessionId = $input.session_id
67
83
  if (-not $rawSessionId -or $rawSessionId -eq "null") { $rawSessionId = "unknown" }
84
+ $transcriptPath = $input.transcript_path
68
85
 
69
86
  # Fallback: read session_id from saved state
70
- if ($rawSessionId -eq "unknown") {
87
+ if ($rawSessionId -eq "unknown" -or $rawSessionId -eq "null" -or [string]::IsNullOrEmpty($rawSessionId)) {
71
88
  $stateFile = Join-Path $env:USERPROFILE ".claude\state\current-session.json"
72
- if (Test-Path $stateFile) {
89
+ if ((Test-Path $stateFile) -and (Test-Path $JsonParseHelper)) {
73
90
  try {
74
- $state = Get-Content $stateFile -Raw | ConvertFrom-Json
75
- $rawSessionId = $state.session_id
76
- } catch {}
91
+ $rawSessionId = node $JsonParseHelper $stateFile '.session_id' 2>$null
92
+ if (-not $rawSessionId) { $rawSessionId = "unknown" }
93
+ } catch {
94
+ $rawSessionId = "unknown"
95
+ }
96
+ }
97
+
98
+ # VSCode extension fallback: Extract session ID from transcript path
99
+ # Path format: ~/.claude/projects/<project>/<session-uuid>.jsonl
100
+ if ($rawSessionId -eq "unknown" -and $transcriptPath -and (Test-Path $transcriptPath)) {
101
+ $rawSessionId = [System.IO.Path]::GetFileNameWithoutExtension($transcriptPath)
77
102
  }
78
103
  }
79
104
 
@@ -108,6 +133,20 @@ if ($queryLower -match '(sql|query|supabase|prisma|database|table|column|select
108
133
  $skillReminders += "SCHEMA REQUIRED: Call ekkOS_GetSchema for correct field names"
109
134
  }
110
135
 
136
+ # ═══════════════════════════════════════════════════════════════════════════
137
+ # Load auth - Read from config.json
138
+ # ═══════════════════════════════════════════════════════════════════════════
139
+ $EkkosConfig = Join-Path $EkkosConfigDir "config.json"
140
+ $authToken = ""
141
+ if ((Test-Path $EkkosConfig) -and (Test-Path $JsonParseHelper)) {
142
+ try {
143
+ $authToken = node $JsonParseHelper $EkkosConfig '.hookApiKey' 2>$null
144
+ if (-not $authToken) {
145
+ $authToken = node $JsonParseHelper $EkkosConfig '.apiKey' 2>$null
146
+ }
147
+ } catch {}
148
+ }
149
+
111
150
  # ═══════════════════════════════════════════════════════════════════════════
112
151
  # SESSION NAME - Resolve early so it's available for all downstream use
113
152
  # ═══════════════════════════════════════════════════════════════════════════
@@ -137,158 +176,282 @@ function Convert-UuidToWords {
137
176
  }
138
177
  }
139
178
 
140
- $sessionName = Convert-UuidToWords $rawSessionId
179
+ $sessionId = $rawSessionId
180
+ if (-not $sessionId -or $sessionId -eq "unknown" -or $sessionId -eq "null") {
181
+ exit 0
182
+ }
183
+
184
+ # Check if SESSION_ID is a UUID (8-4-4-4-12 format)
185
+ $isUuid = $sessionId -match '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
186
+
187
+ $sessionName = ""
188
+ if ($isUuid) {
189
+ $sessionName = Convert-UuidToWords $sessionId
190
+ } else {
191
+ $sessionName = $sessionId
192
+ }
141
193
 
142
194
  # ═══════════════════════════════════════════════════════════════════════════
143
- # PROXY SESSION BIND: _pending → real session name (fires every turn)
144
- # Mirrors bash user-prompt-submit.sh lines 319-338.
145
- # No PTY on Windows so run.ts can't detect session name — hook must bind it.
195
+ # STATE DIRECTORIES
146
196
  # ═══════════════════════════════════════════════════════════════════════════
147
- if ($sessionName -ne "unknown-session" -and $rawSessionId -ne "unknown") {
148
- $configFile = Join-Path $EkkosConfigDir "config.json"
149
- if (Test-Path $configFile) {
150
- try {
151
- $config = Get-Content $configFile -Raw | ConvertFrom-Json
152
- $userId = $config.userId
153
- if ($userId) {
154
- $projectPath = if ($env:PWD) { $env:PWD } else { (Get-Location).Path }
155
- $pendingSession = if ($env:EKKOS_PENDING_SESSION) { $env:EKKOS_PENDING_SESSION } else { "_pending" }
156
- $projectPath = $projectPath -replace '\\', '/'
157
- $bindBody = @{
158
- userId = $userId
159
- realSession = $sessionName
160
- projectPath = $projectPath
161
- pendingSession = $pendingSession
162
- } | ConvertTo-Json -Depth 10 -Compress
163
-
164
- Start-Job -ScriptBlock {
165
- param($body)
166
- Invoke-RestMethod -Uri "https://mcp.ekkos.dev/proxy/session/bind" `
167
- -Method POST `
168
- -Headers @{ "Content-Type" = "application/json" } `
169
- -Body ([System.Text.Encoding]::UTF8.GetBytes($body)) -ErrorAction SilentlyContinue | Out-Null
170
- } -ArgumentList $bindBody | Out-Null
171
- }
172
- } catch {}
173
- }
197
+ $stateDir = Join-Path $ProjectRoot ".claude\state"
198
+ if (-not (Test-Path $stateDir)) {
199
+ New-Item -ItemType Directory -Path $stateDir -Force | Out-Null
174
200
  }
201
+ $sessionFile = Join-Path $stateDir "current-session.json"
202
+
203
+ $projectSessionDir = Join-Path $stateDir "sessions"
204
+ if (-not (Test-Path $projectSessionDir)) {
205
+ New-Item -ItemType Directory -Path $projectSessionDir -Force | Out-Null
206
+ }
207
+ $turnCounterFile = Join-Path $projectSessionDir "$sessionId.turn"
208
+ $contextSizeFile = Join-Path $projectSessionDir "$sessionId.context"
175
209
 
176
210
  # ═══════════════════════════════════════════════════════════════════════════
177
- # SESSION CURRENT: Update Redis with current session name
211
+ # Turn counter - TRANSCRIPT-BASED (source of truth)
212
+ # Count "type":"user" entries in transcript JSONL
178
213
  # ═══════════════════════════════════════════════════════════════════════════
179
- if ($sessionName -ne "unknown-session" -and $rawSessionId -ne "unknown") {
180
- $configFile2 = Join-Path $EkkosConfigDir "config.json"
181
- if (Test-Path $configFile2) {
182
- try {
183
- $config2 = Get-Content $configFile2 -Raw | ConvertFrom-Json
184
- $sessionToken = $config2.hookApiKey
185
- if (-not $sessionToken) { $sessionToken = $config2.apiKey }
186
- if ($sessionToken) {
187
- $sessionBody = @{ session_name = $sessionName } | ConvertTo-Json -Depth 10
188
- Start-Job -ScriptBlock {
189
- param($body, $token)
190
- Invoke-RestMethod -Uri "https://mcp.ekkos.dev/api/v1/working/session/current" `
191
- -Method POST `
192
- -Headers @{ Authorization = "Bearer $token"; "Content-Type" = "application/json" } `
193
- -Body ([System.Text.Encoding]::UTF8.GetBytes($body)) -ErrorAction SilentlyContinue | Out-Null
194
- } -ArgumentList $sessionBody, $sessionToken | Out-Null
195
- }
196
- } catch {}
214
+ $turnNumber = 1
215
+ if ($transcriptPath -and (Test-Path $transcriptPath)) {
216
+ try {
217
+ $turnNumber = node -e "
218
+ const fs = require('fs');
219
+ const lines = fs.readFileSync(process.argv[1], 'utf8').split('\n').filter(Boolean);
220
+ let count = 0;
221
+ for (const line of lines) {
222
+ try { if (JSON.parse(line).type === 'user') count++; } catch(e) {}
223
+ }
224
+ console.log(count || 1);
225
+ " $transcriptPath 2>$null
226
+ $turnNumber = [int]$turnNumber
227
+ if ($turnNumber -eq 0) { $turnNumber = 1 }
228
+ } catch {
229
+ $turnNumber = 1
230
+ }
231
+ }
232
+
233
+ # Detect post-clear: saved count higher than transcript means /clear happened
234
+ $savedTurnCount = 0
235
+ if (Test-Path $turnCounterFile) {
236
+ try {
237
+ $savedTurnCount = [int](Get-Content $turnCounterFile -Raw).Trim()
238
+ } catch {
239
+ $savedTurnCount = 0
197
240
  }
198
241
  }
199
242
 
243
+ $postClearDetected = $false
244
+ if ($savedTurnCount -gt $turnNumber) {
245
+ $postClearDetected = $true
246
+ $turnNumber = $savedTurnCount + 1
247
+ }
248
+
249
+ # Save current turn count
250
+ Set-Content -Path $turnCounterFile -Value $turnNumber -Force
251
+
200
252
  # ═══════════════════════════════════════════════════════════════════════════
201
- # TURN TRACKING & STATE MANAGEMENT
253
+ # Context size tracking - Uses tokenizer script (single source)
202
254
  # ═══════════════════════════════════════════════════════════════════════════
203
- $stateDir = Join-Path $env:USERPROFILE ".claude\state"
204
- if (-not (Test-Path $stateDir)) {
205
- New-Item -ItemType Directory -Path $stateDir -Force | Out-Null
255
+ $prevContextPercent = 0
256
+ if (Test-Path $contextSizeFile) {
257
+ try {
258
+ $prevContextPercent = [int](Get-Content $contextSizeFile -Raw).Trim()
259
+ } catch {
260
+ $prevContextPercent = 0
261
+ }
206
262
  }
207
263
 
208
- $stateFile = Join-Path $stateDir "hook-state.json"
209
- $turn = 0
210
- $contextPercent = ""
264
+ $tokenPercent = 0
265
+ $ipcPercent = 0
266
+ $maxTokens = 200000
267
+ $tokenizerScript = Join-Path $ScriptDir "lib\count-tokens.cjs"
211
268
 
212
- if (Test-Path $stateFile) {
269
+ if ($transcriptPath -and (Test-Path $transcriptPath) -and (Test-Path $tokenizerScript)) {
213
270
  try {
214
- $hookState = Get-Content $stateFile -Raw | ConvertFrom-Json
215
- # Only continue incrementing if this state belongs to the SAME session.
216
- # If session changed, reset turn counter to 0.
217
- if ($hookState.session_id -eq $rawSessionId) {
218
- $turn = [int]$hookState.turn + 1
219
- } else {
220
- $turn = 0
271
+ $tokenCount = node $tokenizerScript $transcriptPath 2>$null
272
+ if ($tokenCount -match '^\d+$' -and [int]$tokenCount -gt 0) {
273
+ $tokenPercent = [int]([int]$tokenCount * 100 / $maxTokens)
274
+ if ($tokenPercent -gt 100) { $tokenPercent = 100 }
275
+ # In proxy mode, IPC compresses ~65-70% - show estimated post-compression %
276
+ $ipcPercent = [int]($tokenPercent * 30 / 100)
277
+ if ($ipcPercent -lt 1) { $ipcPercent = 1 }
221
278
  }
222
- } catch {
223
- $turn = 0
224
- }
279
+ } catch {}
225
280
  }
226
281
 
227
- # Save updated state
228
- $newState = @{
229
- turn = $turn
230
- session_id = $rawSessionId
231
- last_query = $userQuery.Substring(0, [Math]::Min(100, $userQuery.Length))
232
- timestamp = (Get-Date).ToString("o")
233
- } | ConvertTo-Json -Depth 10
282
+ Set-Content -Path $contextSizeFile -Value $tokenPercent -Force
234
283
 
235
- Set-Content -Path $stateFile -Value $newState -Force
284
+ # ═══════════════════════════════════════════════════════════════════════════
285
+ # COLORS
286
+ # ═══════════════════════════════════════════════════════════════════════════
287
+ $esc = [char]27
288
+ $CYAN = "${esc}[0;36m"
289
+ $GREEN = "${esc}[0;32m"
290
+ $MAGENTA = "${esc}[0;35m"
291
+ $DIM = "${esc}[2m"
292
+ $BOLD = "${esc}[1m"
293
+ $RESET = "${esc}[0m"
294
+
295
+ $timestamp = (Get-Date).ToString("yyyy-MM-dd hh:mm:ss tt") + " EST"
296
+
297
+ # ═══════════════════════════════════════════════════════════════════════════
298
+ # SINGLE SOURCE OF TRUTH: Update ALL session tracking systems
299
+ # ═══════════════════════════════════════════════════════════════════════════
300
+ if ($sessionName -and $sessionName -ne "unknown-session") {
301
+ $utcTimestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
302
+ $projectPath = if ($env:PWD) { $env:PWD } else { (Get-Location).Path }
303
+ $projectPathUnix = $projectPath -replace '\\', '/'
304
+
305
+ # 1. Project-level state file
306
+ try {
307
+ $stateJson = @{
308
+ session_id = $sessionId
309
+ session_name = $sessionName
310
+ timestamp = $utcTimestamp
311
+ } | ConvertTo-Json -Depth 10 -Compress
312
+ Set-Content -Path $sessionFile -Value $stateJson -Force
313
+ } catch {}
314
+
315
+ # 2. Global ekkOS state (for extension LOCAL-FIRST read)
316
+ $ekkosGlobalState = Join-Path $EkkosConfigDir "current-session.json"
317
+ try {
318
+ if (-not (Test-Path $EkkosConfigDir)) {
319
+ New-Item -ItemType Directory -Path $EkkosConfigDir -Force | Out-Null
320
+ }
321
+ $globalJson = @{
322
+ session_id = $sessionId
323
+ session_name = $sessionName
324
+ project = $ProjectRoot
325
+ timestamp = $utcTimestamp
326
+ } | ConvertTo-Json -Depth 10 -Compress
327
+ Set-Content -Path $ekkosGlobalState -Value $globalJson -Force
328
+ } catch {}
329
+
330
+ # 3. CLI state file
331
+ $cliStateFile = Join-Path $EkkosConfigDir "state.json"
332
+ try {
333
+ $cliJson = @{
334
+ sessionId = $sessionId
335
+ sessionName = $sessionName
336
+ turnNumber = $turnNumber
337
+ lastUpdated = $utcTimestamp
338
+ projectPath = $ProjectRoot
339
+ } | ConvertTo-Json -Depth 10 -Compress
340
+ Set-Content -Path $cliStateFile -Value $cliJson -Force
341
+ } catch {}
342
+
343
+ # 4. Multi-session tracking - upsert into active-sessions.json
344
+ $activeSessionsFile = Join-Path $EkkosConfigDir "active-sessions.json"
345
+ try {
346
+ node -e "
347
+ const fs = require('fs');
348
+ const sid = process.argv[1], sname = process.argv[2], ts = process.argv[3], proj = process.argv[4];
349
+ try {
350
+ let sessions = [];
351
+ const filePath = process.argv[5];
352
+ if (fs.existsSync(filePath)) {
353
+ sessions = JSON.parse(fs.readFileSync(filePath, 'utf8') || '[]');
354
+ }
355
+ const idx = sessions.findIndex(s => s.sessionId === sid);
356
+ if (idx >= 0) {
357
+ sessions[idx] = {...sessions[idx], sessionName: sname, lastHeartbeat: ts, projectPath: proj};
358
+ } else {
359
+ sessions.push({sessionId: sid, sessionName: sname, pid: 0, startedAt: ts, projectPath: proj, lastHeartbeat: ts});
360
+ }
361
+ fs.writeFileSync(filePath, JSON.stringify(sessions, null, 2));
362
+ } catch(e) {}
363
+ " $sessionId $sessionName $utcTimestamp $ProjectRoot $activeSessionsFile 2>$null
364
+ } catch {}
365
+
366
+ # 5. Update Redis via API (async)
367
+ if ($authToken) {
368
+ $sessionBody = @{ session_name = $sessionName } | ConvertTo-Json -Depth 10 -Compress
369
+ Start-Job -ScriptBlock {
370
+ param($body, $token, $apiUrl)
371
+ try {
372
+ Invoke-RestMethod -Uri "$apiUrl/api/v1/working/session/current" `
373
+ -Method POST `
374
+ -Headers @{ Authorization = "Bearer $token"; "Content-Type" = "application/json" } `
375
+ -Body ([System.Text.Encoding]::UTF8.GetBytes($body)) `
376
+ -TimeoutSec 3 -ErrorAction SilentlyContinue | Out-Null
377
+ } catch {}
378
+ } -ArgumentList $sessionBody, $authToken, $MemoryApiUrl | Out-Null
379
+ }
380
+
381
+ # 6. CRITICAL: Bind session name to proxy for R2 eviction paths
382
+ $ekkosUserId = ""
383
+ if ((Test-Path $EkkosConfig) -and (Test-Path $JsonParseHelper)) {
384
+ try {
385
+ $ekkosUserId = node $JsonParseHelper $EkkosConfig '.userId' 2>$null
386
+ } catch {}
387
+ }
388
+ if ($ekkosUserId -and $sessionName) {
389
+ $pendingSession = if ($env:EKKOS_PENDING_SESSION) { $env:EKKOS_PENDING_SESSION } else { "_pending" }
390
+ $bindBody = @{
391
+ userId = $ekkosUserId
392
+ realSession = $sessionName
393
+ projectPath = $projectPathUnix
394
+ pendingSession = $pendingSession
395
+ } | ConvertTo-Json -Depth 10 -Compress
396
+
397
+ Start-Job -ScriptBlock {
398
+ param($body, $apiUrl)
399
+ try {
400
+ Invoke-RestMethod -Uri "$apiUrl/proxy/session/bind" `
401
+ -Method POST `
402
+ -Headers @{ "Content-Type" = "application/json" } `
403
+ -Body ([System.Text.Encoding]::UTF8.GetBytes($body)) `
404
+ -TimeoutSec 3 -ErrorAction SilentlyContinue | Out-Null
405
+ } catch {}
406
+ } -ArgumentList $bindBody, $MemoryApiUrl | Out-Null
407
+ }
408
+ }
236
409
 
237
410
  # ═══════════════════════════════════════════════════════════════════════════
238
411
  # LOCAL CACHE: Tier 0 capture (async, non-blocking)
239
412
  # Per v1.2 ADDENDUM: Pass instanceId for namespacing
240
413
  # ═══════════════════════════════════════════════════════════════════════════
241
414
  $captureCmd = Get-Command "ekkos-capture" -ErrorAction SilentlyContinue
242
- if ($captureCmd -and $rawSessionId -ne "unknown") {
415
+ if ($captureCmd -and $sessionId -ne "unknown") {
243
416
  try {
244
- # NEW format: ekkos-capture user <instance_id> <session_id> <session_name> <turn_id> <query> [project_path]
245
417
  $queryBase64 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($userQuery))
246
- $projectRoot = if ($env:PWD) { $env:PWD } else { (Get-Location).Path }
418
+ $captureProjectRoot = if ($env:PWD) { $env:PWD } else { (Get-Location).Path }
247
419
 
248
420
  Start-Job -ScriptBlock {
249
- param($instanceId, $sessionId, $sessionName, $turnNum, $queryB64, $projectPath)
421
+ param($instanceId, $sid, $sname, $turnNum, $queryB64, $projPath)
250
422
  try {
251
423
  $decoded = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($queryB64))
252
- & ekkos-capture user $instanceId $sessionId $sessionName $turnNum $decoded $projectPath 2>&1 | Out-Null
424
+ & ekkos-capture user $instanceId $sid $sname $turnNum $decoded $projPath 2>&1 | Out-Null
253
425
  } catch {}
254
- } -ArgumentList $EkkosInstanceId, $rawSessionId, $sessionName, $turn, $queryBase64, $projectRoot | Out-Null
426
+ } -ArgumentList $EkkosInstanceId, $sessionId, $sessionName, $turnNumber, $queryBase64, $captureProjectRoot | Out-Null
255
427
  } catch {}
256
428
  }
257
429
 
258
430
  # ═══════════════════════════════════════════════════════════════════════════
259
431
  # WORKING MEMORY: Fast capture to API (async, non-blocking)
260
432
  # ═══════════════════════════════════════════════════════════════════════════
261
- $configFile = Join-Path $EkkosConfigDir "config.json"
262
- if (Test-Path $configFile) {
433
+ if ($authToken) {
263
434
  try {
264
- $config = Get-Content $configFile -Raw | ConvertFrom-Json
265
- $captureToken = $config.hookApiKey
266
- if (-not $captureToken) { $captureToken = $config.apiKey }
267
-
268
- if ($captureToken) {
269
- # Async capture using Start-Job (non-blocking)
270
- Start-Job -ScriptBlock {
271
- param($token, $instanceId, $sessionId, $sessionName, $turnNum, $query)
272
- $body = @{
273
- session_id = $sessionId
274
- session_name = $sessionName
275
- instance_id = $instanceId
276
- turn = $turnNum
277
- query = $query
278
- } | ConvertTo-Json -Depth 10
279
-
280
- Invoke-RestMethod -Uri "https://mcp.ekkos.dev/api/v1/working/fast-capture" `
435
+ Start-Job -ScriptBlock {
436
+ param($token, $instanceId, $sid, $sname, $turnNum, $query, $apiUrl)
437
+ $body = @{
438
+ session_id = $sid
439
+ session_name = $sname
440
+ instance_id = $instanceId
441
+ turn = $turnNum
442
+ query = $query
443
+ } | ConvertTo-Json -Depth 10
444
+ try {
445
+ Invoke-RestMethod -Uri "$apiUrl/api/v1/working/fast-capture" `
281
446
  -Method POST `
282
- -Headers @{ Authorization = "Bearer $token" } `
283
- -ContentType "application/json" `
284
- -Body ([System.Text.Encoding]::UTF8.GetBytes($body)) -ErrorAction SilentlyContinue
285
- } -ArgumentList $captureToken, $EkkosInstanceId, $rawSessionId, $sessionName, $turn, $userQuery | Out-Null
286
- }
447
+ -Headers @{ Authorization = "Bearer $token"; "Content-Type" = "application/json" } `
448
+ -Body ([System.Text.Encoding]::UTF8.GetBytes($body)) `
449
+ -TimeoutSec 3 -ErrorAction SilentlyContinue | Out-Null
450
+ } catch {}
451
+ } -ArgumentList $authToken, $EkkosInstanceId, $sessionId, $sessionName, $turnNumber, $userQuery, $MemoryApiUrl | Out-Null
287
452
  } catch {}
288
453
  }
289
454
 
290
- $timestamp = (Get-Date).ToString("yyyy-MM-dd hh:mm:ss tt") + " EST"
291
-
292
455
  # ═══════════════════════════════════════════════════════════════════════════
293
456
  # DASHBOARD HINT FILE: write session info for ekkos dashboard --wait-for-new
294
457
  # On Windows, active-sessions.json is never populated (hook PIDs are dead).
@@ -296,12 +459,12 @@ $timestamp = (Get-Date).ToString("yyyy-MM-dd hh:mm:ss tt") + " EST"
296
459
  # ═══════════════════════════════════════════════════════════════════════════
297
460
  if ($sessionName -ne "unknown-session") {
298
461
  try {
299
- $projectPath = if ($env:PWD) { $env:PWD } else { (Get-Location).Path }
462
+ $hintProjectPath = if ($env:PWD) { $env:PWD } else { (Get-Location).Path }
300
463
  $hintFile = Join-Path $EkkosConfigDir "hook-session-hint.json"
301
464
  $hint = @{
302
465
  sessionName = $sessionName
303
- sessionId = $rawSessionId
304
- projectPath = $projectPath
466
+ sessionId = $sessionId
467
+ projectPath = $hintProjectPath
305
468
  ts = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
306
469
  } | ConvertTo-Json -Depth 10 -Compress
307
470
  Set-Content -Path $hintFile -Value $hint -Force
@@ -309,24 +472,251 @@ if ($sessionName -ne "unknown-session") {
309
472
  }
310
473
 
311
474
  # ═══════════════════════════════════════════════════════════════════════════
312
- # OUTPUT SYSTEM REMINDER
475
+ # "/continue" COMMAND: Run AFTER /clear to restore last 5 turns
313
476
  # ═══════════════════════════════════════════════════════════════════════════
314
- $esc = [char]27
315
- $header = "${esc}[0;36m${esc}[1m🧠 ekkOS Memory${esc}[0m ${esc}[2m| $sessionName | $timestamp${esc}[0m"
477
+ $queryLowerTrimmed = $queryLower.Trim()
478
+
479
+ if ($queryLowerTrimmed -eq "/continue" -or $queryLowerTrimmed -match '^/continue\s' -or $queryLowerTrimmed -eq "continue" -or $queryLowerTrimmed -eq "continue.") {
480
+ if ($authToken) {
481
+ try {
482
+ $restoreBody = @{
483
+ session_id = "current"
484
+ last_n = 5
485
+ format = "detailed"
486
+ } | ConvertTo-Json -Depth 10 -Compress
487
+ $restoreBodyBytes = [System.Text.Encoding]::UTF8.GetBytes($restoreBody)
488
+
489
+ $restoreResponse = Invoke-RestMethod -Uri "$MemoryApiUrl/api/v1/turns/recall" `
490
+ -Method POST `
491
+ -Headers @{ Authorization = "Bearer $authToken"; "Content-Type" = "application/json" } `
492
+ -Body $restoreBodyBytes `
493
+ -TimeoutSec 5 -ErrorAction Stop
494
+ } catch {
495
+ $restoreResponse = @{ turns = @() }
496
+ }
497
+
498
+ $restoreJson = $restoreResponse | ConvertTo-Json -Depth 20 -Compress
499
+ $restoredCount = 0
500
+ $lastTask = "unknown task"
501
+ $lastResponse = ""
502
+
503
+ try {
504
+ $restoredCount = node -e "
505
+ const d = JSON.parse(process.argv[1] || '{}');
506
+ console.log((d.turns || []).length);
507
+ " $restoreJson 2>$null
508
+ $restoredCount = [int]$restoredCount
509
+ } catch { $restoredCount = 0 }
510
+
511
+ try {
512
+ $lastTask = node -e "
513
+ const d = JSON.parse(process.argv[1] || '{}');
514
+ const turns = d.turns || [];
515
+ console.log((turns[turns.length-1]?.user_query || 'unknown task').substring(0, 200));
516
+ " $restoreJson 2>$null
517
+ } catch { $lastTask = "unknown task" }
518
+
519
+ try {
520
+ $lastResponse = node -e "
521
+ const d = JSON.parse(process.argv[1] || '{}');
522
+ const turns = d.turns || [];
523
+ console.log((turns[turns.length-1]?.assistant_response || '').substring(0, 500));
524
+ " $restoreJson 2>$null
525
+ } catch { $lastResponse = "" }
526
+
527
+ Write-Output ""
528
+ Write-Output "${GREEN}${BOLD}Session continued${RESET} ${DIM}(${restoredCount} turns restored)${RESET}"
529
+ Write-Output ""
530
+
531
+ $continueOutput = @"
532
+ <system-reminder>
533
+ $("=" * 75)
534
+ CONTEXT RESTORED - Resume seamlessly. DO NOT ask 'what were we doing?'
535
+ $("=" * 75)
536
+
537
+ ## Last User Request:
538
+ $lastTask
316
539
 
317
- $output = @"
318
- $header
540
+ ## Your Last Response (truncated):
541
+ $lastResponse
319
542
 
320
543
  "@
321
544
 
322
- if ($skillReminders.Count -gt 0) {
323
- $output += "${esc}[0;35m${esc}[1m" + ($skillReminders -join "`n") + "${esc}[0m`n"
545
+ if ($restoredCount -gt 1) {
546
+ $continueOutput += "## Recent Context (older -> newer):`n"
547
+ try {
548
+ $recentContext = node -e "
549
+ const d = JSON.parse(process.argv[1] || '{}');
550
+ const turns = d.turns || [];
551
+ turns.slice(0, -1).forEach(t => {
552
+ const q = (t.user_query || '...').substring(0, 100);
553
+ console.log('- Turn ' + (t.turn_number || '?') + ': ' + q + '...');
554
+ });
555
+ " $restoreJson 2>$null
556
+ $continueOutput += "$recentContext`n"
557
+ } catch {}
558
+ $continueOutput += "`n"
559
+ }
560
+
561
+ $continueOutput += @"
562
+ $("=" * 75)
563
+ INSTRUCTION: Start your response with 'Continuing -' then pick up
564
+ exactly where you left off. If mid-task, continue it. If done, ask what's next.
565
+ $("=" * 75)
566
+ </system-reminder>
567
+ "@
568
+
569
+ Write-Output $continueOutput
570
+ Write-Output "${CYAN}${BOLD}ekkOS Memory${RESET} ${DIM}| ${timestamp}${RESET}"
571
+ exit 0
572
+ }
573
+ }
574
+
575
+ # ═══════════════════════════════════════════════════════════════════════════
576
+ # COMPACTION DETECTION: If context dropped dramatically, auto-restore
577
+ # Was >50% last turn, now <15% = compaction happened
578
+ # ═══════════════════════════════════════════════════════════════════════════
579
+ if ($prevContextPercent -gt 50 -and $tokenPercent -lt 15 -and $authToken) {
580
+ Write-Output ""
581
+ Write-Output "${GREEN}${BOLD}CONTEXT RESTORED${RESET} ${DIM}| Compaction detected | Auto-loading recent turns...${RESET}"
582
+
583
+ try {
584
+ $compactBody = @{
585
+ session_id = $sessionId
586
+ last_n = 10
587
+ format = "summary"
588
+ } | ConvertTo-Json -Depth 10 -Compress
589
+ $compactBodyBytes = [System.Text.Encoding]::UTF8.GetBytes($compactBody)
590
+
591
+ $compactResponse = Invoke-RestMethod -Uri "$MemoryApiUrl/api/v1/turns/recall" `
592
+ -Method POST `
593
+ -Headers @{ Authorization = "Bearer $authToken"; "Content-Type" = "application/json" } `
594
+ -Body $compactBodyBytes `
595
+ -TimeoutSec 5 -ErrorAction Stop
596
+ } catch {
597
+ $compactResponse = @{ turns = @() }
598
+ }
599
+
600
+ $compactJson = $compactResponse | ConvertTo-Json -Depth 20 -Compress
601
+ $compactRestoredCount = 0
602
+ try {
603
+ $compactRestoredCount = node -e "
604
+ const d = JSON.parse(process.argv[1] || '{}');
605
+ console.log((d.turns || []).length);
606
+ " $compactJson 2>$null
607
+ $compactRestoredCount = [int]$compactRestoredCount
608
+ } catch { $compactRestoredCount = 0 }
609
+
610
+ if ($compactRestoredCount -gt 0) {
611
+ Write-Output "${GREEN} Restored ${compactRestoredCount} turns from Layer 2${RESET}"
612
+ Write-Output ""
613
+ Write-Output "${MAGENTA}${BOLD}## Recent Context (auto-restored)${RESET}"
614
+ Write-Output ""
615
+
616
+ try {
617
+ $compactTurns = node -e "
618
+ const d = JSON.parse(process.argv[1] || '{}');
619
+ (d.turns || []).forEach(t => {
620
+ const q = (t.user_query || '...').substring(0, 120);
621
+ const a = (t.assistant_response || '...').substring(0, 250);
622
+ console.log('**Turn ' + (t.turn_number || '?') + '**: ' + q + '...\n> ' + a + '...\n');
623
+ });
624
+ " $compactJson 2>$null
625
+ Write-Output $compactTurns
626
+ } catch {}
627
+
628
+ Write-Output ""
629
+ Write-Output "${DIM}Full history: `"turns 1-${turnNumber}`" or `"recall yesterday`"${RESET}"
630
+ }
631
+
632
+ Write-Output ""
633
+ Write-Output "${CYAN}${BOLD}ekkOS Memory${RESET} ${DIM}| ${sessionName} | ${timestamp}${RESET}"
634
+
635
+ } elseif ($postClearDetected -and $authToken) {
636
+ # /clear detected - show visible restoration banner
637
+ $separator = "${GREEN}" + ("=" * 78) + "${RESET}"
638
+
639
+ Write-Output $separator
640
+ Write-Output "${GREEN}${BOLD}SESSION CONTINUED${RESET} ${DIM}| ${turnNumber} turns preserved | Restoring context...${RESET}"
641
+ Write-Output $separator
642
+
643
+ # Also write to stderr for visibility
644
+ [Console]::Error.WriteLine("${GREEN}${BOLD}SESSION CONTINUED${RESET} ${DIM}| ${turnNumber} turns preserved | Context restored${RESET}")
645
+
646
+ try {
647
+ $clearBody = @{
648
+ session_id = $sessionId
649
+ last_n = 10
650
+ format = "summary"
651
+ } | ConvertTo-Json -Depth 10 -Compress
652
+ $clearBodyBytes = [System.Text.Encoding]::UTF8.GetBytes($clearBody)
653
+
654
+ $clearResponse = Invoke-RestMethod -Uri "$MemoryApiUrl/api/v1/turns/recall" `
655
+ -Method POST `
656
+ -Headers @{ Authorization = "Bearer $authToken"; "Content-Type" = "application/json" } `
657
+ -Body $clearBodyBytes `
658
+ -TimeoutSec 5 -ErrorAction Stop
659
+ } catch {
660
+ $clearResponse = @{ turns = @() }
661
+ }
662
+
663
+ $clearJson = $clearResponse | ConvertTo-Json -Depth 20 -Compress
664
+ $clearRestoredCount = 0
665
+ try {
666
+ $clearRestoredCount = node -e "
667
+ const d = JSON.parse(process.argv[1] || '{}');
668
+ console.log((d.turns || []).length);
669
+ " $clearJson 2>$null
670
+ $clearRestoredCount = [int]$clearRestoredCount
671
+ } catch { $clearRestoredCount = 0 }
672
+
673
+ if ($clearRestoredCount -gt 0) {
674
+ Write-Output "${GREEN} Restored ${clearRestoredCount} recent turns${RESET}"
675
+ Write-Output ""
676
+
677
+ try {
678
+ $clearTurns = node -e "
679
+ const d = JSON.parse(process.argv[1] || '{}');
680
+ (d.turns || []).forEach(t => {
681
+ const q = (t.query_preview || t.user_query || '...').substring(0, 80);
682
+ const a = (t.response_preview || t.assistant_response || '...').substring(0, 150);
683
+ console.log('**Turn ' + (t.turn_number || '?') + '**: ' + q + '...\n> ' + a + '...\n');
684
+ });
685
+ " $clearJson 2>$null
686
+ Write-Output $clearTurns
687
+ } catch {}
688
+ } else {
689
+ Write-Output "${GREEN} History preserved (${turnNumber} turns)${RESET}"
690
+ }
691
+
692
+ Write-Output ""
693
+ Write-Output "${DIM}Full history: `"recall`" or `"turns 1-${turnNumber}`"${RESET}"
694
+ Write-Output $separator
695
+ Write-Output ""
696
+ Write-Output "${CYAN}${BOLD}ekkOS Memory${RESET} ${DIM}| ${sessionName} | ${timestamp}${RESET}"
697
+
698
+ } elseif ($tokenPercent -ge 50) {
699
+ # High context - show IPC percent
700
+ Write-Output "${CYAN}${BOLD}ekkOS Memory${RESET} ${DIM}| ~${ipcPercent}% IPC | ${sessionName} | ${timestamp}${RESET}"
701
+
702
+ } else {
703
+ # Normal output - session name + timestamp
704
+ Write-Output "${CYAN}${BOLD}ekkOS Memory${RESET} ${DIM}| ${sessionName} | ${timestamp}${RESET}"
324
705
  }
325
706
 
326
- $output += @"
707
+ # ═══════════════════════════════════════════════════════════════════════════
708
+ # Output skill reminders if detected
709
+ # ═══════════════════════════════════════════════════════════════════════════
710
+ if ($skillReminders.Count -gt 0) {
711
+ Write-Output ""
712
+ Write-Output "${MAGENTA}${BOLD}$($skillReminders -join "`n")${RESET}"
713
+ }
327
714
 
328
- <footer-format>End responses with: Claude Code ({Model}) · 🧠 ekkOS_™ · $sessionName · $timestamp</footer-format>
329
- <footer-note>Do not include a turn counter in the footer.</footer-note>
330
- "@
715
+ # ═══════════════════════════════════════════════════════════════════════════
716
+ # FOOTER FORMAT HINT
717
+ # ═══════════════════════════════════════════════════════════════════════════
718
+ Write-Output ""
719
+ Write-Output "<footer-format>End responses with: Claude Code ({Model}) · ekkOS_ · $sessionName · $timestamp</footer-format>"
720
+ Write-Output "<footer-note>Do not include a turn counter in the footer.</footer-note>"
331
721
 
332
- Write-Output $output
722
+ exit 0