@onlooker-community/ecosystem 0.9.0 → 0.10.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +7 -0
- package/package.json +2 -2
- package/scripts/lib/onlooker-event.mjs +82 -10
- package/test/bats/read-chunk-tracking.bats +73 -0
- package/test/bats/tool-history-tracker.bats +1 -0
- package/test/bats/validate-path.bats +1 -1
- package/test/fixtures/hook-inputs/post-tool-use-read-chunked.json +15 -0
- package/test/node/schema-events.test.mjs +41 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,13 @@
|
|
|
7
7
|
|
|
8
8
|
# Changelog
|
|
9
9
|
|
|
10
|
+
## [0.10.0](https://github.com/onlooker-community/ecosystem/compare/ecosystem-v0.9.0...ecosystem-v0.10.0) (2026-05-22)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
* **hooks:** enrich tool.file.read with read chunking observability ([#25](https://github.com/onlooker-community/ecosystem/issues/25)) ([8eb23c8](https://github.com/onlooker-community/ecosystem/commit/8eb23c8f4f03dfbeb701a30de1fa50c1c8ee48ac))
|
|
16
|
+
|
|
10
17
|
## [0.9.0](https://github.com/onlooker-community/ecosystem/compare/ecosystem-v0.8.0...ecosystem-v0.9.0) (2026-05-22)
|
|
11
18
|
|
|
12
19
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onlooker-community/ecosystem",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Agents, skills, hooks, commands, rules, and MCP configurations that power [Onlooker](https://onlooker.dev)",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Onlooker Community",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"onlooker-install": "install.sh"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@onlooker-community/schema": "^1.4.
|
|
22
|
+
"@onlooker-community/schema": "^1.4.1"
|
|
23
23
|
},
|
|
24
24
|
"scripts": {
|
|
25
25
|
"postinstall": "echo '\\n onlooker-ecosystem installed!\\n Run: npx onlooker-install typescript\\n Docs: https://github.com/onlooker-community/ecosystem\\n'",
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Uses @onlooker-community/schema for envelope shape and validation.
|
|
5
5
|
*/
|
|
6
6
|
import { randomUUID } from 'node:crypto';
|
|
7
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
8
8
|
import { join } from 'node:path';
|
|
9
9
|
import {
|
|
10
10
|
createEvent,
|
|
@@ -81,6 +81,85 @@ function extractPath(toolInput, toolResponse) {
|
|
|
81
81
|
return toolInput?.file_path ?? toolInput?.path ?? toolResponse?.filePath ?? toolResponse?.path ?? undefined;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/** Bytes on disk above which a full read is flagged as large_file_full_read. */
|
|
85
|
+
export const LARGE_FILE_BYTES_ON_DISK = 100_000;
|
|
86
|
+
|
|
87
|
+
const MAX_FILE_LINES_STAT_BYTES = 512 * 1024;
|
|
88
|
+
|
|
89
|
+
function parseNonNegativeInt(value) {
|
|
90
|
+
if (value == null || value === '') return undefined;
|
|
91
|
+
const n = Number.parseInt(String(value), 10);
|
|
92
|
+
return Number.isFinite(n) && n >= 0 ? n : undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function parsePositiveInt(value, min = 1) {
|
|
96
|
+
if (value == null || value === '') return undefined;
|
|
97
|
+
const n = Number.parseInt(String(value), 10);
|
|
98
|
+
return Number.isFinite(n) && n >= min ? n : undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Derive read_mode and line range from Read tool_input (supports common field aliases).
|
|
103
|
+
*/
|
|
104
|
+
export function extractReadRange(toolInput) {
|
|
105
|
+
const input = toolInput ?? {};
|
|
106
|
+
const offset = parseNonNegativeInt(
|
|
107
|
+
input.offset ?? input.start_line ?? input.start_line_one_indexed ?? input.line_offset,
|
|
108
|
+
);
|
|
109
|
+
const limit = parsePositiveInt(input.limit ?? input.line_limit ?? input.num_lines ?? input.line_count);
|
|
110
|
+
const read_mode = offset != null || limit != null ? 'partial' : 'full';
|
|
111
|
+
return stripUndefined({ read_mode, offset, limit });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Stat file on disk for chunking analytics (line count omitted for very large files).
|
|
116
|
+
*/
|
|
117
|
+
export function measureFileOnDisk(filePath) {
|
|
118
|
+
try {
|
|
119
|
+
if (!filePath || !existsSync(filePath)) return {};
|
|
120
|
+
const st = statSync(filePath);
|
|
121
|
+
if (!st.isFile()) return {};
|
|
122
|
+
const file_bytes_on_disk = st.size;
|
|
123
|
+
let file_lines_on_disk;
|
|
124
|
+
if (st.size <= MAX_FILE_LINES_STAT_BYTES) {
|
|
125
|
+
const text = readFileSync(filePath, 'utf8');
|
|
126
|
+
file_lines_on_disk = text.split('\n').length;
|
|
127
|
+
}
|
|
128
|
+
return stripUndefined({ file_bytes_on_disk, file_lines_on_disk });
|
|
129
|
+
} catch {
|
|
130
|
+
return {};
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Build tool.file.read payload from Read tool hook fields.
|
|
136
|
+
*/
|
|
137
|
+
export function buildToolFileReadPayload(toolInput, toolResponse, options = {}) {
|
|
138
|
+
const path = extractPath(toolInput, toolResponse);
|
|
139
|
+
if (!path) return null;
|
|
140
|
+
|
|
141
|
+
const payload = { path, ...extractReadRange(toolInput) };
|
|
142
|
+
const content = toolResponse?.content;
|
|
143
|
+
if (typeof content === 'string') {
|
|
144
|
+
payload.lines_read = content.split('\n').length;
|
|
145
|
+
payload.file_size_bytes = content.length;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (options.measureOnDisk !== false) {
|
|
149
|
+
Object.assign(payload, measureFileOnDisk(path));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (
|
|
153
|
+
payload.read_mode === 'full' &&
|
|
154
|
+
payload.file_bytes_on_disk != null &&
|
|
155
|
+
payload.file_bytes_on_disk >= LARGE_FILE_BYTES_ON_DISK
|
|
156
|
+
) {
|
|
157
|
+
payload.large_file_full_read = true;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return stripUndefined(payload);
|
|
161
|
+
}
|
|
162
|
+
|
|
84
163
|
function stripUndefined(obj) {
|
|
85
164
|
return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined && v !== null && v !== ''));
|
|
86
165
|
}
|
|
@@ -279,16 +358,9 @@ export function mapHookInputToCanonical(hookInput, options) {
|
|
|
279
358
|
|
|
280
359
|
switch (toolName) {
|
|
281
360
|
case 'Read': {
|
|
282
|
-
|
|
283
|
-
if (!
|
|
361
|
+
payload = buildToolFileReadPayload(toolInput, toolResponse);
|
|
362
|
+
if (!payload) return null;
|
|
284
363
|
eventType = TOOL_FILE_READ;
|
|
285
|
-
payload = { path };
|
|
286
|
-
const content = toolResponse?.content;
|
|
287
|
-
if (typeof content === 'string') {
|
|
288
|
-
const lines = content.split('\n').length;
|
|
289
|
-
payload.lines_read = lines;
|
|
290
|
-
payload.file_size_bytes = content.length;
|
|
291
|
-
}
|
|
292
364
|
break;
|
|
293
365
|
}
|
|
294
366
|
case 'Write': {
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env bats
|
|
2
|
+
|
|
3
|
+
setup() {
|
|
4
|
+
# shellcheck source=../helpers/setup.bash
|
|
5
|
+
source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
|
|
6
|
+
load_validate_path
|
|
7
|
+
# shellcheck source=../../scripts/lib/onlooker-schema.sh
|
|
8
|
+
source "${REPO_ROOT}/scripts/lib/onlooker-schema.sh"
|
|
9
|
+
# shellcheck source=../../scripts/lib/tool-history.sh
|
|
10
|
+
source "${REPO_ROOT}/scripts/lib/tool-history.sh"
|
|
11
|
+
export CLAUDE_PLUGIN_ROOT="${REPO_ROOT}"
|
|
12
|
+
|
|
13
|
+
LARGE_FILE="${BATS_TEST_TMPDIR}/large-source.ts"
|
|
14
|
+
# > LARGE_FILE_BYTES_ON_DISK (100_000) for large_file_full_read
|
|
15
|
+
printf '%*s\n' 120000 "" >"$LARGE_FILE"
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
@test "tool_history maps full Read to read_mode full" {
|
|
19
|
+
local fixture="${REPO_ROOT}/test/fixtures/hook-inputs/post-tool-use-read.json"
|
|
20
|
+
local record
|
|
21
|
+
record=$(tool_history_build_record "$(cat "$fixture")")
|
|
22
|
+
echo "$record" | jq -e \
|
|
23
|
+
'.payload.read_mode == "full"
|
|
24
|
+
and .payload.path == "/project/src/main.ts"
|
|
25
|
+
and (.payload.large_file_full_read | not)' \
|
|
26
|
+
>/dev/null
|
|
27
|
+
echo "$record" | onlooker_validate_event
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
@test "tool_history maps chunked Read to read_mode partial with offset and limit" {
|
|
31
|
+
local fixture="${REPO_ROOT}/test/fixtures/hook-inputs/post-tool-use-read-chunked.json"
|
|
32
|
+
local record
|
|
33
|
+
record=$(tool_history_build_record "$(cat "$fixture")")
|
|
34
|
+
echo "$record" | jq -e \
|
|
35
|
+
'.payload.read_mode == "partial"
|
|
36
|
+
and .payload.offset == 400
|
|
37
|
+
and .payload.limit == 80
|
|
38
|
+
and .payload.lines_read == 3' \
|
|
39
|
+
>/dev/null
|
|
40
|
+
echo "$record" | onlooker_validate_event
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
@test "tool_history flags large_file_full_read for full read of large on-disk file" {
|
|
44
|
+
local input
|
|
45
|
+
input=$(jq -n \
|
|
46
|
+
--arg path "$LARGE_FILE" \
|
|
47
|
+
--arg content "peek\n" \
|
|
48
|
+
'{
|
|
49
|
+
session_id: "history-session-002",
|
|
50
|
+
hook_event_name: "PostToolUse",
|
|
51
|
+
tool_name: "Read",
|
|
52
|
+
tool_input: {file_path: $path},
|
|
53
|
+
tool_response: {content: $content}
|
|
54
|
+
}')
|
|
55
|
+
local record
|
|
56
|
+
record=$(tool_history_build_record "$input")
|
|
57
|
+
echo "$record" | jq -e \
|
|
58
|
+
'.payload.read_mode == "full"
|
|
59
|
+
and .payload.large_file_full_read == true
|
|
60
|
+
and .payload.file_bytes_on_disk > 100000' \
|
|
61
|
+
>/dev/null
|
|
62
|
+
echo "$record" | onlooker_validate_event
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
@test "tool-history-tracker appends chunked read to session JSONL" {
|
|
66
|
+
local fixture="${REPO_ROOT}/test/fixtures/hook-inputs/post-tool-use-read-chunked.json"
|
|
67
|
+
local history_file="${ONLOOKER_SESSION_HISTORY_DIR}/history-session-001.jsonl"
|
|
68
|
+
rm -f "$history_file" "${history_file}.lock"
|
|
69
|
+
|
|
70
|
+
cat "$fixture" | "${REPO_ROOT}/scripts/hooks/tool-history-tracker.sh" >/dev/null 2>&1
|
|
71
|
+
|
|
72
|
+
tail -n 1 "$history_file" | jq -e '.payload.read_mode == "partial"' >/dev/null
|
|
73
|
+
}
|
|
@@ -19,6 +19,7 @@ setup() {
|
|
|
19
19
|
'.schema_version == "1.0"
|
|
20
20
|
and .event_type == "tool.file.read"
|
|
21
21
|
and .payload.path == "/project/src/main.ts"
|
|
22
|
+
and .payload.read_mode == "full"
|
|
22
23
|
and .session_id == "history-session-001"' \
|
|
23
24
|
>/dev/null
|
|
24
25
|
echo "$record" | onlooker_validate_event
|
|
@@ -96,7 +96,7 @@ setup() {
|
|
|
96
96
|
export _HOOK_SESSION_ID="emit-session"
|
|
97
97
|
export ONLOOKER_HOOK_TYPE="PreToolUse"
|
|
98
98
|
export ONLOOKER_TOOL_NAME="Read"
|
|
99
|
-
local payload='{"path":"/tmp/example.txt"}'
|
|
99
|
+
local payload='{"path":"/tmp/example.txt","read_mode":"full"}'
|
|
100
100
|
safe_emit "tool.file.read" "$payload"
|
|
101
101
|
[ "$?" -eq 0 ]
|
|
102
102
|
[ -f "$ONLOOKER_EVENTS_LOG" ]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"session_id": "history-session-001",
|
|
3
|
+
"hook_event_name": "PostToolUse",
|
|
4
|
+
"tool_name": "Read",
|
|
5
|
+
"tool_use_id": "toolu_read_002",
|
|
6
|
+
"duration_ms": 18,
|
|
7
|
+
"tool_input": {
|
|
8
|
+
"file_path": "/project/src/large-module.ts",
|
|
9
|
+
"offset": 400,
|
|
10
|
+
"limit": 80
|
|
11
|
+
},
|
|
12
|
+
"tool_response": {
|
|
13
|
+
"content": "// chunk line 1\n// chunk line 2\n"
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
|
-
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { test } from 'node:test';
|
|
@@ -7,6 +7,9 @@ import { fileURLToPath } from 'node:url';
|
|
|
7
7
|
import { validate } from '@onlooker-community/schema';
|
|
8
8
|
import {
|
|
9
9
|
buildCanonicalEvent,
|
|
10
|
+
buildToolFileReadPayload,
|
|
11
|
+
extractReadRange,
|
|
12
|
+
LARGE_FILE_BYTES_ON_DISK,
|
|
10
13
|
mapHookInputToCanonical,
|
|
11
14
|
mapSkillHookInput,
|
|
12
15
|
mapTaskHookInput,
|
|
@@ -32,6 +35,43 @@ test('mapHookInputToCanonical maps PostToolUse Read to tool.file.read', () => {
|
|
|
32
35
|
assert.equal(mapped.event.event_type, 'tool.file.read');
|
|
33
36
|
assert.equal(mapped.event.schema_version, '1.0');
|
|
34
37
|
assert.equal(mapped.event.payload.path, '/project/src/main.ts');
|
|
38
|
+
assert.equal(mapped.event.payload.read_mode, 'full');
|
|
39
|
+
assert.equal(validate(mapped.event).valid, true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('extractReadRange detects partial reads from offset and limit', () => {
|
|
43
|
+
const range = extractReadRange({ offset: 10, limit: 50 });
|
|
44
|
+
assert.equal(range.read_mode, 'partial');
|
|
45
|
+
assert.equal(range.offset, 10);
|
|
46
|
+
assert.equal(range.limit, 50);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('buildToolFileReadPayload flags large_file_full_read', () => {
|
|
50
|
+
const tmpDir = mkdtempSync(join(tmpdir(), 'onlooker-read-chunk-'));
|
|
51
|
+
const filePath = join(tmpDir, 'big.txt');
|
|
52
|
+
const bytes = LARGE_FILE_BYTES_ON_DISK + 1;
|
|
53
|
+
writeFileSync(filePath, 'x'.repeat(bytes), 'utf8');
|
|
54
|
+
|
|
55
|
+
const payload = buildToolFileReadPayload({ file_path: filePath }, { content: 'x\n' });
|
|
56
|
+
assert.equal(payload.read_mode, 'full');
|
|
57
|
+
assert.equal(payload.large_file_full_read, true);
|
|
58
|
+
assert.equal(payload.file_bytes_on_disk, bytes);
|
|
59
|
+
|
|
60
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('mapHookInputToCanonical maps chunked Read to partial read_mode', () => {
|
|
64
|
+
const hookInput = loadFixture('post-tool-use-read-chunked.json');
|
|
65
|
+
const tmpDir = join(REPO_ROOT, 'test/tmp-schema-events');
|
|
66
|
+
const mapped = mapHookInputToCanonical(hookInput, {
|
|
67
|
+
onlookerDir: tmpDir,
|
|
68
|
+
plugin: 'onlooker',
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
assert.equal(mapped.valid, true);
|
|
72
|
+
assert.equal(mapped.event.payload.read_mode, 'partial');
|
|
73
|
+
assert.equal(mapped.event.payload.offset, 400);
|
|
74
|
+
assert.equal(mapped.event.payload.limit, 80);
|
|
35
75
|
assert.equal(validate(mapped.event).valid, true);
|
|
36
76
|
});
|
|
37
77
|
|