@ghl-ai/aw 0.1.55 → 0.1.56-beta.1

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,173 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Usage telemetry — UserPromptSubmit hook.
4
+ *
5
+ * Emits prompt_submitted as a boundary marker.
6
+ * No matchers on any harness — always fires.
7
+ *
8
+ * Outputs {} on stdout.
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ const {
14
+ buildEvent,
15
+ sendAsync,
16
+ persistSessionSkill,
17
+ persistSessionSlashCommand,
18
+ resolvePromptText,
19
+ tryAcquireDedupe,
20
+ isCodexInternalTaskTitlePrompt,
21
+ } = require('../lib/aw-usage-telemetry');
22
+
23
+ const MAX_STDIN = 1024 * 1024;
24
+
25
+ // Slash commands we recognize beyond /aw:*. Each entry maps to a namespace
26
+ // used in the emitted payload so the dashboard can group by tool family.
27
+ // namespace:command → matched by ^/<namespace>:<command>
28
+ // bare-name → matched by ^/<bare-name> (Matt Pocock skills, codex `/tdd`)
29
+ const NAMESPACED_PREFIXES = ['aw', 'caveman', 'codex', 'graphify', 'rtk', 'lean-ctx'];
30
+ const BARE_SLASH_COMMANDS = new Set([
31
+ 'tdd', 'diagnose', 'grill-me', 'grill-with-docs', 'triage', 'to-prd', 'zoom-out',
32
+ ]);
33
+ // `/aw:` sub-commands that count as SDLC stages so the funnel can group by stage.
34
+ const AW_SDLC_STAGES = new Set([
35
+ 'plan', 'build', 'execute', 'test', 'verify', 'review',
36
+ 'deploy', 'ship', 'tdd', 'investigate', 'feature',
37
+ ]);
38
+
39
+ function extractAwSlashCommand(input) {
40
+ const prompt = resolvePromptText(input).trim();
41
+
42
+ // Try `/namespace:command` shape first.
43
+ const namespacedRe = new RegExp(
44
+ `^\\/((?:${NAMESPACED_PREFIXES.join('|')})(?::[a-z0-9-]+)?)(?:\\s+([\\s\\S]*\\S))?\\s*$`,
45
+ 'i',
46
+ );
47
+ let match = prompt.match(namespacedRe);
48
+ let commandFull = null;
49
+ let commandArgs = '';
50
+ if (match) {
51
+ commandFull = match[1].toLowerCase();
52
+ commandArgs = match[2] || '';
53
+ } else {
54
+ // Try bare name shape (e.g. `/tdd ...`).
55
+ const bareRe = /^\/([a-z][a-z0-9-]*)(?:\s+([\s\S]*\S))?\s*$/i;
56
+ const bareMatch = prompt.match(bareRe);
57
+ if (bareMatch && BARE_SLASH_COMMANDS.has(bareMatch[1].toLowerCase())) {
58
+ commandFull = bareMatch[1].toLowerCase();
59
+ commandArgs = bareMatch[2] || '';
60
+ }
61
+ }
62
+ if (!commandFull) return null;
63
+
64
+ // Split into namespace + name. Bare names have no namespace.
65
+ let namespace = null;
66
+ let name = commandFull;
67
+ const colonIdx = commandFull.indexOf(':');
68
+ if (colonIdx > 0) {
69
+ namespace = commandFull.slice(0, colonIdx);
70
+ name = commandFull.slice(colonIdx + 1);
71
+ }
72
+
73
+ const isSdlcStage = namespace === 'aw' && AW_SDLC_STAGES.has(name);
74
+
75
+ return {
76
+ skill_name: commandFull, // kept for backwards compat with existing skill_invoked consumers
77
+ command_namespace: namespace,
78
+ command_name: commandFull,
79
+ command_args: commandArgs,
80
+ is_sdlc_stage: isSdlcStage,
81
+ args: commandArgs, // backwards compat alias
82
+ source: 'user_prompt',
83
+ };
84
+ }
85
+
86
+ function getSessionId(input) {
87
+ return input?.session_id || input?.conversation_id || 'unknown';
88
+ }
89
+
90
+ function shouldSkipPromptSubmitTelemetry(input) {
91
+ return isCodexInternalTaskTitlePrompt(input);
92
+ }
93
+
94
+ function processPromptSubmitInput(input, deps = {}) {
95
+ const emit = typeof deps.emit === 'function' ? deps.emit : () => {};
96
+ const persistSkill = typeof deps.persistSkill === 'function' ? deps.persistSkill : () => {};
97
+ const persistSlashCmd = typeof deps.persistSlashCmd === 'function' ? deps.persistSlashCmd : () => {};
98
+ const events = [];
99
+
100
+ const slash = extractAwSlashCommand(input);
101
+ const promptPayload = {};
102
+ if (slash) {
103
+ promptPayload.command_namespace = slash.command_namespace;
104
+ promptPayload.command_name = slash.command_name;
105
+ promptPayload.command_args = slash.command_args;
106
+ promptPayload.is_sdlc_stage = slash.is_sdlc_stage;
107
+ }
108
+ events.push({ eventType: 'prompt_submitted', payload: promptPayload });
109
+
110
+ if (slash) {
111
+ persistSkill(getSessionId(input), input?.turn_id || null, slash);
112
+ persistSlashCmd(getSessionId(input), slash);
113
+ events.push({
114
+ eventType: 'skill_invoked',
115
+ payload: {
116
+ skill_name: slash.skill_name,
117
+ args: slash.args,
118
+ },
119
+ });
120
+ }
121
+
122
+ for (const event of events) {
123
+ emit(event.eventType, event.payload);
124
+ }
125
+
126
+ return events;
127
+ }
128
+
129
+ function main() {
130
+ let raw = '';
131
+
132
+ process.stdin.setEncoding('utf8');
133
+ process.stdin.on('data', chunk => {
134
+ if (raw.length < MAX_STDIN) {
135
+ raw += chunk.substring(0, MAX_STDIN - raw.length);
136
+ }
137
+ });
138
+
139
+ process.stdin.on('end', () => {
140
+ try {
141
+ const input = JSON.parse(raw);
142
+ if (!shouldSkipPromptSubmitTelemetry(input)
143
+ && tryAcquireDedupe('prompt-submit', [
144
+ getSessionId(input),
145
+ input?.turn_id || '',
146
+ resolvePromptText(input),
147
+ ])) {
148
+ processPromptSubmitInput(input, {
149
+ emit(eventType, payload) {
150
+ sendAsync(buildEvent(input, eventType, payload));
151
+ },
152
+ persistSkill: persistSessionSkill,
153
+ persistSlashCmd: persistSessionSlashCommand,
154
+ });
155
+ }
156
+ } catch {
157
+ // Non-blocking.
158
+ }
159
+
160
+ process.stdout.write('{}');
161
+ });
162
+ }
163
+
164
+ if (require.main === module) {
165
+ main();
166
+ }
167
+
168
+ module.exports = {
169
+ extractAwSlashCommand,
170
+ processPromptSubmitInput,
171
+ resolvePromptText,
172
+ shouldSkipPromptSubmitTelemetry,
173
+ };
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Usage telemetry — SessionStart hook.
4
+ *
5
+ * Captures session_start event and persists the model for later hooks.
6
+ * Claude sends model on SessionStart only — this is the only chance to capture it.
7
+ *
8
+ * Outputs {} on stdout.
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ const { buildEvent, sendAsync, persistSessionModel, pruneStaleSessionFiles } = require('../lib/aw-usage-telemetry');
14
+
15
+ const MAX_STDIN = 1024 * 1024;
16
+ let raw = '';
17
+
18
+ process.stdin.setEncoding('utf8');
19
+ process.stdin.on('data', chunk => {
20
+ if (raw.length < MAX_STDIN) {
21
+ raw += chunk.substring(0, MAX_STDIN - raw.length);
22
+ }
23
+ });
24
+
25
+ process.stdin.on('end', () => {
26
+ try {
27
+ const input = JSON.parse(raw);
28
+ const sessionId = input.session_id
29
+ || input._cursor?.conversation_id
30
+ || input.conversation_id
31
+ || null;
32
+ const model = input.model || input._cursor?.model || null;
33
+
34
+ // Prune stale session files (>24h) to prevent unbounded growth
35
+ pruneStaleSessionFiles();
36
+
37
+ // Persist model so PostToolUse/Stop hooks can read it
38
+ persistSessionModel(sessionId, model);
39
+
40
+ sendAsync(buildEvent(input, 'session_start', {
41
+ model: model || null,
42
+ }));
43
+ } catch {
44
+ // Non-blocking.
45
+ }
46
+
47
+ process.stdout.write('{}');
48
+ });
@@ -0,0 +1,182 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Usage telemetry — Stop hook.
4
+ *
5
+ * Captures response_completed per turn (fires after each Claude/Cursor/Codex response).
6
+ *
7
+ * No harness provides token usage in hook input directly. All three provide
8
+ * transcript_path — we parse the transcript JSONL to extract model, stop_reason,
9
+ * and usage for any harness.
10
+ *
11
+ * Pricing is resolved dynamically via OpenRouter API (24h cached) with a
12
+ * hardcoded fallback for when the API is unreachable and no cache exists.
13
+ *
14
+ * Outputs {} on stdout.
15
+ */
16
+
17
+ 'use strict';
18
+
19
+ const {
20
+ buildEvent,
21
+ sendAsync,
22
+ detectHarness,
23
+ persistSessionModel,
24
+ readLastAssistantFromTranscript,
25
+ tryAcquireDedupe,
26
+ isCodexInternalTaskTitleCompletion,
27
+ } = require('../lib/aw-usage-telemetry');
28
+ const { estimateCost, toNumber } = require('../lib/aw-pricing');
29
+
30
+ const MAX_STDIN = 1024 * 1024;
31
+ function pickFirstNumber(...values) {
32
+ for (const value of values) {
33
+ const n = toNumber(value);
34
+ if (n !== 0) return n;
35
+ }
36
+ return 0;
37
+ }
38
+
39
+ function buildResponseCompletedData(input) {
40
+ const harness = detectHarness(input);
41
+
42
+ // Parse transcript for all harnesses — all three provide transcript_path.
43
+ // The shared parser now understands both Claude/Cursor JSONL and Codex
44
+ // response_item + token_count transcript shapes.
45
+ let transcriptData = null;
46
+ if (input.transcript_path) {
47
+ transcriptData = readLastAssistantFromTranscript(input.transcript_path);
48
+ }
49
+
50
+ // Normalize stop reason across harnesses.
51
+ let stopReason;
52
+ if (harness === 'cursor') {
53
+ // Cursor adapter maps sessionEnd.reason / stop.status to stop_reason.
54
+ stopReason = input.stop_reason || input.status || input.reason || 'unknown';
55
+ } else if (harness === 'codex') {
56
+ stopReason = input.last_assistant_message ? 'completed' : 'unknown';
57
+ } else {
58
+ // Claude: prefer hook input, fall back to transcript.
59
+ stopReason = input.stop_reason
60
+ || (transcriptData && transcriptData.stop_reason)
61
+ || 'unknown';
62
+ }
63
+
64
+ // Token usage — prefer top-level fields (Cursor sends these directly),
65
+ // then input.usage, then transcript as last resort.
66
+ const hookUsage = input.usage || {};
67
+ const txUsage = (transcriptData && transcriptData.usage) || {};
68
+ const usage = Object.keys(hookUsage).length > 0 ? hookUsage : txUsage;
69
+
70
+ const inputTokens = pickFirstNumber(
71
+ input.input_tokens,
72
+ usage.input_tokens,
73
+ usage.prompt_tokens,
74
+ );
75
+ const outputTokens = pickFirstNumber(
76
+ input.output_tokens,
77
+ usage.output_tokens,
78
+ usage.completion_tokens,
79
+ );
80
+ const cacheReadTokens = pickFirstNumber(
81
+ input.cache_read_tokens,
82
+ usage.cache_read_input_tokens,
83
+ usage.cached_input_tokens,
84
+ );
85
+ const cacheCreateTokens = pickFirstNumber(
86
+ input.cache_write_tokens,
87
+ usage.cache_creation_input_tokens,
88
+ );
89
+
90
+ // Model: prefer hook input → transcript → session file.
91
+ const model = input.model
92
+ || input._cursor?.model
93
+ || (transcriptData && transcriptData.model)
94
+ || null;
95
+
96
+ // Persist model so PostToolUse/UserPromptSubmit hooks can read it.
97
+ // Stop fires every turn (before those hooks), so this stays fresh.
98
+ const sessionId = input.session_id || input.conversation_id || 'unknown';
99
+ if (model && sessionId !== 'unknown') {
100
+ persistSessionModel(sessionId, model);
101
+ }
102
+
103
+ const payload = { stop_reason: stopReason };
104
+
105
+ if (model) {
106
+ payload.model = model;
107
+ }
108
+
109
+ if (inputTokens || outputTokens) {
110
+ payload.input_tokens = inputTokens;
111
+ payload.output_tokens = outputTokens;
112
+ payload.estimated_cost_usd = estimateCost(model, inputTokens, outputTokens, {
113
+ cacheReadTokens,
114
+ cacheWriteTokens: cacheCreateTokens,
115
+ });
116
+ }
117
+
118
+ if (cacheReadTokens || cacheCreateTokens) {
119
+ payload.cache_read_tokens = cacheReadTokens;
120
+ if (cacheCreateTokens) {
121
+ payload.cache_create_tokens = cacheCreateTokens;
122
+ }
123
+ }
124
+
125
+ return { model, payload, sessionId };
126
+ }
127
+
128
+ function shouldSkipResponseCompleted(input) {
129
+ return isCodexInternalTaskTitleCompletion(input);
130
+ }
131
+
132
+ function main() {
133
+ let raw = '';
134
+
135
+ process.stdin.setEncoding('utf8');
136
+ process.stdin.on('data', chunk => {
137
+ if (raw.length < MAX_STDIN) {
138
+ raw += chunk.substring(0, MAX_STDIN - raw.length);
139
+ }
140
+ });
141
+
142
+ process.stdin.on('end', () => {
143
+ try {
144
+ const input = JSON.parse(raw);
145
+ if (!shouldSkipResponseCompleted(input)) {
146
+ const { model, payload, sessionId } = buildResponseCompletedData(input);
147
+
148
+ if (tryAcquireDedupe('response-completed', [
149
+ sessionId,
150
+ input?.turn_id || '',
151
+ input?.transcript_path || '',
152
+ input?.last_assistant_message || '',
153
+ input?.model || '',
154
+ payload.stop_reason || '',
155
+ payload.input_tokens || '',
156
+ payload.output_tokens || '',
157
+ ])) {
158
+ // Override model in the event envelope too (buildEvent reads from hook input
159
+ // which doesn't have model for Claude — inject it so the top-level field is set).
160
+ const event = buildEvent(input, 'response_completed', payload);
161
+ if (model && !event.model) {
162
+ event.model = model;
163
+ }
164
+ sendAsync(event);
165
+ }
166
+ }
167
+ } catch {
168
+ // Non-blocking.
169
+ }
170
+
171
+ process.stdout.write('{}');
172
+ });
173
+ }
174
+
175
+ if (require.main === module) {
176
+ main();
177
+ }
178
+
179
+ module.exports = {
180
+ buildResponseCompletedData,
181
+ shouldSkipResponseCompleted,
182
+ };
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Detached sender — receives a single usage telemetry event as argv[2],
4
+ * POSTs it to the telemetry API, then exits.
5
+ *
6
+ * Spawned by sendAsync() in aw-usage-telemetry.js with { detached: true }.
7
+ * This script runs independently after the parent hook process exits.
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ const https = require('https');
13
+ const http = require('http');
14
+
15
+ const DEFAULT_URL = 'https://services.leadconnectorhq.com/agentic-workspace/api/telemetry/usage-events';
16
+ const TIMEOUT_MS = 10_000;
17
+
18
+ function post(url, body) {
19
+ return new Promise((resolve, reject) => {
20
+ const parsed = new URL(url);
21
+ const transport = parsed.protocol === 'https:' ? https : http;
22
+
23
+ const req = transport.request(
24
+ {
25
+ hostname: parsed.hostname,
26
+ port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
27
+ path: parsed.pathname + parsed.search,
28
+ method: 'POST',
29
+ headers: {
30
+ 'Content-Type': 'application/json',
31
+ 'Content-Length': Buffer.byteLength(body),
32
+ },
33
+ timeout: TIMEOUT_MS,
34
+ },
35
+ (res) => {
36
+ // Drain response
37
+ res.resume();
38
+ res.on('end', () => resolve(res.statusCode));
39
+ },
40
+ );
41
+
42
+ req.on('timeout', () => {
43
+ req.destroy();
44
+ reject(new Error('timeout'));
45
+ });
46
+ req.on('error', reject);
47
+ req.write(body);
48
+ req.end();
49
+ });
50
+ }
51
+
52
+ async function main() {
53
+ const eventJson = process.argv[2];
54
+ if (!eventJson) {
55
+ process.exit(0);
56
+ }
57
+
58
+ // Resolve telemetry URL: env var → config file → production default.
59
+ // Config file fallback covers Cursor GUI which doesn't inherit shell env (Bug #9).
60
+ let baseUrl = process.env.AW_TELEMETRY_URL;
61
+ if (!baseUrl) {
62
+ try {
63
+ const fs = require('fs');
64
+ const path = require('path');
65
+ const configPath = path.join(process.env.HOME || require('os').homedir(), '.aw', 'telemetry', 'config.json');
66
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
67
+ baseUrl = config.telemetry_url || null;
68
+ } catch { /* no config file — use default */ }
69
+ }
70
+ const url = baseUrl
71
+ ? `${baseUrl.replace(/\/+$/, '')}/telemetry/usage-events`
72
+ : DEFAULT_URL;
73
+
74
+ try {
75
+ await post(url, eventJson);
76
+ } catch (err) {
77
+ // Non-blocking — log and exit cleanly.
78
+ process.stderr.write(`[aw-telemetry] send failed: ${err.message}\n`);
79
+ }
80
+
81
+ process.exit(0);
82
+ }
83
+
84
+ main();