@yuzc-001/grasp 0.6.6

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.
Files changed (78) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +327 -0
  3. package/README.zh-CN.md +324 -0
  4. package/examples/README.md +31 -0
  5. package/examples/claude-desktop.json +8 -0
  6. package/examples/codex-config.toml +4 -0
  7. package/grasp.skill +0 -0
  8. package/index.js +87 -0
  9. package/package.json +48 -0
  10. package/scripts/grasp_openclaw_ctl.sh +122 -0
  11. package/scripts/run-search-benchmark.mjs +287 -0
  12. package/scripts/update-star-history.mjs +274 -0
  13. package/skill/SKILL.md +61 -0
  14. package/skill/references/tools.md +306 -0
  15. package/src/cli/auto-configure.js +116 -0
  16. package/src/cli/cmd-connect.js +148 -0
  17. package/src/cli/cmd-explain.js +42 -0
  18. package/src/cli/cmd-logs.js +55 -0
  19. package/src/cli/cmd-status.js +119 -0
  20. package/src/cli/config.js +27 -0
  21. package/src/cli/detect-chrome.js +58 -0
  22. package/src/grasp/handoff/events.js +67 -0
  23. package/src/grasp/handoff/persist.js +48 -0
  24. package/src/grasp/handoff/state.js +28 -0
  25. package/src/grasp/page/capture.js +34 -0
  26. package/src/grasp/page/state.js +273 -0
  27. package/src/grasp/verify/evidence.js +40 -0
  28. package/src/grasp/verify/pipeline.js +52 -0
  29. package/src/layer1-bridge/chrome.js +416 -0
  30. package/src/layer1-bridge/webmcp.js +143 -0
  31. package/src/layer2-perception/hints.js +284 -0
  32. package/src/layer3-action/actions.js +400 -0
  33. package/src/runtime/browser-instance.js +65 -0
  34. package/src/runtime/truth/model.js +94 -0
  35. package/src/runtime/truth/snapshot.js +51 -0
  36. package/src/server/affordances.js +47 -0
  37. package/src/server/audit.js +122 -0
  38. package/src/server/boss-fast-path.js +164 -0
  39. package/src/server/boundary-guard.js +53 -0
  40. package/src/server/content.js +97 -0
  41. package/src/server/continuity.js +256 -0
  42. package/src/server/engine-selection.js +29 -0
  43. package/src/server/entry-orchestrator.js +115 -0
  44. package/src/server/error-codes.js +7 -0
  45. package/src/server/explain-share-card.js +113 -0
  46. package/src/server/fast-path-router.js +134 -0
  47. package/src/server/form-runtime.js +602 -0
  48. package/src/server/form-tasks.js +254 -0
  49. package/src/server/gateway-response.js +62 -0
  50. package/src/server/index.js +22 -0
  51. package/src/server/observe.js +52 -0
  52. package/src/server/page-projection.js +31 -0
  53. package/src/server/page-state.js +27 -0
  54. package/src/server/postconditions.js +128 -0
  55. package/src/server/prompt-assembly.js +148 -0
  56. package/src/server/responses.js +44 -0
  57. package/src/server/route-boundary.js +174 -0
  58. package/src/server/route-policy.js +168 -0
  59. package/src/server/runtime-confirmation.js +87 -0
  60. package/src/server/runtime-status.js +7 -0
  61. package/src/server/share-artifacts.js +284 -0
  62. package/src/server/state.js +132 -0
  63. package/src/server/structured-extraction.js +131 -0
  64. package/src/server/surface-prompts.js +166 -0
  65. package/src/server/task-frame.js +11 -0
  66. package/src/server/tasks/search-task.js +321 -0
  67. package/src/server/tools.actions.js +1361 -0
  68. package/src/server/tools.form.js +526 -0
  69. package/src/server/tools.gateway.js +757 -0
  70. package/src/server/tools.handoff.js +210 -0
  71. package/src/server/tools.js +20 -0
  72. package/src/server/tools.legacy.js +983 -0
  73. package/src/server/tools.strategy.js +250 -0
  74. package/src/server/tools.task-surface.js +66 -0
  75. package/src/server/tools.workspace.js +873 -0
  76. package/src/server/workspace-runtime.js +1138 -0
  77. package/src/server/workspace-tasks.js +735 -0
  78. package/start-chrome.bat +84 -0
@@ -0,0 +1,757 @@
1
+ import { z } from 'zod';
2
+
3
+ import { buildGatewayResponse } from './gateway-response.js';
4
+ import { extractObservedContent } from './observe.js';
5
+ import { assessGatewayContinuation } from './continuity.js';
6
+ import { getActivePage } from '../layer1-bridge/chrome.js';
7
+ import { syncPageState } from './state.js';
8
+ import { enterWithStrategy } from './tools.strategy.js';
9
+ import { readFastPath } from './fast-path-router.js';
10
+ import { buildPageProjection } from './page-projection.js';
11
+ import { selectEngine } from './engine-selection.js';
12
+ import { decideRoute, resolveRouteIntent } from './route-policy.js';
13
+ import { auditRouteDecision, readLatestRouteDecision } from './audit.js';
14
+ import { textResponse } from './responses.js';
15
+ import { ROUTE_BLOCKED } from './error-codes.js';
16
+ import { readBrowserInstance } from '../runtime/browser-instance.js';
17
+ import { requireConfirmedRuntimeInstance } from './runtime-confirmation.js';
18
+ import { extractStructuredContent } from './structured-extraction.js';
19
+ import { buildExplainShareCard as defaultBuildExplainShareCard } from './explain-share-card.js';
20
+ import {
21
+ buildBatchMarkdownBundle,
22
+ buildShareHtml,
23
+ buildShareMarkdown,
24
+ renderShareArtifact as renderShareArtifactFile,
25
+ serializeCsv,
26
+ writeArtifactFile,
27
+ } from './share-artifacts.js';
28
+
29
+ function toGatewayPage({ title, url, pageState }, state, { preferCurrentUrl = false } = {}) {
30
+ const pageUrl = preferCurrentUrl
31
+ ? state.lastUrl ?? 'unknown'
32
+ : url ?? state.lastUrl ?? 'unknown';
33
+
34
+ return {
35
+ title: title ?? 'unknown',
36
+ url: pageUrl,
37
+ page_role: pageState?.currentRole ?? state.pageState?.currentRole ?? 'unknown',
38
+ grasp_confidence: pageState?.graspConfidence ?? state.pageState?.graspConfidence ?? 'unknown',
39
+ risk_gate: pageState?.riskGateDetected ?? state.pageState?.riskGateDetected ?? false,
40
+ };
41
+ }
42
+
43
+ function isBlockedHandoffState(handoffState) {
44
+ return handoffState === 'handoff_required'
45
+ || handoffState === 'handoff_in_progress'
46
+ || handoffState === 'awaiting_reacquisition';
47
+ }
48
+
49
+ function isGatedPageState(pageState = {}) {
50
+ return pageState.riskGateDetected || pageState.currentRole === 'checkpoint';
51
+ }
52
+
53
+ function getEntryDirectNextAction(pageState = {}) {
54
+ if (pageState.currentRole === 'workspace' || pageState.workspaceSurface != null) {
55
+ return 'workspace_inspect';
56
+ }
57
+ if (pageState.currentRole === 'form' || pageState.currentRole === 'auth') {
58
+ return 'form_inspect';
59
+ }
60
+ return 'extract';
61
+ }
62
+
63
+ function resolvedDirectEntry(outcome = {}) {
64
+ return outcome.verified === true && !isGatedPageState(outcome.pageState ?? {});
65
+ }
66
+
67
+ function getEffectiveEntryHandoff(outcome = {}) {
68
+ if (resolvedDirectEntry(outcome)) {
69
+ return {
70
+ ...(outcome.handoff ?? {}),
71
+ state: 'idle',
72
+ };
73
+ }
74
+
75
+ return outcome.handoff ?? null;
76
+ }
77
+
78
+ function getGatewayStatus(state) {
79
+ const pageState = state.pageState ?? {};
80
+ const handoffState = state.handoff?.state ?? 'idle';
81
+ if (isBlockedHandoffState(handoffState)) {
82
+ return 'handoff_required';
83
+ }
84
+ if (pageState.riskGateDetected || pageState.currentRole === 'checkpoint') {
85
+ return 'gated';
86
+ }
87
+ return 'direct';
88
+ }
89
+
90
+ function getGatewayContinuation(state, suggestedNextAction) {
91
+ const handoffState = state.handoff?.state ?? 'idle';
92
+ if (getGatewayStatus(state) !== 'direct') {
93
+ return {
94
+ can_continue: false,
95
+ suggested_next_action: 'request_handoff',
96
+ handoff_state: handoffState,
97
+ };
98
+ }
99
+
100
+ return {
101
+ can_continue: true,
102
+ suggested_next_action: suggestedNextAction,
103
+ handoff_state: handoffState,
104
+ };
105
+ }
106
+
107
+ function buildGatewayOutcome(outcome) {
108
+ const strategy = outcome.preflight?.recommended_entry_strategy ?? 'direct';
109
+ const trust = outcome.preflight?.session_trust ?? 'medium';
110
+ const handoffState = outcome.handoff?.state ?? 'idle';
111
+ const pageState = outcome.pageState ?? {};
112
+
113
+ if (resolvedDirectEntry(outcome)) {
114
+ return {
115
+ status: 'direct',
116
+ canContinue: true,
117
+ suggestedNextAction: getEntryDirectNextAction(pageState),
118
+ };
119
+ }
120
+
121
+ if (isBlockedHandoffState(handoffState) || isGatedPageState(pageState)) {
122
+ return {
123
+ status: 'gated',
124
+ canContinue: false,
125
+ suggestedNextAction: 'request_handoff',
126
+ };
127
+ }
128
+
129
+ if (strategy === 'handoff_or_preheat') {
130
+ return {
131
+ status: 'gated',
132
+ canContinue: false,
133
+ suggestedNextAction: outcome.pageState?.riskGateDetected ? 'request_handoff' : 'preheat_session',
134
+ };
135
+ }
136
+
137
+ if (strategy === 'preheat_before_direct_entry' || trust === 'low') {
138
+ return {
139
+ status: 'warmup',
140
+ canContinue: true,
141
+ suggestedNextAction: 'preheat_session',
142
+ };
143
+ }
144
+
145
+ return {
146
+ status: 'direct',
147
+ canContinue: true,
148
+ suggestedNextAction: 'inspect',
149
+ };
150
+ }
151
+
152
+ function getRouteForState({ url, state, intent = null }) {
153
+ const resolvedIntent = resolveRouteIntent({
154
+ intent,
155
+ pageState: state.pageState,
156
+ lastIntent: state.lastRouteTrace?.intent ?? null,
157
+ });
158
+
159
+ return decideRoute({
160
+ url,
161
+ intent: resolvedIntent,
162
+ selection: selectEngine({ tool: resolvedIntent, url }),
163
+ preflight: state.lastRouteTrace?.evidence
164
+ ? {
165
+ session_trust: state.lastRouteTrace.evidence.session_trust,
166
+ recommended_entry_strategy: state.lastRouteTrace.evidence.recommended_entry_strategy,
167
+ }
168
+ : {},
169
+ pageState: state.pageState,
170
+ handoff: state.handoff,
171
+ });
172
+ }
173
+
174
+ async function projectPageContent({
175
+ page,
176
+ state,
177
+ selection,
178
+ include_markdown = false,
179
+ deps = {},
180
+ } = {}) {
181
+ const {
182
+ syncState,
183
+ observeContent,
184
+ readFastPathContent,
185
+ waitUntilStable,
186
+ extractMainContent,
187
+ } = deps;
188
+
189
+ if (selection.engine === 'runtime') {
190
+ await syncState(page, state, { force: true });
191
+ const fastPath = await readFastPathContent(page);
192
+ if (fastPath) {
193
+ return buildPageProjection({
194
+ ...selection,
195
+ surface: fastPath.surface,
196
+ title: fastPath.title,
197
+ url: fastPath.url,
198
+ mainText: fastPath.mainText,
199
+ includeMarkdown: include_markdown,
200
+ });
201
+ }
202
+ } else {
203
+ await syncState(page, state, { force: true });
204
+ }
205
+
206
+ const observed = await observeContent({
207
+ page,
208
+ deps: {
209
+ waitStable: waitUntilStable,
210
+ extractContent: extractMainContent,
211
+ },
212
+ include_markdown,
213
+ });
214
+
215
+ return buildPageProjection({
216
+ ...selection,
217
+ surface: 'content',
218
+ title: await page.title(),
219
+ url: page.url(),
220
+ mainText: observed.main_text,
221
+ markdown: observed.markdown,
222
+ includeMarkdown: include_markdown,
223
+ });
224
+ }
225
+
226
+ function getBatchStatus(records = []) {
227
+ if (records.length === 0) return 'direct';
228
+ const directCount = records.filter((record) => record.status === 'direct').length;
229
+ if (directCount === records.length) return 'direct';
230
+ if (directCount > 0) return 'mixed';
231
+ return 'handoff_required';
232
+ }
233
+
234
+ export function registerGatewayTools(server, state, deps = {}) {
235
+ const enter = deps.enterWithStrategy ?? enterWithStrategy;
236
+ const getPage = deps.getActivePage ?? getActivePage;
237
+ const syncState = deps.syncPageState ?? syncPageState;
238
+ const observeContent = deps.extractObservedContent ?? extractObservedContent;
239
+ const auditRoute = deps.auditRouteDecision ?? auditRouteDecision;
240
+ const readLatestRoute = deps.readLatestRouteDecision ?? readLatestRouteDecision;
241
+ const getBrowserInstance = deps.getBrowserInstance ?? (() => readBrowserInstance(process.env.CHROME_CDP_URL || 'http://localhost:9222'));
242
+ const extractStructured = deps.extractStructuredContent ?? extractStructuredContent;
243
+ const readFastPathContent = deps.readFastPath ?? readFastPath;
244
+ const writeArtifact = deps.writeArtifact ?? writeArtifactFile;
245
+ const renderShareArtifact = deps.renderShareArtifact ?? renderShareArtifactFile;
246
+ const buildExplainShareCard = deps.buildExplainShareCard ?? defaultBuildExplainShareCard;
247
+
248
+ server.registerTool(
249
+ 'entry',
250
+ {
251
+ description: 'Enter a URL through the gateway using preflight strategy metadata.',
252
+ inputSchema: {
253
+ url: z.string().url().describe('Target URL to enter'),
254
+ intent: z.enum(['read', 'extract', 'act', 'submit', 'workspace', 'collect']).optional().describe('Task intent used to choose the best route'),
255
+ },
256
+ },
257
+ async ({ url, intent = 'extract' }) => {
258
+ const instance = await getBrowserInstance();
259
+ const confirmationError = requireConfirmedRuntimeInstance(state, instance, 'entry');
260
+ if (confirmationError) return confirmationError;
261
+ const outcome = await enter({ url, state, deps: { auditName: 'entry' } });
262
+ const effectiveHandoff = getEffectiveEntryHandoff(outcome);
263
+ const gatewayOutcome = buildGatewayOutcome({
264
+ ...outcome,
265
+ handoff: effectiveHandoff,
266
+ });
267
+ const preferCurrentUrl = outcome.preflight?.recommended_entry_strategy === 'handoff_or_preheat';
268
+ const route = decideRoute({
269
+ url,
270
+ intent,
271
+ selection: selectEngine({ tool: intent, url }),
272
+ preflight: outcome.preflight,
273
+ pageState: outcome.pageState ?? state.pageState,
274
+ handoff: effectiveHandoff ?? state.handoff,
275
+ });
276
+ const routeTrace = {
277
+ url,
278
+ intent,
279
+ status: gatewayOutcome.status,
280
+ ...route,
281
+ failure_type: route.selected_mode === 'handoff' ? 'route_blocked' : 'none',
282
+ error_code: route.selected_mode === 'handoff' ? ROUTE_BLOCKED : null,
283
+ };
284
+
285
+ state.lastRouteTrace = routeTrace;
286
+ await auditRoute(routeTrace);
287
+
288
+ return buildGatewayResponse({
289
+ status: gatewayOutcome.status,
290
+ page: toGatewayPage(outcome, state, { preferCurrentUrl }),
291
+ continuation: {
292
+ can_continue: gatewayOutcome.canContinue,
293
+ suggested_next_action: gatewayOutcome.suggestedNextAction,
294
+ handoff_state: effectiveHandoff?.state ?? state.handoff?.state ?? 'idle',
295
+ },
296
+ evidence: { strategy: outcome.preflight ?? null },
297
+ runtime: instance ? { instance } : {},
298
+ route: routeTrace,
299
+ ...(routeTrace.error_code ? { error_code: routeTrace.error_code } : {}),
300
+ });
301
+ }
302
+ );
303
+
304
+ server.registerTool(
305
+ 'inspect',
306
+ {
307
+ description: 'Inspect the current gateway page status and handoff state.',
308
+ inputSchema: {},
309
+ },
310
+ async () => {
311
+ const page = await getPage({ state });
312
+ await syncState(page, state, { force: true });
313
+ const instance = await getBrowserInstance();
314
+ const route = getRouteForState({ url: page.url(), state });
315
+
316
+ return buildGatewayResponse({
317
+ status: getGatewayStatus(state),
318
+ page: toGatewayPage({
319
+ title: await page.title(),
320
+ url: page.url(),
321
+ pageState: state.pageState,
322
+ }, state),
323
+ continuation: getGatewayContinuation(state, 'extract'),
324
+ runtime: instance ? { instance } : {},
325
+ route,
326
+ });
327
+ }
328
+ );
329
+
330
+ server.registerTool(
331
+ 'extract',
332
+ {
333
+ description: 'Extract a concise summary of the current page content.',
334
+ inputSchema: {
335
+ include_markdown: z.boolean().optional().describe('Include a minimal Markdown rendering of the extracted content'),
336
+ },
337
+ },
338
+ async ({ include_markdown = false } = {}) => {
339
+ const page = await getPage({ state });
340
+ const instance = await getBrowserInstance();
341
+ const selection = selectEngine({ tool: 'extract', url: page.url() });
342
+ const route = getRouteForState({ url: page.url(), state, intent: 'extract' });
343
+ const result = await projectPageContent({
344
+ page,
345
+ state,
346
+ selection,
347
+ include_markdown,
348
+ deps: {
349
+ syncState,
350
+ observeContent,
351
+ readFastPathContent,
352
+ waitUntilStable: deps.waitUntilStable,
353
+ extractMainContent: deps.extractMainContent,
354
+ },
355
+ });
356
+
357
+ return buildGatewayResponse({
358
+ status: getGatewayStatus(state),
359
+ page: toGatewayPage({
360
+ title: result.title ?? await page.title(),
361
+ url: result.url ?? page.url(),
362
+ pageState: state.pageState,
363
+ }, state),
364
+ result,
365
+ continuation: getGatewayContinuation(state, 'inspect'),
366
+ runtime: instance ? { instance } : {},
367
+ route,
368
+ });
369
+ }
370
+ );
371
+
372
+ server.registerTool(
373
+ 'extract_structured',
374
+ {
375
+ description: 'Extract the current page into a structured record for the requested fields and return JSON/Markdown exports.',
376
+ inputSchema: {
377
+ fields: z.array(z.string()).min(1).describe('Field labels to extract from the current page into a structured record'),
378
+ include_markdown: z.boolean().optional().describe('Include a Markdown export alongside the JSON export'),
379
+ },
380
+ },
381
+ async ({ fields, include_markdown = false } = {}) => {
382
+ const page = await getPage({ state });
383
+ const instance = await getBrowserInstance();
384
+ const route = getRouteForState({ url: page.url(), state, intent: 'extract' });
385
+ const selection = selectEngine({ tool: 'extract_structured', url: page.url() });
386
+ const projection = await projectPageContent({
387
+ page,
388
+ state,
389
+ selection,
390
+ include_markdown,
391
+ deps: {
392
+ syncState,
393
+ observeContent,
394
+ readFastPathContent,
395
+ waitUntilStable: deps.waitUntilStable,
396
+ extractMainContent: deps.extractMainContent,
397
+ },
398
+ });
399
+ const structured = await extractStructured(page, fields);
400
+ const exports = {
401
+ json: JSON.stringify({
402
+ title: projection.title,
403
+ url: projection.url,
404
+ record: structured.record,
405
+ missing_fields: structured.missing_fields,
406
+ }, null, 2),
407
+ ...(projection.markdown !== undefined ? { markdown: projection.markdown } : {}),
408
+ };
409
+
410
+ return buildGatewayResponse({
411
+ status: getGatewayStatus(state),
412
+ page: toGatewayPage({
413
+ title: projection.title,
414
+ url: projection.url,
415
+ pageState: state.pageState,
416
+ }, state),
417
+ result: {
418
+ ...projection,
419
+ structured,
420
+ exports,
421
+ },
422
+ continuation: getGatewayContinuation(state, 'inspect'),
423
+ runtime: instance ? { instance } : {},
424
+ route,
425
+ });
426
+ }
427
+ );
428
+
429
+ server.registerTool(
430
+ 'extract_batch',
431
+ {
432
+ description: 'Visit a list of URLs through the runtime loop, extract structured records, and export CSV/JSON/Markdown artifacts.',
433
+ inputSchema: {
434
+ urls: z.array(z.string().url()).min(1).describe('URLs to visit sequentially through the same runtime'),
435
+ fields: z.array(z.string()).min(1).describe('Field labels to extract into structured records and CSV columns'),
436
+ include_markdown: z.boolean().optional().describe('Also write a Markdown bundle alongside the CSV and JSON exports'),
437
+ },
438
+ },
439
+ async ({ urls, fields, include_markdown = false } = {}) => {
440
+ const instance = await getBrowserInstance();
441
+ const confirmationError = requireConfirmedRuntimeInstance(state, instance, 'extract_batch');
442
+ if (confirmationError) return confirmationError;
443
+
444
+ const records = [];
445
+
446
+ for (const inputUrl of urls) {
447
+ const outcome = await enter({ url: inputUrl, state, deps: { auditName: 'extract_batch' } });
448
+ let page = null;
449
+ try {
450
+ page = await getPage({ state });
451
+ } catch {
452
+ page = null;
453
+ }
454
+
455
+ const status = getGatewayStatus(state);
456
+ if (!page || status !== 'direct') {
457
+ records.push({
458
+ input_url: inputUrl,
459
+ final_url: outcome.final_url ?? outcome.url ?? inputUrl,
460
+ status,
461
+ title: outcome.title ?? 'unknown',
462
+ record: {},
463
+ missing_fields: [...fields],
464
+ route: outcome.preflight?.recommended_entry_strategy ?? 'unknown',
465
+ });
466
+ continue;
467
+ }
468
+
469
+ const selection = selectEngine({ tool: 'extract_structured', url: page.url() });
470
+ const projection = await projectPageContent({
471
+ page,
472
+ state,
473
+ selection,
474
+ include_markdown,
475
+ deps: {
476
+ syncState,
477
+ observeContent,
478
+ readFastPathContent,
479
+ waitUntilStable: deps.waitUntilStable,
480
+ extractMainContent: deps.extractMainContent,
481
+ },
482
+ });
483
+ const structured = await extractStructured(page, fields);
484
+
485
+ records.push({
486
+ input_url: inputUrl,
487
+ final_url: projection.url,
488
+ status,
489
+ title: projection.title,
490
+ record: structured.record,
491
+ missing_fields: structured.missing_fields,
492
+ evidence: structured.evidence,
493
+ });
494
+ }
495
+
496
+ const columns = ['input_url', 'final_url', 'status', 'title', ...fields];
497
+ const csvRows = records.map((record) => ({
498
+ input_url: record.input_url,
499
+ final_url: record.final_url,
500
+ status: record.status,
501
+ title: record.title,
502
+ ...Object.fromEntries(fields.map((field) => [field, record.record?.[field] ?? ''])),
503
+ }));
504
+ const csv = serializeCsv(columns, csvRows);
505
+ const json = JSON.stringify({
506
+ fields,
507
+ records,
508
+ }, null, 2);
509
+ const artifacts = {
510
+ csv: await writeArtifact({
511
+ filename: 'batch-extract.csv',
512
+ data: csv,
513
+ encoding: 'utf8',
514
+ mimeType: 'text/csv',
515
+ }),
516
+ json: await writeArtifact({
517
+ filename: 'batch-extract.json',
518
+ data: json,
519
+ encoding: 'utf8',
520
+ mimeType: 'application/json',
521
+ }),
522
+ };
523
+
524
+ if (include_markdown) {
525
+ artifacts.markdown = await writeArtifact({
526
+ filename: 'batch-extract.md',
527
+ data: buildBatchMarkdownBundle({ fields, records }),
528
+ encoding: 'utf8',
529
+ mimeType: 'text/markdown',
530
+ });
531
+ }
532
+
533
+ const page = await getPage({ state });
534
+ const batchStatus = getBatchStatus(records);
535
+ const route = getRouteForState({ url: page.url(), state, intent: 'extract' });
536
+
537
+ return buildGatewayResponse({
538
+ status: batchStatus,
539
+ page: toGatewayPage({
540
+ title: await page.title(),
541
+ url: page.url(),
542
+ pageState: state.pageState,
543
+ }, state),
544
+ result: {
545
+ fields,
546
+ records,
547
+ artifacts,
548
+ },
549
+ continuation: getGatewayContinuation(state, 'inspect'),
550
+ runtime: instance ? { instance } : {},
551
+ route,
552
+ message: [
553
+ `Status: ${batchStatus}`,
554
+ `Visited URLs: ${urls.length}`,
555
+ `Structured records: ${records.length}`,
556
+ `CSV artifact: ${artifacts.csv.path}`,
557
+ `JSON artifact: ${artifacts.json.path}`,
558
+ ...(artifacts.markdown ? [`Markdown artifact: ${artifacts.markdown.path}`] : []),
559
+ `Next: inspect`,
560
+ ],
561
+ });
562
+ }
563
+ );
564
+
565
+ server.registerTool(
566
+ 'share_page',
567
+ {
568
+ description: 'Export the current page into a shareable Markdown, screenshot, or PDF artifact.',
569
+ inputSchema: {
570
+ format: z.enum(['markdown', 'screenshot', 'pdf']).describe('Share artifact format to generate from the current page'),
571
+ },
572
+ },
573
+ async ({ format }) => {
574
+ const page = await getPage({ state });
575
+ const instance = await getBrowserInstance();
576
+ const selection = selectEngine({ tool: 'extract', url: page.url() });
577
+ const route = getRouteForState({ url: page.url(), state, intent: 'extract' });
578
+ const projection = await projectPageContent({
579
+ page,
580
+ state,
581
+ selection,
582
+ include_markdown: true,
583
+ deps: {
584
+ syncState,
585
+ observeContent,
586
+ readFastPathContent,
587
+ waitUntilStable: deps.waitUntilStable,
588
+ extractMainContent: deps.extractMainContent,
589
+ },
590
+ });
591
+ const explainCard = await buildExplainShareCard(page, projection);
592
+ let artifactMeta = null;
593
+
594
+ if (format === 'markdown') {
595
+ artifactMeta = await writeArtifact({
596
+ filename: 'share-page.md',
597
+ data: buildShareMarkdown({ projection, explainCard }),
598
+ encoding: 'utf8',
599
+ mimeType: 'text/markdown',
600
+ });
601
+ } else {
602
+ const rendered = await renderShareArtifact(page, buildShareHtml({ projection, explainCard }), format);
603
+ artifactMeta = await writeArtifact({
604
+ filename: `share-page.${rendered.extension}`,
605
+ data: rendered.data,
606
+ mimeType: rendered.mimeType,
607
+ });
608
+ }
609
+
610
+ return buildGatewayResponse({
611
+ status: getGatewayStatus(state),
612
+ page: toGatewayPage({
613
+ title: projection.title,
614
+ url: projection.url,
615
+ pageState: state.pageState,
616
+ }, state),
617
+ result: {
618
+ projection,
619
+ explain_card: explainCard,
620
+ artifact: {
621
+ format,
622
+ ...artifactMeta,
623
+ },
624
+ },
625
+ continuation: getGatewayContinuation(state, 'inspect'),
626
+ runtime: instance ? { instance } : {},
627
+ route,
628
+ });
629
+ }
630
+ );
631
+
632
+ server.registerTool(
633
+ 'explain_share_card',
634
+ {
635
+ description: 'Explain how Grasp would lay out the current page as a share card, using Pretext when available.',
636
+ inputSchema: {
637
+ width: z.number().int().positive().optional().describe('Target card width in pixels'),
638
+ },
639
+ },
640
+ async ({ width = 640 } = {}) => {
641
+ const page = await getPage({ state });
642
+ const instance = await getBrowserInstance();
643
+ const selection = selectEngine({ tool: 'extract', url: page.url() });
644
+ const route = getRouteForState({ url: page.url(), state, intent: 'extract' });
645
+ const projection = await projectPageContent({
646
+ page,
647
+ state,
648
+ selection,
649
+ include_markdown: true,
650
+ deps: {
651
+ syncState,
652
+ observeContent,
653
+ readFastPathContent,
654
+ waitUntilStable: deps.waitUntilStable,
655
+ extractMainContent: deps.extractMainContent,
656
+ },
657
+ });
658
+ const explainCard = await buildExplainShareCard(page, projection, { width });
659
+
660
+ return buildGatewayResponse({
661
+ status: getGatewayStatus(state),
662
+ page: toGatewayPage({
663
+ title: projection.title,
664
+ url: projection.url,
665
+ pageState: state.pageState,
666
+ }, state),
667
+ result: {
668
+ projection,
669
+ explain_card: explainCard,
670
+ },
671
+ continuation: getGatewayContinuation(state, 'share_page'),
672
+ runtime: instance ? { instance } : {},
673
+ route,
674
+ message: [
675
+ `Status: ${getGatewayStatus(state)}`,
676
+ `Page: ${projection.title}`,
677
+ `URL: ${projection.url}`,
678
+ `Explain card engine: ${explainCard.engine}`,
679
+ `Estimated height: ${explainCard.estimated_height}px`,
680
+ `Next: share_page`,
681
+ ],
682
+ });
683
+ }
684
+ );
685
+
686
+ server.registerTool(
687
+ 'continue',
688
+ {
689
+ description: 'Decide the next continuation step without triggering browser actions.',
690
+ inputSchema: {},
691
+ },
692
+ async () => {
693
+ const page = await getPage({ state });
694
+ await syncState(page, state, { force: true });
695
+ const instance = await getBrowserInstance();
696
+ const outcome = await assessGatewayContinuation(page, state);
697
+ const route = getRouteForState({ url: page.url(), state });
698
+
699
+ return buildGatewayResponse({
700
+ status: outcome.status,
701
+ page: toGatewayPage({
702
+ title: await page.title(),
703
+ url: page.url(),
704
+ pageState: state.pageState,
705
+ }, state),
706
+ continuation: outcome.continuation,
707
+ runtime: instance ? { instance } : {},
708
+ route,
709
+ });
710
+ }
711
+ );
712
+
713
+ server.registerTool(
714
+ 'explain_route',
715
+ {
716
+ description: 'Explain the latest route decision and why Grasp chose it.',
717
+ inputSchema: {},
718
+ },
719
+ async () => {
720
+ const route = state.lastRouteTrace ?? await readLatestRoute();
721
+
722
+ if (!route) {
723
+ return textResponse([
724
+ 'Route explanation unavailable.',
725
+ 'No route decision recorded yet.',
726
+ 'Call entry(url, intent) first.',
727
+ ]);
728
+ }
729
+
730
+ const fallback = route.fallback_chain?.length
731
+ ? route.fallback_chain.join(' -> ')
732
+ : 'none';
733
+ const triggers = route.evidence?.triggers?.length
734
+ ? route.evidence.triggers.join(', ')
735
+ : 'none';
736
+ const alternatives = route.alternatives?.length
737
+ ? route.alternatives.map((candidate) => `${candidate.mode} (${candidate.reason})`).join('; ')
738
+ : 'none';
739
+
740
+ return textResponse([
741
+ 'Route explanation',
742
+ `Mode: ${route.selected_mode ?? 'unknown'}`,
743
+ `Template: ${route.policy_template ?? 'unknown'}`,
744
+ `Intent: ${route.intent ?? 'unknown'}`,
745
+ `Status: ${route.status ?? 'unknown'}`,
746
+ `Confidence: ${route.confidence ?? 'unknown'}`,
747
+ `Risk: ${route.risk_level ?? 'unknown'}`,
748
+ `Requires human: ${route.requires_human ? 'yes' : 'no'}`,
749
+ `Next: ${route.next_step ?? 'unknown'}`,
750
+ `Fallback: ${fallback}`,
751
+ `Alternatives: ${alternatives}`,
752
+ `Failure type: ${route.failure_type ?? 'none'}`,
753
+ `Evidence: ${triggers}`,
754
+ ], { route });
755
+ }
756
+ );
757
+ }