Skip to content

Commit 37020c4

Browse files
authored
feat(browser): add network --filter <fields> for agent-native request discovery (#1103)
Agents often know what fields a target request's body should contain but not which captured request carries it. --filter lets them declare the field set and get back only matching entries. Matching is "any-segment": a field matches when it equals any segment name of any inferShape() path (ignoring root $, array indices, and bracket-quoted key syntax). Multiple fields AND together. Case-sensitive. - invalid_filter for empty / commas-only values - invalid_args when combined with --detail (mutually exclusive) - 0 matches is a valid empty result, not an error - persisted cache stays unfiltered so later --detail lookups still resolve Envelope gains `filter` (echo) and `filter_dropped` (count of entries passing the static-resource filter but not --filter). Existing --raw and --all compose normally.
1 parent 6cf5cb2 commit 37020c4

6 files changed

Lines changed: 438 additions & 11 deletions

File tree

skills/opencli-adapter-author/references/api-discovery.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,22 @@ opencli browser network
2929
- 路径里出现 `nickname / avatar / title / price / tweets / items` → 就是它
3030
- shape 只有 `$: string` 或全是 HTML 噪音 → 下一条
3131

32+
### 按期望字段反查(`--filter`
33+
34+
已经知道目标 body 该含哪些字段就直接让 CLI 把列表筛到只剩候选,不用自己 scroll 翻 shape:
35+
36+
```bash
37+
opencli browser network --filter author,text,likes
38+
```
39+
40+
- 字段以英文逗号分隔;AND 语义,必须每个字段都作为 shape 路径的**任意一段**出现才保留(`$.data.items[0].author` 命中 `author``items``data` 都算)
41+
- 区分大小写(JSON key 本来就 case-sensitive)
42+
- 输出 envelope 新增 `filter` / `filter_dropped``count` 是过滤后数量
43+
- 0 命中不是 error,返回 `entries: []`;说明字段组合不对,换一组或去掉约束再试
44+
- 不要跟 `--detail` 一起用——`--detail` 按 key 取单条、`--filter` 是列表缩窄,组合会报 `invalid_args`
45+
- 空值 / `,,,``invalid_filter` 结构化错误
46+
- capture 依然按全量持久化,后续 `--detail <key>` 能找到被过滤掉的条目
47+
3248
### 拉完整 body
3349

3450
候选定了再拉完整 body(by key,不是 index — 数组顺序会随每次 capture 变):

skills/opencli-autofix/SKILL.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,9 @@ opencli browser open https://example.com/target-page && opencli browser state
130130
# Interact to trigger API calls
131131
opencli browser click <N> && opencli browser network
132132

133+
# Narrow to the request you care about by the fields its body should have
134+
opencli browser network --filter author,text,likes
135+
133136
# Inspect specific API response (key is the `key` field from the default JSON output)
134137
opencli browser network --detail <key>
135138
```

src/browser/shape-filter.test.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { describe, expect, it } from 'vitest';
2+
import type { Shape } from './shape.js';
3+
import {
4+
collectShapeSegments,
5+
extractSegments,
6+
parseFilter,
7+
shapeMatchesFilter,
8+
} from './shape-filter.js';
9+
10+
describe('parseFilter', () => {
11+
it('splits comma-separated fields, trims, and drops empty tokens', () => {
12+
const r = parseFilter('author, text , likes');
13+
expect(r).toEqual({ fields: ['author', 'text', 'likes'] });
14+
});
15+
16+
it('dedupes while preserving first-seen order', () => {
17+
const r = parseFilter('a,b,a,c,b');
18+
expect(r).toEqual({ fields: ['a', 'b', 'c'] });
19+
});
20+
21+
it('rejects empty string as invalid_filter', () => {
22+
const r = parseFilter('');
23+
expect(r).toMatchObject({ reason: expect.stringContaining('non-empty') });
24+
});
25+
26+
it('rejects whitespace-only as invalid_filter', () => {
27+
const r = parseFilter(' ');
28+
expect(r).toMatchObject({ reason: expect.stringContaining('non-empty') });
29+
});
30+
31+
it('rejects commas-only as invalid_filter', () => {
32+
const r = parseFilter(',,,');
33+
expect(r).toMatchObject({ reason: expect.stringContaining('non-empty') });
34+
});
35+
36+
it('accepts a single field', () => {
37+
expect(parseFilter('author')).toEqual({ fields: ['author'] });
38+
});
39+
});
40+
41+
describe('extractSegments', () => {
42+
it('returns empty for root', () => {
43+
expect(extractSegments('$')).toEqual([]);
44+
});
45+
46+
it('splits dotted path and drops $', () => {
47+
expect(extractSegments('$.data.user.name')).toEqual(['data', 'user', 'name']);
48+
});
49+
50+
it('drops numeric array indices', () => {
51+
expect(extractSegments('$.items[0].author')).toEqual(['items', 'author']);
52+
expect(extractSegments('$.rows[0][12]')).toEqual(['rows']);
53+
});
54+
55+
it('unwraps bracket-quoted keys', () => {
56+
expect(extractSegments('$.data["weird key"]')).toEqual(['data', 'weird key']);
57+
});
58+
59+
it('handles bracket-quoted keys at root', () => {
60+
expect(extractSegments('$["123bad"]')).toEqual(['123bad']);
61+
});
62+
63+
it('mixes bracket keys and dot segments', () => {
64+
expect(extractSegments('$.data.user["nick name"].age'))
65+
.toEqual(['data', 'user', 'nick name', 'age']);
66+
});
67+
});
68+
69+
describe('collectShapeSegments', () => {
70+
it('collects every segment name from every path in a shape', () => {
71+
const shape: Shape = {
72+
'$': 'object',
73+
'$.data': 'object',
74+
'$.data.items': 'array(3)',
75+
'$.data.items[0]': 'object',
76+
'$.data.items[0].author': 'string',
77+
'$.data.items[0].text': 'string',
78+
};
79+
const segs = collectShapeSegments(shape);
80+
expect(segs.has('data')).toBe(true);
81+
expect(segs.has('items')).toBe(true);
82+
expect(segs.has('author')).toBe(true);
83+
expect(segs.has('text')).toBe(true);
84+
expect(segs.has('$')).toBe(false);
85+
expect(segs.has('[0]')).toBe(false);
86+
});
87+
88+
it('returns an empty set for an empty shape', () => {
89+
expect(collectShapeSegments({}).size).toBe(0);
90+
});
91+
});
92+
93+
describe('shapeMatchesFilter', () => {
94+
const shape: Shape = {
95+
'$': 'object',
96+
'$.data': 'object',
97+
'$.data.items': 'array(1)',
98+
'$.data.items[0].author': 'string',
99+
'$.data.items[0].text': 'string',
100+
'$.data.items[0].likes': 'number',
101+
};
102+
103+
it('returns true when every field matches some path segment (AND)', () => {
104+
expect(shapeMatchesFilter(shape, ['author', 'text', 'likes'])).toBe(true);
105+
});
106+
107+
it('matches nested container names, not just leaves (any-segment rule)', () => {
108+
// `data` and `items` are container segments, not leaves; still must match.
109+
expect(shapeMatchesFilter(shape, ['data', 'items'])).toBe(true);
110+
});
111+
112+
it('returns false when any field is missing', () => {
113+
expect(shapeMatchesFilter(shape, ['author', 'missing'])).toBe(false);
114+
});
115+
116+
it('is case-sensitive', () => {
117+
expect(shapeMatchesFilter(shape, ['Author'])).toBe(false);
118+
expect(shapeMatchesFilter(shape, ['author'])).toBe(true);
119+
});
120+
121+
it('empty filter list vacuously matches', () => {
122+
expect(shapeMatchesFilter(shape, [])).toBe(true);
123+
});
124+
125+
it('rejects requests whose shape has no body (empty shape)', () => {
126+
expect(shapeMatchesFilter({}, ['author'])).toBe(false);
127+
});
128+
});

src/browser/shape-filter.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/**
2+
* Shape-based field filter for `browser network --filter <fields>`.
3+
*
4+
* Agents know what fields a target request's body should contain
5+
* (e.g. "author, text, likes") but not which of the captured requests
6+
* carries that body. This module lets the network command filter
7+
* entries down to those whose inferred shape exposes every requested
8+
* field name as some path segment.
9+
*
10+
* Matching is "any-segment" (not last-segment-only): a field matches
11+
* if it equals any segment name of any path in the shape map. This
12+
* keeps nested-container fields (e.g. `legacy`, `author` used as an
13+
* object key with further nesting) findable.
14+
*/
15+
import type { Shape } from './shape.js';
16+
17+
export interface ParsedFilter {
18+
/** Deduped, order-preserving, trimmed non-empty field names. */
19+
fields: string[];
20+
}
21+
22+
export interface FilterParseError {
23+
/** `invalid_filter` structured error reason for agents. */
24+
reason: string;
25+
}
26+
27+
/**
28+
* Parse `--filter` argument value. Splits on `,`, trims, drops empties,
29+
* and dedupes (first-seen wins). Returns `FilterParseError` when the
30+
* result is empty after cleaning — which means the caller passed only
31+
* whitespace, commas, or an empty string.
32+
*/
33+
export function parseFilter(raw: string): ParsedFilter | FilterParseError {
34+
if (typeof raw !== 'string') {
35+
return { reason: `--filter value must be a non-empty comma-separated field list` };
36+
}
37+
const parts = raw.split(',').map((p) => p.trim()).filter((p) => p.length > 0);
38+
if (parts.length === 0) {
39+
return { reason: `--filter value must be a non-empty comma-separated field list (got "${raw}")` };
40+
}
41+
const seen = new Set<string>();
42+
const fields: string[] = [];
43+
for (const p of parts) {
44+
if (!seen.has(p)) { seen.add(p); fields.push(p); }
45+
}
46+
return { fields };
47+
}
48+
49+
/**
50+
* Extract named segments from a shape path. Drops the leading `$`,
51+
* strips `[N]` array indices, and unwraps `["key"]` bracket-quoted
52+
* keys back to their raw string.
53+
*
54+
* Examples:
55+
* `$` → []
56+
* `$.data.items[0].author` → ['data','items','author']
57+
* `$.data.user["nick name"]` → ['data','user','nick name']
58+
* `$.rows[0][1]` → ['rows']
59+
*/
60+
export function extractSegments(path: string): string[] {
61+
if (!path || path === '$') return [];
62+
const out: string[] = [];
63+
// Start past the leading `$`; if path doesn't start with `$` treat
64+
// it as a raw segment list (keeps us robust to unexpected input).
65+
let i = path.startsWith('$') ? 1 : 0;
66+
while (i < path.length) {
67+
const c = path[i];
68+
if (c === '.') { i++; continue; }
69+
if (c === '[') {
70+
// Either `[N]` (numeric) or `["key"]` (quoted key). Handle both.
71+
const end = path.indexOf(']', i);
72+
if (end === -1) break;
73+
const inner = path.slice(i + 1, end);
74+
i = end + 1;
75+
if (inner.length >= 2 && inner.startsWith('"') && inner.endsWith('"')) {
76+
try { out.push(JSON.parse(inner) as string); }
77+
catch { out.push(inner.slice(1, -1)); }
78+
}
79+
// numeric index: drop
80+
continue;
81+
}
82+
// Bare identifier: read up to next `.` or `[`
83+
let j = i;
84+
while (j < path.length && path[j] !== '.' && path[j] !== '[') j++;
85+
out.push(path.slice(i, j));
86+
i = j;
87+
}
88+
return out;
89+
}
90+
91+
/**
92+
* Collect the set of segment names used anywhere in a shape map.
93+
* The returned set is what we test field membership against.
94+
*/
95+
export function collectShapeSegments(shape: Shape): Set<string> {
96+
const acc = new Set<string>();
97+
for (const p of Object.keys(shape)) {
98+
for (const seg of extractSegments(p)) acc.add(seg);
99+
}
100+
return acc;
101+
}
102+
103+
/**
104+
* True iff every field in `fields` equals some segment name in `shape`.
105+
* AND semantics: all fields must be present.
106+
*/
107+
export function shapeMatchesFilter(shape: Shape, fields: string[]): boolean {
108+
if (fields.length === 0) return true;
109+
const segs = collectShapeSegments(shape);
110+
for (const f of fields) {
111+
if (!segs.has(f)) return false;
112+
}
113+
return true;
114+
}

0 commit comments

Comments
 (0)