@rulecatch/ai-pooler 0.4.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 +232 -0
- package/dist/cli.js +1338 -0
- package/dist/cli.js.map +1 -0
- package/dist/flush.js +1114 -0
- package/dist/flush.js.map +1 -0
- package/dist/index.cjs +1278 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +625 -0
- package/dist/index.js +1240 -0
- package/dist/index.js.map +1 -0
- package/package.json +63 -0
- package/templates/rulecatch-track.sh +549 -0
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Rulecatch AI tracking hook - Auto-generated
|
|
3
|
+
# Writes events to buffer files for batch processing
|
|
4
|
+
# Supports: Zero-knowledge encryption, Regional routing, Adaptive throttling
|
|
5
|
+
|
|
6
|
+
# Debug logging - set RULECATCH_DEBUG=true for verbose output
|
|
7
|
+
DEBUG="${RULECATCH_DEBUG:-false}"
|
|
8
|
+
LOG_FILE="/tmp/rulecatch-hook.log"
|
|
9
|
+
|
|
10
|
+
log() {
|
|
11
|
+
echo "[$(date)] $1" >> "$LOG_FILE"
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
log_debug() {
|
|
15
|
+
if [ "$DEBUG" == "true" ]; then
|
|
16
|
+
echo "[$(date)] [DEBUG] $1" >> "$LOG_FILE"
|
|
17
|
+
fi
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
log "Hook called"
|
|
21
|
+
|
|
22
|
+
# ============================================================================
|
|
23
|
+
# CONFIGURATION
|
|
24
|
+
# ============================================================================
|
|
25
|
+
|
|
26
|
+
CONFIG_DIR="$HOME/.claude/rulecatch"
|
|
27
|
+
CONFIG_FILE="$CONFIG_DIR/config.json"
|
|
28
|
+
BUFFER_DIR="$CONFIG_DIR/buffer"
|
|
29
|
+
FLUSH_SCRIPT="$HOME/.claude/hooks/rulecatch-flush.js"
|
|
30
|
+
|
|
31
|
+
# Exit if not configured
|
|
32
|
+
[ ! -f "$CONFIG_FILE" ] && exit 0
|
|
33
|
+
|
|
34
|
+
# Exit if data collection is paused (subscription expired)
|
|
35
|
+
# User must run `npx @rulecatch/ai-pooler reactivate` to resume
|
|
36
|
+
PAUSED_FILE="$CONFIG_DIR/.paused"
|
|
37
|
+
[ -f "$PAUSED_FILE" ] && exit 0
|
|
38
|
+
|
|
39
|
+
# Read config
|
|
40
|
+
API_KEY=$(jq -r '.apiKey // empty' "$CONFIG_FILE")
|
|
41
|
+
[ -z "$API_KEY" ] && exit 0
|
|
42
|
+
|
|
43
|
+
REGION=$(jq -r '.region // "us"' "$CONFIG_FILE")
|
|
44
|
+
ENCRYPTION_KEY=$(jq -r '.encryptionKey // empty' "$CONFIG_FILE")
|
|
45
|
+
SALT=$(jq -r '.salt // "rulecatch"' "$CONFIG_FILE")
|
|
46
|
+
|
|
47
|
+
# Ensure buffer directory exists
|
|
48
|
+
mkdir -p "$BUFFER_DIR"
|
|
49
|
+
|
|
50
|
+
# Determine if privacy/encryption is enabled
|
|
51
|
+
PRIVACY_ENABLED="false"
|
|
52
|
+
if [ -n "$ENCRYPTION_KEY" ]; then
|
|
53
|
+
PRIVACY_ENABLED="true"
|
|
54
|
+
fi
|
|
55
|
+
|
|
56
|
+
# ============================================================================
|
|
57
|
+
# ENCRYPTION FUNCTIONS
|
|
58
|
+
# Uses AES-256-GCM with PBKDF2 key derivation
|
|
59
|
+
# Format: iv_base64:ciphertext_base64 (compatible with browser Web Crypto API)
|
|
60
|
+
# ============================================================================
|
|
61
|
+
|
|
62
|
+
# Encrypt a single value using AES-256-GCM
|
|
63
|
+
encrypt_pii() {
|
|
64
|
+
local plaintext="$1"
|
|
65
|
+
|
|
66
|
+
if [ -z "$plaintext" ] || [ "$PRIVACY_ENABLED" != "true" ]; then
|
|
67
|
+
echo "$plaintext"
|
|
68
|
+
return
|
|
69
|
+
fi
|
|
70
|
+
|
|
71
|
+
# Use Python for reliable PBKDF2 + AES-GCM (matches browser Web Crypto)
|
|
72
|
+
python3 - "$plaintext" "$ENCRYPTION_KEY" <<'PYEOF' 2>/dev/null
|
|
73
|
+
import sys
|
|
74
|
+
import os
|
|
75
|
+
import base64
|
|
76
|
+
import hashlib
|
|
77
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
78
|
+
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
|
79
|
+
from cryptography.hazmat.primitives import hashes
|
|
80
|
+
|
|
81
|
+
plaintext = sys.argv[1]
|
|
82
|
+
password = sys.argv[2]
|
|
83
|
+
|
|
84
|
+
# Derive key using PBKDF2 (must match browser: salt="rulecatch", iterations=100000)
|
|
85
|
+
kdf = PBKDF2HMAC(
|
|
86
|
+
algorithm=hashes.SHA256(),
|
|
87
|
+
length=32,
|
|
88
|
+
salt=b'rulecatch',
|
|
89
|
+
iterations=100000,
|
|
90
|
+
)
|
|
91
|
+
key = kdf.derive(password.encode())
|
|
92
|
+
|
|
93
|
+
# Generate random IV (12 bytes for GCM)
|
|
94
|
+
iv = os.urandom(12)
|
|
95
|
+
|
|
96
|
+
# Encrypt with AES-256-GCM
|
|
97
|
+
aesgcm = AESGCM(key)
|
|
98
|
+
ciphertext = aesgcm.encrypt(iv, plaintext.encode(), None)
|
|
99
|
+
|
|
100
|
+
# Output format: iv:ciphertext (both base64)
|
|
101
|
+
print(base64.b64encode(iv).decode() + ':' + base64.b64encode(ciphertext).decode())
|
|
102
|
+
PYEOF
|
|
103
|
+
|
|
104
|
+
# If Python encryption failed, return original value
|
|
105
|
+
if [ $? -ne 0 ]; then
|
|
106
|
+
echo "$plaintext"
|
|
107
|
+
fi
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
# Hash a value for searchable indexing (one-way, for grouping/filtering)
|
|
111
|
+
hash_for_index() {
|
|
112
|
+
local plaintext="$1"
|
|
113
|
+
if [ -z "$plaintext" ]; then
|
|
114
|
+
echo ""
|
|
115
|
+
return
|
|
116
|
+
fi
|
|
117
|
+
# Truncated SHA256 hash with salt
|
|
118
|
+
echo -n "${plaintext}${SALT}" | sha256sum | cut -c1-16
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
# ============================================================================
|
|
122
|
+
# MAIN LOGIC
|
|
123
|
+
# ============================================================================
|
|
124
|
+
|
|
125
|
+
# Read JSON from stdin
|
|
126
|
+
INPUT=$(cat)
|
|
127
|
+
|
|
128
|
+
# Extract hook event name
|
|
129
|
+
HOOK_EVENT=$(echo "$INPUT" | jq -r '.hook_event_name // empty')
|
|
130
|
+
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // empty')
|
|
131
|
+
CWD=$(echo "$INPUT" | jq -r '.cwd // empty')
|
|
132
|
+
|
|
133
|
+
# Capture git info (run in CWD context)
|
|
134
|
+
log_debug "CWD=$CWD"
|
|
135
|
+
log_debug "Checking git repo..."
|
|
136
|
+
if [ -n "$CWD" ] && (cd "$CWD" && git rev-parse --git-dir >/dev/null 2>&1); then
|
|
137
|
+
log_debug "In git repo, capturing git info"
|
|
138
|
+
GIT_USERNAME=$(cd "$CWD" && git config user.name 2>/dev/null || echo "")
|
|
139
|
+
GIT_EMAIL=$(cd "$CWD" && git config user.email 2>/dev/null || echo "")
|
|
140
|
+
GIT_REPO=$(cd "$CWD" && git remote get-url origin 2>/dev/null || echo "")
|
|
141
|
+
GIT_BRANCH=$(cd "$CWD" && git branch --show-current 2>/dev/null || echo "")
|
|
142
|
+
GIT_COMMIT=$(cd "$CWD" && git rev-parse --short HEAD 2>/dev/null || echo "")
|
|
143
|
+
GIT_DIRTY=$(cd "$CWD" && [ -n "$(git status --porcelain 2>/dev/null)" ] && echo "true" || echo "false")
|
|
144
|
+
log_debug "Git: user=$GIT_USERNAME, email=$GIT_EMAIL, repo=$GIT_REPO, branch=$GIT_BRANCH, commit=$GIT_COMMIT"
|
|
145
|
+
else
|
|
146
|
+
log_debug "Not in git repo or CWD empty"
|
|
147
|
+
GIT_USERNAME=""
|
|
148
|
+
GIT_EMAIL=""
|
|
149
|
+
GIT_REPO=""
|
|
150
|
+
GIT_BRANCH=""
|
|
151
|
+
GIT_COMMIT=""
|
|
152
|
+
GIT_DIRTY="false"
|
|
153
|
+
fi
|
|
154
|
+
|
|
155
|
+
# ============================================================================
|
|
156
|
+
# ENCRYPT PII FIELDS
|
|
157
|
+
# ============================================================================
|
|
158
|
+
|
|
159
|
+
# Auto-detect project ID from git repo name (BEFORE encryption clears CWD)
|
|
160
|
+
PROJECT_ID=""
|
|
161
|
+
if [ -n "$GIT_REPO" ]; then
|
|
162
|
+
# Extract repo name from URL (handles both HTTPS and SSH)
|
|
163
|
+
PROJECT_ID=$(echo "$GIT_REPO" | sed 's/.*[/:]\([^/]*\)\.git$/\1/' | sed 's/.*[/:]\([^/]*\)$/\1/')
|
|
164
|
+
fi
|
|
165
|
+
if [ -z "$PROJECT_ID" ] && [ -n "$CWD" ]; then
|
|
166
|
+
PROJECT_ID=$(basename "$CWD")
|
|
167
|
+
fi
|
|
168
|
+
[ -z "$PROJECT_ID" ] && PROJECT_ID="unknown"
|
|
169
|
+
|
|
170
|
+
# Initialize encrypted/hash variables
|
|
171
|
+
GIT_USERNAME_ENCRYPTED=""
|
|
172
|
+
GIT_EMAIL_ENCRYPTED=""
|
|
173
|
+
CWD_ENCRYPTED=""
|
|
174
|
+
PROJECT_ID_ENCRYPTED=""
|
|
175
|
+
GIT_USERNAME_HASH=""
|
|
176
|
+
GIT_EMAIL_HASH=""
|
|
177
|
+
CWD_HASH=""
|
|
178
|
+
PROJECT_ID_HASH=""
|
|
179
|
+
|
|
180
|
+
# If privacy is enabled, encrypt PII fields (decryptable) + create hashes (for indexing)
|
|
181
|
+
if [ "$PRIVACY_ENABLED" == "true" ]; then
|
|
182
|
+
GIT_USERNAME_ENCRYPTED=$(encrypt_pii "$GIT_USERNAME")
|
|
183
|
+
GIT_EMAIL_ENCRYPTED=$(encrypt_pii "$GIT_EMAIL")
|
|
184
|
+
CWD_ENCRYPTED=$(encrypt_pii "$CWD")
|
|
185
|
+
PROJECT_ID_ENCRYPTED=$(encrypt_pii "$PROJECT_ID")
|
|
186
|
+
|
|
187
|
+
GIT_USERNAME_HASH=$(hash_for_index "$GIT_USERNAME")
|
|
188
|
+
GIT_EMAIL_HASH=$(hash_for_index "$GIT_EMAIL")
|
|
189
|
+
CWD_HASH=$(hash_for_index "$CWD")
|
|
190
|
+
PROJECT_ID_HASH=$(hash_for_index "$PROJECT_ID")
|
|
191
|
+
|
|
192
|
+
# Clear plaintext values
|
|
193
|
+
GIT_USERNAME=""
|
|
194
|
+
GIT_EMAIL=""
|
|
195
|
+
CWD=""
|
|
196
|
+
PROJECT_ID=""
|
|
197
|
+
fi
|
|
198
|
+
|
|
199
|
+
# ============================================================================
|
|
200
|
+
# BUILD EVENT PAYLOAD
|
|
201
|
+
# ============================================================================
|
|
202
|
+
|
|
203
|
+
case "$HOOK_EVENT" in
|
|
204
|
+
"SessionStart")
|
|
205
|
+
STATS_FILE="$HOME/.claude/stats-cache.json"
|
|
206
|
+
if [ -f "$STATS_FILE" ]; then
|
|
207
|
+
cp "$STATS_FILE" "/tmp/rulecatch-stats-start-$SESSION_ID.json"
|
|
208
|
+
fi
|
|
209
|
+
|
|
210
|
+
CLAUDE_VERSION=$(echo "$INPUT" | jq -r '.version // empty')
|
|
211
|
+
|
|
212
|
+
# Get account info
|
|
213
|
+
ACCOUNT_EMAIL=""
|
|
214
|
+
ORG_NAME=""
|
|
215
|
+
BILLING_TYPE=""
|
|
216
|
+
HAS_OPUS_DEFAULT="false"
|
|
217
|
+
|
|
218
|
+
for f in "$HOME/.claude/"*.json; do
|
|
219
|
+
if [ -f "$f" ]; then
|
|
220
|
+
EMAIL=$(jq -r '.oauthAccount.emailAddress // empty' "$f" 2>/dev/null)
|
|
221
|
+
if [ -n "$EMAIL" ]; then
|
|
222
|
+
ACCOUNT_EMAIL="$EMAIL"
|
|
223
|
+
ORG_NAME=$(jq -r '.oauthAccount.organizationName // empty' "$f" 2>/dev/null)
|
|
224
|
+
BILLING_TYPE=$(jq -r '.oauthAccount.organizationBillingType // empty' "$f" 2>/dev/null)
|
|
225
|
+
HAS_OPUS_DEFAULT=$(jq -r '.hasOpusPlanDefault // false' "$f" 2>/dev/null)
|
|
226
|
+
break
|
|
227
|
+
fi
|
|
228
|
+
fi
|
|
229
|
+
done
|
|
230
|
+
|
|
231
|
+
# Encrypt/hash account email if privacy enabled
|
|
232
|
+
ACCOUNT_EMAIL_ENCRYPTED=""
|
|
233
|
+
ACCOUNT_EMAIL_HASH=""
|
|
234
|
+
if [ "$PRIVACY_ENABLED" == "true" ]; then
|
|
235
|
+
ACCOUNT_EMAIL_ENCRYPTED=$(encrypt_pii "$ACCOUNT_EMAIL")
|
|
236
|
+
ACCOUNT_EMAIL_HASH=$(hash_for_index "$ACCOUNT_EMAIL")
|
|
237
|
+
ACCOUNT_EMAIL=""
|
|
238
|
+
fi
|
|
239
|
+
|
|
240
|
+
EVENT=$(jq -n \
|
|
241
|
+
--arg type "session_start" \
|
|
242
|
+
--arg sessionId "$SESSION_ID" \
|
|
243
|
+
--arg projectId "$PROJECT_ID" \
|
|
244
|
+
--arg projectIdEncrypted "$PROJECT_ID_ENCRYPTED" \
|
|
245
|
+
--arg projectIdHash "$PROJECT_ID_HASH" \
|
|
246
|
+
--arg cwd "$CWD" \
|
|
247
|
+
--arg cwdEncrypted "$CWD_ENCRYPTED" \
|
|
248
|
+
--arg cwdHash "$CWD_HASH" \
|
|
249
|
+
--arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
|
250
|
+
--arg claudeVersion "$CLAUDE_VERSION" \
|
|
251
|
+
--arg accountEmail "$ACCOUNT_EMAIL" \
|
|
252
|
+
--arg accountEmailEncrypted "$ACCOUNT_EMAIL_ENCRYPTED" \
|
|
253
|
+
--arg accountEmailHash "$ACCOUNT_EMAIL_HASH" \
|
|
254
|
+
--arg orgName "$ORG_NAME" \
|
|
255
|
+
--arg billingType "$BILLING_TYPE" \
|
|
256
|
+
--argjson hasOpusDefault "$HAS_OPUS_DEFAULT" \
|
|
257
|
+
--arg gitUsername "$GIT_USERNAME" \
|
|
258
|
+
--arg gitUsernameEncrypted "$GIT_USERNAME_ENCRYPTED" \
|
|
259
|
+
--arg gitUsernameHash "$GIT_USERNAME_HASH" \
|
|
260
|
+
--arg gitEmail "$GIT_EMAIL" \
|
|
261
|
+
--arg gitEmailEncrypted "$GIT_EMAIL_ENCRYPTED" \
|
|
262
|
+
--arg gitEmailHash "$GIT_EMAIL_HASH" \
|
|
263
|
+
--arg gitRepo "$GIT_REPO" \
|
|
264
|
+
--arg gitBranch "$GIT_BRANCH" \
|
|
265
|
+
--arg gitCommit "$GIT_COMMIT" \
|
|
266
|
+
--argjson gitDirty "$GIT_DIRTY" \
|
|
267
|
+
--arg region "$REGION" \
|
|
268
|
+
--argjson privacyEnabled "$PRIVACY_ENABLED" \
|
|
269
|
+
'{type: $type, sessionId: $sessionId, projectId: $projectId, projectIdEncrypted: $projectIdEncrypted, projectIdHash: $projectIdHash, cwd: $cwd, cwdEncrypted: $cwdEncrypted, cwdHash: $cwdHash, timestamp: $timestamp, claudeVersion: $claudeVersion, accountEmail: $accountEmail, accountEmailEncrypted: $accountEmailEncrypted, accountEmailHash: $accountEmailHash, orgName: $orgName, billingType: $billingType, hasOpusDefault: $hasOpusDefault, gitUsername: $gitUsername, gitUsernameEncrypted: $gitUsernameEncrypted, gitUsernameHash: $gitUsernameHash, gitEmail: $gitEmail, gitEmailEncrypted: $gitEmailEncrypted, gitEmailHash: $gitEmailHash, gitRepo: $gitRepo, gitBranch: $gitBranch, gitCommit: $gitCommit, gitDirty: $gitDirty, region: $region, privacyEnabled: $privacyEnabled}')
|
|
270
|
+
;;
|
|
271
|
+
|
|
272
|
+
"SessionEnd")
|
|
273
|
+
TOTAL_ADDED=0
|
|
274
|
+
TOTAL_REMOVED=0
|
|
275
|
+
FILES_CHANGED=0
|
|
276
|
+
CHANGED_FILES_JSON="[]"
|
|
277
|
+
CHANGED_FILES_HASHES="[]"
|
|
278
|
+
|
|
279
|
+
# Use original CWD for git operations (before encryption cleared it)
|
|
280
|
+
ORIGINAL_CWD=$(echo "$INPUT" | jq -r '.cwd // empty')
|
|
281
|
+
|
|
282
|
+
if [ -n "$ORIGINAL_CWD" ] && (cd "$ORIGINAL_CWD" && git rev-parse --git-dir >/dev/null 2>&1); then
|
|
283
|
+
DIFF_STAT=$(cd "$ORIGINAL_CWD" && git diff --numstat HEAD 2>/dev/null)
|
|
284
|
+
STAGED_STAT=$(cd "$ORIGINAL_CWD" && git diff --numstat --cached 2>/dev/null)
|
|
285
|
+
ALL_STATS=$(echo -e "$DIFF_STAT\n$STAGED_STAT" | grep -v '^$')
|
|
286
|
+
|
|
287
|
+
if [ -n "$ALL_STATS" ]; then
|
|
288
|
+
while IFS=$'\t' read -r added removed file; do
|
|
289
|
+
if [ "$added" != "-" ] && [ "$removed" != "-" ]; then
|
|
290
|
+
TOTAL_ADDED=$((TOTAL_ADDED + added))
|
|
291
|
+
TOTAL_REMOVED=$((TOTAL_REMOVED + removed))
|
|
292
|
+
fi
|
|
293
|
+
FILES_CHANGED=$((FILES_CHANGED + 1))
|
|
294
|
+
done <<< "$ALL_STATS"
|
|
295
|
+
|
|
296
|
+
if [ "$PRIVACY_ENABLED" == "true" ]; then
|
|
297
|
+
CHANGED_FILES_HASHES=$(cd "$ORIGINAL_CWD" && git diff --name-only HEAD 2>/dev/null | while read f; do hash_for_index "$f"; done | jq -R -s 'split("\n") | map(select(length > 0))')
|
|
298
|
+
CHANGED_FILES_JSON="[]"
|
|
299
|
+
else
|
|
300
|
+
CHANGED_FILES_JSON=$(cd "$ORIGINAL_CWD" && git diff --name-only HEAD 2>/dev/null | jq -R -s 'split("\n") | map(select(length > 0))')
|
|
301
|
+
fi
|
|
302
|
+
[ -z "$CHANGED_FILES_JSON" ] && CHANGED_FILES_JSON="[]"
|
|
303
|
+
[ -z "$CHANGED_FILES_HASHES" ] && CHANGED_FILES_HASHES="[]"
|
|
304
|
+
fi
|
|
305
|
+
fi
|
|
306
|
+
|
|
307
|
+
# Token delta calculation
|
|
308
|
+
STATS_FILE="$HOME/.claude/stats-cache.json"
|
|
309
|
+
START_STATS="/tmp/rulecatch-stats-start-$SESSION_ID.json"
|
|
310
|
+
|
|
311
|
+
INPUT_TOKENS=0
|
|
312
|
+
OUTPUT_TOKENS=0
|
|
313
|
+
CACHE_READ_TOKENS=0
|
|
314
|
+
CACHE_WRITE_TOKENS=0
|
|
315
|
+
MODEL_USED=""
|
|
316
|
+
|
|
317
|
+
if [ -f "$STATS_FILE" ] && [ -f "$START_STATS" ]; then
|
|
318
|
+
END_STATS=$(cat "$STATS_FILE")
|
|
319
|
+
START_STATS_JSON=$(cat "$START_STATS")
|
|
320
|
+
|
|
321
|
+
for MODEL in "claude-opus-4-5-20251101" "claude-sonnet-4-5-20250929"; do
|
|
322
|
+
START_IN=$(echo "$START_STATS_JSON" | jq -r ".modelUsage.\"$MODEL\".inputTokens // 0")
|
|
323
|
+
END_IN=$(echo "$END_STATS" | jq -r ".modelUsage.\"$MODEL\".inputTokens // 0")
|
|
324
|
+
DELTA_IN=$((END_IN - START_IN))
|
|
325
|
+
|
|
326
|
+
if [ "$DELTA_IN" -gt 0 ]; then
|
|
327
|
+
MODEL_USED="$MODEL"
|
|
328
|
+
INPUT_TOKENS=$DELTA_IN
|
|
329
|
+
|
|
330
|
+
START_OUT=$(echo "$START_STATS_JSON" | jq -r ".modelUsage.\"$MODEL\".outputTokens // 0")
|
|
331
|
+
END_OUT=$(echo "$END_STATS" | jq -r ".modelUsage.\"$MODEL\".outputTokens // 0")
|
|
332
|
+
OUTPUT_TOKENS=$((END_OUT - START_OUT))
|
|
333
|
+
|
|
334
|
+
START_CACHE_R=$(echo "$START_STATS_JSON" | jq -r ".modelUsage.\"$MODEL\".cacheReadInputTokens // 0")
|
|
335
|
+
END_CACHE_R=$(echo "$END_STATS" | jq -r ".modelUsage.\"$MODEL\".cacheReadInputTokens // 0")
|
|
336
|
+
CACHE_READ_TOKENS=$((END_CACHE_R - START_CACHE_R))
|
|
337
|
+
|
|
338
|
+
START_CACHE_W=$(echo "$START_STATS_JSON" | jq -r ".modelUsage.\"$MODEL\".cacheCreationInputTokens // 0")
|
|
339
|
+
END_CACHE_W=$(echo "$END_STATS" | jq -r ".modelUsage.\"$MODEL\".cacheCreationInputTokens // 0")
|
|
340
|
+
CACHE_WRITE_TOKENS=$((END_CACHE_W - START_CACHE_W))
|
|
341
|
+
|
|
342
|
+
break
|
|
343
|
+
fi
|
|
344
|
+
done
|
|
345
|
+
|
|
346
|
+
rm -f "$START_STATS"
|
|
347
|
+
fi
|
|
348
|
+
|
|
349
|
+
ESTIMATED_COST=0
|
|
350
|
+
if [ -n "$MODEL_USED" ]; then
|
|
351
|
+
case "$MODEL_USED" in
|
|
352
|
+
*opus*)
|
|
353
|
+
COST_IN=$(echo "scale=6; $INPUT_TOKENS * 0.000015" | bc)
|
|
354
|
+
COST_OUT=$(echo "scale=6; $OUTPUT_TOKENS * 0.000075" | bc)
|
|
355
|
+
ESTIMATED_COST=$(echo "scale=6; $COST_IN + $COST_OUT" | bc)
|
|
356
|
+
;;
|
|
357
|
+
*sonnet*)
|
|
358
|
+
COST_IN=$(echo "scale=6; $INPUT_TOKENS * 0.000003" | bc)
|
|
359
|
+
COST_OUT=$(echo "scale=6; $OUTPUT_TOKENS * 0.000015" | bc)
|
|
360
|
+
ESTIMATED_COST=$(echo "scale=6; $COST_IN + $COST_OUT" | bc)
|
|
361
|
+
;;
|
|
362
|
+
esac
|
|
363
|
+
fi
|
|
364
|
+
|
|
365
|
+
# Encrypt file paths if privacy enabled
|
|
366
|
+
CHANGED_FILES_ENCRYPTED="[]"
|
|
367
|
+
if [ "$PRIVACY_ENABLED" == "true" ] && [ "$CHANGED_FILES_JSON" != "[]" ]; then
|
|
368
|
+
CHANGED_FILES_ENCRYPTED=$(echo "$CHANGED_FILES_JSON" | jq -r '.[]' | while read f; do
|
|
369
|
+
encrypted=$(encrypt_pii "$f")
|
|
370
|
+
echo "\"$encrypted\""
|
|
371
|
+
done | jq -s '.')
|
|
372
|
+
fi
|
|
373
|
+
|
|
374
|
+
EVENT=$(jq -n \
|
|
375
|
+
--arg type "session_end" \
|
|
376
|
+
--arg sessionId "$SESSION_ID" \
|
|
377
|
+
--arg projectId "$PROJECT_ID" \
|
|
378
|
+
--arg projectIdEncrypted "$PROJECT_ID_ENCRYPTED" \
|
|
379
|
+
--arg projectIdHash "$PROJECT_ID_HASH" \
|
|
380
|
+
--arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
|
381
|
+
--argjson linesAdded "$TOTAL_ADDED" \
|
|
382
|
+
--argjson linesRemoved "$TOTAL_REMOVED" \
|
|
383
|
+
--argjson filesChanged "$FILES_CHANGED" \
|
|
384
|
+
--argjson filesModified "$CHANGED_FILES_JSON" \
|
|
385
|
+
--argjson filesModifiedEncrypted "$CHANGED_FILES_ENCRYPTED" \
|
|
386
|
+
--argjson filesModifiedHashes "$CHANGED_FILES_HASHES" \
|
|
387
|
+
--arg model "$MODEL_USED" \
|
|
388
|
+
--argjson inputTokens "$INPUT_TOKENS" \
|
|
389
|
+
--argjson outputTokens "$OUTPUT_TOKENS" \
|
|
390
|
+
--argjson cacheReadTokens "$CACHE_READ_TOKENS" \
|
|
391
|
+
--argjson cacheWriteTokens "$CACHE_WRITE_TOKENS" \
|
|
392
|
+
--argjson estimatedCost "${ESTIMATED_COST:-0}" \
|
|
393
|
+
--arg gitUsername "$GIT_USERNAME" \
|
|
394
|
+
--arg gitUsernameEncrypted "$GIT_USERNAME_ENCRYPTED" \
|
|
395
|
+
--arg gitUsernameHash "$GIT_USERNAME_HASH" \
|
|
396
|
+
--arg gitEmail "$GIT_EMAIL" \
|
|
397
|
+
--arg gitEmailEncrypted "$GIT_EMAIL_ENCRYPTED" \
|
|
398
|
+
--arg gitEmailHash "$GIT_EMAIL_HASH" \
|
|
399
|
+
--arg gitRepo "$GIT_REPO" \
|
|
400
|
+
--arg gitBranch "$GIT_BRANCH" \
|
|
401
|
+
--arg gitCommit "$GIT_COMMIT" \
|
|
402
|
+
--argjson gitDirty "$GIT_DIRTY" \
|
|
403
|
+
'{type: $type, sessionId: $sessionId, projectId: $projectId, projectIdEncrypted: $projectIdEncrypted, projectIdHash: $projectIdHash, timestamp: $timestamp, linesAdded: $linesAdded, linesRemoved: $linesRemoved, filesChanged: $filesChanged, filesModified: $filesModified, filesModifiedEncrypted: $filesModifiedEncrypted, filesModifiedHashes: $filesModifiedHashes, model: $model, inputTokens: $inputTokens, outputTokens: $outputTokens, cacheReadTokens: $cacheReadTokens, cacheWriteTokens: $cacheWriteTokens, estimatedCost: $estimatedCost, gitUsername: $gitUsername, gitUsernameEncrypted: $gitUsernameEncrypted, gitUsernameHash: $gitUsernameHash, gitEmail: $gitEmail, gitEmailEncrypted: $gitEmailEncrypted, gitEmailHash: $gitEmailHash, gitRepo: $gitRepo, gitBranch: $gitBranch, gitCommit: $gitCommit, gitDirty: $gitDirty}')
|
|
404
|
+
;;
|
|
405
|
+
|
|
406
|
+
"PostToolUse"|"PostToolUseFailure")
|
|
407
|
+
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
|
|
408
|
+
TOOL_INPUT=$(echo "$INPUT" | jq -c '.tool_input // {}')
|
|
409
|
+
TOOL_RESPONSE=$(echo "$INPUT" | jq -c '.tool_response // {}')
|
|
410
|
+
SUCCESS=$([[ "$HOOK_EVENT" == "PostToolUse" ]] && echo "true" || echo "false")
|
|
411
|
+
FILE_PATH=$(echo "$TOOL_INPUT" | jq -r '.file_path // .path // empty')
|
|
412
|
+
|
|
413
|
+
INPUT_SIZE=$(echo "$TOOL_INPUT" | wc -c)
|
|
414
|
+
OUTPUT_SIZE=$(echo "$TOOL_RESPONSE" | wc -c)
|
|
415
|
+
TOOL_INPUT_TOKENS=$((INPUT_SIZE / 4))
|
|
416
|
+
TOOL_OUTPUT_TOKENS=$((OUTPUT_SIZE / 4))
|
|
417
|
+
|
|
418
|
+
# Truncate tool input/output for rule matching (max 4KB each)
|
|
419
|
+
TOOL_INPUT_TRUNCATED=$(echo "$TOOL_INPUT" | head -c 4096)
|
|
420
|
+
TOOL_OUTPUT_TRUNCATED=$(echo "$TOOL_RESPONSE" | head -c 4096)
|
|
421
|
+
|
|
422
|
+
# Save original file path for language detection before encryption
|
|
423
|
+
FILE_PATH_ORIGINAL="$FILE_PATH"
|
|
424
|
+
|
|
425
|
+
# Encrypt/hash file path if privacy enabled
|
|
426
|
+
FILE_PATH_ENCRYPTED=""
|
|
427
|
+
FILE_PATH_HASH=""
|
|
428
|
+
if [ "$PRIVACY_ENABLED" == "true" ] && [ -n "$FILE_PATH" ]; then
|
|
429
|
+
FILE_PATH_ENCRYPTED=$(encrypt_pii "$FILE_PATH")
|
|
430
|
+
FILE_PATH_HASH=$(hash_for_index "$FILE_PATH")
|
|
431
|
+
FILE_PATH=""
|
|
432
|
+
fi
|
|
433
|
+
|
|
434
|
+
# Detect language from original path
|
|
435
|
+
LANGUAGE=""
|
|
436
|
+
if [ -n "$FILE_PATH_ORIGINAL" ]; then
|
|
437
|
+
EXT="${FILE_PATH_ORIGINAL##*.}"
|
|
438
|
+
case "$EXT" in
|
|
439
|
+
ts|tsx) LANGUAGE="typescript" ;;
|
|
440
|
+
js|jsx|mjs|cjs) LANGUAGE="javascript" ;;
|
|
441
|
+
py) LANGUAGE="python" ;;
|
|
442
|
+
rs) LANGUAGE="rust" ;;
|
|
443
|
+
go) LANGUAGE="go" ;;
|
|
444
|
+
java) LANGUAGE="java" ;;
|
|
445
|
+
rb) LANGUAGE="ruby" ;;
|
|
446
|
+
php) LANGUAGE="php" ;;
|
|
447
|
+
cs) LANGUAGE="csharp" ;;
|
|
448
|
+
cpp|cc|cxx|c|h|hpp) LANGUAGE="cpp" ;;
|
|
449
|
+
swift) LANGUAGE="swift" ;;
|
|
450
|
+
kt|kts) LANGUAGE="kotlin" ;;
|
|
451
|
+
sh|bash|zsh) LANGUAGE="shell" ;;
|
|
452
|
+
sql) LANGUAGE="sql" ;;
|
|
453
|
+
html|htm) LANGUAGE="html" ;;
|
|
454
|
+
css|scss|sass|less) LANGUAGE="css" ;;
|
|
455
|
+
json) LANGUAGE="json" ;;
|
|
456
|
+
yaml|yml) LANGUAGE="yaml" ;;
|
|
457
|
+
md|mdx) LANGUAGE="markdown" ;;
|
|
458
|
+
*) LANGUAGE="other" ;;
|
|
459
|
+
esac
|
|
460
|
+
fi
|
|
461
|
+
|
|
462
|
+
case "$TOOL_NAME" in
|
|
463
|
+
"Edit") FILE_OP="edit" ;;
|
|
464
|
+
"Write") FILE_OP="write" ;;
|
|
465
|
+
"Read") FILE_OP="read" ;;
|
|
466
|
+
"Bash") FILE_OP="bash" ;;
|
|
467
|
+
"Glob"|"Grep") FILE_OP="search" ;;
|
|
468
|
+
"Task") FILE_OP="agent" ;;
|
|
469
|
+
"WebFetch"|"WebSearch") FILE_OP="web" ;;
|
|
470
|
+
*) FILE_OP="other" ;;
|
|
471
|
+
esac
|
|
472
|
+
|
|
473
|
+
EVENT=$(jq -n \
|
|
474
|
+
--arg type "tool_call" \
|
|
475
|
+
--arg sessionId "$SESSION_ID" \
|
|
476
|
+
--arg projectId "$PROJECT_ID" \
|
|
477
|
+
--arg projectIdEncrypted "$PROJECT_ID_ENCRYPTED" \
|
|
478
|
+
--arg projectIdHash "$PROJECT_ID_HASH" \
|
|
479
|
+
--arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
|
480
|
+
--arg toolName "$TOOL_NAME" \
|
|
481
|
+
--argjson toolSuccess "$SUCCESS" \
|
|
482
|
+
--arg filePath "$FILE_PATH" \
|
|
483
|
+
--arg filePathEncrypted "$FILE_PATH_ENCRYPTED" \
|
|
484
|
+
--arg filePathHash "$FILE_PATH_HASH" \
|
|
485
|
+
--argjson toolInputSize "$TOOL_INPUT_TOKENS" \
|
|
486
|
+
--argjson toolOutputSize "$TOOL_OUTPUT_TOKENS" \
|
|
487
|
+
--arg toolInput "$TOOL_INPUT_TRUNCATED" \
|
|
488
|
+
--arg toolOutput "$TOOL_OUTPUT_TRUNCATED" \
|
|
489
|
+
--arg language "$LANGUAGE" \
|
|
490
|
+
--arg fileOperation "$FILE_OP" \
|
|
491
|
+
--arg gitUsername "$GIT_USERNAME" \
|
|
492
|
+
--arg gitUsernameEncrypted "$GIT_USERNAME_ENCRYPTED" \
|
|
493
|
+
--arg gitUsernameHash "$GIT_USERNAME_HASH" \
|
|
494
|
+
--arg gitEmail "$GIT_EMAIL" \
|
|
495
|
+
--arg gitEmailEncrypted "$GIT_EMAIL_ENCRYPTED" \
|
|
496
|
+
--arg gitEmailHash "$GIT_EMAIL_HASH" \
|
|
497
|
+
--arg gitRepo "$GIT_REPO" \
|
|
498
|
+
--arg gitBranch "$GIT_BRANCH" \
|
|
499
|
+
--arg gitCommit "$GIT_COMMIT" \
|
|
500
|
+
--argjson gitDirty "$GIT_DIRTY" \
|
|
501
|
+
'{type: $type, sessionId: $sessionId, projectId: $projectId, projectIdEncrypted: $projectIdEncrypted, projectIdHash: $projectIdHash, timestamp: $timestamp, toolName: $toolName, toolSuccess: $toolSuccess, filePath: $filePath, filePathEncrypted: $filePathEncrypted, filePathHash: $filePathHash, toolInputSize: $toolInputSize, toolOutputSize: $toolOutputSize, toolInput: $toolInput, toolOutput: $toolOutput, language: $language, fileOperation: $fileOperation, gitUsername: $gitUsername, gitUsernameEncrypted: $gitUsernameEncrypted, gitUsernameHash: $gitUsernameHash, gitEmail: $gitEmail, gitEmailEncrypted: $gitEmailEncrypted, gitEmailHash: $gitEmailHash, gitRepo: $gitRepo, gitBranch: $gitBranch, gitCommit: $gitCommit, gitDirty: $gitDirty}')
|
|
502
|
+
;;
|
|
503
|
+
|
|
504
|
+
"Stop")
|
|
505
|
+
EVENT=$(jq -n \
|
|
506
|
+
--arg type "turn_complete" \
|
|
507
|
+
--arg sessionId "$SESSION_ID" \
|
|
508
|
+
--arg projectId "$PROJECT_ID" \
|
|
509
|
+
--arg projectIdEncrypted "$PROJECT_ID_ENCRYPTED" \
|
|
510
|
+
--arg projectIdHash "$PROJECT_ID_HASH" \
|
|
511
|
+
--arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
|
512
|
+
'{type: $type, sessionId: $sessionId, projectId: $projectId, projectIdEncrypted: $projectIdEncrypted, projectIdHash: $projectIdHash, timestamp: $timestamp}')
|
|
513
|
+
;;
|
|
514
|
+
|
|
515
|
+
*)
|
|
516
|
+
exit 0
|
|
517
|
+
;;
|
|
518
|
+
esac
|
|
519
|
+
|
|
520
|
+
# ============================================================================
|
|
521
|
+
# WRITE TO BUFFER FILE
|
|
522
|
+
# ============================================================================
|
|
523
|
+
|
|
524
|
+
# Generate unique filename: timestamp-random.json
|
|
525
|
+
TIMESTAMP=$(date +%s%N 2>/dev/null || date +%s)
|
|
526
|
+
RANDOM_SUFFIX=$(head -c 4 /dev/urandom | od -An -tx1 | tr -d ' \n')
|
|
527
|
+
BUFFER_FILE="$BUFFER_DIR/${TIMESTAMP}-${RANDOM_SUFFIX}.json"
|
|
528
|
+
|
|
529
|
+
echo "$EVENT" > "$BUFFER_FILE"
|
|
530
|
+
|
|
531
|
+
log_debug "Event type: $HOOK_EVENT -> $BUFFER_FILE"
|
|
532
|
+
|
|
533
|
+
# ============================================================================
|
|
534
|
+
# TRIGGER FLUSH
|
|
535
|
+
# ============================================================================
|
|
536
|
+
|
|
537
|
+
if [ "$HOOK_EVENT" == "SessionEnd" ] || [ "$HOOK_EVENT" == "Stop" ]; then
|
|
538
|
+
# Force flush on session end (synchronous)
|
|
539
|
+
if [ -f "$FLUSH_SCRIPT" ]; then
|
|
540
|
+
node "$FLUSH_SCRIPT" --force 2>/dev/null
|
|
541
|
+
fi
|
|
542
|
+
else
|
|
543
|
+
# Async flush attempt (non-blocking)
|
|
544
|
+
if [ -f "$FLUSH_SCRIPT" ]; then
|
|
545
|
+
node "$FLUSH_SCRIPT" 2>/dev/null &
|
|
546
|
+
fi
|
|
547
|
+
fi
|
|
548
|
+
|
|
549
|
+
exit 0
|