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.
- package/README.md +318 -107
- package/VERSION +1 -1
- package/config/hooks/cwd-changed.ps1 +144 -0
- package/config/hooks/post-tool-use.ps1 +347 -0
- package/config/hooks/post-tool-use.sh +6 -6
- package/config/hooks/pre-compact.ps1 +238 -0
- package/config/hooks/pre-compact.sh +10 -6
- package/config/hooks/session-start.ps1 +109 -0
- package/config/hooks/session-start.sh +1 -1
- package/config/hooks/user-prompt-submit.ps1 +287 -0
- package/config/hooks/user-prompt-submit.sh +5 -2
- package/config/statusline.ps1 +160 -0
- package/core/cognition/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/cognition/capture/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/cognition/capture/__pycache__/collector.cpython-313.pyc +0 -0
- package/core/cognition/capture/__pycache__/store.cpython-313.pyc +0 -0
- package/core/cognition/insights/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/cognition/insights/__pycache__/store.cpython-313.pyc +0 -0
- package/core/cognition/memory/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/cognition/memory/__pycache__/obsidian.cpython-313.pyc +0 -0
- package/core/cognition/memory/__pycache__/schemas.cpython-313.pyc +0 -0
- package/core/cognition/memory/__pycache__/vector.cpython-313.pyc +0 -0
- package/core/cognition/memory/__pycache__/writer.cpython-313.pyc +0 -0
- package/core/cognition/research/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/cognition/research/__pycache__/profiler.cpython-313.pyc +0 -0
- package/core/cognition/scheduler/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/cognition/scheduler/__pycache__/cli.cpython-313.pyc +0 -0
- package/core/cognition/scheduler/__pycache__/daemon.cpython-313.pyc +0 -0
- package/core/cognition/scheduler/__pycache__/platform.cpython-313.pyc +0 -0
- package/core/cognition/scheduler/daemon.py +77 -21
- package/core/cognition/scheduler/platform.py +43 -12
- package/core/knowledge/__pycache__/vector_store.cpython-313.pyc +0 -0
- package/core/knowledge/vector_store.py +50 -25
- package/core/synapse/__pycache__/layers.cpython-313.pyc +0 -0
- package/core/synapse/layers.py +2 -2
- package/installer/adapters/claude-code.js +72 -45
- package/installer/cli.js +19 -6
- package/installer/doctor.js +130 -18
- package/installer/index.js +592 -149
- package/installer/platform.js +20 -0
- package/installer/prompts.js +109 -5
- package/installer/python-resolver.js +251 -0
- package/installer/update.js +497 -62
- package/package.json +1 -1
- package/pyproject.toml +2 -2
- 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/.
|
|
109
|
-
mkdir -p "$HOME/.
|
|
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/.
|
|
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/.
|
|
176
|
-
METRICS_LOCK="$HOME/.
|
|
177
|
-
mkdir -p "$HOME/.
|
|
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/.
|
|
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]
|
|
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/.
|
|
86
|
-
METRICS_LOCK="$HOME/.
|
|
87
|
-
mkdir -p "$HOME/.
|
|
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"
|