@ekkos/cli 1.0.35 → 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.
@@ -1,6 +1,14 @@
1
1
  # ═══════════════════════════════════════════════════════════════════════════
2
2
  # ekkOS_ Hook: Stop - Captures turns to BOTH Working (Redis) and Episodic (Supabase)
3
3
  # NO jq dependency - uses Node.js for all JSON parsing
4
+ # MANAGED BY ekkos-connect - DO NOT EDIT DIRECTLY
5
+ # EKKOS_MANAGED=1
6
+ # EKKOS_MANIFEST_SHA256=<computed-at-build>
7
+ # EKKOS_TEMPLATE_VERSION=1.0.0
8
+ #
9
+ # Per ekkOS Onboarding Spec v1.2 FINAL + ADDENDUM:
10
+ # - All persisted records MUST include: instanceId, sessionId, sessionName
11
+ # - Uses EKKOS_INSTANCE_ID env var for multi-session isolation
4
12
  # ═══════════════════════════════════════════════════════════════════════════
5
13
  # This hook captures every turn to:
6
14
  # 1. Working Sessions (Redis) - Fast hot cache for /continue
@@ -13,6 +21,9 @@
13
21
 
14
22
  $ErrorActionPreference = "SilentlyContinue"
15
23
 
24
+ # ═══════════════════════════════════════════════════════════════════════════
25
+ # CONFIG PATHS
26
+ # ═══════════════════════════════════════════════════════════════════════════
16
27
  $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
17
28
  $ProjectRoot = Split-Path -Parent (Split-Path -Parent $ScriptDir)
18
29
  $StateDir = Join-Path $ProjectRoot ".claude\state"
@@ -21,218 +32,197 @@ if (-not (Test-Path $StateDir)) {
21
32
  New-Item -ItemType Directory -Path $StateDir -Force | Out-Null
22
33
  }
23
34
 
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
- # ═══════════════════════════════════════════════════════════════════════════
36
- # JSON parsing helper (no jq) - pipes JSON to node, extracts dot-path value
37
- # ═══════════════════════════════════════════════════════════════════════════
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" }
35
+ $EkkosConfigDir = if ($env:EKKOS_CONFIG_DIR) { $env:EKKOS_CONFIG_DIR } else { "$env:USERPROFILE\.ekkos" }
36
+ $SessionWordsJson = "$EkkosConfigDir\session-words.json"
37
+ $SessionWordsDefault = "$EkkosConfigDir\.defaults\session-words.json"
38
+ $JsonParseHelper = "$EkkosConfigDir\.helpers\json-parse.cjs"
66
39
 
67
40
  # ═══════════════════════════════════════════════════════════════════════════
68
- # Session ID
41
+ # INSTANCE ID - Multi-session isolation per v1.2 ADDENDUM
69
42
  # ═══════════════════════════════════════════════════════════════════════════
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
- }
43
+ $EkkosInstanceId = if ($env:EKKOS_INSTANCE_ID) { $env:EKKOS_INSTANCE_ID } else { "default" }
81
44
 
82
45
  # ═══════════════════════════════════════════════════════════════════════════
83
- # WORD-BASED SESSION NAMES - No jq, uses ~/.ekkos/ config (works in ANY project)
46
+ # Load session words from JSON file - NO HARDCODED ARRAYS
84
47
  # ═══════════════════════════════════════════════════════════════════════════
85
- $script:ADJECTIVES = @()
86
- $script:NOUNS = @()
87
- $script:VERBS = @()
88
- $script:SESSION_WORDS_LOADED = $false
48
+ $script:SessionWords = $null
89
49
 
90
50
  function Load-SessionWords {
91
- if ($script:SESSION_WORDS_LOADED) { return }
92
-
93
51
  $wordsFile = $SessionWordsJson
52
+
94
53
  if (-not (Test-Path $wordsFile)) {
95
54
  $wordsFile = $SessionWordsDefault
96
55
  }
97
56
 
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
57
+ if (-not (Test-Path $wordsFile)) {
58
+ return $null
103
59
  }
104
60
 
105
61
  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
62
+ $script:SessionWords = Get-Content $wordsFile -Raw | ConvertFrom-Json
123
63
  } catch {
124
- $script:ADJECTIVES = @("unknown")
125
- $script:NOUNS = @("session")
126
- $script:VERBS = @("starts")
64
+ return $null
127
65
  }
128
66
  }
129
67
 
130
68
  function Convert-UuidToWords {
131
69
  param([string]$uuid)
132
70
 
133
- Load-SessionWords
71
+ if (-not $script:SessionWords) {
72
+ Load-SessionWords
73
+ }
74
+
75
+ if (-not $script:SessionWords) {
76
+ return "unknown-session"
77
+ }
134
78
 
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" }
79
+ $adjectives = $script:SessionWords.adjectives
80
+ $nouns = $script:SessionWords.nouns
81
+ $verbs = $script:SessionWords.verbs
138
82
 
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)
83
+ if (-not $adjectives -or -not $nouns -or -not $verbs) {
84
+ return "unknown-session"
85
+ }
143
86
 
144
- $adjIdx = $adjSeed % $script:ADJECTIVES.Count
145
- $nounIdx = $nounSeed % $script:NOUNS.Count
146
- $verbIdx = $verbSeed % $script:VERBS.Count
87
+ if (-not $uuid -or $uuid -eq "unknown") { return "unknown-session" }
147
88
 
148
- return "$($script:ADJECTIVES[$adjIdx])-$($script:NOUNS[$nounIdx])-$($script:VERBS[$verbIdx])"
89
+ $clean = $uuid -replace "-", ""
90
+ if ($clean.Length -lt 12) { return "unknown-session" }
91
+
92
+ try {
93
+ $a = [Convert]::ToInt32($clean.Substring(0,4), 16) % $adjectives.Length
94
+ $n = [Convert]::ToInt32($clean.Substring(4,4), 16) % $nouns.Length
95
+ $an = [Convert]::ToInt32($clean.Substring(8,4), 16) % $verbs.Length
96
+
97
+ return "$($adjectives[$a])-$($nouns[$n])-$($verbs[$an])"
149
98
  } catch {
150
- return "unknown-session-starts"
99
+ return "unknown-session"
151
100
  }
152
101
  }
153
102
 
154
- if ($IS_UUID) {
155
- $SESSION_NAME = Convert-UuidToWords $SESSION_ID
103
+ # ═══════════════════════════════════════════════════════════════════════════
104
+ # READ INPUT
105
+ # ═══════════════════════════════════════════════════════════════════════════
106
+ $inputJson = [Console]::In.ReadToEnd()
107
+
108
+ # ═══════════════════════════════════════════════════════════════════════════
109
+ # Parse input JSON for session_id, transcript_path, model
110
+ # ═══════════════════════════════════════════════════════════════════════════
111
+ $rawSessionId = "unknown"
112
+ $transcriptPath = ""
113
+ $modelUsed = "claude-sonnet-4-5"
114
+
115
+ if ($inputJson) {
116
+ try {
117
+ $stopInput = $inputJson | ConvertFrom-Json
118
+ if ($stopInput.session_id) { $rawSessionId = $stopInput.session_id }
119
+ if ($stopInput.transcript_path) { $transcriptPath = $stopInput.transcript_path }
120
+ if ($stopInput.model) { $modelUsed = $stopInput.model }
121
+ } catch {}
122
+ }
123
+
124
+ # ═══════════════════════════════════════════════════════════════════════════
125
+ # Session ID validation
126
+ # ═══════════════════════════════════════════════════════════════════════════
127
+ if (-not $rawSessionId -or $rawSessionId -eq "unknown" -or $rawSessionId -eq "null") {
128
+ exit 0
129
+ }
130
+
131
+ $isUuid = $rawSessionId -match '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
132
+
133
+ if ($isUuid) {
134
+ $sessionName = Convert-UuidToWords $rawSessionId
156
135
  } else {
157
- $SESSION_NAME = $SESSION_ID
136
+ $sessionName = $rawSessionId
158
137
  }
159
138
 
160
139
  # ═══════════════════════════════════════════════════════════════════════════
161
140
  # Turn Number - read from turn file written by user-prompt-submit.ps1
162
141
  # ═══════════════════════════════════════════════════════════════════════════
163
- $SessionsDir = Join-Path $StateDir "sessions"
164
- if (-not (Test-Path $SessionsDir)) {
165
- New-Item -ItemType Directory -Path $SessionsDir -Force | Out-Null
142
+ $sessionsDir = Join-Path $StateDir "sessions"
143
+ if (-not (Test-Path $sessionsDir)) {
144
+ New-Item -ItemType Directory -Path $sessionsDir -Force | Out-Null
166
145
  }
167
- $TURN_FILE = Join-Path $SessionsDir "$SESSION_ID.turn"
168
- $TURN_NUMBER = 1
169
- if (Test-Path $TURN_FILE) {
146
+
147
+ $turnFile = Join-Path $sessionsDir "$rawSessionId.turn"
148
+ $turn = 1
149
+ if (Test-Path $turnFile) {
170
150
  try {
171
- $TURN_NUMBER = [int](Get-Content $TURN_FILE -Raw).Trim()
151
+ $turnContent = Get-Content $turnFile -Raw
152
+ $turn = [int]$turnContent.Trim()
172
153
  } catch {
173
- $TURN_NUMBER = 1
154
+ $turn = 1
174
155
  }
175
156
  }
176
157
 
177
158
  # ═══════════════════════════════════════════════════════════════════════════
178
159
  # Load auth - No jq
179
160
  # ═══════════════════════════════════════════════════════════════════════════
180
- $EKKOS_CONFIG = Join-Path $env:USERPROFILE ".ekkos\config.json"
181
- $AUTH_TOKEN = ""
182
- $USER_ID = ""
161
+ $configFile = Join-Path $EkkosConfigDir "config.json"
162
+ $authToken = ""
163
+ $userId = ""
183
164
 
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
165
+ if ((Test-Path $configFile) -and (Test-Path $JsonParseHelper)) {
166
+ try {
167
+ $authToken = & node $JsonParseHelper $configFile '.hookApiKey' 2>$null
168
+ if (-not $authToken) {
169
+ $authToken = & node $JsonParseHelper $configFile '.apiKey' 2>$null
170
+ }
171
+ $userId = & node $JsonParseHelper $configFile '.userId' 2>$null
172
+ } catch {}
173
+ } elseif (Test-Path $configFile) {
174
+ try {
175
+ $config = Get-Content $configFile -Raw | ConvertFrom-Json
176
+ $authToken = if ($config.hookApiKey) { $config.hookApiKey } else { $config.apiKey }
177
+ $userId = $config.userId
178
+ } catch {}
190
179
  }
191
180
 
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
181
+ if (-not $authToken) {
182
+ $envLocal = Join-Path $ProjectRoot ".env.local"
183
+ if (Test-Path $envLocal) {
184
+ try {
185
+ $envContent = Get-Content $envLocal -Raw
186
+ if ($envContent -match 'SUPABASE_SECRET_KEY=["'']?([^"''\r\n]+)') {
187
+ $authToken = $Matches[1]
200
188
  }
201
- }
189
+ } catch {}
202
190
  }
203
191
  }
204
192
 
205
- if (-not $AUTH_TOKEN) { exit 0 }
193
+ if (-not $authToken) { exit 0 }
206
194
 
207
- $MEMORY_API_URL = "https://mcp.ekkos.dev"
195
+ $memoryApiUrl = "https://api.ekkos.dev"
208
196
 
209
197
  # ═══════════════════════════════════════════════════════════════════════════
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.
198
+ # SESSION BINDING: Bridge _pending real session name for proxy eviction
199
+ # Windows has no PTY so run.ts can't detect the session name. The stop hook
200
+ # is the first place we have a confirmed session name, so we bind here.
213
201
  # ═══════════════════════════════════════════════════════════════════════════
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
202
+ if ($sessionName -and $sessionName -ne "unknown-session" -and $userId) {
203
+ try {
204
+ $projectPath = ((Get-Location).Path) -replace '\\', '/'
205
+ $pendingSession = if ($env:EKKOS_PENDING_SESSION) { $env:EKKOS_PENDING_SESSION } else { "_pending" }
206
+
207
+ $bindBody = @{
208
+ userId = $userId
209
+ realSession = $sessionName
210
+ projectPath = $projectPath
211
+ pendingSession = $pendingSession
212
+ } | ConvertTo-Json -Depth 10 -Compress
213
+
214
+ Start-Job -ScriptBlock {
215
+ param($body, $apiUrl)
216
+ try {
217
+ Invoke-RestMethod -Uri "$apiUrl/proxy/session/bind" `
218
+ -Method POST `
219
+ -Headers @{ "Content-Type" = "application/json" } `
220
+ -Body ([System.Text.Encoding]::UTF8.GetBytes($body)) `
221
+ -TimeoutSec 2 `
222
+ -ErrorAction SilentlyContinue | Out-Null
223
+ } catch {}
224
+ } -ArgumentList $bindBody, $memoryApiUrl | Out-Null
225
+ } catch {}
236
226
  }
237
227
 
238
228
  # ═══════════════════════════════════════════════════════════════════════════
@@ -241,27 +231,49 @@ if ($SESSION_NAME -and $SESSION_NAME -ne "unknown-session-starts" -and $USER_ID)
241
231
  # ═══════════════════════════════════════════════════════════════════════════
242
232
 
243
233
  # ═══════════════════════════════════════════════════════════════════════════
244
- # Check for interruption - No jq
234
+ # LOCAL CACHE: ACK turn to mark as synced
235
+ # Per v1.2 ADDENDUM: Pass instanceId for namespacing
245
236
  # ═══════════════════════════════════════════════════════════════════════════
246
- $IS_INTERRUPTED = Parse-JsonValue $INPUT ".interrupted"
247
- if (-not $IS_INTERRUPTED) { $IS_INTERRUPTED = "false" }
248
- $STOP_REASON = Parse-JsonValue $INPUT ".stop_reason"
237
+ $captureCmd = Get-Command "ekkos-capture" -ErrorAction SilentlyContinue
238
+ if ($captureCmd -and $rawSessionId -ne "unknown") {
239
+ try {
240
+ Start-Job -ScriptBlock {
241
+ param($instanceId, $sessionId, $turnNum)
242
+ try {
243
+ & ekkos-capture ack $sessionId $turnNum "--instance=$instanceId" 2>&1 | Out-Null
244
+ } catch {}
245
+ } -ArgumentList $EkkosInstanceId, $rawSessionId, $turn | Out-Null
246
+ } catch {}
247
+ }
249
248
 
250
- if ($IS_INTERRUPTED -eq "true" -or $STOP_REASON -eq "user_cancelled" -or $STOP_REASON -eq "interrupted") {
249
+ # ═══════════════════════════════════════════════════════════════════════════
250
+ # CHECK INTERRUPTION - Skip capture if user cancelled
251
+ # ═══════════════════════════════════════════════════════════════════════════
252
+ $isInterrupted = $false
253
+ $stopReason = ""
254
+ if ($inputJson) {
255
+ try {
256
+ $stopParsed = $inputJson | ConvertFrom-Json
257
+ $isInterrupted = $stopParsed.interrupted -eq $true
258
+ $stopReason = $stopParsed.stop_reason
259
+ } catch {}
260
+ }
261
+
262
+ if ($isInterrupted -or $stopReason -eq "user_cancelled" -or $stopReason -eq "interrupted") {
251
263
  exit 0
252
264
  }
253
265
 
254
266
  # ═══════════════════════════════════════════════════════════════════════════
255
- # Extract conversation from transcript using Node (no jq)
267
+ # EXTRACT CONVERSATION FROM TRANSCRIPT
268
+ # Mirrors stop.sh: Extract last user query, assistant response, file changes
256
269
  # ═══════════════════════════════════════════════════════════════════════════
257
- $LAST_USER = ""
258
- $LAST_ASSISTANT = ""
259
- $FILE_CHANGES = "[]"
270
+ $lastUser = ""
271
+ $lastAssistant = ""
272
+ $fileChangesJson = "[]"
260
273
 
261
- if ($TRANSCRIPT_PATH -and (Test-Path $TRANSCRIPT_PATH)) {
274
+ if ($transcriptPath -and (Test-Path $transcriptPath)) {
262
275
  try {
263
- $tpNorm = $TRANSCRIPT_PATH -replace '\\', '/'
264
- $EXTRACTION = & node -e @"
276
+ $extraction = node -e @"
265
277
  const fs = require('fs');
266
278
  const lines = fs.readFileSync(process.argv[1], 'utf8').split('\n').filter(Boolean);
267
279
  const entries = lines.map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
@@ -303,7 +315,7 @@ entries.filter(e => e.type === 'assistant').forEach(e => {
303
315
  const content = e.message?.content;
304
316
  if (Array.isArray(content)) {
305
317
  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()});
318
+ fileChanges.push({tool: c.name, path: (c.input?.file_path || c.input?.path || '').replace(/\\\\/g, '/'), action: c.name.toLowerCase()});
307
319
  });
308
320
  }
309
321
  });
@@ -313,162 +325,157 @@ console.log(JSON.stringify({
313
325
  assistant: lastAssistant.substring(0, 50000),
314
326
  fileChanges: fileChanges.slice(0, 20)
315
327
  }));
316
- "@ -- $tpNorm 2>$null
317
-
318
- if (-not $EXTRACTION) {
319
- $EXTRACTION = '{"user":"","assistant":"","fileChanges":[]}'
328
+ "@ $transcriptPath 2>$null
329
+
330
+ if ($extraction) {
331
+ $parsed = $extraction | ConvertFrom-Json
332
+ $lastUser = $parsed.user
333
+ $lastAssistant = $parsed.assistant
334
+ $fileChangesJson = ($parsed.fileChanges | ConvertTo-Json -Depth 10 -Compress)
335
+ if (-not $fileChangesJson -or $fileChangesJson -eq "null") { $fileChangesJson = "[]" }
320
336
  }
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
337
  } catch {}
327
338
  }
328
339
 
329
- if (-not $LAST_USER -or $LAST_USER -match '\[Request interrupted') {
340
+ if (-not $lastUser -or $lastUser -match '\[Request interrupted') {
330
341
  exit 0
331
342
  }
332
343
 
333
344
  # ═══════════════════════════════════════════════════════════════════════════
334
- # Capture to BOTH Working Sessions (Redis) AND Episodic (Supabase) - No jq
345
+ # Extract tools_used and files_referenced from file changes
335
346
  # ═══════════════════════════════════════════════════════════════════════════
336
- if ($LAST_USER -and $LAST_ASSISTANT) {
337
- $TIMESTAMP = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
347
+ $toolsUsed = @()
348
+ $filesReferenced = @()
338
349
 
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 = "[]" }
350
+ if ($parsed -and $parsed.fileChanges) {
351
+ try {
352
+ $toolsUsed = @($parsed.fileChanges | ForEach-Object { $_.tool } | Where-Object { $_ } | Select-Object -Unique)
353
+ $filesReferenced = @($parsed.fileChanges | ForEach-Object { $_.path } | Where-Object { $_ } | Select-Object -Unique)
354
+ } catch {
355
+ $toolsUsed = @()
356
+ $filesReferenced = @()
357
+ }
358
+ }
359
+
360
+ # ═══════════════════════════════════════════════════════════════════════════
361
+ # Token breakdown from tokenizer script
362
+ # ═══════════════════════════════════════════════════════════════════════════
363
+ $totalTokens = 0
364
+ $inputTokens = 0
365
+ $cacheReadTokens = 0
366
+ $cacheCreationTokens = 0
367
+
368
+ $tokenizerScript = Join-Path $ScriptDir "lib\count-tokens.cjs"
369
+ if ($transcriptPath -and (Test-Path $transcriptPath) -and (Test-Path $tokenizerScript)) {
370
+ try {
371
+ $tokenJson = & node $tokenizerScript $transcriptPath --json 2>$null
372
+ if ($tokenJson) {
373
+ $tokenData = $tokenJson | ConvertFrom-Json
374
+ if ($tokenData.total_tokens) { $totalTokens = [int]$tokenData.total_tokens }
375
+ if ($tokenData.input_tokens) { $inputTokens = [int]$tokenData.input_tokens }
376
+ if ($tokenData.cache_read_tokens) { $cacheReadTokens = [int]$tokenData.cache_read_tokens }
377
+ if ($tokenData.cache_creation_tokens) { $cacheCreationTokens = [int]$tokenData.cache_creation_tokens }
378
+ }
379
+ } catch {}
380
+ }
344
381
 
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 = "[]" }
382
+ # ═══════════════════════════════════════════════════════════════════════════
383
+ # CAPTURE TO BOTH Working Sessions (Redis) AND Episodic Memory (Supabase)
384
+ # Mirrors stop.sh dual-write at lines 271-356
385
+ # ═══════════════════════════════════════════════════════════════════════════
386
+ if ($lastUser -and $lastAssistant -and $authToken) {
387
+ $timestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
388
+ $projectPath = ((Get-Location).Path) -replace '\\', '/'
350
389
 
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"
390
+ # Prepare tools_used and files_referenced as JSON strings for passing into jobs
391
+ $toolsUsedJson = ($toolsUsed | ConvertTo-Json -Depth 5 -Compress)
392
+ if (-not $toolsUsedJson -or $toolsUsedJson -eq "null") { $toolsUsedJson = "[]" }
393
+ # Single-element arrays lose their array wrapper in PowerShell ConvertTo-Json
394
+ if ($toolsUsed.Count -eq 1) { $toolsUsedJson = "[$toolsUsedJson]" }
357
395
 
358
- if ($TRANSCRIPT_PATH -and (Test-Path $TRANSCRIPT_PATH) -and (Test-Path $TOKENIZER_SCRIPT)) {
359
- 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
- }
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 }
372
- }
396
+ $filesRefJson = ($filesReferenced | ConvertTo-Json -Depth 5 -Compress)
397
+ if (-not $filesRefJson -or $filesRefJson -eq "null") { $filesRefJson = "[]" }
398
+ if ($filesReferenced.Count -eq 1) { $filesRefJson = "[$filesRefJson]" }
373
399
 
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)
400
+ # 1. WORKING SESSIONS (Redis)
377
401
  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) ---
402
+ param($token, $apiUrl, $sessionName, $turnNum, $userQuery, $agentResponse, $model, $toolsJson, $filesJson, $totalTok, $inputTok, $cacheReadTok, $cacheCreateTok)
386
403
  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
404
+ $truncated = $agentResponse
405
+ if ($agentResponse.Length -gt 50000) {
406
+ $truncated = $agentResponse.Substring(0, 50000)
412
407
  }
408
+ $body = @{
409
+ session_name = $sessionName
410
+ turn_number = [int]$turnNum
411
+ user_query = $userQuery
412
+ agent_response = $truncated
413
+ model = $model
414
+ tools_used = @(($toolsJson | ConvertFrom-Json))
415
+ files_referenced = @(($filesJson | ConvertFrom-Json))
416
+ total_context_tokens = [int]$totalTok
417
+ token_breakdown = @{
418
+ input_tokens = [int]$inputTok
419
+ cache_read_tokens = [int]$cacheReadTok
420
+ cache_creation_tokens = [int]$cacheCreateTok
421
+ }
422
+ } | ConvertTo-Json -Depth 10 -Compress
423
+
424
+ Invoke-RestMethod -Uri "$apiUrl/api/v1/working/turn" `
425
+ -Method POST `
426
+ -Headers @{ Authorization = "Bearer $token"; "Content-Type" = "application/json" } `
427
+ -Body ([System.Text.Encoding]::UTF8.GetBytes($body)) `
428
+ -TimeoutSec 5 `
429
+ -ErrorAction SilentlyContinue | Out-Null
413
430
  } catch {}
431
+ } -ArgumentList $authToken, $memoryApiUrl, $sessionName, $turn, $lastUser, $lastAssistant, $modelUsed, $toolsUsedJson, $filesRefJson, $totalTokens, $inputTokens, $cacheReadTokens, $cacheCreationTokens | Out-Null
414
432
 
415
- # --- 2. EPISODIC MEMORY (Supabase) ---
433
+ # 2. EPISODIC MEMORY (Supabase)
434
+ Start-Job -ScriptBlock {
435
+ param($token, $apiUrl, $userQuery, $agentResponse, $sessionId, $uid, $fileChanges, $model, $ts, $turnNum, $sessionName)
416
436
  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
- }
437
+ $fcArray = @()
438
+ try { $fcArray = @(($fileChanges | ConvertFrom-Json)) } catch { $fcArray = @() }
439
+
440
+ $body = @{
441
+ user_query = $userQuery
442
+ assistant_response = $agentResponse
443
+ session_id = $sessionId
444
+ user_id = if ($uid) { $uid } else { "system" }
445
+ file_changes = $fcArray
446
+ metadata = @{
447
+ source = "claude-code"
448
+ model_used = $model
449
+ captured_at = $ts
450
+ turn_number = [int]$turnNum
451
+ session_name = $sessionName
452
+ minimal_hook = $true
453
+ }
454
+ } | ConvertTo-Json -Depth 10 -Compress
455
+
456
+ Invoke-RestMethod -Uri "$apiUrl/api/v1/memory/capture" `
457
+ -Method POST `
458
+ -Headers @{ Authorization = "Bearer $token"; "Content-Type" = "application/json" } `
459
+ -Body ([System.Text.Encoding]::UTF8.GetBytes($body)) `
460
+ -TimeoutSec 5 `
461
+ -ErrorAction SilentlyContinue | Out-Null
443
462
  } 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
463
+ } -ArgumentList $authToken, $memoryApiUrl, $lastUser, $lastAssistant, $rawSessionId, $userId, $fileChangesJson, $modelUsed, $timestamp, $turn, $sessionName | Out-Null
448
464
  }
449
465
 
450
466
  # ═══════════════════════════════════════════════════════════════════════════
451
467
  # Update local .ekkos/current-focus.md (if exists) - SILENT
452
468
  # ═══════════════════════════════════════════════════════════════════════════
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
469
+ $ekkosDir = Join-Path $ProjectRoot ".ekkos"
470
+ if ((Test-Path $ekkosDir) -and $lastUser) {
471
+ try {
472
+ $focusFile = Join-Path $ekkosDir "current-focus.md"
473
+ $taskSummary = $lastUser.Substring(0, [Math]::Min(100, $lastUser.Length))
474
+ if ($lastUser.Length -gt 100) { $taskSummary += "..." }
475
+ $focusTimestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
476
+ $focusContent = "---`nlast_updated: $focusTimestamp`nsession_id: $rawSessionId`n---`n`n# Current Focus`n$taskSummary"
477
+ Set-Content -Path $focusFile -Value $focusContent -Force
478
+ } catch {}
472
479
  }
473
480
 
474
481
  exit 0