@ghl-ai/aw 0.1.55 → 0.1.56-beta.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.
@@ -0,0 +1,503 @@
1
+ /**
2
+ * AW Usage Telemetry — shared collection module.
3
+ *
4
+ * Two exports:
5
+ * buildEvent(hookInput, eventType, payload) — normalize cross-harness fields
6
+ * sendAsync(event) — fire-and-forget POST via detached child
7
+ *
8
+ * Called by individual hook scripts (post-tool-use, stop, etc.).
9
+ * CJS module — consistent with existing aw-ecc hook ecosystem.
10
+ */
11
+
12
+ 'use strict';
13
+
14
+ const path = require('path');
15
+ const fs = require('fs');
16
+ const os = require('os');
17
+ const { spawn, execSync } = require('child_process');
18
+ const crypto = require('crypto');
19
+
20
+ const SENDER_SCRIPT = path.join(__dirname, '..', 'hooks', 'aw-usage-telemetry-send.js');
21
+ const AW_HOME = path.join(os.homedir(), '.aw');
22
+ const CONFIG_PATH = path.join(AW_HOME, 'telemetry', 'config.json');
23
+ const SESSION_DIR = path.join(AW_HOME, 'telemetry', 'sessions');
24
+ const DEDUPE_DIR = path.join(os.tmpdir(), 'aw-usage-telemetry-dedupe');
25
+
26
+ // ── Git config cache (once per process) ──────────────────────────────
27
+
28
+ let _gitCache = null;
29
+
30
+ function getGitInfo() {
31
+ if (_gitCache) return _gitCache;
32
+ _gitCache = { user: null, email: null };
33
+ try {
34
+ _gitCache.user = execSync('git config user.name', { encoding: 'utf8', timeout: 3000 }).trim() || null;
35
+ } catch { /* ignore */ }
36
+ try {
37
+ _gitCache.email = execSync('git config user.email', { encoding: 'utf8', timeout: 3000 }).trim() || null;
38
+ } catch { /* ignore */ }
39
+ return _gitCache;
40
+ }
41
+
42
+ // ── Telemetry config ─────────────────────────────────────────────────
43
+
44
+ let _configCache = null;
45
+
46
+ function generateMachineId() {
47
+ const raw = `${os.hostname()}:${os.userInfo().username}`;
48
+ return crypto.createHash('sha256').update(raw).digest('hex');
49
+ }
50
+
51
+ function loadConfig() {
52
+ if (_configCache) return _configCache;
53
+ try {
54
+ _configCache = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
55
+ } catch {
56
+ // Config missing or corrupt — self-heal by generating it
57
+ _configCache = { enabled: true, machine_id: generateMachineId() };
58
+ try {
59
+ fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
60
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(_configCache, null, 2) + '\n');
61
+ } catch { /* best effort — don't block the hook */ }
62
+ }
63
+ // Backfill machine_id if config exists but field is missing
64
+ if (!_configCache.machine_id || _configCache.machine_id === 'unknown') {
65
+ _configCache.machine_id = generateMachineId();
66
+ try {
67
+ fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
68
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(_configCache, null, 2) + '\n');
69
+ } catch { /* best effort */ }
70
+ }
71
+ return _configCache;
72
+ }
73
+
74
+ // ── Opt-out check ────────────────────────────────────────────────────
75
+
76
+ function isDisabled() {
77
+ if (process.env.DO_NOT_TRACK === '1') return true;
78
+ if (process.env.AW_TELEMETRY_DISABLED === '1') return true;
79
+ const cfg = loadConfig();
80
+ return cfg.enabled === false;
81
+ }
82
+
83
+ // ── AW version ───────────────────────────────────────────────────────
84
+
85
+ let _awVersion = null;
86
+
87
+ function parseVersionString(raw) {
88
+ if (!raw) return null;
89
+ const match = String(raw).match(/\bv?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\b/);
90
+ return match ? match[1] : null;
91
+ }
92
+
93
+ function getAwVersion() {
94
+ if (_awVersion) return _awVersion;
95
+ const envVersion = parseVersionString(process.env.AW_VERSION);
96
+ if (envVersion) {
97
+ _awVersion = envVersion;
98
+ return _awVersion;
99
+ }
100
+ try {
101
+ const cliVersion = parseVersionString(execSync('aw --version', {
102
+ encoding: 'utf8',
103
+ timeout: 1500,
104
+ stdio: ['ignore', 'pipe', 'ignore'],
105
+ }));
106
+ if (cliVersion) {
107
+ _awVersion = cliVersion;
108
+ return _awVersion;
109
+ }
110
+ } catch { /* ignore */ }
111
+ const candidates = [
112
+ path.join(AW_HOME, 'node_modules', '@ghl-ai', 'aw', 'package.json'),
113
+ ];
114
+ // Derive global npm prefix from the running node binary (no shell needed)
115
+ try {
116
+ const nodeDir = path.dirname(process.execPath);
117
+ candidates.push(path.join(nodeDir, '..', 'lib', 'node_modules', '@ghl-ai', 'aw', 'package.json'));
118
+ } catch { /* ignore */ }
119
+ try {
120
+ const globalPrefix = execSync('npm prefix -g', { encoding: 'utf8', timeout: 3000 }).trim();
121
+ candidates.push(path.join(globalPrefix, 'lib', 'node_modules', '@ghl-ai', 'aw', 'package.json'));
122
+ } catch { /* ignore */ }
123
+ // aw-ecc version as last-resort fallback
124
+ candidates.push(path.join(os.homedir(), '.aw-ecc', 'package.json'));
125
+ for (const pkgPath of candidates) {
126
+ try {
127
+ _awVersion = parseVersionString(JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version) || null;
128
+ if (_awVersion) return _awVersion;
129
+ } catch { /* ignore */ }
130
+ }
131
+ _awVersion = null;
132
+ return _awVersion;
133
+ }
134
+
135
+ // ── Harness detection ────────────────────────────────────────────────
136
+
137
+ function detectHarness(input) {
138
+ // Explicit harness override from shell wrapper (e.g. Codex SessionStart has no turn_id)
139
+ if (process.env.AW_HARNESS) return process.env.AW_HARNESS;
140
+ if (input._cursor || input.conversation_id || input.cursor_version) return 'cursor';
141
+ // Codex provides turn_id on turn-scoped hooks, Claude does not
142
+ if (input.turn_id !== undefined) return 'codex';
143
+ return 'claude';
144
+ }
145
+
146
+ // ── Project hash ─────────────────────────────────────────────────────
147
+
148
+ function computeProjectHash(cwd) {
149
+ if (!cwd) return null;
150
+ return crypto.createHash('sha256').update(cwd).digest('hex').slice(0, 16);
151
+ }
152
+
153
+ // ── Session file cleanup ─────────────────────────────────────────────
154
+ // Prune session files older than SESSION_MAX_AGE_MS to prevent unbounded growth.
155
+ // Called once per session start — best-effort, never blocks.
156
+
157
+ const SESSION_MAX_AGE_MS = 72 * 60 * 60 * 1000; // 72 hours
158
+
159
+ function pruneStaleSessionFiles() {
160
+ try {
161
+ const entries = fs.readdirSync(SESSION_DIR);
162
+ const now = Date.now();
163
+ for (const entry of entries) {
164
+ if (!entry.endsWith('.json')) continue;
165
+ const filePath = path.join(SESSION_DIR, entry);
166
+ const stat = fs.statSync(filePath);
167
+ if (now - stat.mtimeMs > SESSION_MAX_AGE_MS) {
168
+ fs.unlinkSync(filePath);
169
+ }
170
+ }
171
+ } catch { /* best effort */ }
172
+ }
173
+
174
+ // ── Session model persistence ────────────────────────────────────────
175
+ // SessionStart captures the model; later hooks read it from disk.
176
+
177
+ function persistSessionModel(sessionId, model) {
178
+ if (!sessionId || !model) return;
179
+ try {
180
+ fs.mkdirSync(SESSION_DIR, { recursive: true });
181
+ const state = readSessionState(sessionId);
182
+ fs.writeFileSync(path.join(SESSION_DIR, sessionId + '.json'), JSON.stringify({
183
+ ...state,
184
+ model,
185
+ }));
186
+ } catch { /* ignore */ }
187
+ }
188
+
189
+ function readSessionModel(sessionId) {
190
+ const state = readSessionState(sessionId);
191
+ return state.model || null;
192
+ }
193
+
194
+ function readSessionState(sessionId) {
195
+ if (!sessionId) return {};
196
+ try {
197
+ const filePath = path.join(SESSION_DIR, sessionId + '.json');
198
+ const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
199
+ // Touch mtime so active sessions are never pruned by cleanup
200
+ try { const now = new Date(); fs.utimesSync(filePath, now, now); } catch { /* ignore */ }
201
+ return data;
202
+ } catch { return {}; }
203
+ }
204
+
205
+ function persistSessionSkill(sessionId, turnId, skill) {
206
+ if (!sessionId || !skill?.skill_name) return;
207
+ try {
208
+ fs.mkdirSync(SESSION_DIR, { recursive: true });
209
+ const state = readSessionState(sessionId);
210
+ fs.writeFileSync(path.join(SESSION_DIR, sessionId + '.json'), JSON.stringify({
211
+ ...state,
212
+ last_skill: {
213
+ turn_id: turnId || null,
214
+ skill_name: skill.skill_name,
215
+ args: skill.args || '',
216
+ source: skill.source || 'unknown',
217
+ updated_at: new Date().toISOString(),
218
+ },
219
+ }));
220
+ } catch { /* ignore */ }
221
+ }
222
+
223
+ function readSessionSkill(sessionId, turnId) {
224
+ const skill = readSessionState(sessionId)?.last_skill;
225
+ if (!skill?.skill_name) return null;
226
+ if (turnId) {
227
+ return skill.turn_id === turnId ? skill : null;
228
+ }
229
+ return skill.turn_id ? null : skill;
230
+ }
231
+
232
+ // Persist the most recent slash command from UserPromptSubmit so that
233
+ // later PostToolUse / Stop hooks can correlate test/artifact writes back
234
+ // to the originating /aw:* invocation. Separate from `last_skill` because
235
+ // the source is the prompt, not the tool, and the lifetime is the whole
236
+ // session (not just one turn).
237
+ function persistSessionSlashCommand(sessionId, slashCommand) {
238
+ if (!sessionId || !slashCommand?.command_name) return;
239
+ try {
240
+ fs.mkdirSync(SESSION_DIR, { recursive: true });
241
+ const state = readSessionState(sessionId);
242
+ fs.writeFileSync(path.join(SESSION_DIR, sessionId + '.json'), JSON.stringify({
243
+ ...state,
244
+ last_slash_command: {
245
+ command_namespace: slashCommand.command_namespace || null,
246
+ command_name: slashCommand.command_name,
247
+ command_args: slashCommand.command_args || '',
248
+ is_sdlc_stage: Boolean(slashCommand.is_sdlc_stage),
249
+ updated_at: new Date().toISOString(),
250
+ },
251
+ }));
252
+ } catch { /* ignore */ }
253
+ }
254
+
255
+ function readSessionLastSlashCommand(sessionId) {
256
+ const cmd = readSessionState(sessionId)?.last_slash_command;
257
+ if (!cmd?.command_name) return null;
258
+ return cmd;
259
+ }
260
+
261
+ // ── Short-TTL dedupe guards ──────────────────────────────────────────
262
+
263
+ function normalizeDedupePart(value) {
264
+ if (value === undefined || value === null) return '';
265
+ if (typeof value === 'string') return value;
266
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
267
+ try {
268
+ return JSON.stringify(value);
269
+ } catch {
270
+ return String(value);
271
+ }
272
+ }
273
+
274
+ function tryAcquireDedupe(scope, parts, ttlMs = 2500) {
275
+ const normalizedParts = Array.isArray(parts) ? parts.map(normalizeDedupePart) : [normalizeDedupePart(parts)];
276
+ const digest = crypto.createHash('sha256')
277
+ .update(normalizedParts.join('\n'))
278
+ .digest('hex');
279
+ const safeScope = String(scope || 'event').replace(/[^a-z0-9_-]+/gi, '-').toLowerCase();
280
+ const lockPath = path.join(DEDUPE_DIR, `${safeScope}-${digest}.lock`);
281
+
282
+ try {
283
+ fs.mkdirSync(DEDUPE_DIR, { recursive: true });
284
+ } catch {
285
+ return true;
286
+ }
287
+
288
+ try {
289
+ const stat = fs.statSync(lockPath);
290
+ if (Date.now() - stat.mtimeMs <= ttlMs) {
291
+ return false;
292
+ }
293
+ fs.unlinkSync(lockPath);
294
+ } catch { /* no active lock */ }
295
+
296
+ try {
297
+ fs.writeFileSync(lockPath, String(Date.now()), { flag: 'wx' });
298
+ return true;
299
+ } catch {
300
+ return false;
301
+ }
302
+ }
303
+
304
+ // ── Codex internal-session filters ───────────────────────────────────
305
+
306
+ function resolvePromptText(input) {
307
+ const candidates = [
308
+ input?.prompt,
309
+ input?.user_prompt,
310
+ input?.message,
311
+ input?.text,
312
+ ];
313
+ for (const candidate of candidates) {
314
+ if (typeof candidate === 'string' && candidate.trim()) {
315
+ return candidate;
316
+ }
317
+ }
318
+ return '';
319
+ }
320
+
321
+ function isCodexInternalTaskTitlePrompt(input) {
322
+ if (detectHarness(input) !== 'codex') return false;
323
+ if (input?.transcript_path) return false;
324
+ const prompt = resolvePromptText(input);
325
+ return prompt.includes('Generate a concise UI title')
326
+ && prompt.includes('User prompt:');
327
+ }
328
+
329
+ function isCodexInternalTaskTitleCompletion(input) {
330
+ if (detectHarness(input) !== 'codex') return false;
331
+ if (input?.transcript_path) return false;
332
+ const rawMessage = typeof input?.last_assistant_message === 'string'
333
+ ? input.last_assistant_message.trim()
334
+ : '';
335
+ if (!rawMessage) return false;
336
+ try {
337
+ const parsed = JSON.parse(rawMessage);
338
+ return parsed
339
+ && typeof parsed === 'object'
340
+ && !Array.isArray(parsed)
341
+ && typeof parsed.title === 'string'
342
+ && Object.keys(parsed).length === 1;
343
+ } catch {
344
+ return false;
345
+ }
346
+ }
347
+
348
+ // ── Transcript parsing ───────────────────────────────────────────────
349
+
350
+ function buildCodexUsage(entry) {
351
+ if (entry?.type !== 'event_msg' || entry?.payload?.type !== 'token_count') return null;
352
+ const info = entry.payload?.info || {};
353
+ const usage = info.last_token_usage || info.total_token_usage;
354
+ if (!usage || typeof usage !== 'object') return null;
355
+ return {
356
+ input_tokens: usage.input_tokens ?? null,
357
+ output_tokens: usage.output_tokens ?? null,
358
+ cached_input_tokens: usage.cached_input_tokens ?? null,
359
+ reasoning_output_tokens: usage.reasoning_output_tokens ?? null,
360
+ };
361
+ }
362
+
363
+ function isCodexAssistantEntry(entry) {
364
+ return entry?.type === 'response_item'
365
+ && entry?.payload?.type === 'message'
366
+ && entry?.payload?.role === 'assistant';
367
+ }
368
+
369
+ /**
370
+ * Read the last assistant entry from a transcript JSONL file.
371
+ * Reads the last 256KB (enough for several entries) to avoid loading
372
+ * the entire file which can be 10MB+.
373
+ *
374
+ * Works across harnesses — Claude/Cursor transcripts expose assistant
375
+ * rows directly, while Codex writes `response_item` + `event_msg`
376
+ * lines and surfaces usage via `token_count`.
377
+ *
378
+ * Returns { model, stop_reason, usage } or null.
379
+ */
380
+ function readLastAssistantFromTranscript(transcriptPath) {
381
+ if (!transcriptPath) return null;
382
+ try {
383
+ const stat = fs.statSync(transcriptPath);
384
+ const TAIL_BYTES = 256 * 1024;
385
+ const start = Math.max(0, stat.size - TAIL_BYTES);
386
+ const fd = fs.openSync(transcriptPath, 'r');
387
+ const buf = Buffer.alloc(Math.min(TAIL_BYTES, stat.size));
388
+ fs.readSync(fd, buf, 0, buf.length, start);
389
+ fs.closeSync(fd);
390
+
391
+ const chunk = buf.toString('utf8');
392
+ const lines = chunk.split('\n').filter(Boolean);
393
+ let latestCodexUsage = null;
394
+
395
+ // Walk backward to find the last assistant entry for any harness.
396
+ for (let i = lines.length - 1; i >= 0; i--) {
397
+ try {
398
+ const entry = JSON.parse(lines[i]);
399
+ if (!latestCodexUsage) {
400
+ latestCodexUsage = buildCodexUsage(entry);
401
+ }
402
+ if (entry.type === 'assistant' && entry.message) {
403
+ const msg = entry.message;
404
+ return {
405
+ model: msg.model || null,
406
+ stop_reason: msg.stop_reason || null,
407
+ usage: msg.usage || null,
408
+ };
409
+ }
410
+ if (isCodexAssistantEntry(entry)) {
411
+ return {
412
+ model: null,
413
+ stop_reason: null,
414
+ usage: latestCodexUsage,
415
+ };
416
+ }
417
+ } catch { /* skip malformed lines */ }
418
+ }
419
+ } catch { /* transcript unreadable — non-blocking */ }
420
+ return null;
421
+ }
422
+
423
+ // ── buildEvent ───────────────────────────────────────────────────────
424
+
425
+ function buildEvent(hookInput, eventType, payload) {
426
+ const input = hookInput || {};
427
+ const cfg = loadConfig();
428
+ const git = getGitInfo();
429
+ const harness = detectHarness(input);
430
+
431
+ // Normalize session_id: Claude/Codex use session_id, Cursor uses conversation_id
432
+ const sessionId = input.session_id
433
+ || input._cursor?.conversation_id
434
+ || input.conversation_id
435
+ || null;
436
+
437
+ // generation_id: Cursor only
438
+ const generationId = input.generation_id || null;
439
+
440
+ // model: Claude only on SessionStart, Codex/Cursor on all hooks
441
+ // Fall back to persisted session model from SessionStart
442
+ const model = input.model
443
+ || input._cursor?.model
444
+ || readSessionModel(sessionId)
445
+ || null;
446
+
447
+ // cwd: Claude/Codex have input.cwd, Cursor uses workspace_roots
448
+ const cwd = input.cwd
449
+ || (input.workspace_roots && input.workspace_roots[0])
450
+ || null;
451
+
452
+ return {
453
+ session_id: sessionId,
454
+ generation_id: generationId,
455
+ harness,
456
+ model,
457
+ machine_id: cfg.machine_id || 'unknown',
458
+ github_user: git.user || input.user_email || null,
459
+ github_email: git.email || input.user_email || null,
460
+ project_hash: computeProjectHash(cwd),
461
+ aw_version: getAwVersion(),
462
+ event: eventType,
463
+ client_ts: new Date().toISOString(),
464
+ payload: payload || {},
465
+ };
466
+ }
467
+
468
+ // ── sendAsync ────────────────────────────────────────────────────────
469
+
470
+ function sendAsync(event) {
471
+ if (isDisabled()) return;
472
+ if (!event) return;
473
+
474
+ try {
475
+ const child = spawn('node', [SENDER_SCRIPT, JSON.stringify(event)], {
476
+ detached: true,
477
+ stdio: 'ignore',
478
+ });
479
+ child.unref();
480
+ } catch {
481
+ // Fire-and-forget — never block the hook.
482
+ }
483
+ }
484
+
485
+ module.exports = {
486
+ buildEvent,
487
+ sendAsync,
488
+ isDisabled,
489
+ detectHarness,
490
+ loadConfig,
491
+ persistSessionModel,
492
+ pruneStaleSessionFiles,
493
+ readSessionModel,
494
+ persistSessionSkill,
495
+ readSessionSkill,
496
+ persistSessionSlashCommand,
497
+ readSessionLastSlashCommand,
498
+ readLastAssistantFromTranscript,
499
+ resolvePromptText,
500
+ tryAcquireDedupe,
501
+ isCodexInternalTaskTitlePrompt,
502
+ isCodexInternalTaskTitleCompletion,
503
+ };
@@ -0,0 +1,4 @@
1
+ {
2
+ "type": "commonjs",
3
+ "_comment": "Override parent libs/aw/package.json's \"type\": \"module\". The aw-usage hook scripts use require() because they're invoked by Claude Code / Cursor / Codex hooks via `node <path>` — they need to behave the same when bundled in @ghl-ai/aw as when installed by aw-ecc directly into ~/.claude/scripts/hooks/."
4
+ }