@ekkos/cli 1.0.33 → 1.0.35

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 (51) 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 +221 -259
  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 +4 -2
  19. package/templates/CLAUDE.md +23 -135
  20. package/templates/agents/README.md +182 -0
  21. package/templates/agents/code-reviewer.md +166 -0
  22. package/templates/agents/debug-detective.md +169 -0
  23. package/templates/agents/ekkOS_Vercel.md +99 -0
  24. package/templates/agents/extension-manager.md +229 -0
  25. package/templates/agents/git-companion.md +185 -0
  26. package/templates/agents/github-test-agent.md +321 -0
  27. package/templates/agents/railway-manager.md +179 -0
  28. package/templates/ekkos-manifest.json +8 -8
  29. package/templates/hooks/assistant-response.ps1 +160 -256
  30. package/templates/hooks/assistant-response.sh +66 -130
  31. package/templates/hooks/hooks.json +0 -6
  32. package/templates/hooks/lib/contract.sh +31 -43
  33. package/templates/hooks/lib/count-tokens.cjs +0 -0
  34. package/templates/hooks/lib/ekkos-reminders.sh +0 -0
  35. package/templates/hooks/lib/state.sh +1 -53
  36. package/templates/hooks/session-start.ps1 +391 -91
  37. package/templates/hooks/session-start.sh +166 -201
  38. package/templates/hooks/stop.ps1 +341 -202
  39. package/templates/hooks/stop.sh +948 -275
  40. package/templates/hooks/user-prompt-submit.ps1 +548 -224
  41. package/templates/hooks/user-prompt-submit.sh +456 -382
  42. package/templates/plan-template.md +0 -0
  43. package/templates/spec-template.md +0 -0
  44. package/templates/windsurf-hooks/before-submit-prompt.sh +238 -0
  45. package/templates/windsurf-hooks/hooks.json +2 -9
  46. package/templates/windsurf-hooks/install.sh +0 -0
  47. package/templates/windsurf-hooks/lib/contract.sh +0 -2
  48. package/templates/windsurf-hooks/post-cascade-response.sh +0 -0
  49. package/templates/windsurf-hooks/pre-user-prompt.sh +0 -0
  50. package/templates/windsurf-skills/ekkos-memory/SKILL.md +219 -0
  51. package/README.md +0 -57
@@ -1,205 +1,267 @@
1
1
  # ═══════════════════════════════════════════════════════════════════════════
2
- # ekkOS_ Hook: Stop - Session cleanup and capture finalization (Windows)
3
- # MANAGED BY ekkos-connect - DO NOT EDIT DIRECTLY
4
- # EKKOS_MANAGED=1
5
- # EKKOS_MANIFEST_SHA256=<computed-at-build>
6
- # EKKOS_TEMPLATE_VERSION=1.0.0
2
+ # ekkOS_ Hook: Stop - Captures turns to BOTH Working (Redis) and Episodic (Supabase)
3
+ # NO jq dependency - uses Node.js for all JSON parsing
4
+ # ═══════════════════════════════════════════════════════════════════════════
5
+ # This hook captures every turn to:
6
+ # 1. Working Sessions (Redis) - Fast hot cache for /continue
7
+ # 2. Episodic Memory (Supabase) - Permanent cold storage
7
8
  #
8
- # Per ekkOS Onboarding Spec v1.2 FINAL + ADDENDUM:
9
- # - All persisted records MUST include: instanceId, sessionId, sessionName
10
- # - Uses EKKOS_INSTANCE_ID env var for multi-session isolation
9
+ # NO compliance checking - skills handle that
10
+ # NO PatternGuard validation - skills handle that
11
+ # NO verbose output - just capture silently
11
12
  # ═══════════════════════════════════════════════════════════════════════════
12
13
 
13
14
  $ErrorActionPreference = "SilentlyContinue"
14
15
 
16
+ $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
17
+ $ProjectRoot = Split-Path -Parent (Split-Path -Parent $ScriptDir)
18
+ $StateDir = Join-Path $ProjectRoot ".claude\state"
19
+
20
+ if (-not (Test-Path $StateDir)) {
21
+ New-Item -ItemType Directory -Path $StateDir -Force | Out-Null
22
+ }
23
+
24
+ # ═══════════════════════════════════════════════════════════════════════════
25
+ # CONFIG PATHS - No jq dependency (v1.2 spec)
26
+ # Session words live in ~/.ekkos/ so they work in ANY project
27
+ # ═══════════════════════════════════════════════════════════════════════════
28
+ $EkkosConfigDir = if ($env:EKKOS_CONFIG_DIR) { $env:EKKOS_CONFIG_DIR } else { Join-Path $env:USERPROFILE ".ekkos" }
29
+ $SessionWordsJson = Join-Path $EkkosConfigDir "session-words.json"
30
+ $SessionWordsDefault = Join-Path $EkkosConfigDir ".defaults\session-words.json"
31
+ $JsonParseHelper = Join-Path $EkkosConfigDir ".helpers\json-parse.cjs"
32
+
33
+ $INPUT = [Console]::In.ReadToEnd()
34
+
15
35
  # ═══════════════════════════════════════════════════════════════════════════
16
- # CONFIG PATHS
36
+ # JSON parsing helper (no jq) - pipes JSON to node, extracts dot-path value
17
37
  # ═══════════════════════════════════════════════════════════════════════════
18
- $EkkosConfigDir = if ($env:EKKOS_CONFIG_DIR) { $env:EKKOS_CONFIG_DIR } else { "$env:USERPROFILE\.ekkos" }
19
- $SessionWordsJson = "$EkkosConfigDir\session-words.json"
20
- $SessionWordsDefault = "$EkkosConfigDir\.defaults\session-words.json"
38
+ function Parse-JsonValue {
39
+ param(
40
+ [string]$Json,
41
+ [string]$Path
42
+ )
43
+ if (-not $Json) { return "" }
44
+ try {
45
+ $result = $Json | node -e "
46
+ const data = JSON.parse(require('fs').readFileSync('/dev/stdin', 'utf8') || '{}');
47
+ const path = '$Path'.replace(/^\./,'').split('.').filter(Boolean);
48
+ let result = data;
49
+ for (const p of path) {
50
+ if (result === undefined || result === null) { result = undefined; break; }
51
+ result = result[p];
52
+ }
53
+ if (result !== undefined && result !== null) console.log(result);
54
+ " 2>$null
55
+ if ($result) { return $result.Trim() } else { return "" }
56
+ } catch {
57
+ return ""
58
+ }
59
+ }
60
+
61
+ $RAW_SESSION_ID = Parse-JsonValue $INPUT ".session_id"
62
+ if (-not $RAW_SESSION_ID) { $RAW_SESSION_ID = "unknown" }
63
+ $TRANSCRIPT_PATH = Parse-JsonValue $INPUT ".transcript_path"
64
+ $MODEL_USED = Parse-JsonValue $INPUT ".model"
65
+ if (-not $MODEL_USED) { $MODEL_USED = "claude-sonnet-4-5" }
21
66
 
22
67
  # ═══════════════════════════════════════════════════════════════════════════
23
- # INSTANCE ID - Multi-session isolation per v1.2 ADDENDUM
68
+ # Session ID
24
69
  # ═══════════════════════════════════════════════════════════════════════════
25
- $EkkosInstanceId = if ($env:EKKOS_INSTANCE_ID) { $env:EKKOS_INSTANCE_ID } else { "default" }
70
+ $SESSION_ID = $RAW_SESSION_ID
71
+
72
+ if (-not $SESSION_ID -or $SESSION_ID -eq "unknown" -or $SESSION_ID -eq "null") {
73
+ exit 0
74
+ }
75
+
76
+ # Check if SESSION_ID is a UUID (8-4-4-4-12 format)
77
+ $IS_UUID = $false
78
+ if ($SESSION_ID -match '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$') {
79
+ $IS_UUID = $true
80
+ }
26
81
 
27
82
  # ═══════════════════════════════════════════════════════════════════════════
28
- # Load session words from JSON file - NO HARDCODED ARRAYS
83
+ # WORD-BASED SESSION NAMES - No jq, uses ~/.ekkos/ config (works in ANY project)
29
84
  # ═══════════════════════════════════════════════════════════════════════════
30
- $script:SessionWords = $null
85
+ $script:ADJECTIVES = @()
86
+ $script:NOUNS = @()
87
+ $script:VERBS = @()
88
+ $script:SESSION_WORDS_LOADED = $false
31
89
 
32
90
  function Load-SessionWords {
33
- $wordsFile = $SessionWordsJson
91
+ if ($script:SESSION_WORDS_LOADED) { return }
34
92
 
93
+ $wordsFile = $SessionWordsJson
35
94
  if (-not (Test-Path $wordsFile)) {
36
95
  $wordsFile = $SessionWordsDefault
37
96
  }
38
97
 
39
- if (-not (Test-Path $wordsFile)) {
40
- return $null
98
+ if ((-not (Test-Path $wordsFile)) -or (-not (Test-Path $JsonParseHelper))) {
99
+ $script:ADJECTIVES = @("unknown")
100
+ $script:NOUNS = @("session")
101
+ $script:VERBS = @("starts")
102
+ return
41
103
  }
42
104
 
43
105
  try {
44
- $script:SessionWords = Get-Content $wordsFile -Raw | ConvertFrom-Json
106
+ $adjRaw = & node $JsonParseHelper $wordsFile ".adjectives" 2>$null
107
+ $nounRaw = & node $JsonParseHelper $wordsFile ".nouns" 2>$null
108
+ $verbRaw = & node $JsonParseHelper $wordsFile ".verbs" 2>$null
109
+
110
+ $script:ADJECTIVES = @()
111
+ $script:NOUNS = @()
112
+ $script:VERBS = @()
113
+
114
+ if ($adjRaw) { $script:ADJECTIVES = @($adjRaw -split "`n" | Where-Object { $_ }) }
115
+ if ($nounRaw) { $script:NOUNS = @($nounRaw -split "`n" | Where-Object { $_ }) }
116
+ if ($verbRaw) { $script:VERBS = @($verbRaw -split "`n" | Where-Object { $_ }) }
117
+
118
+ if ($script:ADJECTIVES.Count -eq 0) { $script:ADJECTIVES = @("unknown") }
119
+ if ($script:NOUNS.Count -eq 0) { $script:NOUNS = @("session") }
120
+ if ($script:VERBS.Count -eq 0) { $script:VERBS = @("starts") }
121
+
122
+ $script:SESSION_WORDS_LOADED = $true
45
123
  } catch {
46
- return $null
124
+ $script:ADJECTIVES = @("unknown")
125
+ $script:NOUNS = @("session")
126
+ $script:VERBS = @("starts")
47
127
  }
48
128
  }
49
129
 
50
130
  function Convert-UuidToWords {
51
131
  param([string]$uuid)
52
132
 
53
- if (-not $script:SessionWords) {
54
- Load-SessionWords
55
- }
133
+ Load-SessionWords
56
134
 
57
- if (-not $script:SessionWords) {
58
- return "unknown-session"
59
- }
135
+ $hex = $uuid -replace "-", ""
136
+ $hex = $hex.Substring(0, [Math]::Min(12, $hex.Length))
137
+ if ($hex -notmatch '^[0-9a-fA-F]+$') { return "unknown-session-starts" }
60
138
 
61
- $adjectives = $script:SessionWords.adjectives
62
- $nouns = $script:SessionWords.nouns
63
- $verbs = $script:SessionWords.verbs
139
+ try {
140
+ $adjSeed = [Convert]::ToInt32($hex.Substring(0, 4), 16)
141
+ $nounSeed = [Convert]::ToInt32($hex.Substring(4, 4), 16)
142
+ $verbSeed = [Convert]::ToInt32($hex.Substring(8, 4), 16)
64
143
 
65
- if (-not $adjectives -or -not $nouns -or -not $verbs) {
66
- return "unknown-session"
67
- }
144
+ $adjIdx = $adjSeed % $script:ADJECTIVES.Count
145
+ $nounIdx = $nounSeed % $script:NOUNS.Count
146
+ $verbIdx = $verbSeed % $script:VERBS.Count
68
147
 
69
- if (-not $uuid -or $uuid -eq "unknown") { return "unknown-session" }
148
+ return "$($script:ADJECTIVES[$adjIdx])-$($script:NOUNS[$nounIdx])-$($script:VERBS[$verbIdx])"
149
+ } catch {
150
+ return "unknown-session-starts"
151
+ }
152
+ }
70
153
 
71
- $clean = $uuid -replace "-", ""
72
- if ($clean.Length -lt 12) { return "unknown-session" }
154
+ if ($IS_UUID) {
155
+ $SESSION_NAME = Convert-UuidToWords $SESSION_ID
156
+ } else {
157
+ $SESSION_NAME = $SESSION_ID
158
+ }
73
159
 
160
+ # ═══════════════════════════════════════════════════════════════════════════
161
+ # Turn Number - read from turn file written by user-prompt-submit.ps1
162
+ # ═══════════════════════════════════════════════════════════════════════════
163
+ $SessionsDir = Join-Path $StateDir "sessions"
164
+ if (-not (Test-Path $SessionsDir)) {
165
+ New-Item -ItemType Directory -Path $SessionsDir -Force | Out-Null
166
+ }
167
+ $TURN_FILE = Join-Path $SessionsDir "$SESSION_ID.turn"
168
+ $TURN_NUMBER = 1
169
+ if (Test-Path $TURN_FILE) {
74
170
  try {
75
- $a = [Convert]::ToInt32($clean.Substring(0,4), 16) % $adjectives.Length
76
- $n = [Convert]::ToInt32($clean.Substring(4,4), 16) % $nouns.Length
77
- $an = [Convert]::ToInt32($clean.Substring(8,4), 16) % $verbs.Length
78
-
79
- return "$($adjectives[$a])-$($nouns[$n])-$($verbs[$an])"
171
+ $TURN_NUMBER = [int](Get-Content $TURN_FILE -Raw).Trim()
80
172
  } catch {
81
- return "unknown-session"
173
+ $TURN_NUMBER = 1
82
174
  }
83
175
  }
84
176
 
85
177
  # ═══════════════════════════════════════════════════════════════════════════
86
- # READ INPUT
178
+ # Load auth - No jq
87
179
  # ═══════════════════════════════════════════════════════════════════════════
88
- $inputJson = [Console]::In.ReadToEnd()
89
-
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"
93
- $rawSessionId = "unknown"
94
- $turn = 0
95
-
96
- if (Test-Path $stateFile) {
97
- try {
98
- $hookState = Get-Content $stateFile -Raw | ConvertFrom-Json
99
- $rawSessionId = $hookState.session_id
100
- $turn = [int]$hookState.turn
101
- } catch {}
180
+ $EKKOS_CONFIG = Join-Path $env:USERPROFILE ".ekkos\config.json"
181
+ $AUTH_TOKEN = ""
182
+ $USER_ID = ""
183
+
184
+ if ((Test-Path $EKKOS_CONFIG) -and (Test-Path $JsonParseHelper)) {
185
+ $AUTH_TOKEN = & node $JsonParseHelper $EKKOS_CONFIG ".hookApiKey" 2>$null
186
+ if (-not $AUTH_TOKEN) {
187
+ $AUTH_TOKEN = & node $JsonParseHelper $EKKOS_CONFIG ".apiKey" 2>$null
188
+ }
189
+ $USER_ID = & node $JsonParseHelper $EKKOS_CONFIG ".userId" 2>$null
102
190
  }
103
191
 
104
- if ($rawSessionId -eq "unknown" -and (Test-Path $sessionFile)) {
105
- try {
106
- $sessionData = Get-Content $sessionFile -Raw | ConvertFrom-Json
107
- $rawSessionId = $sessionData.session_id
108
- } catch {}
192
+ if (-not $AUTH_TOKEN) {
193
+ $envLocalFile = Join-Path $ProjectRoot ".env.local"
194
+ if (Test-Path $envLocalFile) {
195
+ $envLines = Get-Content $envLocalFile
196
+ foreach ($line in $envLines) {
197
+ if ($line -match '^SUPABASE_SECRET_KEY=(.+)$') {
198
+ $AUTH_TOKEN = $Matches[1].Trim('"', "'", ' ', "`r")
199
+ break
200
+ }
201
+ }
202
+ }
109
203
  }
110
204
 
111
- $sessionName = Convert-UuidToWords $rawSessionId
205
+ if (-not $AUTH_TOKEN) { exit 0 }
206
+
207
+ $MEMORY_API_URL = "https://mcp.ekkos.dev"
112
208
 
113
209
  # ═══════════════════════════════════════════════════════════════════════════
114
- # SESSION BINDING: Bridge _pending real session name for proxy eviction
115
- # Windows has no PTY so run.ts can't detect the session name. The stop hook
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.
210
+ # SESSION BINDING: Bridge _pending -> real session name for proxy eviction
211
+ # The CLI may be in spawn pass-through mode (no PTY = blind to TUI output),
212
+ # so the stop hook (which IS sighted) must bind the session.
118
213
  # ═══════════════════════════════════════════════════════════════════════════
119
- $configFile = Join-Path $EkkosConfigDir "config.json"
120
- if ((Test-Path $configFile) -and $sessionName -ne "unknown-session") {
121
- try {
122
- $config = Get-Content $configFile -Raw | ConvertFrom-Json
123
- $userId = $config.userId
124
- $authToken = if ($config.hookApiKey) { $config.hookApiKey } else { $config.apiKey }
125
-
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" `
141
- -Method POST `
142
- -Headers @{ "Content-Type" = "application/json" } `
143
- -Body ([System.Text.Encoding]::UTF8.GetBytes($body)) -ErrorAction SilentlyContinue | Out-Null
144
- } -ArgumentList $bindBody, $authToken | Out-Null
145
- }
146
- } catch {}
214
+ if ($SESSION_NAME -and $SESSION_NAME -ne "unknown-session-starts" -and $USER_ID) {
215
+ $PROJECT_PATH_FOR_BIND = ((Get-Location).Path) -replace '\\', '/'
216
+ $PENDING_SESSION_FOR_BIND = if ($env:EKKOS_PENDING_SESSION) { $env:EKKOS_PENDING_SESSION } else { "_pending" }
217
+
218
+ $bindBody = @{
219
+ userId = $USER_ID
220
+ realSession = $SESSION_NAME
221
+ projectPath = $PROJECT_PATH_FOR_BIND
222
+ pendingSession = $PENDING_SESSION_FOR_BIND
223
+ } | ConvertTo-Json -Depth 10 -Compress
224
+
225
+ Start-Job -ScriptBlock {
226
+ param($url, $body)
227
+ try {
228
+ Invoke-RestMethod -Uri "$url/proxy/session/bind" `
229
+ -Method POST `
230
+ -ContentType "application/json" `
231
+ -Body ([System.Text.Encoding]::UTF8.GetBytes($body)) `
232
+ -TimeoutSec 2 `
233
+ -ErrorAction SilentlyContinue | Out-Null
234
+ } catch {}
235
+ } -ArgumentList $MEMORY_API_URL, $bindBody | Out-Null
147
236
  }
148
237
 
149
238
  # ═══════════════════════════════════════════════════════════════════════════
150
- # LOCAL CACHE: ACK turn to mark as synced
151
- # Per v1.2 ADDENDUM: Pass instanceId for namespacing
239
+ # EVICTION: Handled by IPC (In-Place Progressive Compression) in the proxy.
240
+ # No hook-side eviction needed - passthrough is default for cache stability.
152
241
  # ═══════════════════════════════════════════════════════════════════════════
153
- $captureCmd = Get-Command "ekkos-capture" -ErrorAction SilentlyContinue
154
- if ($captureCmd -and $rawSessionId -ne "unknown") {
155
- try {
156
- Start-Job -ScriptBlock {
157
- param($instanceId, $sessionId, $turnNum)
158
- try {
159
- & ekkos-capture ack $sessionId $turnNum "--instance=$instanceId" 2>&1 | Out-Null
160
- } catch {}
161
- } -ArgumentList $EkkosInstanceId, $rawSessionId, $turn | Out-Null
162
- } catch {}
163
- }
164
242
 
165
243
  # ═══════════════════════════════════════════════════════════════════════════
166
- # CHECK INTERRUPTION - Skip capture if user cancelled
244
+ # Check for interruption - No jq
167
245
  # ═══════════════════════════════════════════════════════════════════════════
168
- $isInterrupted = $false
169
- $stopReason = ""
170
- if ($inputJson) {
171
- try {
172
- $stopInput = $inputJson | ConvertFrom-Json
173
- $isInterrupted = $stopInput.interrupted -eq $true
174
- $stopReason = $stopInput.stop_reason
175
- } catch {}
176
- }
246
+ $IS_INTERRUPTED = Parse-JsonValue $INPUT ".interrupted"
247
+ if (-not $IS_INTERRUPTED) { $IS_INTERRUPTED = "false" }
248
+ $STOP_REASON = Parse-JsonValue $INPUT ".stop_reason"
177
249
 
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)"
250
+ if ($IS_INTERRUPTED -eq "true" -or $STOP_REASON -eq "user_cancelled" -or $STOP_REASON -eq "interrupted") {
181
251
  exit 0
182
252
  }
183
253
 
184
254
  # ═══════════════════════════════════════════════════════════════════════════
185
- # EXTRACT CONVERSATION FROM TRANSCRIPT
186
- # Mirrors stop.sh: Extract last user query, assistant response, file changes
255
+ # Extract conversation from transcript using Node (no jq)
187
256
  # ═══════════════════════════════════════════════════════════════════════════
188
- $transcriptPath = ""
189
- if ($inputJson) {
190
- try {
191
- $stopInput2 = $inputJson | ConvertFrom-Json
192
- $transcriptPath = $stopInput2.transcript_path
193
- } catch {}
194
- }
195
-
196
- $lastUser = ""
197
- $lastAssistant = ""
198
- $fileChangesJson = "[]"
257
+ $LAST_USER = ""
258
+ $LAST_ASSISTANT = ""
259
+ $FILE_CHANGES = "[]"
199
260
 
200
- if ($transcriptPath -and (Test-Path $transcriptPath)) {
261
+ if ($TRANSCRIPT_PATH -and (Test-Path $TRANSCRIPT_PATH)) {
201
262
  try {
202
- $extraction = node -e @"
263
+ $tpNorm = $TRANSCRIPT_PATH -replace '\\', '/'
264
+ $EXTRACTION = & node -e @"
203
265
  const fs = require('fs');
204
266
  const lines = fs.readFileSync(process.argv[1], 'utf8').split('\n').filter(Boolean);
205
267
  const entries = lines.map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
@@ -228,6 +290,7 @@ for (let i = entries.length - 1; i >= 0; i--) {
228
290
  const parts = content.map(c => {
229
291
  if (c.type === 'text') return c.text;
230
292
  if (c.type === 'tool_use') return '[TOOL: ' + c.name + ']';
293
+ if (c.type === 'thinking') return '[THINKING]' + (c.thinking || c.text || '') + '[/THINKING]';
231
294
  return '';
232
295
  }).filter(Boolean);
233
296
  lastAssistant = parts.join('\n'); break;
@@ -240,7 +303,7 @@ entries.filter(e => e.type === 'assistant').forEach(e => {
240
303
  const content = e.message?.content;
241
304
  if (Array.isArray(content)) {
242
305
  content.filter(c => c.type === 'tool_use' && ['Edit', 'Write', 'Read'].includes(c.name)).forEach(c => {
243
- fileChanges.push({tool: c.name, path: (c.input?.file_path || c.input?.path || '').replace(/\\\\/g, '/'), action: c.name.toLowerCase()});
306
+ fileChanges.push({tool: c.name, path: c.input?.file_path || c.input?.path, action: c.name.toLowerCase()});
244
307
  });
245
308
  }
246
309
  });
@@ -250,86 +313,162 @@ console.log(JSON.stringify({
250
313
  assistant: lastAssistant.substring(0, 50000),
251
314
  fileChanges: fileChanges.slice(0, 20)
252
315
  }));
253
- "@ $transcriptPath 2>$null
254
-
255
- if ($extraction) {
256
- $parsed = $extraction | ConvertFrom-Json
257
- $lastUser = $parsed.user
258
- $lastAssistant = $parsed.assistant
259
- $fileChangesJson = ($parsed.fileChanges | ConvertTo-Json -Depth 10 -Compress)
260
- if (-not $fileChangesJson) { $fileChangesJson = "[]" }
316
+ "@ -- $tpNorm 2>$null
317
+
318
+ if (-not $EXTRACTION) {
319
+ $EXTRACTION = '{"user":"","assistant":"","fileChanges":[]}'
261
320
  }
321
+
322
+ $LAST_USER = $EXTRACTION | node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));console.log(d.user||'')" 2>$null
323
+ $LAST_ASSISTANT = $EXTRACTION | node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));console.log(d.assistant||'')" 2>$null
324
+ $FILE_CHANGES = $EXTRACTION | node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));console.log(JSON.stringify(d.fileChanges||[]))" 2>$null
325
+ if (-not $FILE_CHANGES) { $FILE_CHANGES = "[]" }
262
326
  } catch {}
263
327
  }
264
328
 
329
+ if (-not $LAST_USER -or $LAST_USER -match '\[Request interrupted') {
330
+ exit 0
331
+ }
332
+
265
333
  # ═══════════════════════════════════════════════════════════════════════════
266
- # CAPTURE TO BOTH Working Sessions (Redis) AND Episodic Memory (Supabase)
267
- # Mirrors stop.sh dual-write at lines 271-356
334
+ # Capture to BOTH Working Sessions (Redis) AND Episodic (Supabase) - No jq
268
335
  # ═══════════════════════════════════════════════════════════════════════════
269
- if ($lastUser -and $lastAssistant -and $authToken) {
270
- $modelUsed = "claude-sonnet-4-5"
271
- if ($inputJson) {
336
+ if ($LAST_USER -and $LAST_ASSISTANT) {
337
+ $TIMESTAMP = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
338
+
339
+ $TOOLS_USED = $FILE_CHANGES | node -e "
340
+ const d = JSON.parse(require('fs').readFileSync('/dev/stdin','utf8') || '[]');
341
+ console.log(JSON.stringify([...new Set(d.map(f => f.tool).filter(Boolean))]));
342
+ " 2>$null
343
+ if (-not $TOOLS_USED) { $TOOLS_USED = "[]" }
344
+
345
+ $FILES_REF = $FILE_CHANGES | node -e "
346
+ const d = JSON.parse(require('fs').readFileSync('/dev/stdin','utf8') || '[]');
347
+ console.log(JSON.stringify([...new Set(d.map(f => f.path).filter(Boolean))]));
348
+ " 2>$null
349
+ if (-not $FILES_REF) { $FILES_REF = "[]" }
350
+
351
+ # Token breakdown from tokenizer script
352
+ $TOTAL_TOKENS = 0
353
+ $INPUT_TOKENS = 0
354
+ $CACHE_READ_TOKENS = 0
355
+ $CACHE_CREATION_TOKENS = 0
356
+ $TOKENIZER_SCRIPT = Join-Path $ScriptDir "lib\count-tokens.cjs"
357
+
358
+ if ($TRANSCRIPT_PATH -and (Test-Path $TRANSCRIPT_PATH) -and (Test-Path $TOKENIZER_SCRIPT)) {
272
359
  try {
273
- $stopInput3 = $inputJson | ConvertFrom-Json
274
- if ($stopInput3.model) { $modelUsed = $stopInput3.model }
360
+ $TOKEN_JSON = & node $TOKENIZER_SCRIPT $TRANSCRIPT_PATH --json 2>$null
361
+ if ($TOKEN_JSON) {
362
+ $TOTAL_TOKENS = $TOKEN_JSON | node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')||'{}');console.log(d.total_tokens||0)" 2>$null
363
+ $INPUT_TOKENS = $TOKEN_JSON | node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')||'{}');console.log(d.input_tokens||0)" 2>$null
364
+ $CACHE_READ_TOKENS = $TOKEN_JSON | node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')||'{}');console.log(d.cache_read_tokens||0)" 2>$null
365
+ $CACHE_CREATION_TOKENS = $TOKEN_JSON | node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')||'{}');console.log(d.cache_creation_tokens||0)" 2>$null
366
+ }
275
367
  } catch {}
368
+ if ($TOTAL_TOKENS -notmatch '^\d+$') { $TOTAL_TOKENS = 0 }
369
+ if ($INPUT_TOKENS -notmatch '^\d+$') { $INPUT_TOKENS = 0 }
370
+ if ($CACHE_READ_TOKENS -notmatch '^\d+$') { $CACHE_READ_TOKENS = 0 }
371
+ if ($CACHE_CREATION_TOKENS -notmatch '^\d+$') { $CACHE_CREATION_TOKENS = 0 }
276
372
  }
277
373
 
278
- $timestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
279
- $projectPath = ((Get-Location).Path) -replace '\\', '/'
280
-
281
- # 1. WORKING SESSIONS (Redis)
282
- Start-Job -ScriptBlock {
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
300
-
301
- # 2. EPISODIC MEMORY (Supabase)
374
+ # 1. WORKING SESSIONS (Redis) - No jq payload building
375
+ # 2. EPISODIC MEMORY (Supabase) - No jq payload building
376
+ # Both fire in a single background job (mirrors the bash subshell)
302
377
  Start-Job -ScriptBlock {
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
378
+ param(
379
+ $apiUrl, $authToken,
380
+ $sessionName, $turnNumber, $lastUser, $lastAssistant, $modelUsed,
381
+ $toolsUsed, $filesRef, $totalTokens, $inputTokens, $cacheReadTokens, $cacheCreationTokens,
382
+ $sessionId, $userId, $fileChanges, $timestamp
383
+ )
384
+
385
+ # --- 1. WORKING SESSIONS (Redis) ---
386
+ try {
387
+ $workingPayload = & node -e "
388
+ console.log(JSON.stringify({
389
+ session_name: process.argv[1],
390
+ turn_number: parseInt(process.argv[2]),
391
+ user_query: process.argv[3],
392
+ agent_response: process.argv[4].substring(0, 50000),
393
+ model: process.argv[5],
394
+ tools_used: JSON.parse(process.argv[6] || '[]'),
395
+ files_referenced: JSON.parse(process.argv[7] || '[]'),
396
+ total_context_tokens: parseInt(process.argv[8]) || 0,
397
+ token_breakdown: {
398
+ input_tokens: parseInt(process.argv[9]) || 0,
399
+ cache_read_tokens: parseInt(process.argv[10]) || 0,
400
+ cache_creation_tokens: parseInt(process.argv[11]) || 0
401
+ }
402
+ }));
403
+ " -- $sessionName $turnNumber $lastUser $lastAssistant $modelUsed $toolsUsed $filesRef $totalTokens $inputTokens $cacheReadTokens $cacheCreationTokens 2>$null
404
+
405
+ if ($workingPayload) {
406
+ Invoke-RestMethod -Uri "$apiUrl/api/v1/working/turn" `
407
+ -Method POST `
408
+ -Headers @{ Authorization = "Bearer $authToken"; "Content-Type" = "application/json" } `
409
+ -Body ([System.Text.Encoding]::UTF8.GetBytes($workingPayload)) `
410
+ -TimeoutSec 5 `
411
+ -ErrorAction SilentlyContinue | Out-Null
317
412
  }
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
413
+ } catch {}
414
+
415
+ # --- 2. EPISODIC MEMORY (Supabase) ---
416
+ try {
417
+ $episodicPayload = & node -e "
418
+ console.log(JSON.stringify({
419
+ user_query: process.argv[1],
420
+ assistant_response: process.argv[2],
421
+ session_id: process.argv[3],
422
+ user_id: process.argv[4] || 'system',
423
+ file_changes: JSON.parse(process.argv[5] || '[]'),
424
+ metadata: {
425
+ source: 'claude-code',
426
+ model_used: process.argv[6],
427
+ captured_at: process.argv[7],
428
+ turn_number: parseInt(process.argv[8]) || 1,
429
+ session_name: process.argv[9],
430
+ minimal_hook: true
431
+ }
432
+ }));
433
+ " -- $lastUser $lastAssistant $sessionId $userId $fileChanges $modelUsed $timestamp $turnNumber $sessionName 2>$null
434
+
435
+ if ($episodicPayload) {
436
+ Invoke-RestMethod -Uri "$apiUrl/api/v1/memory/capture" `
437
+ -Method POST `
438
+ -Headers @{ Authorization = "Bearer $authToken"; "Content-Type" = "application/json" } `
439
+ -Body ([System.Text.Encoding]::UTF8.GetBytes($episodicPayload)) `
440
+ -TimeoutSec 5 `
441
+ -ErrorAction SilentlyContinue | Out-Null
442
+ }
443
+ } catch {}
444
+ } -ArgumentList $MEMORY_API_URL, $AUTH_TOKEN, `
445
+ $SESSION_NAME, $TURN_NUMBER, $LAST_USER, $LAST_ASSISTANT, $MODEL_USED, `
446
+ $TOOLS_USED, $FILES_REF, $TOTAL_TOKENS, $INPUT_TOKENS, $CACHE_READ_TOKENS, $CACHE_CREATION_TOKENS, `
447
+ $SESSION_ID, $USER_ID, $FILE_CHANGES, $TIMESTAMP | Out-Null
326
448
  }
327
449
 
328
450
  # ═══════════════════════════════════════════════════════════════════════════
329
- # CLEAN UP STATE FILES
451
+ # Update local .ekkos/current-focus.md (if exists) - SILENT
330
452
  # ═══════════════════════════════════════════════════════════════════════════
331
- if (Test-Path $stateFile) {
332
- Remove-Item $stateFile -Force
453
+ $EKKOS_LOCAL_DIR = Join-Path $ProjectRoot ".ekkos"
454
+ if ((Test-Path $EKKOS_LOCAL_DIR) -and $LAST_USER) {
455
+ $FOCUS_FILE = Join-Path $EKKOS_LOCAL_DIR "current-focus.md"
456
+ $TASK_SUMMARY = $LAST_USER
457
+ if ($LAST_USER.Length -gt 100) {
458
+ $TASK_SUMMARY = $LAST_USER.Substring(0, 100) + "..."
459
+ }
460
+ $focusTimestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
461
+
462
+ $focusContent = @"
463
+ ---
464
+ last_updated: $focusTimestamp
465
+ session_id: $SESSION_ID
466
+ ---
467
+
468
+ # Current Focus
469
+ $TASK_SUMMARY
470
+ "@
471
+ Set-Content -Path $FOCUS_FILE -Value $focusContent -Force
333
472
  }
334
473
 
335
- Write-Output "ekkOS session ended"
474
+ exit 0