arkaos 2.10.0 → 2.11.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 (46) hide show
  1. package/README.md +318 -107
  2. package/VERSION +1 -1
  3. package/config/hooks/cwd-changed.ps1 +144 -0
  4. package/config/hooks/post-tool-use.ps1 +347 -0
  5. package/config/hooks/post-tool-use.sh +6 -6
  6. package/config/hooks/pre-compact.ps1 +238 -0
  7. package/config/hooks/pre-compact.sh +10 -6
  8. package/config/hooks/session-start.ps1 +109 -0
  9. package/config/hooks/session-start.sh +1 -1
  10. package/config/hooks/user-prompt-submit.ps1 +287 -0
  11. package/config/hooks/user-prompt-submit.sh +5 -2
  12. package/config/statusline.ps1 +160 -0
  13. package/core/cognition/__pycache__/__init__.cpython-313.pyc +0 -0
  14. package/core/cognition/capture/__pycache__/__init__.cpython-313.pyc +0 -0
  15. package/core/cognition/capture/__pycache__/collector.cpython-313.pyc +0 -0
  16. package/core/cognition/capture/__pycache__/store.cpython-313.pyc +0 -0
  17. package/core/cognition/insights/__pycache__/__init__.cpython-313.pyc +0 -0
  18. package/core/cognition/insights/__pycache__/store.cpython-313.pyc +0 -0
  19. package/core/cognition/memory/__pycache__/__init__.cpython-313.pyc +0 -0
  20. package/core/cognition/memory/__pycache__/obsidian.cpython-313.pyc +0 -0
  21. package/core/cognition/memory/__pycache__/schemas.cpython-313.pyc +0 -0
  22. package/core/cognition/memory/__pycache__/vector.cpython-313.pyc +0 -0
  23. package/core/cognition/memory/__pycache__/writer.cpython-313.pyc +0 -0
  24. package/core/cognition/research/__pycache__/__init__.cpython-313.pyc +0 -0
  25. package/core/cognition/research/__pycache__/profiler.cpython-313.pyc +0 -0
  26. package/core/cognition/scheduler/__pycache__/__init__.cpython-313.pyc +0 -0
  27. package/core/cognition/scheduler/__pycache__/cli.cpython-313.pyc +0 -0
  28. package/core/cognition/scheduler/__pycache__/daemon.cpython-313.pyc +0 -0
  29. package/core/cognition/scheduler/__pycache__/platform.cpython-313.pyc +0 -0
  30. package/core/cognition/scheduler/daemon.py +77 -21
  31. package/core/cognition/scheduler/platform.py +43 -12
  32. package/core/knowledge/__pycache__/vector_store.cpython-313.pyc +0 -0
  33. package/core/knowledge/vector_store.py +50 -25
  34. package/core/synapse/__pycache__/layers.cpython-313.pyc +0 -0
  35. package/core/synapse/layers.py +2 -2
  36. package/installer/adapters/claude-code.js +72 -45
  37. package/installer/cli.js +19 -6
  38. package/installer/doctor.js +130 -18
  39. package/installer/index.js +592 -149
  40. package/installer/platform.js +20 -0
  41. package/installer/prompts.js +109 -5
  42. package/installer/python-resolver.js +251 -0
  43. package/installer/update.js +497 -62
  44. package/package.json +1 -1
  45. package/pyproject.toml +2 -2
  46. package/scripts/start-dashboard.ps1 +271 -0
@@ -0,0 +1,347 @@
1
+ # ============================================================================
2
+ # ArkaOS — PostToolUse Hook (Gotchas Memory) (Windows / PowerShell 5.1+)
3
+ #
4
+ # Port of config/hooks/post-tool-use.sh. Detects errors emitted by tool runs
5
+ # and records recurring patterns in a local gotchas file so ArkaOS can learn
6
+ # which mistakes repeat across projects.
7
+ #
8
+ # Contract:
9
+ # - Reads a hook payload JSON from stdin (tool_name, tool_output, exit_code,
10
+ # cwd).
11
+ # - Always exits 0 and writes `{}` on stdout — PostToolUse does not inject
12
+ # context.
13
+ # - Side effect: updates %USERPROFILE%\.arkaos\gotchas.json and
14
+ # %USERPROFILE%\.arkaos\hook-metrics.json.
15
+ #
16
+ # State files live under the canonical v2 runtime directory `~/.arkaos/`.
17
+ # Older builds of the bash twin used `~/.arka-os/` (with a dash); the
18
+ # bash hook has been fixed in the same commit series, and the installer
19
+ # update path now carries legacy state forward on the first update.
20
+ # ============================================================================
21
+
22
+ $ErrorActionPreference = 'Stop'
23
+ [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
24
+
25
+ # Hard timeout budget for the whole hook: 5 seconds (same as settings).
26
+ $sw = [System.Diagnostics.Stopwatch]::StartNew()
27
+
28
+ # ─── Helper functions (used by both gotchas and metrics blocks) ───────
29
+ # Defined at module scope so the metrics-write block at the bottom of
30
+ # the file can still reach them when `shouldProcessGotchas` is false
31
+ # and the gotchas branch is skipped.
32
+
33
+ function Acquire-FileLock {
34
+ param([string]$LockPath, [int]$TimeoutMs = 3000)
35
+ $deadline = [Environment]::TickCount + $TimeoutMs
36
+ while ([Environment]::TickCount -lt $deadline) {
37
+ try {
38
+ $fs = [System.IO.File]::Open(
39
+ $LockPath,
40
+ [System.IO.FileMode]::OpenOrCreate,
41
+ [System.IO.FileAccess]::ReadWrite,
42
+ [System.IO.FileShare]::None
43
+ )
44
+ return $fs
45
+ } catch {
46
+ Start-Sleep -Milliseconds 50
47
+ }
48
+ }
49
+ return $null
50
+ }
51
+
52
+ # PS 5.1's `Set-Content -Encoding UTF8` writes a BOM; tools that parse
53
+ # UTF-8 strictly (jq, python's json module, etc.) treat the BOM as a
54
+ # stray character. Always write JSON state files BOM-less via the .NET
55
+ # API with an explicit UTF8Encoding($false).
56
+ function Write-JsonAtomic {
57
+ param([string]$Path, [string]$Json)
58
+ $tmp = "$Path.tmp"
59
+ $utf8NoBom = [System.Text.UTF8Encoding]::new($false)
60
+ [System.IO.File]::WriteAllText($tmp, $Json, $utf8NoBom)
61
+ Move-Item -Force -LiteralPath $tmp -Destination $Path
62
+ }
63
+
64
+ # PS 5.1 `ConvertTo-Json` on a single-item collection serializes as a
65
+ # bare object, not a single-element array. We always want `[...]` on
66
+ # disk, even with 0 or 1 entries, so state files stay homogeneous and
67
+ # round-trip cleanly. This helper forces array wrapping by serializing
68
+ # each item and joining manually.
69
+ function ConvertTo-JsonArray {
70
+ param($Items, [int]$Depth = 10)
71
+ $arr = @($Items)
72
+ if ($arr.Count -eq 0) { return '[]' }
73
+ $parts = foreach ($item in $arr) {
74
+ $item | ConvertTo-Json -Depth $Depth -Compress
75
+ }
76
+ return '[' + ($parts -join ',') + ']'
77
+ }
78
+
79
+ # ArrayList survives ConvertTo-Json as a proper JSON array even with
80
+ # 0 or 1 elements, unlike `@()` which PS 5.1 happily collapses.
81
+ function New-StringArrayList {
82
+ param([string[]]$Initial)
83
+ $list = New-Object System.Collections.ArrayList
84
+ if ($Initial) {
85
+ foreach ($s in $Initial) {
86
+ if ($null -ne $s -and $s -ne '') { [void]$list.Add($s) }
87
+ }
88
+ }
89
+ return ,$list
90
+ }
91
+
92
+ # ─── Read hook payload from stdin (with empty-stdin tolerance) ────────
93
+ # On Claude Code Windows v2.1.97 the hook stdin pipe is opened but the
94
+ # parent writes zero bytes before closing (upstream bug — see
95
+ # Projects/ARKA OS/Windows Handoff.md canary data). We still want to
96
+ # log the metric that proves the hook fired, so instead of early-exiting
97
+ # on empty stdin we just skip the gotchas-processing block and fall
98
+ # through to the metrics write + final '{}' at the bottom of the file.
99
+ $stdinText = [Console]::In.ReadToEnd()
100
+ $shouldProcessGotchas = $true
101
+ $payload = $null
102
+ $toolName = ''
103
+ $toolOutput = ''
104
+ $exitCode = '0'
105
+ $cwd = ''
106
+
107
+ if ([string]::IsNullOrWhiteSpace($stdinText)) {
108
+ $shouldProcessGotchas = $false
109
+ } else {
110
+ try {
111
+ $payload = $stdinText | ConvertFrom-Json
112
+ } catch {
113
+ $shouldProcessGotchas = $false
114
+ }
115
+ }
116
+
117
+ if ($shouldProcessGotchas -and $null -ne $payload) {
118
+ $toolName = if ($null -ne $payload.tool_name) { [string]$payload.tool_name } else { '' }
119
+ $toolOutput = if ($null -ne $payload.tool_output) { [string]$payload.tool_output } else { '' }
120
+ $exitCode = if ($null -ne $payload.exit_code) { [string]$payload.exit_code } else { '0' }
121
+ $cwd = if ($null -ne $payload.cwd) { [string]$payload.cwd } else { '' }
122
+
123
+ # ─── Only process when there is actually an error ─────────────────
124
+ $errorPattern = '(?i)(error:|fatal:|exception:|failed|ENOENT|EACCES|EPERM|panic:)'
125
+ if ($exitCode -eq '0' -or [string]::IsNullOrEmpty($exitCode)) {
126
+ if ($toolOutput -notmatch $errorPattern) {
127
+ $shouldProcessGotchas = $false
128
+ }
129
+ }
130
+ }
131
+
132
+ # Paths that the metrics block needs are declared up here, outside the
133
+ # gotchas-processing branch, so the metrics write at the very bottom of
134
+ # the file works even when `shouldProcessGotchas` is false (empty stdin,
135
+ # invalid JSON, no error pattern, etc.).
136
+ $arkaosRuntimeDir = Join-Path $env:USERPROFILE '.arkaos'
137
+ $null = New-Item -ItemType Directory -Force -Path $arkaosRuntimeDir -ErrorAction SilentlyContinue
138
+
139
+ # Gotchas processing lives inside a `do { ... } while ($false)` single-
140
+ # iteration loop so any inner step that used to `exit 0` can now `break`
141
+ # out of the gotchas block without bypassing the metrics write.
142
+ if ($shouldProcessGotchas) { do {
143
+
144
+ # ─── Extract first meaningful error line ─────────────────────────────
145
+ $lines = $toolOutput -split "`r?`n"
146
+ $errorLineRegex = '(?i)(error|fatal|exception|failed|ENOENT|EACCES|EPERM|panic|cannot|not found|permission denied)'
147
+ $errorLine = $null
148
+ foreach ($ln in $lines) {
149
+ if ($ln -match $errorLineRegex) { $errorLine = $ln; break }
150
+ }
151
+ if ([string]::IsNullOrWhiteSpace($errorLine)) {
152
+ # Fallback: first-5-lines-tail-1, same as `head -5 | tail -1` in bash.
153
+ $firstFive = @($lines | Select-Object -First 5)
154
+ if ($firstFive.Count -gt 0) { $errorLine = $firstFive[-1] }
155
+ }
156
+ if ([string]::IsNullOrWhiteSpace($errorLine)) {
157
+ break
158
+ }
159
+
160
+ # ─── Normalize pattern (matches bash sed chain exactly) ───────────────
161
+ $pattern = $errorLine
162
+ $pattern = $pattern -replace '[0-9]{4}-[0-9]{2}-[0-9]{2}[T ][0-9]{2}:[0-9]{2}:[0-9]{2}[^ ]*', 'TIMESTAMP'
163
+ $pattern = $pattern -replace '[0-9a-f]{7,40}', 'HASH'
164
+ $pattern = $pattern -replace 'line [0-9]+', 'line N'
165
+ $pattern = $pattern -replace ':[0-9]+:', ':N:'
166
+ if ($pattern.Length -gt 200) { $pattern = $pattern.Substring(0, 200) }
167
+ if ([string]::IsNullOrWhiteSpace($pattern)) {
168
+ break
169
+ }
170
+
171
+ # ─── Categorize ───────────────────────────────────────────────────────
172
+ $category = 'general'
173
+ if ($errorLine -match '(?i)(artisan|eloquent|laravel|blade|migration|composer|php )') { $category = 'laravel' }
174
+ elseif ($errorLine -match '(?i)(npm|node|vue|react|nuxt|next|vite|webpack|typescript|tsx|jsx)') { $category = 'frontend' }
175
+ elseif ($errorLine -match '(?i)(git |merge|rebase|checkout|branch|commit|push|pull)') { $category = 'git' }
176
+ elseif ($errorLine -match '(?i)(sql|postgres|mysql|database|migration|table|column|constraint)') { $category = 'database' }
177
+ elseif ($errorLine -match '(?i)(permission|denied|EACCES|EPERM|chmod|chown|sudo)') { $category = 'permissions' }
178
+ elseif ($errorLine -match '(?i)(test|assert|expect|jest|phpunit|bats|coverage)') { $category = 'testing' }
179
+
180
+ # ─── Match fix suggestion from gotchas-fixes.json ─────────────────────
181
+ $arkaSkillRoot = if ($env:ARKA_OS) { $env:ARKA_OS } else { Join-Path $env:USERPROFILE '.claude\skills\arka' }
182
+ $fixesFile = Join-Path $arkaSkillRoot 'config\gotchas-fixes.json'
183
+ if (-not (Test-Path -LiteralPath $fixesFile)) {
184
+ $repoPathFile = Join-Path $arkaSkillRoot '.repo-path'
185
+ if (Test-Path -LiteralPath $repoPathFile) {
186
+ try {
187
+ $repoPath = (Get-Content -Raw -LiteralPath $repoPathFile -Encoding UTF8).Trim()
188
+ if ($repoPath) {
189
+ $candidate = Join-Path $repoPath 'config\gotchas-fixes.json'
190
+ if (Test-Path -LiteralPath $candidate) { $fixesFile = $candidate }
191
+ }
192
+ } catch { }
193
+ }
194
+ }
195
+
196
+ $suggestion = ''
197
+ if (Test-Path -LiteralPath $fixesFile) {
198
+ try {
199
+ $fixes = (Get-Content -Raw -LiteralPath $fixesFile -Encoding UTF8 | ConvertFrom-Json).fixes
200
+ foreach ($fix in @($fixes)) {
201
+ if ($fix.pattern_match -and ($errorLine -imatch [string]$fix.pattern_match)) {
202
+ $suggestion = [string]$fix.suggestion
203
+ break
204
+ }
205
+ }
206
+ } catch { }
207
+ }
208
+
209
+ # ─── Detect active project ────────────────────────────────────────────
210
+ $project = ''
211
+ if ($cwd) {
212
+ try {
213
+ $repoPathFile = Join-Path $arkaSkillRoot '.repo-path'
214
+ if (Test-Path -LiteralPath $repoPathFile) {
215
+ $repoPath = (Get-Content -Raw -LiteralPath $repoPathFile -Encoding UTF8).Trim()
216
+ $projectsDir = Join-Path $repoPath 'projects'
217
+ if ($repoPath -and (Test-Path -LiteralPath $projectsDir -PathType Container)) {
218
+ foreach ($projDir in (Get-ChildItem -LiteralPath $projectsDir -Directory -ErrorAction SilentlyContinue)) {
219
+ $projectPathFile = Join-Path $projDir.FullName '.project-path'
220
+ if (Test-Path -LiteralPath $projectPathFile) {
221
+ $projPath = (Get-Content -Raw -LiteralPath $projectPathFile -Encoding UTF8).Trim()
222
+ if ($projPath -and $cwd.StartsWith($projPath)) {
223
+ $project = $projDir.Name
224
+ break
225
+ }
226
+ }
227
+ }
228
+ }
229
+ }
230
+ } catch { }
231
+ if (-not $project) { $project = Split-Path -Leaf $cwd.TrimEnd('\','/') }
232
+ }
233
+
234
+ # ─── Update ~/.arkaos/gotchas.json (best-effort under a file lock) ────
235
+ # $arkaosRuntimeDir was declared at the top of the script so the metrics
236
+ # block can always reach it.
237
+ $gotchasFile = Join-Path $arkaosRuntimeDir 'gotchas.json'
238
+ $gotchasLock = Join-Path $arkaosRuntimeDir 'gotchas.lock'
239
+
240
+ $lock = Acquire-FileLock -LockPath $gotchasLock
241
+ if ($null -ne $lock) {
242
+ try {
243
+ $now = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
244
+
245
+ $gotchas = @()
246
+ if (Test-Path -LiteralPath $gotchasFile) {
247
+ try {
248
+ $raw = Get-Content -Raw -LiteralPath $gotchasFile -Encoding UTF8
249
+ if ($raw -and $raw.Trim()) {
250
+ $parsed = $raw | ConvertFrom-Json
251
+ $gotchas = @($parsed)
252
+ }
253
+ } catch {
254
+ $gotchas = @()
255
+ }
256
+ }
257
+
258
+ $existingIdx = -1
259
+ for ($i = 0; $i -lt $gotchas.Count; $i++) {
260
+ if ([string]$gotchas[$i].pattern -eq $pattern) {
261
+ $existingIdx = $i
262
+ break
263
+ }
264
+ }
265
+
266
+ if ($existingIdx -ge 0) {
267
+ $entry = $gotchas[$existingIdx]
268
+ $entry.count = [int]$entry.count + 1
269
+ $entry.last_seen = $now
270
+ if ($project) {
271
+ $existingProjects = @()
272
+ if ($entry.projects) { $existingProjects = @($entry.projects) }
273
+ if ($existingProjects -notcontains $project) {
274
+ $existingProjects += $project
275
+ }
276
+ $entry.projects = (New-StringArrayList -Initial $existingProjects)
277
+ }
278
+ if ($suggestion -and [string]::IsNullOrEmpty([string]$entry.suggestion)) {
279
+ $entry | Add-Member -NotePropertyName suggestion -NotePropertyValue $suggestion -Force
280
+ }
281
+ $gotchas[$existingIdx] = $entry
282
+ } else {
283
+ $fullPattern = if ($errorLine.Length -gt 500) { $errorLine.Substring(0, 500) } else { $errorLine }
284
+ $projectsList = if ($project) {
285
+ New-StringArrayList -Initial @($project)
286
+ } else {
287
+ New-StringArrayList
288
+ }
289
+ $newEntry = [pscustomobject][ordered]@{
290
+ pattern = $pattern
291
+ full_pattern = $fullPattern
292
+ category = $category
293
+ tool = $toolName
294
+ count = 1
295
+ first_seen = $now
296
+ last_seen = $now
297
+ projects = $projectsList
298
+ suggestion = if ($suggestion) { $suggestion } else { $null }
299
+ }
300
+ $gotchas = @($gotchas) + $newEntry
301
+ }
302
+
303
+ # Keep top 100 by count desc. Force array with @() after the
304
+ # pipeline — PS 5.1 unwraps to a scalar when only one remains.
305
+ $gotchas = @($gotchas | Sort-Object -Property count -Descending | Select-Object -First 100)
306
+
307
+ Write-JsonAtomic -Path $gotchasFile -Json (ConvertTo-JsonArray -Items $gotchas -Depth 10)
308
+ } catch {
309
+ # Swallow — gotchas tracking is best-effort, must not fail the hook.
310
+ } finally {
311
+ $lock.Close()
312
+ $lock.Dispose()
313
+ }
314
+ }
315
+
316
+ } while ($false) } # end: do/while single-iteration + if ($shouldProcessGotchas)
317
+
318
+ # ─── Log hook metrics (best-effort) ───────────────────────────────────
319
+ $metricsFile = Join-Path $arkaosRuntimeDir 'hook-metrics.json'
320
+ $metricsLock = Join-Path $arkaosRuntimeDir 'hook-metrics.lock'
321
+ $metricsLockHandle = Acquire-FileLock -LockPath $metricsLock -TimeoutMs 2000
322
+ if ($null -ne $metricsLockHandle) {
323
+ try {
324
+ $metrics = @()
325
+ if (Test-Path -LiteralPath $metricsFile) {
326
+ try {
327
+ $raw = Get-Content -Raw -LiteralPath $metricsFile -Encoding UTF8
328
+ if ($raw -and $raw.Trim()) { $metrics = @(($raw | ConvertFrom-Json)) }
329
+ } catch { $metrics = @() }
330
+ }
331
+ $metrics = @($metrics) + ([pscustomobject]@{
332
+ hook = 'post-tool-use'
333
+ duration_ms = [int]$sw.ElapsedMilliseconds
334
+ timestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
335
+ })
336
+ if ($metrics.Count -gt 500) { $metrics = @($metrics[-500..-1]) }
337
+ Write-JsonAtomic -Path $metricsFile -Json (ConvertTo-JsonArray -Items $metrics -Depth 5)
338
+ } catch {
339
+ # Metrics are non-critical.
340
+ } finally {
341
+ $metricsLockHandle.Close()
342
+ $metricsLockHandle.Dispose()
343
+ }
344
+ }
345
+
346
+ # Silent output — PostToolUse does not inject context.
347
+ '{}'
@@ -105,11 +105,11 @@ if [ -n "$CWD" ]; then
105
105
  fi
106
106
 
107
107
  # ─── Store in gotchas.json ────────────────────────────────────────────────
108
- GOTCHAS_FILE="$HOME/.arka-os/gotchas.json"
109
- mkdir -p "$HOME/.arka-os"
108
+ GOTCHAS_FILE="$HOME/.arkaos/gotchas.json"
109
+ mkdir -p "$HOME/.arkaos"
110
110
 
111
111
  # Use flock for concurrent safety (fallback if flock not available on macOS)
112
- LOCK_FILE="$HOME/.arka-os/gotchas.lock"
112
+ LOCK_FILE="$HOME/.arkaos/gotchas.lock"
113
113
  if command -v flock &>/dev/null; then
114
114
  LOCK_CMD="flock -w 3 200"
115
115
  else
@@ -172,9 +172,9 @@ fi
172
172
 
173
173
  # ─── Log Metrics ─────────────────────────────────────────────────────────
174
174
  _DURATION_MS=$(_hook_ms)
175
- METRICS_FILE="$HOME/.arka-os/hook-metrics.json"
176
- METRICS_LOCK="$HOME/.arka-os/hook-metrics.lock"
177
- mkdir -p "$HOME/.arka-os"
175
+ METRICS_FILE="$HOME/.arkaos/hook-metrics.json"
176
+ METRICS_LOCK="$HOME/.arkaos/hook-metrics.lock"
177
+ mkdir -p "$HOME/.arkaos"
178
178
  (
179
179
  if command -v flock &>/dev/null; then flock -w 2 200; else true; fi
180
180
  [ ! -f "$METRICS_FILE" ] && echo '[]' > "$METRICS_FILE"
@@ -0,0 +1,238 @@
1
+ # ============================================================================
2
+ # ArkaOS - PreCompact Hook (Session Digest) (Windows / PowerShell 5.1+)
3
+ #
4
+ # Port of config/hooks/pre-compact.sh. Fires before Claude Code compacts the
5
+ # context window. Saves a markdown digest of the session so nothing is lost.
6
+ #
7
+ # Contract:
8
+ # - Reads a JSON payload from stdin with `session_id`, `transcript`, and
9
+ # optionally `messages`.
10
+ # - Writes `%USERPROFILE%\.arkaos\session-digests\<ts>-<id>.md`.
11
+ # - Prunes the digest directory to the 50 most recent entries.
12
+ # - Logs a metrics row to `hook-metrics.json`.
13
+ # - Emits `{}` on stdout - no context injection.
14
+ #
15
+ # State files live under the canonical v2 runtime directory `~/.arkaos/`.
16
+ # Older builds of the bash twin used `~/.arka-os/` (with a dash); the
17
+ # bash hook has been fixed in the same commit series, and the installer
18
+ # update path now carries legacy state forward on the first update.
19
+ #
20
+ # File is pure ASCII on purpose; PS 5.1 reads source files as ANSI by
21
+ # default, which would mojibake any embedded Unicode. Typographic chars
22
+ # used in the output markdown are built from [char] codes at runtime.
23
+ # ============================================================================
24
+
25
+ $ErrorActionPreference = 'Stop'
26
+ [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
27
+
28
+ $sw = [System.Diagnostics.Stopwatch]::StartNew()
29
+
30
+ # --- Read stdin --------------------------------------------------------
31
+ $stdinText = [Console]::In.ReadToEnd()
32
+ $payload = $null
33
+ if (-not [string]::IsNullOrWhiteSpace($stdinText)) {
34
+ try { $payload = $stdinText | ConvertFrom-Json } catch { $payload = $null }
35
+ }
36
+
37
+ $sessionId = if ($payload -and $payload.session_id) { [string]$payload.session_id } else { 'unknown' }
38
+ $transcript = if ($payload -and $payload.transcript) { [string]$payload.transcript } else { '' }
39
+
40
+ # --- Paths -------------------------------------------------------------
41
+ # $arkaosRuntimeDir is declared up-front so the metrics block at the
42
+ # bottom of the file can always reach it, even when we skip the digest
43
+ # write below on empty stdin.
44
+ $arkaosRuntimeDir = Join-Path $env:USERPROFILE '.arkaos'
45
+ $null = New-Item -ItemType Directory -Force -Path $arkaosRuntimeDir -ErrorAction SilentlyContinue
46
+
47
+ # --- Empty-stdin guard (Windows CC v2.1.97 upstream bug) --------------
48
+ # Claude Code on Windows currently opens the hook stdin pipe but writes
49
+ # zero bytes, so `$payload.messages` and `$payload.transcript` are both
50
+ # empty. Writing a `(no transcript available)` digest in that case just
51
+ # spams the session-digests directory with empty files and risks
52
+ # evicting real digests when the 50-file rotation runs.
53
+ #
54
+ # Fast-path: if there is nothing to digest, skip the file write entirely
55
+ # but still fall through to the metrics block so we can prove the hook
56
+ # fired. Mirrors the `shouldProcess` pattern used in post-tool-use.ps1.
57
+ $hasContent = $false
58
+ if ($transcript) { $hasContent = $true }
59
+ if (-not $hasContent -and $payload -and $payload.messages) {
60
+ try {
61
+ foreach ($m in @($payload.messages)) {
62
+ if ($m.role -eq 'assistant' -and $null -ne $m.content -and [string]$m.content) {
63
+ $hasContent = $true
64
+ break
65
+ }
66
+ }
67
+ } catch { }
68
+ }
69
+
70
+ # When $hasContent is false we still compute the digest path variables
71
+ # below (they have no side effects) but everything that writes to disk
72
+ # under the digest directory is wrapped in `if ($hasContent)` so we
73
+ # can't spam ghost digests or prune real ones on empty input.
74
+
75
+ $digestDir = Join-Path $arkaosRuntimeDir 'session-digests'
76
+ $timestamp = (Get-Date).ToString('yyyyMMdd-HHmmss')
77
+ $shortId = if ($sessionId.Length -ge 8) { $sessionId.Substring(0, 8) } else { $sessionId }
78
+ $digestFile = Join-Path $digestDir "$timestamp-$shortId.md"
79
+
80
+ if ($hasContent) {
81
+ $null = New-Item -ItemType Directory -Force -Path $digestDir -ErrorAction SilentlyContinue
82
+ }
83
+
84
+ # --- Extract tail of transcript (last 50 lines) -----------------------
85
+ $tailLines = '(no transcript available)'
86
+ if ($transcript) {
87
+ $split = $transcript -split "`r?`n"
88
+ if ($split.Count -gt 50) {
89
+ $tailLines = ($split[-50..-1] -join "`n")
90
+ } else {
91
+ $tailLines = ($split -join "`n")
92
+ }
93
+ }
94
+
95
+ # --- Extract last 5 assistant messages --------------------------------
96
+ # Walks payload.messages, takes the subset where role == "assistant",
97
+ # grabs the last up-to-five .content values, and joins them with blank
98
+ # lines. The bash twin (config/hooks/pre-compact.sh) is fixed at the
99
+ # same time by replacing `jq '... | last(5) | ...'` (a no-op) with the
100
+ # `[-5:]` array slice.
101
+ $assistantMsgs = ''
102
+ if ($payload -and $payload.messages) {
103
+ try {
104
+ $assistantContents = @()
105
+ foreach ($m in @($payload.messages)) {
106
+ if ($m.role -eq 'assistant' -and $null -ne $m.content) {
107
+ $assistantContents += [string]$m.content
108
+ }
109
+ }
110
+ if ($assistantContents.Count -gt 0) {
111
+ $lastFive = if ($assistantContents.Count -gt 5) {
112
+ $assistantContents[-5..-1]
113
+ } else {
114
+ $assistantContents
115
+ }
116
+ # Single newline matches the bash `jq '... | .[]'` iteration.
117
+ $assistantMsgs = ($lastFive -join "`n")
118
+ }
119
+ } catch {
120
+ # Malformed messages array - fall back to empty, like bash `|| true`.
121
+ }
122
+ }
123
+
124
+ # --- Build digest markdown -----------------------------------------
125
+ $emdash = [char]0x2014
126
+ $nowHuman = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss')
127
+ $assistantBlock = if ($assistantMsgs) { $assistantMsgs } else { '_(none captured)_' }
128
+
129
+ # $utf8NoBom is also used by the metrics block below, so declare it
130
+ # outside the $hasContent branch.
131
+ $utf8NoBom = [System.Text.UTF8Encoding]::new($false)
132
+
133
+ if ($hasContent) {
134
+ $digest = @"
135
+ ---
136
+ type: session-digest
137
+ session_id: $sessionId
138
+ timestamp: $timestamp
139
+ trigger: pre-compact
140
+ ---
141
+
142
+ # Session Digest $emdash $timestamp
143
+
144
+ **Session:** ``$sessionId``
145
+ **Saved at:** $nowHuman
146
+ **Trigger:** Context compaction
147
+
148
+ ## Last Assistant Messages
149
+
150
+ $assistantBlock
151
+
152
+ ## Transcript Tail (last 50 lines)
153
+
154
+ ``````
155
+ $tailLines
156
+ ``````
157
+ "@
158
+
159
+ # Write BOM-less UTF-8 so other tools that read these files (grep, jq,
160
+ # python) behave consistently across platforms.
161
+ [System.IO.File]::WriteAllText($digestFile, $digest, $utf8NoBom)
162
+
163
+ # --- Prune to last 50 digests -------------------------------------
164
+ try {
165
+ $digests = @(
166
+ Get-ChildItem -LiteralPath $digestDir -Filter '*.md' -ErrorAction SilentlyContinue |
167
+ Sort-Object LastWriteTime -Descending
168
+ )
169
+ if ($digests.Count -gt 50) {
170
+ $toRemove = $digests[50..($digests.Count - 1)]
171
+ foreach ($f in $toRemove) {
172
+ Remove-Item -LiteralPath $f.FullName -Force -ErrorAction SilentlyContinue
173
+ }
174
+ }
175
+ } catch {
176
+ # Best-effort cleanup.
177
+ }
178
+ }
179
+
180
+ # --- Metrics (shared shape with post-tool-use) ------------------------
181
+ function Acquire-FileLock {
182
+ param([string]$LockPath, [int]$TimeoutMs = 2000)
183
+ $deadline = [Environment]::TickCount + $TimeoutMs
184
+ while ([Environment]::TickCount -lt $deadline) {
185
+ try {
186
+ return [System.IO.File]::Open(
187
+ $LockPath,
188
+ [System.IO.FileMode]::OpenOrCreate,
189
+ [System.IO.FileAccess]::ReadWrite,
190
+ [System.IO.FileShare]::None
191
+ )
192
+ } catch {
193
+ Start-Sleep -Milliseconds 50
194
+ }
195
+ }
196
+ return $null
197
+ }
198
+
199
+ function ConvertTo-JsonArray {
200
+ param($Items, [int]$Depth = 5)
201
+ $arr = @($Items)
202
+ if ($arr.Count -eq 0) { return '[]' }
203
+ $parts = foreach ($item in $arr) { $item | ConvertTo-Json -Depth $Depth -Compress }
204
+ return '[' + ($parts -join ',') + ']'
205
+ }
206
+
207
+ $metricsFile = Join-Path $arkaosRuntimeDir 'hook-metrics.json'
208
+ $metricsLock = Join-Path $arkaosRuntimeDir 'hook-metrics.lock'
209
+ $lock = Acquire-FileLock -LockPath $metricsLock
210
+ if ($null -ne $lock) {
211
+ try {
212
+ $metrics = @()
213
+ if (Test-Path -LiteralPath $metricsFile) {
214
+ try {
215
+ $raw = Get-Content -Raw -LiteralPath $metricsFile -Encoding UTF8
216
+ if ($raw -and $raw.Trim()) { $metrics = @(($raw | ConvertFrom-Json)) }
217
+ } catch { $metrics = @() }
218
+ }
219
+ $metrics = @($metrics) + ([pscustomobject]@{
220
+ hook = 'pre-compact'
221
+ duration_ms = [int]$sw.ElapsedMilliseconds
222
+ timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
223
+ })
224
+ if ($metrics.Count -gt 500) { $metrics = @($metrics[-500..-1]) }
225
+
226
+ $tmp = "$metricsFile.tmp"
227
+ [System.IO.File]::WriteAllText($tmp, (ConvertTo-JsonArray -Items $metrics -Depth 5), $utf8NoBom)
228
+ Move-Item -Force -LiteralPath $tmp -Destination $metricsFile
229
+ } catch {
230
+ # Non-critical.
231
+ } finally {
232
+ $lock.Close()
233
+ $lock.Dispose()
234
+ }
235
+ }
236
+
237
+ # Silent output.
238
+ '{}'
@@ -24,7 +24,7 @@ SESSION_ID=$(echo "$input" | jq -r '.session_id // "unknown"' 2>/dev/null)
24
24
  TRANSCRIPT=$(echo "$input" | jq -r '.transcript // ""' 2>/dev/null)
25
25
 
26
26
  # ─── Setup ────────────────────────────────────────────────────────────────
27
- DIGEST_DIR="$HOME/.arka-os/session-digests"
27
+ DIGEST_DIR="$HOME/.arkaos/session-digests"
28
28
  mkdir -p "$DIGEST_DIR"
29
29
 
30
30
  TIMESTAMP=$(date +%Y%m%d-%H%M%S)
@@ -39,11 +39,15 @@ else
39
39
  TAIL_LINES="(no transcript available)"
40
40
  fi
41
41
 
42
- # Extract last 5 assistant messages from JSON if available
42
+ # Extract last 5 assistant messages from JSON if available.
43
+ # The previous filter used `last(5)` which in jq returns the LAST element
44
+ # of the generator `5` (the constant 5 itself), so the pipeline collapsed
45
+ # to `5 | .[]` and always produced nothing. Use the array slice `[-5:]`
46
+ # to actually take the last up-to-five elements, then iterate.
43
47
  ASSISTANT_MSGS=""
44
48
  if echo "$input" | jq -e '.messages' &>/dev/null; then
45
49
  ASSISTANT_MSGS=$(echo "$input" | jq -r '
46
- [.messages[] | select(.role == "assistant") | .content] | last(5) | .[] // empty
50
+ [.messages[] | select(.role == "assistant") | .content][-5:] | .[]
47
51
  ' 2>/dev/null)
48
52
  fi
49
53
 
@@ -82,9 +86,9 @@ fi
82
86
 
83
87
  # ─── Log Metrics ─────────────────────────────────────────────────────────
84
88
  _DURATION_MS=$(_hook_ms)
85
- METRICS_FILE="$HOME/.arka-os/hook-metrics.json"
86
- METRICS_LOCK="$HOME/.arka-os/hook-metrics.lock"
87
- mkdir -p "$HOME/.arka-os"
89
+ METRICS_FILE="$HOME/.arkaos/hook-metrics.json"
90
+ METRICS_LOCK="$HOME/.arkaos/hook-metrics.lock"
91
+ mkdir -p "$HOME/.arkaos"
88
92
  (
89
93
  if command -v flock &>/dev/null; then flock -w 2 200; else true; fi
90
94
  [ ! -f "$METRICS_FILE" ] && echo '[]' > "$METRICS_FILE"