@panphora/clay-agent 0.0.0-stage → 0.1.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,157 @@
1
+ import { Worker } from 'node:worker_threads';
2
+
3
+ import { ClayError } from '../errors.js';
4
+ import { located, parse, pathOf } from './source.js';
5
+
6
+ const SNIPPET_MAX = 80;
7
+
8
+ function lineStarts(html) {
9
+ const starts = [];
10
+ let index = html.indexOf('\n');
11
+ while (index !== -1) {
12
+ starts.push(index);
13
+ index = html.indexOf('\n', index + 1);
14
+ }
15
+ return starts;
16
+ }
17
+
18
+ function lineAt(starts, offset) {
19
+ let lo = 0;
20
+ let hi = starts.length;
21
+ while (lo < hi) {
22
+ const mid = (lo + hi) >> 1;
23
+ if (starts[mid] < offset) lo = mid + 1;
24
+ else hi = mid;
25
+ }
26
+ return lo + 1;
27
+ }
28
+
29
+ function elementIndex($) {
30
+ const items = [];
31
+ for (const el of $('*').toArray()) {
32
+ const loc = located(el);
33
+ if (loc) items.push({ ...loc, el });
34
+ }
35
+ return items;
36
+ }
37
+
38
+ function enclosing(items, offset) {
39
+ let lo = 0;
40
+ let hi = items.length - 1;
41
+ let found = -1;
42
+ while (lo <= hi) {
43
+ const mid = (lo + hi) >> 1;
44
+ if (items[mid].start <= offset) {
45
+ found = mid;
46
+ lo = mid + 1;
47
+ } else {
48
+ hi = mid - 1;
49
+ }
50
+ }
51
+ for (let i = found; i >= 0; i -= 1) {
52
+ if (items[i].end > offset) return items[i].el;
53
+ }
54
+ return null;
55
+ }
56
+
57
+ function snippet(html, start, end) {
58
+ const matchLength = end - start;
59
+ if (matchLength >= SNIPPET_MAX) return html.slice(start, start + SNIPPET_MAX);
60
+ const budget = SNIPPET_MAX - matchLength;
61
+ const before = Math.min(Math.floor(budget / 2), start);
62
+ const after = Math.min(budget - before, html.length - end);
63
+ return html.slice(start - before, end + after);
64
+ }
65
+
66
+ function literalMatches(html, text, onMatch) {
67
+ let from = 0;
68
+ for (;;) {
69
+ const start = html.indexOf(text, from);
70
+ if (start === -1) return;
71
+ onMatch(start, start + text.length);
72
+ from = start + text.length;
73
+ }
74
+ }
75
+
76
+ const REGEX_DEADLINE_MS = 2000;
77
+
78
+ function regexRanges(html, source, max) {
79
+ return new Promise((resolve, reject) => {
80
+ const worker = new Worker(new URL('./regex-worker.js', import.meta.url), {
81
+ workerData: { html, source, max },
82
+ });
83
+ const timer = setTimeout(() => {
84
+ worker.terminate();
85
+ reject(
86
+ new ClayError('find-timeout', 'That regular expression took too long on this document.', {
87
+ hint: 'Use a simpler pattern, or pass `text` for a literal search.',
88
+ }),
89
+ );
90
+ }, REGEX_DEADLINE_MS);
91
+ worker.once('message', (result) => {
92
+ clearTimeout(timer);
93
+ worker.terminate();
94
+ resolve(result);
95
+ });
96
+ worker.once('error', () => {
97
+ clearTimeout(timer);
98
+ reject(new ClayError('internal', 'The regular expression search failed.'));
99
+ });
100
+ });
101
+ }
102
+
103
+ export async function find(html, { text, regex, limit = 20 } = {}) {
104
+ const hasText = text !== undefined && text !== null;
105
+ const hasRegex = regex !== undefined && regex !== null;
106
+ if (hasText === hasRegex) {
107
+ throw new ClayError('invalid-find', 'Pass exactly one of `text` or `regex`.', {
108
+ hint: 'Give a literal string as `text`, or a regular expression source as `regex`.',
109
+ });
110
+ }
111
+ if (hasText && (typeof text !== 'string' || text.length === 0)) {
112
+ throw new ClayError('invalid-find', '`text` must be a non-empty string.', {
113
+ hint: 'Pass the exact characters to look for.',
114
+ });
115
+ }
116
+ if (hasRegex && typeof regex !== 'string') {
117
+ throw new ClayError('invalid-find', '`regex` must be a string.', {
118
+ hint: 'Pass a JavaScript regular expression source string.',
119
+ });
120
+ }
121
+ const max = Number.isInteger(limit) && limit > 0 ? limit : 0;
122
+ let count = 0;
123
+ let ranges = [];
124
+ if (hasText) {
125
+ const onMatch = (start, end) => {
126
+ count += 1;
127
+ if (ranges.length < max) ranges.push({ start, end });
128
+ };
129
+ literalMatches(html, text, onMatch);
130
+ } else {
131
+ try {
132
+ new RegExp(regex, 'g');
133
+ } catch {
134
+ throw new ClayError('invalid-find', `That is not a valid regular expression: ${regex}`, {
135
+ hint: 'Pass a JavaScript regular expression source string, such as `id="[0-9]+"`.',
136
+ });
137
+ }
138
+ const result = await regexRanges(html, regex, max);
139
+ count = result.count;
140
+ ranges = result.ranges;
141
+ }
142
+ if (count === 0) return { count, matches: [] };
143
+ const { $ } = parse(html);
144
+ const starts = lineStarts(html);
145
+ const items = elementIndex($);
146
+ const matches = ranges.map(({ start, end }) => {
147
+ const el = enclosing(items, start);
148
+ return {
149
+ start,
150
+ end,
151
+ line: lineAt(starts, start),
152
+ path: el ? pathOf(el) : null,
153
+ snippet: snippet(html, start, end),
154
+ };
155
+ });
156
+ return { count, matches };
157
+ }
@@ -0,0 +1,154 @@
1
+ import { ClayError } from '../errors.js';
2
+ import { isElement, located, parse, pathOf } from './source.js';
3
+
4
+ const NO_TEXT = new Set(['script', 'style']);
5
+ const TEXT_LIMIT = 60;
6
+ const MAX_CHILDREN = 40;
7
+ const OUTLINE_BUDGET = 8000;
8
+ const CAPPED_HINT =
9
+ 'Part of the outline is hidden. Outline one part with select (a path from this outline), or use find_in_document.';
10
+
11
+ function collapseWhitespace(value) {
12
+ return value.replace(/\s+/g, ' ').trim();
13
+ }
14
+
15
+ function directText(el) {
16
+ let text = '';
17
+ for (const child of el.children) {
18
+ if (child.type === 'text') text += child.data;
19
+ }
20
+ return collapseWhitespace(text).slice(0, TEXT_LIMIT);
21
+ }
22
+
23
+ function classOf(el) {
24
+ const value = el.attribs?.class;
25
+ return typeof value === 'string' ? value : null;
26
+ }
27
+
28
+ function runKey(el) {
29
+ const className = classOf(el);
30
+ return `${el.name}\u0000${className === null ? '\u0000' : className}`;
31
+ }
32
+
33
+ function expand(children) {
34
+ const out = [];
35
+ for (const child of children) {
36
+ if (!isElement(child)) continue;
37
+ if (!located(child)) {
38
+ out.push(...expand(child.children));
39
+ continue;
40
+ }
41
+ out.push(child);
42
+ }
43
+ return out;
44
+ }
45
+
46
+ function entryFor(el, level, depth, last, count, state) {
47
+ const loc = located(el);
48
+ const lastLoc = last === el ? loc : located(last);
49
+ const entry = { path: pathOf(el), tag: el.name };
50
+ const id = el.attribs?.id;
51
+ if (id) entry.id = id;
52
+ const className = classOf(el);
53
+ if (className !== null) entry.class = className;
54
+ entry.start = loc.start;
55
+ entry.end = lastLoc.end;
56
+ entry.chars = lastLoc.end - loc.start;
57
+ if (count > 1) {
58
+ entry.repeated = count;
59
+ entry.lastPath = pathOf(last);
60
+ }
61
+ if (NO_TEXT.has(el.name)) {
62
+ const type = el.attribs?.type;
63
+ if (type) entry.type = type;
64
+ } else {
65
+ const text = directText(el);
66
+ if (text) entry.text = text;
67
+ if (level < depth) {
68
+ const children = buildEntries(expand(el.children), level + 1, depth, state);
69
+ if (children.length > 0) entry.children = children;
70
+ }
71
+ }
72
+ return entry;
73
+ }
74
+
75
+ function buildEntries(children, level, depth, state) {
76
+ const runs = [];
77
+ let i = 0;
78
+ while (i < children.length) {
79
+ const el = children[i];
80
+ let j = i;
81
+ while (j + 1 < children.length && runKey(children[j + 1]) === runKey(el)) j += 1;
82
+ runs.push({ el, last: children[j], count: j - i + 1 });
83
+ i = j + 1;
84
+ }
85
+ const shown = runs.slice(0, MAX_CHILDREN);
86
+ const entries = shown.map((run) => entryFor(run.el, level, depth, run.last, run.count, state));
87
+ if (runs.length > shown.length) {
88
+ const hidden = runs.slice(shown.length);
89
+ entries.push({
90
+ more: hidden.length,
91
+ start: located(hidden[0].el).start,
92
+ end: located(hidden[hidden.length - 1].last).end,
93
+ });
94
+ state.capped = true;
95
+ }
96
+ return entries;
97
+ }
98
+
99
+ function readApiRules($) {
100
+ const tag = $('script[data-rules-name~="api"]').first();
101
+ if (tag.length === 0) return { hasApiRules: false, dataKeys: [] };
102
+ let dataKeys = [];
103
+ try {
104
+ const data = JSON.parse(tag.text());
105
+ if (data && typeof data === 'object') dataKeys = Object.keys(data);
106
+ } catch {
107
+ dataKeys = [];
108
+ }
109
+ return { hasApiRules: true, dataKeys };
110
+ }
111
+
112
+ function trimToBudget(entries, maxChars, state) {
113
+ const popped = [];
114
+ while (entries.length > 1 && JSON.stringify(entries).length > maxChars) {
115
+ popped.unshift(entries.pop());
116
+ }
117
+ if (popped.length === 0) return;
118
+ const more = popped.reduce((sum, entry) => sum + (entry.repeated ?? 1) + (entry.more ?? 0), 0);
119
+ entries.push({ more, start: popped[0].start, end: popped[popped.length - 1].end });
120
+ state.capped = true;
121
+ }
122
+
123
+ export function outline(html, { depth = 4, select = null, maxChars = OUTLINE_BUDGET } = {}) {
124
+ const { $ } = parse(html);
125
+ const root = select === null ? $('html').first()[0] : null;
126
+ let start = root;
127
+ if (select !== null) {
128
+ try {
129
+ start = $(select).first()[0] ?? null;
130
+ } catch {
131
+ throw new ClayError('invalid-selector', `That is not a valid CSS selector: ${select}`, {
132
+ hint: 'Pass a selector such as `main#board` or `article.card`.',
133
+ });
134
+ }
135
+ }
136
+ const requested = Number.isInteger(depth) && depth > 0 ? depth : 0;
137
+ const state = { capped: false };
138
+ let used = requested;
139
+ let entries = [];
140
+ if (start && requested > 0) {
141
+ const children = expand(start.children);
142
+ entries = buildEntries(children, 1, used, state);
143
+ while (JSON.stringify(entries).length > maxChars && used > 1) {
144
+ used -= 1;
145
+ state.capped = false;
146
+ entries = buildEntries(children, 1, used, state);
147
+ }
148
+ if (JSON.stringify(entries).length > maxChars) trimToBudget(entries, maxChars, state);
149
+ }
150
+ const truncated = used < requested || state.capped;
151
+ const result = { size: html.length, ...readApiRules($), depth: used, truncated, outline: entries };
152
+ if (truncated) result.hint = CAPPED_HINT;
153
+ return result;
154
+ }
@@ -0,0 +1,17 @@
1
+ import { parentPort, workerData } from 'node:worker_threads';
2
+
3
+ const { html, source, max } = workerData;
4
+ const pattern = new RegExp(source, 'g');
5
+ let count = 0;
6
+ const ranges = [];
7
+ for (;;) {
8
+ const match = pattern.exec(html);
9
+ if (!match) break;
10
+ count += 1;
11
+ if (ranges.length < max) ranges.push({ start: match.index, end: match.index + match[0].length });
12
+ if (match[0].length === 0) {
13
+ pattern.lastIndex = match.index + 1;
14
+ if (pattern.lastIndex > html.length) break;
15
+ }
16
+ }
17
+ parentPort.postMessage({ count, ranges });
@@ -0,0 +1,54 @@
1
+ import { ClayError } from '../errors.js';
2
+ import { located, parse, pathOf } from './source.js';
3
+
4
+ export function select(html, css, { limit = 20, maxChars = 40000 } = {}) {
5
+ const { $ } = parse(html);
6
+ let found;
7
+ try {
8
+ if (typeof css !== 'string' || !css.trim()) throw new Error('empty selector');
9
+ found = $(css).toArray();
10
+ } catch {
11
+ throw new ClayError('invalid-selector', `That is not a valid CSS selector: ${css}`, {
12
+ hint: 'Pass a selector such as `article.card` or `main#board > section`.',
13
+ });
14
+ }
15
+ const locatedMatches = [];
16
+ for (const el of found) {
17
+ const loc = located(el);
18
+ if (!loc) continue;
19
+ locatedMatches.push({ el, ...loc });
20
+ }
21
+ let remaining = maxChars;
22
+ const matches = locatedMatches.slice(0, Math.max(0, limit)).map(({ el, start, end }) => {
23
+ const match = { path: pathOf(el), start, end };
24
+ if (remaining <= 0) {
25
+ match.truncated = true;
26
+ return match;
27
+ }
28
+ const take = Math.min(end - start, remaining);
29
+ match.html = html.slice(start, start + take);
30
+ if (take < end - start) match.truncated = true;
31
+ remaining -= take;
32
+ return match;
33
+ });
34
+ return { count: locatedMatches.length, matches };
35
+ }
36
+
37
+ export function range(html, start, length, { maxChars = Infinity } = {}) {
38
+ if (!Number.isInteger(start) || start < 0) {
39
+ throw new ClayError('invalid-range', 'The range start must be a non-negative whole number.', {
40
+ hint: 'Pass a start offset from a read, such as 0.',
41
+ });
42
+ }
43
+ if (!Number.isInteger(length) || length < 0) {
44
+ throw new ClayError('invalid-range', 'The range length must be a non-negative whole number.', {
45
+ hint: 'Pass the number of characters to read.',
46
+ });
47
+ }
48
+ const from = Math.min(start, html.length);
49
+ const full = Math.min(from + length, html.length);
50
+ const to = Math.min(from + length, html.length, from + maxChars);
51
+ const result = { start: from, end: to, html: html.slice(from, to) };
52
+ if (to < full) result.truncated = true;
53
+ return result;
54
+ }
@@ -0,0 +1,73 @@
1
+ import { load } from 'cheerio';
2
+
3
+ const ELEMENT_TYPES = new Set(['tag', 'script', 'style']);
4
+
5
+ export function isElement(node) {
6
+ return Boolean(node && ELEMENT_TYPES.has(node.type) && node.name);
7
+ }
8
+
9
+ export function parse(html) {
10
+ const $ = load(html, { sourceCodeLocationInfo: true });
11
+ return { html, $ };
12
+ }
13
+
14
+ export function located(el) {
15
+ const loc = el?.sourceCodeLocation;
16
+ if (!loc || typeof loc.startOffset !== 'number' || typeof loc.endOffset !== 'number') return null;
17
+ return { start: loc.startOffset, end: loc.endOffset };
18
+ }
19
+
20
+ function escapeIdent(value) {
21
+ return String(value).replace(/([^\w-])/g, '\\$1');
22
+ }
23
+
24
+ const typePositions = new WeakMap();
25
+
26
+ function positionsUnder(parent) {
27
+ let entry = typePositions.get(parent);
28
+ if (!entry) {
29
+ const position = new Map();
30
+ const total = new Map();
31
+ for (const child of parent.children) {
32
+ if (!isElement(child)) continue;
33
+ const n = (total.get(child.name) ?? 0) + 1;
34
+ total.set(child.name, n);
35
+ position.set(child, n);
36
+ }
37
+ entry = { position, total };
38
+ typePositions.set(parent, entry);
39
+ }
40
+ return entry;
41
+ }
42
+
43
+ function stepFor(el) {
44
+ const id = el.attribs?.id;
45
+ if (id) return `${el.name}#${escapeIdent(id)}`;
46
+ let step = el.name;
47
+ const className = firstClass(el);
48
+ if (className) step += `.${escapeIdent(className)}`;
49
+ const parent = el.parent;
50
+ if (isElement(parent)) {
51
+ const { position, total } = positionsUnder(parent);
52
+ if (total.get(el.name) > 1) step += `:nth-of-type(${position.get(el)})`;
53
+ }
54
+ return step;
55
+ }
56
+
57
+ function firstClass(el) {
58
+ const value = el.attribs?.class;
59
+ if (typeof value !== 'string') return null;
60
+ const trimmed = value.trim();
61
+ return trimmed ? trimmed.split(/\s+/)[0] : null;
62
+ }
63
+
64
+ export function pathOf(el) {
65
+ const chain = [];
66
+ let node = el;
67
+ while (isElement(node)) {
68
+ chain.unshift(node);
69
+ if (node.attribs?.id || node.name === 'html') break;
70
+ node = node.parent;
71
+ }
72
+ return chain.map(stepFor).join(' > ');
73
+ }