@ekkos/cli 1.0.35 → 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.
Files changed (36) hide show
  1. package/README.md +57 -0
  2. package/dist/commands/dashboard.js +561 -186
  3. package/dist/deploy/settings.js +13 -26
  4. package/package.json +2 -4
  5. package/templates/CLAUDE.md +135 -23
  6. package/templates/ekkos-manifest.json +8 -8
  7. package/templates/hooks/assistant-response.ps1 +256 -160
  8. package/templates/hooks/assistant-response.sh +130 -66
  9. package/templates/hooks/hooks.json +24 -6
  10. package/templates/hooks/lib/contract.sh +43 -31
  11. package/templates/hooks/lib/count-tokens.cjs +0 -0
  12. package/templates/hooks/lib/ekkos-reminders.sh +0 -0
  13. package/templates/hooks/lib/state.sh +53 -1
  14. package/templates/hooks/session-start.ps1 +91 -391
  15. package/templates/hooks/session-start.sh +201 -166
  16. package/templates/hooks/stop.ps1 +202 -341
  17. package/templates/hooks/stop.sh +275 -948
  18. package/templates/hooks/user-prompt-submit.ps1 +224 -548
  19. package/templates/hooks/user-prompt-submit.sh +382 -456
  20. package/templates/plan-template.md +0 -0
  21. package/templates/spec-template.md +0 -0
  22. package/templates/windsurf-hooks/hooks.json +9 -2
  23. package/templates/windsurf-hooks/install.sh +0 -0
  24. package/templates/windsurf-hooks/lib/contract.sh +2 -0
  25. package/templates/windsurf-hooks/post-cascade-response.sh +0 -0
  26. package/templates/windsurf-hooks/pre-user-prompt.sh +0 -0
  27. package/templates/agents/README.md +0 -182
  28. package/templates/agents/code-reviewer.md +0 -166
  29. package/templates/agents/debug-detective.md +0 -169
  30. package/templates/agents/ekkOS_Vercel.md +0 -99
  31. package/templates/agents/extension-manager.md +0 -229
  32. package/templates/agents/git-companion.md +0 -185
  33. package/templates/agents/github-test-agent.md +0 -321
  34. package/templates/agents/railway-manager.md +0 -179
  35. package/templates/windsurf-hooks/before-submit-prompt.sh +0 -238
  36. package/templates/windsurf-skills/ekkos-memory/SKILL.md +0 -219
@@ -1,267 +1,205 @@
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
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
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
8
7
  #
9
- # NO compliance checking - skills handle that
10
- # NO PatternGuard validation - skills handle that
11
- # NO verbose output - just capture silently
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
12
11
  # ═══════════════════════════════════════════════════════════════════════════
13
12
 
14
13
  $ErrorActionPreference = "SilentlyContinue"
15
14
 
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
-
35
15
  # ═══════════════════════════════════════════════════════════════════════════
36
- # JSON parsing helper (no jq) - pipes JSON to node, extracts dot-path value
16
+ # CONFIG PATHS
37
17
  # ═══════════════════════════════════════════════════════════════════════════
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" }
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"
66
21
 
67
22
  # ═══════════════════════════════════════════════════════════════════════════
68
- # Session ID
23
+ # INSTANCE ID - Multi-session isolation per v1.2 ADDENDUM
69
24
  # ═══════════════════════════════════════════════════════════════════════════
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
- }
25
+ $EkkosInstanceId = if ($env:EKKOS_INSTANCE_ID) { $env:EKKOS_INSTANCE_ID } else { "default" }
81
26
 
82
27
  # ═══════════════════════════════════════════════════════════════════════════
83
- # WORD-BASED SESSION NAMES - No jq, uses ~/.ekkos/ config (works in ANY project)
28
+ # Load session words from JSON file - NO HARDCODED ARRAYS
84
29
  # ═══════════════════════════════════════════════════════════════════════════
85
- $script:ADJECTIVES = @()
86
- $script:NOUNS = @()
87
- $script:VERBS = @()
88
- $script:SESSION_WORDS_LOADED = $false
30
+ $script:SessionWords = $null
89
31
 
90
32
  function Load-SessionWords {
91
- if ($script:SESSION_WORDS_LOADED) { return }
92
-
93
33
  $wordsFile = $SessionWordsJson
34
+
94
35
  if (-not (Test-Path $wordsFile)) {
95
36
  $wordsFile = $SessionWordsDefault
96
37
  }
97
38
 
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
39
+ if (-not (Test-Path $wordsFile)) {
40
+ return $null
103
41
  }
104
42
 
105
43
  try {
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
44
+ $script:SessionWords = Get-Content $wordsFile -Raw | ConvertFrom-Json
123
45
  } catch {
124
- $script:ADJECTIVES = @("unknown")
125
- $script:NOUNS = @("session")
126
- $script:VERBS = @("starts")
46
+ return $null
127
47
  }
128
48
  }
129
49
 
130
50
  function Convert-UuidToWords {
131
51
  param([string]$uuid)
132
52
 
133
- Load-SessionWords
134
-
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" }
53
+ if (-not $script:SessionWords) {
54
+ Load-SessionWords
55
+ }
138
56
 
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)
57
+ if (-not $script:SessionWords) {
58
+ return "unknown-session"
59
+ }
143
60
 
144
- $adjIdx = $adjSeed % $script:ADJECTIVES.Count
145
- $nounIdx = $nounSeed % $script:NOUNS.Count
146
- $verbIdx = $verbSeed % $script:VERBS.Count
61
+ $adjectives = $script:SessionWords.adjectives
62
+ $nouns = $script:SessionWords.nouns
63
+ $verbs = $script:SessionWords.verbs
147
64
 
148
- return "$($script:ADJECTIVES[$adjIdx])-$($script:NOUNS[$nounIdx])-$($script:VERBS[$verbIdx])"
149
- } catch {
150
- return "unknown-session-starts"
65
+ if (-not $adjectives -or -not $nouns -or -not $verbs) {
66
+ return "unknown-session"
151
67
  }
152
- }
153
68
 
154
- if ($IS_UUID) {
155
- $SESSION_NAME = Convert-UuidToWords $SESSION_ID
156
- } else {
157
- $SESSION_NAME = $SESSION_ID
158
- }
69
+ if (-not $uuid -or $uuid -eq "unknown") { return "unknown-session" }
70
+
71
+ $clean = $uuid -replace "-", ""
72
+ if ($clean.Length -lt 12) { return "unknown-session" }
159
73
 
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) {
170
74
  try {
171
- $TURN_NUMBER = [int](Get-Content $TURN_FILE -Raw).Trim()
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])"
172
80
  } catch {
173
- $TURN_NUMBER = 1
81
+ return "unknown-session"
174
82
  }
175
83
  }
176
84
 
177
85
  # ═══════════════════════════════════════════════════════════════════════════
178
- # Load auth - No jq
86
+ # READ INPUT
179
87
  # ═══════════════════════════════════════════════════════════════════════════
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
190
- }
88
+ $inputJson = [Console]::In.ReadToEnd()
191
89
 
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
- }
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 {}
203
102
  }
204
103
 
205
- if (-not $AUTH_TOKEN) { exit 0 }
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 {}
109
+ }
206
110
 
207
- $MEMORY_API_URL = "https://mcp.ekkos.dev"
111
+ $sessionName = Convert-UuidToWords $rawSessionId
208
112
 
209
113
  # ═══════════════════════════════════════════════════════════════════════════
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.
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.
213
118
  # ═══════════════════════════════════════════════════════════════════════════
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
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 {}
236
147
  }
237
148
 
238
149
  # ═══════════════════════════════════════════════════════════════════════════
239
- # EVICTION: Handled by IPC (In-Place Progressive Compression) in the proxy.
240
- # No hook-side eviction needed - passthrough is default for cache stability.
150
+ # LOCAL CACHE: ACK turn to mark as synced
151
+ # Per v1.2 ADDENDUM: Pass instanceId for namespacing
241
152
  # ═══════════════════════════════════════════════════════════════════════════
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
+ }
242
164
 
243
165
  # ═══════════════════════════════════════════════════════════════════════════
244
- # Check for interruption - No jq
166
+ # CHECK INTERRUPTION - Skip capture if user cancelled
245
167
  # ═══════════════════════════════════════════════════════════════════════════
246
- $IS_INTERRUPTED = Parse-JsonValue $INPUT ".interrupted"
247
- if (-not $IS_INTERRUPTED) { $IS_INTERRUPTED = "false" }
248
- $STOP_REASON = Parse-JsonValue $INPUT ".stop_reason"
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
+ }
249
177
 
250
- if ($IS_INTERRUPTED -eq "true" -or $STOP_REASON -eq "user_cancelled" -or $STOP_REASON -eq "interrupted") {
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)"
251
181
  exit 0
252
182
  }
253
183
 
254
184
  # ═══════════════════════════════════════════════════════════════════════════
255
- # Extract conversation from transcript using Node (no jq)
185
+ # EXTRACT CONVERSATION FROM TRANSCRIPT
186
+ # Mirrors stop.sh: Extract last user query, assistant response, file changes
256
187
  # ═══════════════════════════════════════════════════════════════════════════
257
- $LAST_USER = ""
258
- $LAST_ASSISTANT = ""
259
- $FILE_CHANGES = "[]"
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 = "[]"
260
199
 
261
- if ($TRANSCRIPT_PATH -and (Test-Path $TRANSCRIPT_PATH)) {
200
+ if ($transcriptPath -and (Test-Path $transcriptPath)) {
262
201
  try {
263
- $tpNorm = $TRANSCRIPT_PATH -replace '\\', '/'
264
- $EXTRACTION = & node -e @"
202
+ $extraction = node -e @"
265
203
  const fs = require('fs');
266
204
  const lines = fs.readFileSync(process.argv[1], 'utf8').split('\n').filter(Boolean);
267
205
  const entries = lines.map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
@@ -290,7 +228,6 @@ for (let i = entries.length - 1; i >= 0; i--) {
290
228
  const parts = content.map(c => {
291
229
  if (c.type === 'text') return c.text;
292
230
  if (c.type === 'tool_use') return '[TOOL: ' + c.name + ']';
293
- if (c.type === 'thinking') return '[THINKING]' + (c.thinking || c.text || '') + '[/THINKING]';
294
231
  return '';
295
232
  }).filter(Boolean);
296
233
  lastAssistant = parts.join('\n'); break;
@@ -303,7 +240,7 @@ entries.filter(e => e.type === 'assistant').forEach(e => {
303
240
  const content = e.message?.content;
304
241
  if (Array.isArray(content)) {
305
242
  content.filter(c => c.type === 'tool_use' && ['Edit', 'Write', 'Read'].includes(c.name)).forEach(c => {
306
- fileChanges.push({tool: c.name, path: c.input?.file_path || c.input?.path, action: c.name.toLowerCase()});
243
+ fileChanges.push({tool: c.name, path: (c.input?.file_path || c.input?.path || '').replace(/\\\\/g, '/'), action: c.name.toLowerCase()});
307
244
  });
308
245
  }
309
246
  });
@@ -313,162 +250,86 @@ console.log(JSON.stringify({
313
250
  assistant: lastAssistant.substring(0, 50000),
314
251
  fileChanges: fileChanges.slice(0, 20)
315
252
  }));
316
- "@ -- $tpNorm 2>$null
317
-
318
- if (-not $EXTRACTION) {
319
- $EXTRACTION = '{"user":"","assistant":"","fileChanges":[]}'
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 = "[]" }
320
261
  }
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 = "[]" }
326
262
  } catch {}
327
263
  }
328
264
 
329
- if (-not $LAST_USER -or $LAST_USER -match '\[Request interrupted') {
330
- exit 0
331
- }
332
-
333
265
  # ═══════════════════════════════════════════════════════════════════════════
334
- # Capture to BOTH Working Sessions (Redis) AND Episodic (Supabase) - No jq
266
+ # CAPTURE TO BOTH Working Sessions (Redis) AND Episodic Memory (Supabase)
267
+ # Mirrors stop.sh dual-write at lines 271-356
335
268
  # ═══════════════════════════════════════════════════════════════════════════
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)) {
269
+ if ($lastUser -and $lastAssistant -and $authToken) {
270
+ $modelUsed = "claude-sonnet-4-5"
271
+ if ($inputJson) {
359
272
  try {
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
- }
273
+ $stopInput3 = $inputJson | ConvertFrom-Json
274
+ if ($stopInput3.model) { $modelUsed = $stopInput3.model }
367
275
  } 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 }
372
276
  }
373
277
 
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)
377
- Start-Job -ScriptBlock {
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
278
+ $timestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
279
+ $projectPath = ((Get-Location).Path) -replace '\\', '/'
404
280
 
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
412
- }
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
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)
302
+ 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
442
317
  }
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
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
448
326
  }
449
327
 
450
328
  # ═══════════════════════════════════════════════════════════════════════════
451
- # Update local .ekkos/current-focus.md (if exists) - SILENT
329
+ # CLEAN UP STATE FILES
452
330
  # ═══════════════════════════════════════════════════════════════════════════
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
331
+ if (Test-Path $stateFile) {
332
+ Remove-Item $stateFile -Force
472
333
  }
473
334
 
474
- exit 0
335
+ Write-Output "ekkOS session ended"