@ghl-ai/aw 0.1.93 → 0.2.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/aw-usage-wiring.mjs +386 -0
- package/codex.mjs +74 -19
- package/commands/c4.mjs +7 -5
- package/commands/init.mjs +89 -28
- package/ecc.mjs +1 -1
- package/hook-cleanup.mjs +4 -3
- package/hooks/aw-usage/hooks/aw-usage-commit-created.js +21 -0
- package/hooks/aw-usage/hooks/aw-usage-post-tool-use.js +159 -33
- package/hooks/aw-usage/hooks/aw-usage-prompt-submit.js +39 -74
- package/hooks/aw-usage/hooks/aw-usage-telemetry-send.js +67 -7
- package/hooks/aw-usage/lib/aw-usage-commands.js +122 -0
- package/hooks/aw-usage/lib/aw-usage-envelope.js +121 -0
- package/hooks/aw-usage/lib/aw-usage-queue.js +168 -0
- package/hooks/aw-usage/lib/aw-usage-skill-source.js +138 -0
- package/hooks/aw-usage/lib/aw-usage-stage-events.js +179 -0
- package/hooks/aw-usage/lib/aw-usage-telemetry.js +163 -3
- package/hooks/aw-usage/package.json +1 -1
- package/hooks/codex-home.mjs +8 -6
- package/hooks.mjs +5 -8
- package/install-aw-usage-hooks.mjs +65 -215
- package/package.json +2 -1
- package/startup.mjs +54 -155
|
@@ -5,12 +5,18 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Spawned by sendAsync() in aw-usage-telemetry.js with { detached: true }.
|
|
7
7
|
* This script runs independently after the parent hook process exits.
|
|
8
|
+
*
|
|
9
|
+
* B7: an event that fails (network error, timeout, HTTP 5xx) goes to the
|
|
10
|
+
* local queue (~/.aw/telemetry/queue/, lib/aw-usage-queue.js). After the next
|
|
11
|
+
* successful POST, queued events are resent oldest first with their original
|
|
12
|
+
* event_id, so the server's unique index drops any duplicate.
|
|
8
13
|
*/
|
|
9
14
|
|
|
10
15
|
'use strict';
|
|
11
16
|
|
|
12
17
|
const https = require('https');
|
|
13
18
|
const http = require('http');
|
|
19
|
+
const queue = require('../lib/aw-usage-queue');
|
|
14
20
|
|
|
15
21
|
const DEFAULT_URL = 'https://services.leadconnectorhq.com/agentic-workspace/api/telemetry/usage-events';
|
|
16
22
|
const TIMEOUT_MS = 10_000;
|
|
@@ -49,6 +55,59 @@ function post(url, body) {
|
|
|
49
55
|
});
|
|
50
56
|
}
|
|
51
57
|
|
|
58
|
+
// Network error, timeout, 5xx, or the server asking to come back later.
|
|
59
|
+
function isRetryable(status) {
|
|
60
|
+
return status === null || status === 408 || status === 429 || status >= 500;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function tryPost(post, body) {
|
|
64
|
+
try {
|
|
65
|
+
return await post(body);
|
|
66
|
+
} catch (err) {
|
|
67
|
+
// Network error or timeout: one stderr line, no event content.
|
|
68
|
+
process.stderr.write(`[aw-telemetry] send failed: ${err.message}\n`);
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// One file at a time, so a claim is never older than one POST and another
|
|
74
|
+
// sender cannot release it as stale mid-drain.
|
|
75
|
+
async function drainQueue(post, queueDir) {
|
|
76
|
+
for (let sent = 0; sent < queue.MAX_DRAIN_PER_RUN; sent++) {
|
|
77
|
+
const [claimed] = queue.claimBatch({ dir: queueDir, limit: 1 });
|
|
78
|
+
if (!claimed) return;
|
|
79
|
+
const queuedEvent = queue.readClaimedEvent(claimed);
|
|
80
|
+
// A corrupt file can never be sent: drop it.
|
|
81
|
+
const status = queuedEvent ? await tryPost(post, JSON.stringify(queuedEvent)) : 200;
|
|
82
|
+
const failed = isRetryable(status);
|
|
83
|
+
queue.settle(claimed, !failed);
|
|
84
|
+
if (failed) return;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* POST one event; queue it when it fails, drain the queue when it succeeds.
|
|
90
|
+
* @param {{ event: object, body?: string, post: (body: string) => Promise<number>, queueDir?: string }} opts
|
|
91
|
+
*/
|
|
92
|
+
async function sendWithQueue({ event, body = JSON.stringify(event), post, queueDir = queue.QUEUE_DIR }) {
|
|
93
|
+
const status = await tryPost(post, body);
|
|
94
|
+
if (isRetryable(status)) {
|
|
95
|
+
// v1 senders have no event_id; enqueue refuses those.
|
|
96
|
+
queue.enqueue(event, { dir: queueDir });
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (status >= 200 && status < 300) await drainQueue(post, queueDir);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function parseEvent(eventJson) {
|
|
103
|
+
try {
|
|
104
|
+
return JSON.parse(eventJson);
|
|
105
|
+
} catch {
|
|
106
|
+
// Not JSON: still POSTed as given (server answers 400), never queued.
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
52
111
|
async function main() {
|
|
53
112
|
const eventJson = process.argv[2];
|
|
54
113
|
if (!eventJson) {
|
|
@@ -71,14 +130,15 @@ async function main() {
|
|
|
71
130
|
? `${baseUrl.replace(/\/+$/, '')}/telemetry/usage-events`
|
|
72
131
|
: DEFAULT_URL;
|
|
73
132
|
|
|
74
|
-
|
|
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
|
-
}
|
|
133
|
+
await sendWithQueue({ event: parseEvent(eventJson), body: eventJson, post: (body) => post(url, body) });
|
|
80
134
|
|
|
81
135
|
process.exit(0);
|
|
82
136
|
}
|
|
83
137
|
|
|
84
|
-
main
|
|
138
|
+
if (require.main === module) {
|
|
139
|
+
// Detached process: a failure here must not leave a non-zero exit behind; the event
|
|
140
|
+
// is lost exactly as it was before the queue existed.
|
|
141
|
+
main().catch(() => process.exit(0));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
module.exports = { sendWithQueue };
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AW Usage Telemetry — typed command recognition (B2).
|
|
3
|
+
*
|
|
4
|
+
* extractTypedCommands(input) — every command the user typed in one prompt,
|
|
5
|
+
* as { raw_name, command_form, namespace }
|
|
6
|
+
* commandKey(name) — the command with its form removed (`plan`)
|
|
7
|
+
* isSdlcStageCommand(command) — v1 is_sdlc_stage rule on the stripped name
|
|
8
|
+
*
|
|
9
|
+
* Only the command name and how it was typed leave the laptop, never the rest
|
|
10
|
+
* of the prompt (spec decision 6). The server maps raw names to the catalog.
|
|
11
|
+
*
|
|
12
|
+
* | Form | Typed | raw_name | command_form |
|
|
13
|
+
* |------------------------------|-------------------|--------------------|--------------|
|
|
14
|
+
* | namespaced slash (v1, start) | /aw:plan, /tdd | aw:plan, tdd | slash_ns |
|
|
15
|
+
* | Cursor slash / menu pick | /aw-plan | /aw-plan | slash |
|
|
16
|
+
* | Cursor command folder | /aw/plan | aw:plan | slash |
|
|
17
|
+
* | Codex dollar | $aw-plan | $aw-plan | dollar |
|
|
18
|
+
* | Codex prompts (older Codex) | /prompts:aw-plan | /prompts:aw-plan | prompts |
|
|
19
|
+
* | Cursor @ pick | @aw-plan | aw-plan | attach |
|
|
20
|
+
*
|
|
21
|
+
* Only the namespaced form must open the prompt; the others count anywhere
|
|
22
|
+
* after the start or whitespace, outside fenced code blocks, with a name of
|
|
23
|
+
* at most 40 characters.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
const NAMESPACED_PREFIXES = ['aw', 'caveman', 'codex', 'graphify', 'rtk', 'lean-ctx'];
|
|
29
|
+
const BARE_SLASH_COMMANDS = new Set([
|
|
30
|
+
'tdd', 'diagnose', 'grill-me', 'grill-with-docs', 'triage', 'to-prd', 'zoom-out',
|
|
31
|
+
]);
|
|
32
|
+
// `/aw:` sub-commands that count as SDLC stages so the funnel can group by stage.
|
|
33
|
+
const AW_SDLC_STAGES = new Set([
|
|
34
|
+
'plan', 'build', 'execute', 'test', 'verify', 'review',
|
|
35
|
+
'deploy', 'ship', 'tdd', 'investigate', 'feature',
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
const NAMESPACED_AT_START = new RegExp(`^\\/((?:${NAMESPACED_PREFIXES.join('|')})(?::[a-z0-9-]+)?)(?=\\s|$)`, 'i');
|
|
39
|
+
const BARE_AT_START = /^\/([a-z][a-z0-9-]*)(?=\s|$)/i;
|
|
40
|
+
// A longer "name" is pasted text, not a command; it is never sent.
|
|
41
|
+
const MAX_NAME_LENGTH = 40;
|
|
42
|
+
const FENCED_CODE = /```[\s\S]*?(?:```|$)/g;
|
|
43
|
+
const ANYWHERE_FORMS = [
|
|
44
|
+
{ pattern: /(?<=^|\s)\/aw-([a-z0-9-]+)\b/gi, form: 'slash', rawName: name => `/aw-${name}` },
|
|
45
|
+
{ pattern: /(?<=^|\s)\/aw\/([a-z0-9-]+)\b/gi, form: 'slash', rawName: name => `aw:${name}` },
|
|
46
|
+
{ pattern: /(?<=^|\s)\$aw-([a-z0-9-]+)\b/gi, form: 'dollar', rawName: name => `$aw-${name}` },
|
|
47
|
+
{ pattern: /(?<=^|\s)\/prompts:aw-([a-z0-9-]+)\b/gi, form: 'prompts', rawName: name => `/prompts:aw-${name}` },
|
|
48
|
+
{ pattern: /(?<=^|\s)@aw-([a-z0-9-]+)\b/gi, form: 'attach', rawName: name => `aw-${name}` },
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
function promptText(input) {
|
|
52
|
+
if (typeof input === 'string') return input;
|
|
53
|
+
for (const candidate of [input?.prompt, input?.user_prompt, input?.message, input?.text]) {
|
|
54
|
+
if (typeof candidate === 'string' && candidate.trim()) return candidate;
|
|
55
|
+
}
|
|
56
|
+
return '';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function commandKey(name) {
|
|
60
|
+
return String(name || '')
|
|
61
|
+
.toLowerCase()
|
|
62
|
+
.replace(/^[/$]/, '')
|
|
63
|
+
.replace(/^prompts:/, '')
|
|
64
|
+
.replace(/^aw:/, '')
|
|
65
|
+
.replace(/^aw-/, '');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function namespacedAtStart(prompt) {
|
|
69
|
+
const match = prompt.match(NAMESPACED_AT_START);
|
|
70
|
+
if (match) {
|
|
71
|
+
const rawName = match[1].toLowerCase();
|
|
72
|
+
const colon = rawName.indexOf(':');
|
|
73
|
+
return { raw_name: rawName, command_form: 'slash_ns', namespace: colon > 0 ? rawName.slice(0, colon) : null };
|
|
74
|
+
}
|
|
75
|
+
const bare = prompt.match(BARE_AT_START);
|
|
76
|
+
if (bare && BARE_SLASH_COMMANDS.has(bare[1].toLowerCase())) {
|
|
77
|
+
return { raw_name: bare[1].toLowerCase(), command_form: 'slash_ns', namespace: null };
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function anywhereMatches(prompt) {
|
|
83
|
+
const found = [];
|
|
84
|
+
for (const { pattern, form, rawName } of ANYWHERE_FORMS) {
|
|
85
|
+
for (const match of prompt.matchAll(pattern)) {
|
|
86
|
+
if (match[1].length > MAX_NAME_LENGTH) continue;
|
|
87
|
+
found.push({ index: match.index, command: { raw_name: rawName(match[1].toLowerCase()), command_form: form, namespace: 'aw' } });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return found.sort((a, b) => a.index - b.index).map(entry => entry.command);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function extractTypedCommands(input) {
|
|
94
|
+
// Commands inside fenced code blocks are quoted, not typed.
|
|
95
|
+
const prompt = promptText(input).replace(FENCED_CODE, ' ').trim();
|
|
96
|
+
if (!prompt) return [];
|
|
97
|
+
|
|
98
|
+
const first = namespacedAtStart(prompt);
|
|
99
|
+
const commands = first ? [first] : [];
|
|
100
|
+
const seen = new Set(commands.map(command => command.raw_name));
|
|
101
|
+
const startKey = first ? commandKey(first.raw_name) : null;
|
|
102
|
+
|
|
103
|
+
for (const command of anywhereMatches(prompt)) {
|
|
104
|
+
if (seen.has(command.raw_name)) continue;
|
|
105
|
+
// `/aw:plan … $aw-plan` is one command typed twice.
|
|
106
|
+
if (startKey && commandKey(command.raw_name) === startKey) continue;
|
|
107
|
+
seen.add(command.raw_name);
|
|
108
|
+
commands.push(command);
|
|
109
|
+
}
|
|
110
|
+
return commands;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function isSdlcStageCommand(command) {
|
|
114
|
+
return command?.namespace === 'aw' && AW_SDLC_STAGES.has(commandKey(command.raw_name));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = {
|
|
118
|
+
AW_SDLC_STAGES,
|
|
119
|
+
extractTypedCommands,
|
|
120
|
+
commandKey,
|
|
121
|
+
isSdlcStageCommand,
|
|
122
|
+
};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AW Usage Telemetry — v2 event envelope helpers.
|
|
3
|
+
*
|
|
4
|
+
* newEventId() — UUID v4; the server dedupes resends on it
|
|
5
|
+
* readHookVersion(pkgPath) — `version` stamped by the aw CLI into ~/.aw/aw-usage/package.json
|
|
6
|
+
* nextSeq(sessionId, { sessionsDir }) — 1, 2, 3 … per session, or null when it cannot be taken safely
|
|
7
|
+
* withFileLock(lockPath, fn) — run fn while holding a lock folder; shared by seq,
|
|
8
|
+
* stage history and the session activity index
|
|
9
|
+
*
|
|
10
|
+
* Telemetry is best-effort: when a lock or a file cannot be used, the helpers
|
|
11
|
+
* return null / { ok: false } and the event is still sent without the field.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const path = require('path');
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const os = require('os');
|
|
19
|
+
const crypto = require('crypto');
|
|
20
|
+
|
|
21
|
+
const DEFAULT_PKG_PATH = path.join(__dirname, '..', 'package.json');
|
|
22
|
+
const SESSION_DIR = path.join(os.homedir(), '.aw', 'telemetry', 'sessions');
|
|
23
|
+
const SAFE_FILE_NAME = /^[A-Za-z0-9._-]{1,200}$/;
|
|
24
|
+
const SEQ_LOCK_WAIT_MS = 200;
|
|
25
|
+
const sleepCell = new Int32Array(new SharedArrayBuffer(4));
|
|
26
|
+
|
|
27
|
+
function newEventId() {
|
|
28
|
+
return crypto.randomUUID();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function readHookVersion(pkgPath = DEFAULT_PKG_PATH) {
|
|
32
|
+
try {
|
|
33
|
+
const version = JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version;
|
|
34
|
+
return typeof version === 'string' && version ? version : null;
|
|
35
|
+
} catch {
|
|
36
|
+
// No stamped package.json (running from the repo, or an old install): omit hook_version.
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function sleepSync(ms) {
|
|
42
|
+
Atomics.wait(sleepCell, 0, 0, ms);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Only one waiter can rename the stale lock away, so two waiters never both
|
|
46
|
+
// remove it (and one of them a lock the other just took).
|
|
47
|
+
function removeStaleLock(lockPath, staleMs) {
|
|
48
|
+
try {
|
|
49
|
+
if (Date.now() - fs.statSync(lockPath).mtimeMs <= staleMs) return;
|
|
50
|
+
const doomed = `${lockPath}.stale-${process.pid}-${crypto.randomUUID()}`;
|
|
51
|
+
fs.renameSync(lockPath, doomed);
|
|
52
|
+
fs.rmSync(doomed, { recursive: true, force: true });
|
|
53
|
+
} catch {
|
|
54
|
+
// Lock vanished or another waiter took it over; the next mkdir decides.
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function withFileLock(lockPath, fn, { waitMs = 50, staleMs = 5000 } = {}) {
|
|
59
|
+
const deadline = Date.now() + waitMs;
|
|
60
|
+
let acquired = false;
|
|
61
|
+
while (!acquired) {
|
|
62
|
+
try {
|
|
63
|
+
fs.mkdirSync(lockPath);
|
|
64
|
+
acquired = true;
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (error.code !== 'EEXIST') return { ok: false };
|
|
67
|
+
removeStaleLock(lockPath, staleMs);
|
|
68
|
+
if (Date.now() >= deadline) return { ok: false };
|
|
69
|
+
sleepSync(5);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
return { ok: true, value: fn() };
|
|
74
|
+
} finally {
|
|
75
|
+
// fn is a few small file operations, far below staleMs, so the lock is still ours.
|
|
76
|
+
fs.rmSync(lockPath, { recursive: true, force: true });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function sessionFileName(sessionId) {
|
|
81
|
+
return SAFE_FILE_NAME.test(sessionId) && !/^\.+$/.test(sessionId)
|
|
82
|
+
? sessionId
|
|
83
|
+
: crypto.createHash('sha256').update(sessionId).digest('hex').slice(0, 32);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function readCounter(seqPath) {
|
|
87
|
+
try {
|
|
88
|
+
const value = Number.parseInt(fs.readFileSync(seqPath, 'utf8'), 10);
|
|
89
|
+
return Number.isInteger(value) && value > 0 ? value : 0;
|
|
90
|
+
} catch {
|
|
91
|
+
// First event of the session: no counter file yet.
|
|
92
|
+
return 0;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function nextSeq(sessionId, { sessionsDir = SESSION_DIR } = {}) {
|
|
97
|
+
if (typeof sessionId !== 'string' || !sessionId) return null;
|
|
98
|
+
const seqPath = path.join(sessionsDir, `${sessionFileName(sessionId)}.seq`);
|
|
99
|
+
try {
|
|
100
|
+
fs.mkdirSync(sessionsDir, { recursive: true, mode: 0o700 });
|
|
101
|
+
// Cursor runs several hooks at once for one tool call; wait as long as
|
|
102
|
+
// the session and stage locks do, so seq is not dropped under that load.
|
|
103
|
+
const result = withFileLock(`${seqPath}.lock`, () => {
|
|
104
|
+
const seq = readCounter(seqPath) + 1;
|
|
105
|
+
fs.writeFileSync(seqPath, String(seq), { mode: 0o600 });
|
|
106
|
+
return seq;
|
|
107
|
+
}, { waitMs: SEQ_LOCK_WAIT_MS });
|
|
108
|
+
return result.ok ? result.value : null;
|
|
109
|
+
} catch {
|
|
110
|
+
// Telemetry folder not writable: the event is sent without seq.
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
module.exports = {
|
|
116
|
+
newEventId,
|
|
117
|
+
readHookVersion,
|
|
118
|
+
nextSeq,
|
|
119
|
+
withFileLock,
|
|
120
|
+
sessionFileName,
|
|
121
|
+
};
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AW Usage Telemetry — local resend queue (B7).
|
|
3
|
+
*
|
|
4
|
+
* The sender puts an event here when the POST fails (network error, timeout,
|
|
5
|
+
* HTTP 5xx) and drains the folder after the next successful POST. Each event
|
|
6
|
+
* is one file `<enqueued-at-ms>-<event_id>.json`, so concurrent detached
|
|
7
|
+
* senders never corrupt each other and no lock is needed: a sender claims a
|
|
8
|
+
* file by renaming it to `.sending`, and only one rename can win.
|
|
9
|
+
*
|
|
10
|
+
* Queued events hold the same fields the sender already has (including
|
|
11
|
+
* github_email / account_email), so the folder is 0700 and files are 0600.
|
|
12
|
+
* Bounded: at most MAX_QUEUED files and MAX_AGE_MS old; oldest dropped first.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const os = require('os');
|
|
20
|
+
|
|
21
|
+
const QUEUE_DIR = path.join(os.homedir(), '.aw', 'telemetry', 'queue');
|
|
22
|
+
const MAX_QUEUED = 500;
|
|
23
|
+
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
24
|
+
const MAX_DRAIN_PER_RUN = 50;
|
|
25
|
+
const CLAIM_STALE_MS = 60 * 1000;
|
|
26
|
+
const EVENT_ID = /^[A-Za-z0-9-]{1,64}$/;
|
|
27
|
+
const QUEUED = '.json';
|
|
28
|
+
const CLAIMED = '.sending';
|
|
29
|
+
|
|
30
|
+
function listQueue(dir) {
|
|
31
|
+
try {
|
|
32
|
+
return fs.readdirSync(dir).filter(name => name.endsWith(QUEUED) || name.endsWith(CLAIMED)).sort();
|
|
33
|
+
} catch {
|
|
34
|
+
// No queue folder yet: nothing queued.
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function enqueuedAt(name) {
|
|
40
|
+
const value = Number.parseInt(name, 10);
|
|
41
|
+
return Number.isFinite(value) ? value : 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function removeQuietly(filePath) {
|
|
45
|
+
try {
|
|
46
|
+
fs.unlinkSync(filePath);
|
|
47
|
+
} catch {
|
|
48
|
+
// Already removed by another sender or by trim.
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function trim({ dir = QUEUE_DIR, now = Date.now(), maxQueued = MAX_QUEUED, maxAgeMs = MAX_AGE_MS } = {}) {
|
|
53
|
+
const kept = [];
|
|
54
|
+
for (const name of listQueue(dir)) {
|
|
55
|
+
if (now - enqueuedAt(name) > maxAgeMs) removeQuietly(path.join(dir, name));
|
|
56
|
+
else kept.push(name);
|
|
57
|
+
}
|
|
58
|
+
for (const name of kept.slice(0, Math.max(0, kept.length - maxQueued))) {
|
|
59
|
+
removeQuietly(path.join(dir, name));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The folder holds PII, so it must be a real folder owned by this user, not a link.
|
|
64
|
+
function ensureQueueDir(dir) {
|
|
65
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
66
|
+
const stat = fs.lstatSync(dir);
|
|
67
|
+
if (!stat.isDirectory() || (typeof process.getuid === 'function' && stat.uid !== process.getuid())) {
|
|
68
|
+
throw new Error('queue folder is not a directory owned by this user');
|
|
69
|
+
}
|
|
70
|
+
fs.chmodSync(dir, 0o700);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Queue one event for a later resend. Returns false when it was not queued. */
|
|
74
|
+
function enqueue(event, { dir = QUEUE_DIR, now = Date.now(), maxQueued = MAX_QUEUED, maxAgeMs = MAX_AGE_MS } = {}) {
|
|
75
|
+
const eventId = event && typeof event === 'object' ? event.event_id : null;
|
|
76
|
+
if (typeof eventId !== 'string' || !EVENT_ID.test(eventId)) return false;
|
|
77
|
+
try {
|
|
78
|
+
ensureQueueDir(dir);
|
|
79
|
+
fs.writeFileSync(path.join(dir, `${now}-${eventId}${QUEUED}`), JSON.stringify(event), { mode: 0o600, flag: 'wx' });
|
|
80
|
+
} catch {
|
|
81
|
+
// Queue folder unusable (not writable, a link, another owner): the event is dropped,
|
|
82
|
+
// as it was before the queue existed.
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
trim({ dir, now, maxQueued, maxAgeMs });
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function claimFile(dir, name, now) {
|
|
90
|
+
const from = path.join(dir, name);
|
|
91
|
+
const to = path.join(dir, name.slice(0, -QUEUED.length) + CLAIMED);
|
|
92
|
+
try {
|
|
93
|
+
fs.renameSync(from, to);
|
|
94
|
+
} catch {
|
|
95
|
+
// Another sender claimed it first.
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
const claimedAt = new Date(now);
|
|
100
|
+
fs.utimesSync(to, claimedAt, claimedAt);
|
|
101
|
+
} catch {
|
|
102
|
+
// Claim keeps its old mtime: another sender may release it early; the server dedupes on event_id.
|
|
103
|
+
}
|
|
104
|
+
return to;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function releaseStaleClaim(dir, name, now) {
|
|
108
|
+
const from = path.join(dir, name);
|
|
109
|
+
try {
|
|
110
|
+
if (now - fs.statSync(from).mtimeMs <= CLAIM_STALE_MS) return null;
|
|
111
|
+
const to = path.join(dir, name.slice(0, -CLAIMED.length) + QUEUED);
|
|
112
|
+
fs.renameSync(from, to);
|
|
113
|
+
return path.basename(to);
|
|
114
|
+
} catch {
|
|
115
|
+
// Settled or reclaimed by another sender meanwhile.
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Claim up to `limit` queued events, oldest first. Returns the claimed file paths. */
|
|
121
|
+
function claimBatch({ dir = QUEUE_DIR, limit = MAX_DRAIN_PER_RUN, now = Date.now() } = {}) {
|
|
122
|
+
// Expired events are never resent, even when no new failure ran trim().
|
|
123
|
+
trim({ dir, now });
|
|
124
|
+
const claimed = [];
|
|
125
|
+
for (const name of listQueue(dir)) {
|
|
126
|
+
if (claimed.length >= limit) break;
|
|
127
|
+
// A sender that died mid-drain leaves `.sending` files behind; release them after a minute.
|
|
128
|
+
const queuedName = name.endsWith(CLAIMED) ? releaseStaleClaim(dir, name, now) : name;
|
|
129
|
+
if (!queuedName) continue;
|
|
130
|
+
const claimedPath = claimFile(dir, queuedName, now);
|
|
131
|
+
if (claimedPath) claimed.push(claimedPath);
|
|
132
|
+
}
|
|
133
|
+
return claimed;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function readClaimedEvent(claimedPath) {
|
|
137
|
+
try {
|
|
138
|
+
return JSON.parse(fs.readFileSync(claimedPath, 'utf8'));
|
|
139
|
+
} catch {
|
|
140
|
+
// Corrupt or vanished file: the caller settles it as sent so it is not retried forever.
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** ok: the event reached the server, delete it. Not ok: put it back for a later run. */
|
|
146
|
+
function settle(claimedPath, ok) {
|
|
147
|
+
if (ok) {
|
|
148
|
+
removeQuietly(claimedPath);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
fs.renameSync(claimedPath, claimedPath.slice(0, -CLAIMED.length) + QUEUED);
|
|
153
|
+
} catch {
|
|
154
|
+
// Removed by trim meanwhile: nothing to put back.
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
module.exports = {
|
|
159
|
+
QUEUE_DIR,
|
|
160
|
+
MAX_QUEUED,
|
|
161
|
+
MAX_AGE_MS,
|
|
162
|
+
MAX_DRAIN_PER_RUN,
|
|
163
|
+
enqueue,
|
|
164
|
+
trim,
|
|
165
|
+
claimBatch,
|
|
166
|
+
settle,
|
|
167
|
+
readClaimedEvent,
|
|
168
|
+
};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AW Usage Telemetry — how a skill was reached and where its file came from (B1, B6).
|
|
3
|
+
*
|
|
4
|
+
* skillOrigin({ name } | { path }) — 'aw' | 'other' | null (P11)
|
|
5
|
+
* skillSourceFields(source, input, skill) — payload fields for a skill_invoked event
|
|
6
|
+
* shellSkillReads(command) — every SKILL.md a shell command reads
|
|
7
|
+
* cursorProbeSkillReads(tool, toolInput) — B6 probe: a SKILL.md path given to a tool
|
|
8
|
+
* this hook does not know
|
|
9
|
+
*
|
|
10
|
+
* skill_origin rule (P11):
|
|
11
|
+
* - Skill tool: `aw` for `aw:<x>`, `other` for any other namespace, omitted for a bare name.
|
|
12
|
+
* - File or shell read: `aw` when the SKILL.md is under ~/.aw-ecc/, ~/.aw/,
|
|
13
|
+
* ~/.claude/plugins/cache/aw-marketplace/, or is a destinationPath in
|
|
14
|
+
* ~/.cursor/ecc-install-state.json or ~/.codex/ecc-install-state.json
|
|
15
|
+
* (those folders mix AW and other skills; the install state lists what AW
|
|
16
|
+
* copied). Otherwise `other`. No skill list lives in the hook (spec decision 1).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
const path = require('path');
|
|
22
|
+
const fs = require('fs');
|
|
23
|
+
const os = require('os');
|
|
24
|
+
|
|
25
|
+
const MAX_STATE_BYTES = 2 * 1024 * 1024;
|
|
26
|
+
const INSTALL_STATE_FILES = [['.cursor', 'ecc-install-state.json'], ['.codex', 'ecc-install-state.json']];
|
|
27
|
+
const AW_ROOTS = [['.aw-ecc'], ['.aw'], ['.claude', 'plugins', 'cache', 'aw-marketplace']];
|
|
28
|
+
// B6 probe skips tools whose skill reads are already counted, and search tools:
|
|
29
|
+
// Cursor Greps for `**/<name>/SKILL.md` before reading it, which is not a load.
|
|
30
|
+
const PROBE_SKIPPED_TOOLS = new Set(['Read', 'Bash', 'Shell', 'Skill', 'Grep', 'Glob']);
|
|
31
|
+
const SKILL_FILE = /(?<=^|[\s'"`;|&<>()=])((?:[^\s'"`;|&<>()]*\/)?skills\/([A-Za-z0-9._:-]+)\/SKILL\.md)(?![\w.-])/g;
|
|
32
|
+
|
|
33
|
+
const listedCache = new Map();
|
|
34
|
+
|
|
35
|
+
function toForwardSlashes(value) {
|
|
36
|
+
return String(value).replace(/\\/g, '/');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function expandHome(filePath, home) {
|
|
40
|
+
const match = filePath.match(/^(?:~|\$HOME|\$\{HOME\})(?=\/|$)/);
|
|
41
|
+
return match ? path.join(home, filePath.slice(match[0].length)) : filePath;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function readInstallState(statePath) {
|
|
45
|
+
try {
|
|
46
|
+
if (fs.statSync(statePath).size > MAX_STATE_BYTES) return [];
|
|
47
|
+
const operations = JSON.parse(fs.readFileSync(statePath, 'utf8'))?.operations;
|
|
48
|
+
return Array.isArray(operations) ? operations.map(op => op?.destinationPath).filter(p => typeof p === 'string') : [];
|
|
49
|
+
} catch {
|
|
50
|
+
// Missing, too big or not JSON: nothing is listed, so the skill counts as `other`.
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Install-state files are read once per process.
|
|
56
|
+
function listedSkillPaths(home) {
|
|
57
|
+
if (!listedCache.has(home)) {
|
|
58
|
+
const listed = new Set();
|
|
59
|
+
for (const segments of INSTALL_STATE_FILES) {
|
|
60
|
+
for (const destination of readInstallState(path.join(home, ...segments))) {
|
|
61
|
+
if (/\/SKILL\.md$/i.test(toForwardSlashes(destination))) listed.add(toForwardSlashes(path.normalize(destination)));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
listedCache.set(home, listed);
|
|
65
|
+
}
|
|
66
|
+
return listedCache.get(home);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function originFromPath(filePath, home) {
|
|
70
|
+
const absolute = toForwardSlashes(path.normalize(expandHome(filePath, home)));
|
|
71
|
+
const underAwRoot = AW_ROOTS.some(segments => absolute.startsWith(`${toForwardSlashes(path.join(home, ...segments))}/`));
|
|
72
|
+
return underAwRoot || listedSkillPaths(home).has(absolute) ? 'aw' : 'other';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function originFromName(name) {
|
|
76
|
+
const colon = String(name).indexOf(':');
|
|
77
|
+
if (colon <= 0) return null;
|
|
78
|
+
return name.slice(0, colon).toLowerCase() === 'aw' ? 'aw' : 'other';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** @param {{ name?: string, path?: string }} skill */
|
|
82
|
+
function skillOrigin(skill, { home = os.homedir() } = {}) {
|
|
83
|
+
if (typeof skill?.path === 'string' && skill.path) return originFromPath(skill.path, home);
|
|
84
|
+
if (typeof skill?.name === 'string' && skill.name) return originFromName(skill.name);
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @param {'skill_tool'|'file_read'|'shell_read'|'other_tool'} source
|
|
90
|
+
* @param {object} input hook input
|
|
91
|
+
* @param {{ name?: string, path?: string }} skill
|
|
92
|
+
*/
|
|
93
|
+
function skillSourceFields(source, input, skill, opts = {}) {
|
|
94
|
+
const origin = skillOrigin(skill, opts);
|
|
95
|
+
return {
|
|
96
|
+
source,
|
|
97
|
+
tool_name: input?.tool_name || 'unknown',
|
|
98
|
+
...(typeof input?.tool_use_id === 'string' && input.tool_use_id ? { tool_use_id: input.tool_use_id } : {}),
|
|
99
|
+
...(origin ? { skill_origin: origin } : {}),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Every distinct skill whose SKILL.md appears in the text, first-seen order. */
|
|
104
|
+
function shellSkillReads(text) {
|
|
105
|
+
const reads = [];
|
|
106
|
+
const seen = new Set();
|
|
107
|
+
for (const match of toForwardSlashes(text || '').matchAll(SKILL_FILE)) {
|
|
108
|
+
if (seen.has(match[2])) continue;
|
|
109
|
+
seen.add(match[2]);
|
|
110
|
+
reads.push({ name: match[2], path: match[1] });
|
|
111
|
+
}
|
|
112
|
+
return reads;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function shellSkillNames(command) {
|
|
116
|
+
return shellSkillReads(command).map(read => read.name);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function stringsToDepth(value, depth) {
|
|
120
|
+
if (typeof value === 'string') return [value];
|
|
121
|
+
if (depth === 0 || !value || typeof value !== 'object') return [];
|
|
122
|
+
return Object.values(value).flatMap(child => stringsToDepth(child, depth - 1));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// B6 probe: remove in the release after the one that ships it
|
|
126
|
+
// (https://app.clickup.com/t/14zhcp5e0b5).
|
|
127
|
+
function cursorProbeSkillReads(toolName, toolInput) {
|
|
128
|
+
if (!toolName || PROBE_SKIPPED_TOOLS.has(toolName)) return [];
|
|
129
|
+
return shellSkillReads(stringsToDepth(toolInput, 2).join('\n'));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
module.exports = {
|
|
133
|
+
skillOrigin,
|
|
134
|
+
skillSourceFields,
|
|
135
|
+
shellSkillReads,
|
|
136
|
+
shellSkillNames,
|
|
137
|
+
cursorProbeSkillReads,
|
|
138
|
+
};
|