dsh-builtin-browser 0.4.0 → 0.4.2
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/README.en.md +68 -30
- package/README.md +66 -30
- package/client.js +60 -6
- package/cordis.patch.yml +21 -0
- package/desktop-bridge/plugin-browser-bridge.js +12 -4
- package/docs/architecture.md +12 -2
- package/docs/tool-reference.md +17 -17
- package/docs/user-guide.md +13 -4
- package/docs/why-browser.md +2 -2
- package/lib/browser/types.d.ts +25 -1
- package/lib/browser-electron/bridge-connection.d.ts +31 -5
- package/lib/browser-electron/bridge-connection.js +143 -21
- package/lib/browser-electron/entry.d.ts +1 -1
- package/lib/browser-electron/entry.js +61 -35
- package/lib/browser-electron/ephemeral-profile.d.ts +37 -0
- package/lib/browser-electron/ephemeral-profile.js +146 -0
- package/lib/browser-electron/history-store.d.ts +25 -0
- package/lib/browser-electron/history-store.js +51 -4
- package/lib/browser-electron/host-main.js +75 -27
- package/lib/browser-electron/provider.d.ts +44 -1
- package/lib/browser-electron/provider.js +379 -48
- package/lib/browser-electron/remote-host.d.ts +52 -3
- package/lib/browser-electron/remote-host.js +244 -23
- package/lib/browser-electron/settings-route.d.ts +28 -0
- package/lib/browser-electron/settings-route.js +28 -0
- package/lib/browser-electron/settings-store.d.ts +40 -0
- package/lib/browser-electron/settings-store.js +107 -11
- package/lib/browser-electron/system-browser.d.ts +71 -3
- package/lib/browser-electron/system-browser.js +355 -38
- package/lib/tool-browser/index.js +143 -34
- package/package.json +5 -3
- package/scripts/verify-host-compat.mjs +195 -0
- package/src/browser/types.ts +25 -1
- package/src/browser-electron/bridge-connection.ts +142 -21
- package/src/browser-electron/entry.ts +58 -33
- package/src/browser-electron/ephemeral-profile.ts +152 -0
- package/src/browser-electron/history-store.ts +51 -4
- package/src/browser-electron/host-main.ts +105 -35
- package/src/browser-electron/provider.ts +401 -51
- package/src/browser-electron/remote-host.ts +261 -36
- package/src/browser-electron/settings-route.ts +46 -0
- package/src/browser-electron/settings-store.ts +121 -11
- package/src/browser-electron/system-browser.ts +342 -21
- package/src/tool-browser/index.ts +144 -36
|
@@ -66,7 +66,10 @@ export const READ_ONLY_TOOLS = new Set([
|
|
|
66
66
|
'browser_list_tabs', 'browser_session', 'browser_history', 'browser_visited',
|
|
67
67
|
// browser_auth reads cookies out of the browser: an observation, and one the
|
|
68
68
|
// credentials switch gates on its own (see credentials.allowRead in the settings).
|
|
69
|
-
|
|
69
|
+
// browser_auth is deliberately NOT here. It reads cookies on "flush" but WRITES them on
|
|
70
|
+
// "restore", to arbitrary domains, which is an action like any other — exempting the tool
|
|
71
|
+
// would let a task that restricted its own actions rewrite the shared browser's logins.
|
|
72
|
+
//
|
|
70
73
|
// Getting out of a bad state, or lifting the restriction itself. Without these a task
|
|
71
74
|
// that restricted everything could neither recover nor be released.
|
|
72
75
|
'browser_restrict', 'browser_reset_session', 'browser_reset',
|
|
@@ -94,6 +97,26 @@ function assertAllowed(state, toolName, task) {
|
|
|
94
97
|
function agentOf(exec) {
|
|
95
98
|
return exec?.agent;
|
|
96
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* Read a target's `index`, refusing the values that cannot mean anything.
|
|
102
|
+
*
|
|
103
|
+
* The index is looked up in the page (`els[index] ?? null`), so a negative one — `-1`, the
|
|
104
|
+
* common "give me the last one" shorthand — matches nothing. The page script then treated
|
|
105
|
+
* that as "not on the page yet" and polled for its whole 10s budget before reporting
|
|
106
|
+
* "element not found", which reads like a slow page rather than a bad argument. Refusing it
|
|
107
|
+
* here names the real problem immediately.
|
|
108
|
+
* @param target - the caller's target, or undefined when there is none.
|
|
109
|
+
* @returns the index, or undefined to let the page default to 0.
|
|
110
|
+
*/
|
|
111
|
+
function targetIndex(target) {
|
|
112
|
+
const index = target?.index;
|
|
113
|
+
if (index === undefined)
|
|
114
|
+
return undefined;
|
|
115
|
+
if (typeof index !== 'number' || !Number.isSafeInteger(index) || index < 0) {
|
|
116
|
+
throw new Error(`target.index must be a non-negative integer (got ${JSON.stringify(index)}); it is a 0-based position among the matches, and negative values such as -1 match nothing — pass the position you want, or narrow the selector`);
|
|
117
|
+
}
|
|
118
|
+
return index;
|
|
119
|
+
}
|
|
97
120
|
/**
|
|
98
121
|
* The task key for a tool call: the calling DSH session id, or the shared
|
|
99
122
|
* default key when the call carries no agent context (CLI probes, tests).
|
|
@@ -191,7 +214,7 @@ function parseFillValue(v) {
|
|
|
191
214
|
*/
|
|
192
215
|
function formatSnapshot(snapshot, options = {}) {
|
|
193
216
|
const showCoords = options.coords === true;
|
|
194
|
-
const lines = snapshot.elements.map(el => `[${el.ref}] ${el.kind}: ${el.label}${el.frame === true ? ' (iframe)' : ''}${showCoords ? ` (${el.x},${el.y})` : ''}`);
|
|
217
|
+
const lines = snapshot.elements.map(el => `[${el.ref}] ${el.kind}: ${el.label}${el.selector !== undefined && el.selector !== '' ? ` {${el.selector}}` : ''}${el.frame === true ? ' (iframe)' : ''}${showCoords ? ` (${el.x},${el.y})` : ''}`);
|
|
195
218
|
const header = `URL: ${snapshot.url}${snapshot.title !== undefined ? `\nTitle: ${snapshot.title}` : ''}`;
|
|
196
219
|
const body = lines.length > 0 ? lines.join('\n') : '(no interactive elements found)';
|
|
197
220
|
const tail = snapshot.truncated === true ? '\n(snapshot truncated)' : '';
|
|
@@ -203,6 +226,25 @@ function formatSnapshot(snapshot, options = {}) {
|
|
|
203
226
|
/** Register all browser tools with `ctx.tools`. */
|
|
204
227
|
export function apply(ctx, config = {}) {
|
|
205
228
|
const timeoutMs = config.timeoutMs ?? 60_000;
|
|
229
|
+
/**
|
|
230
|
+
* Longest wait `browser_wait` may poll for, leaving room for the tool call itself to
|
|
231
|
+
* finish and report.
|
|
232
|
+
*
|
|
233
|
+
* `browser_wait` promises a VERDICT — `{ready:false, reason}` — and the enclosing tool
|
|
234
|
+
* call aborts at `timeoutMs`. A caller-supplied budget longer than that aborted the call
|
|
235
|
+
* instead, so the documented verdict never arrived. Clamping to just inside the budget is
|
|
236
|
+
* what keeps the promise: the polling stops, the reason is returned.
|
|
237
|
+
*/
|
|
238
|
+
const maxWaitMs = Math.max(250, timeoutMs - 5_000);
|
|
239
|
+
/**
|
|
240
|
+
* Most fields `browser_fill` accepts in one batch.
|
|
241
|
+
*
|
|
242
|
+
* The batch runs as ONE page evaluation, so hundreds of fields do not produce hundreds of
|
|
243
|
+
* per-field verdicts — they produce a single evaluate that exceeds the tool budget and
|
|
244
|
+
* throws, which is the opposite of the documented "per-field failures are reported instead
|
|
245
|
+
* of throwing". Real forms are far smaller than this; anything larger should be split.
|
|
246
|
+
*/
|
|
247
|
+
const maxFillFields = 200;
|
|
206
248
|
// Per-context state: sessions, in-flight opens, and the restriction are
|
|
207
249
|
// scoped to THIS plugin apply, so parallel contexts never share sessions
|
|
208
250
|
// or leak restrictions into each other.
|
|
@@ -246,6 +288,12 @@ export function apply(ctx, config = {}) {
|
|
|
246
288
|
x: { type: 'number', required: true },
|
|
247
289
|
y: { type: 'number', required: true },
|
|
248
290
|
frame: { type: 'boolean' },
|
|
291
|
+
// The page-side script computes this for every element and the tool layer
|
|
292
|
+
// passes it through. Without it in the schema, a declared
|
|
293
|
+
// additionalProperties: false rejects the whole result at run time — which is
|
|
294
|
+
// how this was found, on the very first real call, after a static review had
|
|
295
|
+
// called the change verified.
|
|
296
|
+
selector: { type: 'string' },
|
|
249
297
|
},
|
|
250
298
|
},
|
|
251
299
|
},
|
|
@@ -281,7 +329,7 @@ export function apply(ctx, config = {}) {
|
|
|
281
329
|
return {
|
|
282
330
|
url: snapshot.url,
|
|
283
331
|
...snapshot.title !== undefined ? { title: snapshot.title } : {},
|
|
284
|
-
elements: snapshot.elements.map(el => ({ ref: el.ref, kind: el.kind, label: el.label, x: el.x, y: el.y, ...el.frame === true ? { frame: true } : {} })),
|
|
332
|
+
elements: snapshot.elements.map(el => ({ ref: el.ref, kind: el.kind, label: el.label, selector: el.selector, x: el.x, y: el.y, ...el.frame === true ? { frame: true } : {} })),
|
|
285
333
|
truncated: snapshot.truncated,
|
|
286
334
|
...snapshot.challenge !== undefined ? { challenge: snapshot.challenge } : {},
|
|
287
335
|
};
|
|
@@ -314,6 +362,12 @@ export function apply(ctx, config = {}) {
|
|
|
314
362
|
x: { type: 'number', required: true },
|
|
315
363
|
y: { type: 'number', required: true },
|
|
316
364
|
frame: { type: 'boolean' },
|
|
365
|
+
// The page-side script computes this for every element and the tool layer
|
|
366
|
+
// passes it through. Without it in the schema, a declared
|
|
367
|
+
// additionalProperties: false rejects the whole result at run time — which is
|
|
368
|
+
// how this was found, on the very first real call, after a static review had
|
|
369
|
+
// called the change verified.
|
|
370
|
+
selector: { type: 'string' },
|
|
317
371
|
},
|
|
318
372
|
},
|
|
319
373
|
},
|
|
@@ -341,7 +395,7 @@ export function apply(ctx, config = {}) {
|
|
|
341
395
|
return {
|
|
342
396
|
url: snapshot.url,
|
|
343
397
|
...snapshot.title !== undefined ? { title: snapshot.title } : {},
|
|
344
|
-
elements: snapshot.elements.map(el => ({ ref: el.ref, kind: el.kind, label: el.label, x: el.x, y: el.y, ...el.frame === true ? { frame: true } : {} })),
|
|
398
|
+
elements: snapshot.elements.map(el => ({ ref: el.ref, kind: el.kind, label: el.label, selector: el.selector, x: el.x, y: el.y, ...el.frame === true ? { frame: true } : {} })),
|
|
345
399
|
truncated: snapshot.truncated,
|
|
346
400
|
...snapshot.challenge !== undefined ? { challenge: snapshot.challenge } : {},
|
|
347
401
|
};
|
|
@@ -349,10 +403,10 @@ export function apply(ctx, config = {}) {
|
|
|
349
403
|
}));
|
|
350
404
|
ctx.tools.register(defineTool({
|
|
351
405
|
name: 'browser_a11y',
|
|
352
|
-
description: 'Read the page\'s accessibility tree: every interactive node with its semantic role (button/link/textbox/checkbox/…), accessible name, current value, and states (enabled/disabled/checked/expanded/…),
|
|
406
|
+
description: 'Read the page\'s accessibility tree: every interactive node with its semantic role (button/link/textbox/checkbox/…), accessible name, current value, and states (enabled/disabled/checked/expanded/…), (coordinates only when coords: true). Prefer this over browser_snapshot to understand a page\'s structure and find the right element: roles and names tell you WHAT each node is, and the name is what browser_click matches when you target by text. Penetrates same-origin iframes and shadow roots.',
|
|
353
407
|
parameters: {
|
|
354
408
|
includeHidden: { type: 'boolean', description: 'Include hidden elements (default false).' },
|
|
355
|
-
maxNodes: { type: 'number', description: 'Maximum nodes (default
|
|
409
|
+
maxNodes: { type: 'number', description: 'Maximum nodes (default 150, range 10-5000).' },
|
|
356
410
|
coords: { type: 'boolean', description: 'Include each element\'s viewport coordinates. Off by default: a semantic target needs no coordinates, and they are pure noise unless a vision pass is going to click a pixel position.' },
|
|
357
411
|
},
|
|
358
412
|
output: {
|
|
@@ -381,6 +435,12 @@ export function apply(ctx, config = {}) {
|
|
|
381
435
|
x: { type: 'number', required: true },
|
|
382
436
|
y: { type: 'number', required: true },
|
|
383
437
|
frame: { type: 'boolean' },
|
|
438
|
+
// The page-side script computes this for every element and the tool layer
|
|
439
|
+
// passes it through. Without it in the schema, a declared
|
|
440
|
+
// additionalProperties: false rejects the whole result at run time — which is
|
|
441
|
+
// how this was found, on the very first real call, after a static review had
|
|
442
|
+
// called the change verified.
|
|
443
|
+
selector: { type: 'string' },
|
|
384
444
|
},
|
|
385
445
|
},
|
|
386
446
|
},
|
|
@@ -396,13 +456,22 @@ export function apply(ctx, config = {}) {
|
|
|
396
456
|
const showCoords = args.coords === true;
|
|
397
457
|
const lines = nodes.map(n => {
|
|
398
458
|
const valuePart = n.value !== undefined && n.value !== null ? ` value="${String(n.value).slice(0, 60)}"` : '';
|
|
399
|
-
|
|
459
|
+
// One space per level, not two: indentation was 19.7% of a measured 38,953-character
|
|
460
|
+
// result, and DOM depth is not a11y depth — deep wrapper divs push it to the cap.
|
|
461
|
+
const indent = ' '.repeat(Math.max(0, Math.min(8, Number(n.depth) || 0)));
|
|
400
462
|
const coordsPart = showCoords ? ` (${n.x},${n.y})` : '';
|
|
401
|
-
|
|
402
|
-
|
|
463
|
+
// Only states that are not the default. `states=[enabled]` was printed on 405 of 500
|
|
464
|
+
// nodes whose state WAS the default — 22% of the output for no information. Absence
|
|
465
|
+
// now means "enabled", which the description states.
|
|
466
|
+
const notableStates = Array.isArray(n.states) ? n.states.filter((state) => state !== 'enabled') : [];
|
|
467
|
+
const statePart = notableStates.length > 0 ? ` states=[${notableStates.join(',')}]` : '';
|
|
468
|
+
// The provider computes this for a node with an id or a name; printing it is what
|
|
469
|
+
// makes the node something the caller can point at instead of guessing at its text.
|
|
470
|
+
const selectorPart = typeof n.selector === 'string' && n.selector !== '' ? ` {${n.selector}}` : '';
|
|
471
|
+
return `${indent}[${n.ref}] ${n.role} "${n.name}"${valuePart}${coordsPart}${statePart}${selectorPart}${n.frame === true ? ' (iframe)' : ''}`;
|
|
403
472
|
});
|
|
404
473
|
const header = `URL: ${value.url}${value.title !== undefined ? `\nTitle: ${value.title}` : ''}`;
|
|
405
|
-
const hint = showCoords ? '' : '\n(
|
|
474
|
+
const hint = showCoords ? '' : '\n(no coords; pass coords: true)';
|
|
406
475
|
return [{ type: 'text', text: `${header}\n\n${lines.length > 0 ? lines.join('\n') : '(no accessible interactive nodes)'}${value.truncated === true ? '\n(truncated)' : ''}${hint}` }];
|
|
407
476
|
},
|
|
408
477
|
},
|
|
@@ -430,6 +499,11 @@ export function apply(ctx, config = {}) {
|
|
|
430
499
|
states: [...n.states],
|
|
431
500
|
depth: n.depth,
|
|
432
501
|
tag: n.tag,
|
|
502
|
+
// The provider computes this and the renderer prints it, but this mapping is an
|
|
503
|
+
// explicit allow-list rather than a pass-through — so leaving it out here silently
|
|
504
|
+
// dropped the field between the two, and the tool promised a reference it never
|
|
505
|
+
// sent. The snapshot mapping lists its selector; this one did not.
|
|
506
|
+
selector: n.selector,
|
|
433
507
|
x: n.x,
|
|
434
508
|
y: n.y,
|
|
435
509
|
...n.frame === true ? { frame: true } : {},
|
|
@@ -504,7 +578,9 @@ export function apply(ctx, config = {}) {
|
|
|
504
578
|
throw new Error('tool-browser: browser service unavailable');
|
|
505
579
|
const session = await ensureSession(browser, state, taskKey(exec), agentOf(exec));
|
|
506
580
|
const result = await browser.waitFor(session, {
|
|
507
|
-
|
|
581
|
+
// Capped so an over-budget argument returns the documented verdict (see maxWaitMs)
|
|
582
|
+
// rather than aborting the whole tool call.
|
|
583
|
+
...args.timeoutMs !== undefined ? { timeoutMs: Math.min(args.timeoutMs, maxWaitMs) } : {},
|
|
508
584
|
...args.url !== undefined ? { url: args.url } : {},
|
|
509
585
|
...args.selector !== undefined ? { selector: args.selector } : {},
|
|
510
586
|
}, exec.signal);
|
|
@@ -528,7 +604,19 @@ export function apply(ctx, config = {}) {
|
|
|
528
604
|
exception: { type: 'string' },
|
|
529
605
|
},
|
|
530
606
|
},
|
|
531
|
-
|
|
607
|
+
// A return value is whatever the page produced, and `document.body.outerHTML` measured
|
|
608
|
+
// 157,350 characters — ~39-52k tokens from one call. Bounded, with the cut stated so the
|
|
609
|
+
// caller knows to narrow the expression rather than the value being silently short.
|
|
610
|
+
render: (_args, value) => {
|
|
611
|
+
if (!value.ok)
|
|
612
|
+
return [{ type: 'text', text: `Exception: ${value.exception}` }];
|
|
613
|
+
const rendered = String(value.value ?? '');
|
|
614
|
+
const cap = 50_000;
|
|
615
|
+
const text = rendered.length > cap
|
|
616
|
+
? `Result: ${rendered.slice(0, cap)}\n(result truncated at ${cap} of ${rendered.length} characters — return a smaller value, or write it with browser_execute + a variable)`
|
|
617
|
+
: `Result: ${rendered}`;
|
|
618
|
+
return [{ type: 'text', text }];
|
|
619
|
+
},
|
|
532
620
|
},
|
|
533
621
|
timeoutMs,
|
|
534
622
|
isConcurrencySafe: () => false, // page JS can be stateful
|
|
@@ -568,7 +656,7 @@ export function apply(ctx, config = {}) {
|
|
|
568
656
|
truncated: { type: 'boolean', required: true },
|
|
569
657
|
},
|
|
570
658
|
},
|
|
571
|
-
render: (_args, value) => [{ type: 'text', text: value.content + (value.truncated ?
|
|
659
|
+
render: (_args, value) => [{ type: 'text', text: value.content + (value.truncated ? `\n(content truncated — narrow it with selector= or raise maxChars)` : '') }],
|
|
572
660
|
},
|
|
573
661
|
timeoutMs,
|
|
574
662
|
isConcurrencySafe: () => true,
|
|
@@ -614,7 +702,10 @@ export function apply(ctx, config = {}) {
|
|
|
614
702
|
},
|
|
615
703
|
render: (_args, value) => [{
|
|
616
704
|
type: 'text',
|
|
617
|
-
|
|
705
|
+
// Compact, unlike the pretty-printed form this used to emit: two-space indentation
|
|
706
|
+
// carries no information a model can use and cost 13.7% of a measured 39,559-character
|
|
707
|
+
// result. The count leads so the caller can tell a short list from a capped one.
|
|
708
|
+
text: `${value.count} item(s):\n${JSON.stringify(value.items)}`,
|
|
618
709
|
}],
|
|
619
710
|
},
|
|
620
711
|
timeoutMs,
|
|
@@ -670,12 +761,13 @@ export function apply(ctx, config = {}) {
|
|
|
670
761
|
if (target !== undefined && typeof target.value === 'string' && target.value === '') {
|
|
671
762
|
throw new Error('target.value is empty; pass a css selector, visible text or XPath, or omit target entirely to use x/y');
|
|
672
763
|
}
|
|
764
|
+
const index = targetIndex(target);
|
|
673
765
|
if (target !== undefined && typeof target.value === 'string' && target.value !== '') {
|
|
674
766
|
await browser.click(session, {
|
|
675
767
|
target: {
|
|
676
768
|
by: (target.by === 'text' || target.by === 'xpath' ? target.by : 'css'),
|
|
677
769
|
value: target.value,
|
|
678
|
-
...
|
|
770
|
+
...index !== undefined ? { index } : {},
|
|
679
771
|
},
|
|
680
772
|
}, exec.signal);
|
|
681
773
|
}
|
|
@@ -723,13 +815,14 @@ export function apply(ctx, config = {}) {
|
|
|
723
815
|
if (target !== undefined && typeof target.value === 'string' && target.value === '') {
|
|
724
816
|
throw new Error('target.value is empty; pass a css selector, visible text or XPath, or omit target entirely to use x/y');
|
|
725
817
|
}
|
|
818
|
+
const index = targetIndex(target);
|
|
726
819
|
if (target !== undefined && typeof target.value === 'string' && target.value !== '') {
|
|
727
820
|
await browser.type(session, {
|
|
728
821
|
text: args.text,
|
|
729
822
|
target: {
|
|
730
823
|
by: (target.by === 'text' || target.by === 'xpath' ? target.by : 'css'),
|
|
731
824
|
value: target.value,
|
|
732
|
-
...
|
|
825
|
+
...index !== undefined ? { index } : {},
|
|
733
826
|
},
|
|
734
827
|
}, exec.signal);
|
|
735
828
|
}
|
|
@@ -918,7 +1011,11 @@ export function apply(ctx, config = {}) {
|
|
|
918
1011
|
if (browser === undefined)
|
|
919
1012
|
throw new Error('tool-browser: browser service unavailable');
|
|
920
1013
|
const session = await ensureSession(browser, state, taskKey(exec), agentOf(exec));
|
|
921
|
-
const
|
|
1014
|
+
const requested = args.fields ?? [];
|
|
1015
|
+
if (requested.length > maxFillFields) {
|
|
1016
|
+
throw new Error(`browser_fill: ${requested.length} fields in one call exceeds the ${maxFillFields}-field limit; split it into several calls so each one can report per-field results instead of timing out as a batch`);
|
|
1017
|
+
}
|
|
1018
|
+
const fields = requested.map((f) => {
|
|
922
1019
|
// A missing value is a caller mistake, not a request to clear the field. Turning
|
|
923
1020
|
// it into '' silently unchecks a checkbox or empties a text input while the tool
|
|
924
1021
|
// still reports success, so it fails loudly. The schema marks value required, but
|
|
@@ -1149,7 +1246,7 @@ export function apply(ctx, config = {}) {
|
|
|
1149
1246
|
}));
|
|
1150
1247
|
ctx.tools.register(defineTool({
|
|
1151
1248
|
name: 'browser_screenshot',
|
|
1152
|
-
description: 'Capture the current shared-browser page as a screenshot (PNG default, JPEG optional). This is for models that can read images: layout checks, charts, designs, CAPTCHAs, or locating an element by eye before clicking its coordinates. A model without image input gains nothing from it — browser_snapshot, browser_a11y and browser_content carry the same page as text, and browser_scrape extracts structured data. Supports full-page capture, save-to-file, JPEG encoding, and downscaling (maxWidth/maxHeight) to cut vision-tool token cost. JPEG needs a browser whose CDP encoder works — the self-hosted browser and an installed Chrome/Edge; the desktop sidebar
|
|
1249
|
+
description: 'Capture the current shared-browser page as a screenshot (PNG default, JPEG optional). This is for models that can read images: layout checks, charts, designs, CAPTCHAs, or locating an element by eye before clicking its coordinates. A model without image input gains nothing from it — browser_snapshot, browser_a11y and browser_content carry the same page as text, and browser_scrape extracts structured data. Supports full-page capture, save-to-file, JPEG encoding, and downscaling (maxWidth/maxHeight) to cut vision-tool token cost. JPEG needs a browser whose CDP encoder works — the self-hosted browser and an installed Chrome/Edge; some carriers (the desktop sidebar) return PNG instead. Downscaling works on every carrier.',
|
|
1153
1250
|
parameters: {
|
|
1154
1251
|
fullPage: { type: 'boolean', description: 'Capture the full scrollable page instead of the viewport (default false).' },
|
|
1155
1252
|
savePath: { type: 'string', description: 'Absolute file path to also save the image to (e.g. for read_image vision location). Must resolve inside the configured downloadDir (default: the system Downloads folder, localized names such as ~/下载 included); an existing file is never overwritten.' },
|
|
@@ -1163,7 +1260,9 @@ export function apply(ctx, config = {}) {
|
|
|
1163
1260
|
type: 'object',
|
|
1164
1261
|
additionalProperties: false,
|
|
1165
1262
|
properties: {
|
|
1166
|
-
dataUrl: { type: 'string', required: true, description: 'Base64
|
|
1263
|
+
dataUrl: { type: 'string', required: true, description: 'Base64 data URL of the screenshot (PNG, or JPEG when the carrier uses CDP JPEG).' },
|
|
1264
|
+
width: { type: 'number', description: 'Pixel width of the returned image, when the capture was measured (present with maxWidth/maxHeight).' },
|
|
1265
|
+
height: { type: 'number', description: 'Pixel height of the returned image, when the capture was measured.' },
|
|
1167
1266
|
path: { type: 'string', description: 'The file path the screenshot was saved to, when savePath was given.' },
|
|
1168
1267
|
},
|
|
1169
1268
|
},
|
|
@@ -1300,7 +1399,9 @@ export function apply(ctx, config = {}) {
|
|
|
1300
1399
|
ctx.tools.register(defineTool({
|
|
1301
1400
|
name: 'browser_history',
|
|
1302
1401
|
description: 'List the shared browser session\'s recorded operation history (navigate/execute/click/type), newest last, with per-step success/error. Use to understand what the agent did and to pick a step to replay.',
|
|
1303
|
-
parameters: {
|
|
1402
|
+
parameters: {
|
|
1403
|
+
verbose: { type: 'boolean', description: "Include each operation's parameters (default false). They are what you just sent, so they are omitted unless asked for." },
|
|
1404
|
+
},
|
|
1304
1405
|
output: {
|
|
1305
1406
|
schema: {
|
|
1306
1407
|
type: 'object',
|
|
@@ -1324,18 +1425,21 @@ export function apply(ctx, config = {}) {
|
|
|
1324
1425
|
},
|
|
1325
1426
|
},
|
|
1326
1427
|
},
|
|
1327
|
-
render: (
|
|
1428
|
+
render: (args, value) => {
|
|
1328
1429
|
const entries = value.entries;
|
|
1329
1430
|
if (entries.length === 0)
|
|
1330
1431
|
return [{ type: 'text', text: '(no recorded operations yet)' }];
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1432
|
+
// The parameters are what the caller itself just sent, so echoing them back is cost
|
|
1433
|
+
// without information — measured at 18,162 characters for 200 operations, of which the
|
|
1434
|
+
// per-entry params dominated. Only the tail is shown by default: the recent steps are
|
|
1435
|
+
// what a caller reasons about, and the count leads so the tail is not mistaken for all.
|
|
1436
|
+
const recent = entries.slice(-20);
|
|
1437
|
+
const omitted = entries.length - recent.length;
|
|
1438
|
+
const lines = recent.map(e => `#${e.seq} ${e.action} ${e.ok ? 'ok' : 'FAIL'}${e.result !== undefined ? ` -> ${e.result}` : ''}${e.error !== undefined ? ` !! ${e.error}` : ''}${args.verbose === true ? ` ${JSON.stringify(e.params)}` : ''}`);
|
|
1439
|
+
const header = omitted > 0
|
|
1440
|
+
? `${entries.length} operations recorded; showing the last ${recent.length} (${args.verbose === true ? '' : ' (pass verbose: true for parameters)'}):`
|
|
1441
|
+
: `${entries.length} operation(s):`;
|
|
1442
|
+
return [{ type: 'text', text: `${header}\n${lines.join('\n')}` }];
|
|
1339
1443
|
},
|
|
1340
1444
|
},
|
|
1341
1445
|
timeoutMs,
|
|
@@ -1425,15 +1529,20 @@ export function apply(ctx, config = {}) {
|
|
|
1425
1529
|
},
|
|
1426
1530
|
render: (_args, value) => {
|
|
1427
1531
|
const entries = value.entries;
|
|
1532
|
+
let lastSession;
|
|
1428
1533
|
if (entries.length === 0) {
|
|
1429
1534
|
return [{ type: 'text', text: '(no recorded visits — nothing has been browsed yet, or history recording is turned off in settings)' }];
|
|
1430
1535
|
}
|
|
1431
1536
|
return [{
|
|
1432
1537
|
type: 'text',
|
|
1433
1538
|
text: entries.map(entry => {
|
|
1434
|
-
const when = new Date(entry.at).toISOString().replace('T', ' ').slice(0,
|
|
1539
|
+
const when = new Date(entry.at).toISOString().replace('T', ' ').slice(0, 16);
|
|
1435
1540
|
const title = entry.title !== undefined ? `${entry.title} — ` : '';
|
|
1436
|
-
|
|
1541
|
+
const tag = entry.session !== undefined && entry.session !== lastSession
|
|
1542
|
+
? ` [session ${entry.session.slice(0, 8)}]`
|
|
1543
|
+
: '';
|
|
1544
|
+
lastSession = entry.session;
|
|
1545
|
+
return `${when} ${title}${entry.url}${tag}`;
|
|
1437
1546
|
}).join('\n'),
|
|
1438
1547
|
}];
|
|
1439
1548
|
},
|
|
@@ -1584,7 +1693,7 @@ export function apply(ctx, config = {}) {
|
|
|
1584
1693
|
}));
|
|
1585
1694
|
ctx.tools.register(defineTool({
|
|
1586
1695
|
name: 'browser_restrict',
|
|
1587
|
-
description: 'Restrict which browser actions are allowed, to prevent stray clicks/navigation. Pass a list of browser tool names (e.g. ["browser_snapshot","browser_content","browser_click"]) — any other browser_* call is refused. Pass an empty list or omit to lift the restriction.
|
|
1696
|
+
description: 'Restrict which browser actions are allowed, to prevent stray clicks/navigation. Pass a list of browser tool names (e.g. ["browser_snapshot","browser_content","browser_click"]) — any other browser_* call is refused. Pass an empty list or omit to lift the restriction. Never blocked, whatever the list says: the observing tools (snapshot/a11y/content/scrape/screenshot/get_value/wait/challenge/list_tabs/session/history/visited) and the three that lift a restriction or recover from a bad state (restrict/reset/reset_session) — so a restriction can always be read around and undone. IMPORTANT: this is a SOFT guardrail against accidental actions, NOT a security boundary — you (the model) can lift it yourself with an empty list.',
|
|
1588
1697
|
parameters: {
|
|
1589
1698
|
allowed: {
|
|
1590
1699
|
type: 'array',
|
|
@@ -1619,7 +1728,7 @@ export function apply(ctx, config = {}) {
|
|
|
1619
1728
|
}));
|
|
1620
1729
|
ctx.tools.register(defineTool({
|
|
1621
1730
|
name: 'browser_auth',
|
|
1622
|
-
description: 'Export or restore the browser session\'s cookies (login state). Use "flush" to get a JSON cookie list (save it to a private file to persist logins), or "restore" with that list to put logins back (e.g. after the browser host restarted). Available on the self-hosted browser. Exported cookies are LIVE CREDENTIALS: treat them as secrets — do not echo them into the conversation, keep them out of logs, and store the list in a private file.',
|
|
1731
|
+
description: 'Export or restore the browser session\'s cookies (login state). Use "flush" to get a JSON cookie list (save it to a private file to persist logins), or "restore" with that list to put logins back (e.g. after the browser host restarted). Available on all three carriers: the self-hosted browser reads its own session, while the desktop sidebar and an installed Chrome/Edge go through CDP. Refused entirely when the credential switch is off. Exported cookies are LIVE CREDENTIALS: treat them as secrets — do not echo them into the conversation, keep them out of logs, and store the list in a private file.',
|
|
1623
1732
|
parameters: {
|
|
1624
1733
|
action: { type: 'string', required: true, enum: ['flush', 'restore'], description: 'flush = export cookies; restore = import cookies.' },
|
|
1625
1734
|
cookies: { type: 'array', items: { type: 'object', additionalProperties: true }, description: 'Cookie list to restore (required when action=restore).' },
|
|
@@ -1651,7 +1760,7 @@ export function apply(ctx, config = {}) {
|
|
|
1651
1760
|
// empty call returned {restored: 0} and rendered 'Restored 0 cookies.', which
|
|
1652
1761
|
// reads as success and would leave the caller believing the login was restored.
|
|
1653
1762
|
if (!Array.isArray(args.cookies) || args.cookies.length === 0) {
|
|
1654
|
-
throw new Error('browser_auth: action "restore" needs a non-empty "cookies" array (get one from action "
|
|
1763
|
+
throw new Error('browser_auth: action "restore" needs a non-empty "cookies" array (get one from action "flush" on the same profile)');
|
|
1655
1764
|
}
|
|
1656
1765
|
const list = args.cookies;
|
|
1657
1766
|
const restored = await browser.restoreAuth(session, list);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-builtin-browser",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"description": "Shared real browser plugin for DeepSeek Harness: install-and-use — a visible native browser the human can take over, driven by the agent over CDP. DOM-level interaction (React/Vue safe), per-task session isolation, cookie persistence (browser_auth), CAPTCHA detection, batch form fill (browser_fill), authenticated download, history/replay, action restriction",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -40,7 +40,8 @@
|
|
|
40
40
|
"cordis.patch.yml",
|
|
41
41
|
"README.md",
|
|
42
42
|
"README.en.md",
|
|
43
|
-
"LICENSE"
|
|
43
|
+
"LICENSE",
|
|
44
|
+
"scripts"
|
|
44
45
|
],
|
|
45
46
|
"scripts": {
|
|
46
47
|
"build": "tsc -p tsconfig.json",
|
|
@@ -102,7 +103,8 @@
|
|
|
102
103
|
"0.1.1-rc.1": "compatible",
|
|
103
104
|
"0.1.1-rc.2": "compatible",
|
|
104
105
|
"0.2.0-rc.1": "compatible",
|
|
105
|
-
"0.2.0-rc.2": "compatible"
|
|
106
|
+
"0.2.0-rc.2": "compatible",
|
|
107
|
+
"0.2.1-alpha.1": "compatible"
|
|
106
108
|
}
|
|
107
109
|
}
|
|
108
110
|
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Host-compatibility probe: does THIS build of the plugin run on the DSH in front of it?
|
|
4
|
+
*
|
|
5
|
+
* A version range in package.json is a claim, not evidence. This assembles a host out of the
|
|
6
|
+
* real DSH packages installed in a profile, loads the plugin's compiled output, registers its
|
|
7
|
+
* tools through the host's own `defineTool`, and calls two of them over the plugin's provider
|
|
8
|
+
* seam. It is the check that answers "did DSH 0.2.x break us?" without booting a browser.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* node scripts/verify-host-compat.mjs # probe the profile named by DSH_PROFILE
|
|
12
|
+
* DSH_PROFILE=desktop node scripts/verify-host-compat.mjs
|
|
13
|
+
* DSH_HOME=/path/to/.dsh node scripts/verify-host-compat.mjs
|
|
14
|
+
*
|
|
15
|
+
* Resolution: the profile's own package.json is the module root, so `@deepseek-ai/*` resolves
|
|
16
|
+
* exactly as it does at runtime (links included). With no usable profile the probe reports
|
|
17
|
+
* that it could not run and exits 0 — an unprobeable host is not a compatibility failure, and
|
|
18
|
+
* making CI red for it would be reporting the wrong thing.
|
|
19
|
+
*
|
|
20
|
+
* What it does NOT cover: the browser actually launching (no Electron here), and the client
|
|
21
|
+
* panel rendering in a real session.
|
|
22
|
+
*/
|
|
23
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
24
|
+
import { createRequire } from 'node:module'
|
|
25
|
+
import { join } from 'node:path'
|
|
26
|
+
import { pathToFileURL } from 'node:url'
|
|
27
|
+
|
|
28
|
+
const REPO = new URL('..', import.meta.url)
|
|
29
|
+
const failures = []
|
|
30
|
+
|
|
31
|
+
const ok = (label, condition, detail = '') => {
|
|
32
|
+
console.log(` ${condition ? 'OK ' : 'FAIL'} ${label}${detail === '' ? '' : ` — ${detail}`}`)
|
|
33
|
+
if (!condition) failures.push(label)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The profile whose node_modules resolve the host packages, or undefined. */
|
|
37
|
+
function findProfileRoot() {
|
|
38
|
+
const home = process.env.DSH_HOME ?? join(process.env.USERPROFILE ?? process.env.HOME ?? '', '.dsh')
|
|
39
|
+
const profiles = join(home, 'profiles')
|
|
40
|
+
if (!existsSync(profiles)) return undefined
|
|
41
|
+
const named = process.env.DSH_PROFILE
|
|
42
|
+
const candidates = named !== undefined && named !== ''
|
|
43
|
+
? [named]
|
|
44
|
+
: readdirSync(profiles).filter(entry => existsSync(join(profiles, entry, 'package.json')))
|
|
45
|
+
for (const candidate of candidates) {
|
|
46
|
+
const manifest = join(profiles, candidate, 'package.json')
|
|
47
|
+
if (existsSync(manifest)) return { name: candidate, manifest }
|
|
48
|
+
}
|
|
49
|
+
return undefined
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const profile = findProfileRoot()
|
|
53
|
+
if (profile === undefined) {
|
|
54
|
+
console.log('No DSH profile found (looked under $DSH_HOME/profiles).')
|
|
55
|
+
console.log('Set DSH_HOME or DSH_PROFILE to point at one; nothing to verify.')
|
|
56
|
+
process.exit(0)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const require = createRequire(profile.manifest)
|
|
60
|
+
const load = async (spec) => import(pathToFileURL(require.resolve(spec)).href)
|
|
61
|
+
|
|
62
|
+
console.log(`Host: profile "${profile.name}" at ${profile.manifest}`)
|
|
63
|
+
let hostVersion = 'unknown'
|
|
64
|
+
try {
|
|
65
|
+
hostVersion = JSON.parse(readFileSync(require.resolve('@deepseek-ai/dsh-system-prompt/package.json'), 'utf8')).version
|
|
66
|
+
} catch { /* reported as unknown; the probe below is what matters */ }
|
|
67
|
+
console.log(` @deepseek-ai/dsh-system-prompt = ${hostVersion}`)
|
|
68
|
+
console.log('')
|
|
69
|
+
|
|
70
|
+
console.log('=== 1. the host API surface this plugin imports ===')
|
|
71
|
+
let cordis
|
|
72
|
+
let dshTools
|
|
73
|
+
let dshLlm
|
|
74
|
+
let schemastery
|
|
75
|
+
try {
|
|
76
|
+
cordis = await load('@deepseek-ai/cordis')
|
|
77
|
+
dshTools = await load('@deepseek-ai/dsh-tools')
|
|
78
|
+
dshLlm = await load('@deepseek-ai/dsh-llm')
|
|
79
|
+
schemastery = await load('@deepseek-ai/schemastery')
|
|
80
|
+
} catch (error) {
|
|
81
|
+
console.log(` FAIL a host package could not be resolved: ${String(error && error.message)}`)
|
|
82
|
+
process.exit(1)
|
|
83
|
+
}
|
|
84
|
+
const z = schemastery.default ?? schemastery
|
|
85
|
+
|
|
86
|
+
ok('@deepseek-ai/cordis exports Context', typeof cordis.Context === 'function')
|
|
87
|
+
ok('@deepseek-ai/cordis exports Service', typeof cordis.Service === 'function')
|
|
88
|
+
ok('@deepseek-ai/dsh-tools exports defineTool', typeof dshTools.defineTool === 'function')
|
|
89
|
+
ok('@deepseek-ai/dsh-llm exports HarnessError', typeof dshLlm.HarnessError === 'function')
|
|
90
|
+
ok('@deepseek-ai/schemastery default is the schema builder', typeof z?.string === 'function')
|
|
91
|
+
|
|
92
|
+
console.log('')
|
|
93
|
+
console.log('=== 2. the plugin loads against a real cordis context ===')
|
|
94
|
+
const root = new cordis.Context()
|
|
95
|
+
|
|
96
|
+
// Stub services. DSH's own tools/systemPrompt implementations are not what is under test and
|
|
97
|
+
// standing them up would mean booting a profile; only their SHAPE matters here, and it is
|
|
98
|
+
// taken from the real ones: `ctx.tools.register(definition)`, `ctx.systemPrompt.section(spec)`.
|
|
99
|
+
const registered = new Map()
|
|
100
|
+
class StubTools extends cordis.Service {
|
|
101
|
+
constructor(ctx) { super(ctx, 'tools') }
|
|
102
|
+
register(definition) { registered.set(definition.name, definition); return () => {} }
|
|
103
|
+
}
|
|
104
|
+
class StubSystemPrompt extends cordis.Service {
|
|
105
|
+
constructor(ctx) { super(ctx, 'systemPrompt'); this.sections = [] }
|
|
106
|
+
section(spec) { this.sections.push(spec); return () => {} }
|
|
107
|
+
}
|
|
108
|
+
new StubTools(root)
|
|
109
|
+
const prompts = new StubSystemPrompt(root)
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
const runtimeModule = await import(new URL('lib/browser/runtime.js', REPO).href)
|
|
113
|
+
ok('the plugin ships a browser seam service', typeof runtimeModule.default === 'function')
|
|
114
|
+
new runtimeModule.default(root)
|
|
115
|
+
ok('the seam registered itself as ctx.browser', root.get('browser') !== undefined)
|
|
116
|
+
|
|
117
|
+
const layer = await import(new URL('lib/tool-browser/index.js', REPO).href)
|
|
118
|
+
ok('the tool layer exports an apply()', typeof layer.apply === 'function')
|
|
119
|
+
layer.apply(root, {})
|
|
120
|
+
ok('tools registered through the host\'s own defineTool', registered.size > 30, `${registered.size} tools`)
|
|
121
|
+
ok('the system prompt section was declared', prompts.sections.length === 1)
|
|
122
|
+
} catch (error) {
|
|
123
|
+
ok('the plugin loads and registers', false, String(error && error.stack).split('\n').slice(0, 2).join(' | '))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
console.log('')
|
|
127
|
+
console.log('=== 3. the definitions the host will consume ===')
|
|
128
|
+
// `defineTool` already accepted every spec the plugin handed it — it throws on a shape it
|
|
129
|
+
// cannot compile, and the tools came back — so what is left is the stored definition: a JSON
|
|
130
|
+
// Schema the host can show a model, plus the output contract.
|
|
131
|
+
const SCALAR_TYPES = new Set(['string', 'number', 'integer', 'boolean', 'array', 'object', 'null'])
|
|
132
|
+
const schemaFailures = []
|
|
133
|
+
for (const [name, definition] of registered) {
|
|
134
|
+
if (typeof definition.execute !== 'function') schemaFailures.push(`${name}: no execute`)
|
|
135
|
+
if (definition.output?.schema === undefined) schemaFailures.push(`${name}: no output.schema`)
|
|
136
|
+
if (typeof definition.output?.render !== 'function') schemaFailures.push(`${name}: no output.render`)
|
|
137
|
+
if (typeof definition.description !== 'string' || definition.description === '') schemaFailures.push(`${name}: no description`)
|
|
138
|
+
const parameters = definition.parameters
|
|
139
|
+
if (typeof parameters !== 'object' || parameters === null) {
|
|
140
|
+
schemaFailures.push(`${name}: parameters is not a schema object`)
|
|
141
|
+
continue
|
|
142
|
+
}
|
|
143
|
+
if (parameters.type !== 'object') schemaFailures.push(`${name}: parameters.type is ${String(parameters.type)}`)
|
|
144
|
+
for (const [field, spec] of Object.entries(parameters.properties ?? {})) {
|
|
145
|
+
if (typeof spec?.type !== 'string' || !SCALAR_TYPES.has(spec.type)) schemaFailures.push(`${name}.${field}: type ${String(spec?.type)}`)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
ok('every tool definition carries a usable schema', schemaFailures.length === 0, schemaFailures.slice(0, 4).join(' | '))
|
|
149
|
+
|
|
150
|
+
console.log('')
|
|
151
|
+
console.log('=== 4. a tool runs, over the plugin\'s own provider seam ===')
|
|
152
|
+
const fakeSession = 'browser:00000000-0000-4000-8000-000000000001'
|
|
153
|
+
try {
|
|
154
|
+
root.get('browser').registerBrowserProvider({
|
|
155
|
+
id: 'verify-host-compat',
|
|
156
|
+
available: () => true,
|
|
157
|
+
open: async () => fakeSession,
|
|
158
|
+
listTabs: async () => [{ id: 'tab:1', title: 'verify', url: 'about:blank', active: true }],
|
|
159
|
+
a11y: async () => ({
|
|
160
|
+
url: 'about:blank',
|
|
161
|
+
count: 1,
|
|
162
|
+
truncated: false,
|
|
163
|
+
nodes: [{ ref: 1, role: 'button', name: 'Probe', states: ['enabled'], depth: 0, tag: 'button', selector: '#probe', x: 1, y: 2 }],
|
|
164
|
+
}),
|
|
165
|
+
})
|
|
166
|
+
} catch (error) {
|
|
167
|
+
ok('a provider can register on the seam', false, String(error && error.message))
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const exec = { agent: { id: 'verify-host-compat' } }
|
|
171
|
+
try {
|
|
172
|
+
const value = await registered.get('browser_session').execute({}, exec)
|
|
173
|
+
ok('browser_session ran', value !== undefined, JSON.stringify(value).slice(0, 100))
|
|
174
|
+
} catch (error) {
|
|
175
|
+
ok('browser_session ran', false, String(error && error.message))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
const tool = registered.get('browser_a11y')
|
|
180
|
+
const value = await tool.execute({}, exec)
|
|
181
|
+
const text = JSON.stringify(tool.output.render({}, value))
|
|
182
|
+
ok('browser_a11y ran and rendered', value?.nodes?.length === 1, '')
|
|
183
|
+
ok('its renderer emits the element selector', text.includes('#probe'), text.slice(0, 120))
|
|
184
|
+
} catch (error) {
|
|
185
|
+
ok('browser_a11y ran', false, String(error && error.message))
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
console.log('')
|
|
189
|
+
if (failures.length === 0) {
|
|
190
|
+
console.log(`VERDICT: this build runs on the host above (${hostVersion}).`)
|
|
191
|
+
} else {
|
|
192
|
+
console.log(`VERDICT: ${failures.length} failure(s) — this build does NOT match the host above:`)
|
|
193
|
+
for (const failure of failures) console.log(` - ${failure}`)
|
|
194
|
+
process.exitCode = 1
|
|
195
|
+
}
|