@onlooker-community/ecosystem 0.0.2 → 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.
@@ -0,0 +1,278 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Canonical Onlooker event helpers for bash hooks.
4
+ * Uses @onlooker-community/schema for envelope shape and validation.
5
+ */
6
+ import { randomUUID } from 'node:crypto';
7
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import {
10
+ createEvent,
11
+ TOOL_AGENT_COMPLETE,
12
+ TOOL_AGENT_SPAWN,
13
+ TOOL_FILE_EDIT,
14
+ TOOL_FILE_READ,
15
+ TOOL_FILE_WRITE,
16
+ TOOL_SHELL_EXEC,
17
+ TOOL_WEB_FETCH,
18
+ validate,
19
+ } from '@onlooker-community/schema';
20
+
21
+ export function ensureMachineId(onlookerDir) {
22
+ const path = join(onlookerDir, 'machine_id');
23
+ if (existsSync(path)) {
24
+ return readFileSync(path, 'utf8').trim();
25
+ }
26
+ mkdirSync(onlookerDir, { recursive: true });
27
+ const id = randomUUID();
28
+ writeFileSync(path, `${id}\n`, 'utf8');
29
+ return id;
30
+ }
31
+
32
+ /** File-backed monotonic sequence (per ONLOOKER_DIR). */
33
+ export function nextSequence(onlookerDir) {
34
+ const path = join(onlookerDir, 'event-sequence');
35
+ let current = 0;
36
+ if (existsSync(path)) {
37
+ const parsed = Number.parseInt(readFileSync(path, 'utf8').trim(), 10);
38
+ if (!Number.isNaN(parsed)) current = parsed;
39
+ }
40
+ writeFileSync(path, String(current + 1), 'utf8');
41
+ return current;
42
+ }
43
+
44
+ export function buildCanonicalEvent({
45
+ onlookerDir,
46
+ runtime = 'claude-code',
47
+ adapter_id,
48
+ plugin,
49
+ session_id,
50
+ event_type,
51
+ payload,
52
+ cost_usd,
53
+ token_count,
54
+ }) {
55
+ const event = createEvent({
56
+ runtime,
57
+ adapter_id,
58
+ plugin,
59
+ machine_id: ensureMachineId(onlookerDir),
60
+ session_id,
61
+ event_type,
62
+ payload,
63
+ cost_usd,
64
+ token_count,
65
+ });
66
+ event.sequence = nextSequence(onlookerDir);
67
+ return event;
68
+ }
69
+
70
+ function summarizeText(value, maxLen = 1000) {
71
+ if (value == null) return undefined;
72
+ const text = String(value).replace(/\s+/g, ' ').trim();
73
+ if (!text) return undefined;
74
+ return text.length > maxLen ? `${text.slice(0, maxLen)}…` : text;
75
+ }
76
+
77
+ function extractPath(toolInput, toolResponse) {
78
+ return toolInput?.file_path ?? toolInput?.path ?? toolResponse?.filePath ?? toolResponse?.path ?? undefined;
79
+ }
80
+
81
+ /**
82
+ * Map Claude Code PostToolUse / PostToolUseFailure hook input to a canonical event.
83
+ * Returns null when the tool is not mapped to a schema event type.
84
+ */
85
+ export function mapHookInputToCanonical(hookInput, options) {
86
+ const { onlookerDir, plugin, runtime = 'claude-code', adapter_id = 'ecosystem.hooks' } = options;
87
+
88
+ const toolName = hookInput?.tool_name;
89
+ const hookEvent = hookInput?.hook_event_name ?? 'PostToolUse';
90
+ const isFailure = hookEvent === 'PostToolUseFailure';
91
+ const toolInput = hookInput?.tool_input ?? {};
92
+ const toolResponse = hookInput?.tool_response ?? {};
93
+ const sessionId = hookInput?.session_id ?? 'unknown';
94
+ const durationMs = hookInput?.duration_ms;
95
+
96
+ let eventType;
97
+ let payload;
98
+
99
+ switch (toolName) {
100
+ case 'Read': {
101
+ const path = extractPath(toolInput, toolResponse);
102
+ if (!path) return null;
103
+ eventType = TOOL_FILE_READ;
104
+ payload = { path };
105
+ const content = toolResponse?.content;
106
+ if (typeof content === 'string') {
107
+ const lines = content.split('\n').length;
108
+ payload.lines_read = lines;
109
+ payload.file_size_bytes = content.length;
110
+ }
111
+ break;
112
+ }
113
+ case 'Write': {
114
+ const path = extractPath(toolInput, toolResponse);
115
+ if (!path) return null;
116
+ eventType = TOOL_FILE_WRITE;
117
+ payload = {
118
+ path,
119
+ operation: toolResponse?.success === false ? 'overwrite' : 'create',
120
+ };
121
+ break;
122
+ }
123
+ case 'Edit': {
124
+ const path = extractPath(toolInput, toolResponse);
125
+ if (!path) return null;
126
+ eventType = TOOL_FILE_EDIT;
127
+ payload = { path };
128
+ break;
129
+ }
130
+ case 'Bash': {
131
+ const command = toolInput?.command;
132
+ if (!command) return null;
133
+ eventType = TOOL_SHELL_EXEC;
134
+ payload = {
135
+ command,
136
+ exit_code: isFailure ? 1 : Number.isFinite(toolResponse?.exit_code) ? toolResponse.exit_code : 0,
137
+ duration_ms: durationMs,
138
+ working_directory: toolInput?.cwd ?? hookInput?.cwd,
139
+ blocked: isFailure ? true : undefined,
140
+ };
141
+ if (payload.blocked === undefined) delete payload.blocked;
142
+ break;
143
+ }
144
+ case 'WebFetch': {
145
+ const url = toolInput?.url;
146
+ if (!url) return null;
147
+ eventType = TOOL_WEB_FETCH;
148
+ payload = {
149
+ url,
150
+ status_code: toolResponse?.status_code,
151
+ blocked: isFailure ? true : undefined,
152
+ };
153
+ if (payload.blocked === undefined) delete payload.blocked;
154
+ break;
155
+ }
156
+ case 'Agent': {
157
+ const subagentId = toolInput?.agent_id ?? hookInput?.tool_use_id ?? 'unknown';
158
+ if (hookEvent === 'PreToolUse') {
159
+ eventType = TOOL_AGENT_SPAWN;
160
+ payload = {
161
+ subagent_id: subagentId,
162
+ agent_name: toolInput?.subagent_type,
163
+ task_summary: toolInput?.description,
164
+ };
165
+ } else {
166
+ eventType = TOOL_AGENT_COMPLETE;
167
+ payload = {
168
+ subagent_id: subagentId,
169
+ success: !isFailure && toolResponse?.success !== false,
170
+ agent_name: toolInput?.subagent_type,
171
+ duration_ms: durationMs,
172
+ output_summary: isFailure
173
+ ? summarizeText(hookInput?.error)
174
+ : summarizeText(toolInput?.description ?? toolResponse?.status),
175
+ };
176
+ if (!payload.output_summary) delete payload.output_summary;
177
+ }
178
+ break;
179
+ }
180
+ default:
181
+ return null;
182
+ }
183
+
184
+ const event = buildCanonicalEvent({
185
+ onlookerDir,
186
+ runtime,
187
+ adapter_id,
188
+ plugin,
189
+ session_id: sessionId,
190
+ event_type: eventType,
191
+ payload,
192
+ });
193
+
194
+ const result = validate(event);
195
+ if (!result.valid) {
196
+ return { valid: false, errors: result.errors, event_type: eventType };
197
+ }
198
+ return { valid: true, event: result.event };
199
+ }
200
+
201
+ function readStdin() {
202
+ return new Promise((resolve) => {
203
+ const chunks = [];
204
+ process.stdin.setEncoding('utf8');
205
+ process.stdin.on('data', (c) => chunks.push(c));
206
+ process.stdin.on('end', () => resolve(chunks.join('')));
207
+ });
208
+ }
209
+
210
+ async function main() {
211
+ const [command, ...args] = process.argv.slice(2);
212
+ const onlookerDir = process.env.ONLOOKER_DIR ?? join(process.env.HOME ?? '/tmp', '.onlooker');
213
+ const plugin = process.env.ONLOOKER_PLUGIN_NAME ?? 'onlooker';
214
+
215
+ if (command === 'validate') {
216
+ const raw = await readStdin();
217
+ const parsed = JSON.parse(raw || '{}');
218
+ const result = validate(parsed);
219
+ if (!result.valid) {
220
+ console.error(JSON.stringify(result.errors, null, 2));
221
+ process.exit(1);
222
+ }
223
+ console.log(JSON.stringify(result.event));
224
+ return;
225
+ }
226
+
227
+ if (command === 'emit-from-hook') {
228
+ const raw = await readStdin();
229
+ const hookInput = JSON.parse(raw || '{}');
230
+ const mapped = mapHookInputToCanonical(hookInput, { onlookerDir, plugin });
231
+ if (!mapped) {
232
+ process.exit(0);
233
+ }
234
+ if (!mapped.valid) {
235
+ console.error(JSON.stringify(mapped.errors, null, 2));
236
+ process.exit(1);
237
+ }
238
+ console.log(JSON.stringify(mapped.event));
239
+ return;
240
+ }
241
+
242
+ if (command === 'emit') {
243
+ let params;
244
+ const fileArg = args.find((a) => a.startsWith('--params='));
245
+ if (fileArg) {
246
+ params = JSON.parse(readFileSync(fileArg.slice(9), 'utf8'));
247
+ } else {
248
+ params = JSON.parse(await readStdin());
249
+ }
250
+ const event = buildCanonicalEvent({
251
+ onlookerDir,
252
+ plugin: params.plugin ?? plugin,
253
+ runtime: params.runtime ?? 'claude-code',
254
+ adapter_id: params.adapter_id,
255
+ session_id: params.session_id,
256
+ event_type: params.event_type,
257
+ payload: params.payload,
258
+ });
259
+ const result = validate(event);
260
+ if (!result.valid) {
261
+ console.error(JSON.stringify(result.errors, null, 2));
262
+ process.exit(1);
263
+ }
264
+ console.log(JSON.stringify(result.event));
265
+ return;
266
+ }
267
+
268
+ console.error(`Usage: onlooker-event.mjs <validate|emit-from-hook|emit>`);
269
+ process.exit(2);
270
+ }
271
+
272
+ const isMain = process.argv[1]?.endsWith('onlooker-event.mjs') ?? false;
273
+ if (isMain) {
274
+ main().catch((err) => {
275
+ console.error(err);
276
+ process.exit(1);
277
+ });
278
+ }
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env bash
2
+ # Bash wrappers for canonical Onlooker events (@onlooker-community/schema).
3
+ # Requires validate-path.sh to be sourced first.
4
+
5
+ _ONLOOKER_EVENT_JS="${_ONLOOKER_EVENT_JS:-${CLAUDE_PLUGIN_ROOT:-}/scripts/lib/onlooker-event.mjs}"
6
+
7
+ # Append a validated canonical event JSON line to the global events log.
8
+ # Usage: onlooker_append_event "$event_json"
9
+ onlooker_append_event() {
10
+ local event_json="${1:-}"
11
+ [[ -z "$event_json" ]] && return 0
12
+
13
+ ensure_dir_exists "$(dirname "$ONLOOKER_EVENTS_LOG")" || return 1
14
+ printf '%s\n' "$event_json" >>"$ONLOOKER_EVENTS_LOG" 2>/dev/null
15
+ }
16
+
17
+ # Build a canonical event from Claude Code hook stdin JSON (prints event or empty).
18
+ # Usage: event=$(onlooker_event_from_hook "$INPUT")
19
+ onlooker_event_from_hook() {
20
+ local hook_input="${1:-}"
21
+ [[ -z "$hook_input" ]] && return 0
22
+
23
+ if [[ ! -f "$_ONLOOKER_EVENT_JS" ]]; then
24
+ return 1
25
+ fi
26
+
27
+ printf '%s' "$hook_input" | ONLOOKER_DIR="$ONLOOKER_DIR" ONLOOKER_PLUGIN_NAME="$ONLOOKER_PLUGIN_NAME" \
28
+ node "$_ONLOOKER_EVENT_JS" emit-from-hook 2>/dev/null
29
+ }
30
+
31
+ # Emit canonical event: validate via schema and append to events log.
32
+ # Usage: onlooker_emit_from_hook "$INPUT"
33
+ onlooker_emit_from_hook() {
34
+ local event
35
+ event=$(onlooker_event_from_hook "${1:-}")
36
+ [[ -z "$event" ]] && return 0
37
+ onlooker_append_event "$event"
38
+ }
39
+
40
+ # Validate stdin JSON against the canonical envelope; exit 0/1.
41
+ # Usage: echo "$event" | onlooker_validate_event
42
+ onlooker_validate_event() {
43
+ [[ -f "$_ONLOOKER_EVENT_JS" ]] || return 1
44
+ node "$_ONLOOKER_EVENT_JS" validate
45
+ }
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env bash
2
+ # Tool history helpers — canonical session JSONL via @onlooker-community/schema.
3
+ #
4
+ # Source after validate-path.sh and onlooker-schema.sh:
5
+ # source "$CLAUDE_PLUGIN_ROOT/scripts/lib/validate-path.sh"
6
+ # source "$CLAUDE_PLUGIN_ROOT/scripts/lib/onlooker-schema.sh"
7
+ # source "$CLAUDE_PLUGIN_ROOT/scripts/lib/tool-history.sh"
8
+
9
+ # Build a canonical OnlookerEvent from hook stdin (empty when unmapped).
10
+ # Usage: record=$(tool_history_build_record "$INPUT")
11
+ tool_history_build_record() {
12
+ local input_json="${1:-}"
13
+ onlooker_event_from_hook "$input_json"
14
+ }
15
+
16
+ # Append a canonical event to the session JSONL history (flock-protected).
17
+ # Usage: tool_history_append "$SESSION_ID" "$event_json"
18
+ tool_history_append() {
19
+ local session_id="${1:-}"
20
+ local record_json="${2:-}"
21
+
22
+ [[ -z "$session_id" || "$session_id" == "null" || -z "$record_json" ]] && return 0
23
+
24
+ local history_file="${ONLOOKER_SESSION_HISTORY_DIR}/${session_id}.jsonl"
25
+ ensure_dir_exists "$ONLOOKER_SESSION_HISTORY_DIR" || return 1
26
+
27
+ local lockfile="${history_file}.lock"
28
+ exec 202>"$lockfile"
29
+ if ! flock -w 5 202; then
30
+ return 1
31
+ fi
32
+
33
+ printf '%s\n' "$record_json" >>"$history_file" 2>/dev/null
34
+ flock -u 202
35
+ }