dsh-moli 1.1.12 → 1.1.13

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/index.js CHANGED
@@ -1,8 +1,6 @@
1
1
  /** Host half of dsh-moli: expose the local `moli` headless browser as callable tools. */
2
- import { performAction } from './lib/actions.js';
3
2
  import { MANUAL_INSTALL, installMoli } from './lib/install.js';
4
3
  import { findMoli, moliCandidates, probe, runFetch } from './lib/moli.js';
5
- import { SessionManager, sessionSupported, sessionUnsupportedReason } from './lib/session.js';
6
4
 
7
5
  export const name = 'dsh-moli';
8
6
  export const inject = ['tools'];
@@ -69,6 +67,10 @@ const FETCH_PARAMETERS = {
69
67
  type: 'string',
70
68
  description: 'JavaScript expression evaluated once the page is ready; its value is returned as text or JSON instead of the page. Combining it with `dump` does NOT return both: the engine discards the expression value and dumps the resulting page state instead (its own documented semantics). Use `eval` alone to read a value, or `dump` alone to read the page.',
71
69
  },
70
+ scroll: {
71
+ type: 'string',
72
+ description: 'Scroll the page before capturing it: a number of pixels down as a string ("800", "-500" to go up), or "top" / "bottom". A page that loads content on its first scroll event shows a placeholder without this and the loaded content with it, so use it for lazy lists, infinite scroll, and content revealed by an intersection observer. Runs as part of the engine\'s own pre-capture evaluation, so it happens before any `eval` expression reads the page.',
73
+ },
72
74
  wait: {
73
75
  type: 'string',
74
76
  enum: ['domcontentloaded', 'load', 'networkidle', 'domstable', 'done'],
@@ -228,174 +230,6 @@ function renderProbe(_args, value) {
228
230
  return [{ type: 'text', text: lines.join('\n') }];
229
231
  }
230
232
 
231
- /** Actions `moli_session` accepts. */
232
- const SESSION_ACTIONS = ['start', 'open', 'snapshot', 'click', 'type', 'press', 'scroll', 'waitFor', 'eval', 'cdp', 'screenshot', 'status', 'close'];
233
-
234
- /**
235
- * Parameters of `moli_session`.
236
- *
237
- * Raw JSON Schema with a root `type: "object"`, like the other tools: `schemaOf`
238
- * projects this straight to the provider, so the author DSL would arrive as
239
- * `type: null` and be rejected.
240
- */
241
- const SESSION_PARAMETERS = {
242
- type: 'object',
243
- additionalProperties: false,
244
- required: ['action'],
245
- properties: {
246
- action: {
247
- type: 'string',
248
- enum: SESSION_ACTIONS,
249
- description: 'start: ensure the browser is running. open: navigate. snapshot: read the rendered page. '
250
- + 'click/type/press/scroll: interact. waitFor: wait for a selector to appear or disappear. '
251
- + 'eval: run JavaScript in the page. cdp: send a raw Chrome DevTools Protocol command. '
252
- + 'screenshot: write a PNG. status: report the session and which browser it uses. close: stop the browser.',
253
- },
254
- url: { type: 'string', description: 'open: absolute http:// or https:// URL to navigate to.' },
255
- selector: { type: 'string', description: 'CSS selector for click, type, scroll and waitFor.' },
256
- text: { type: 'string', description: 'type: the text to enter.' },
257
- submit: { type: 'boolean', description: 'type: press Enter after entering the text.' },
258
- clear: { type: 'boolean', description: 'type: empty the field before entering text.' },
259
- key: { type: 'string', description: 'press: key name, e.g. Enter, Tab, Escape, ArrowDown.' },
260
- dy: { type: 'number', description: 'scroll: pixels to move down (negative moves up). Defaults to 800.' },
261
- block: { type: 'string', enum: ['start', 'center', 'end', 'nearest'], description: 'scroll: where to align the element. Defaults to center.' },
262
- state: { type: 'string', enum: ['attached', 'detached'], description: 'waitFor: wait for the selector to appear (attached) or vanish (detached).' },
263
- expression: { type: 'string', description: 'eval: a JavaScript expression; promises are awaited and the value is returned.' },
264
- method: { type: 'string', description: 'cdp: the protocol method to invoke, e.g. "Network.enable" or "DOM.setFileInputFiles".' },
265
- params: { description: 'cdp: the method parameters, as an object or a JSON string. Optional.' },
266
- writeTo: { type: 'string', description: 'screenshot: absolute path for the PNG. Required, since images are not returned inline.' },
267
- format: { type: 'string', enum: ['png', 'jpeg'], description: 'screenshot: image format. Defaults to png.' },
268
- includeHtml: { type: 'boolean', description: 'snapshot: also return the rendered HTML.' },
269
- maxChars: { type: 'integer', description: 'snapshot and cdp: truncate text output to this many characters.' },
270
- timeoutMs: { type: 'integer', description: 'Deadline for this action. Defaults to the plugin configuration.' },
271
- port: { type: 'integer', description: 'start: CDP port. Defaults to the plugin configuration (9222).' },
272
- cdpEndpoint: {
273
- type: 'string',
274
- description: 'Attach to a browser that is already running, e.g. "http://127.0.0.1:9222", instead of starting one. This is how you drive your own Chrome with its logins: start it with --remote-debugging-port and pass that URL.',
275
- },
276
- },
277
- };
278
-
279
- const SESSION_OUTPUT_SCHEMA = {
280
- type: 'object',
281
- additionalProperties: true,
282
- required: ['ok'],
283
- properties: {
284
- ok: { type: 'boolean' },
285
- action: { type: 'string' },
286
- error: { type: 'string' },
287
- hint: { type: 'string' },
288
- url: { type: 'string' },
289
- title: { type: 'string' },
290
- text: { type: 'string' },
291
- chars: { type: 'integer' },
292
- truncated: { type: 'boolean' },
293
- links: { type: 'array', items: { type: 'object', additionalProperties: true } },
294
- fields: { type: 'array', items: { type: 'object', additionalProperties: true } },
295
- html: { type: 'string' },
296
- selector: { type: 'string' },
297
- clicked: { type: 'object', additionalProperties: true },
298
- navigated: { type: 'boolean' },
299
- submitted: { type: 'boolean' },
300
- value: {},
301
- serialized: { type: 'string' },
302
- key: { type: 'string' },
303
- y: { type: 'number' },
304
- height: { type: 'number' },
305
- viewport: { type: 'number' },
306
- state: { type: 'string' },
307
- waitedMs: { type: 'integer' },
308
- path: { type: 'string' },
309
- bytes: { type: 'integer' },
310
- format: { type: 'string' },
311
- method: { type: 'string' },
312
- result: {},
313
- bytes: { type: 'integer' },
314
- backend: { type: 'string' },
315
- geometry: { type: 'string' },
316
- browserVersion: { type: 'string' },
317
- running: { type: 'boolean' },
318
- ownedByThisPlugin: { type: 'boolean' },
319
- external: { type: 'boolean' },
320
- pid: { type: 'integer' },
321
- endpoint: { type: 'string' },
322
- logPath: { type: 'string' },
323
- settled: { type: 'boolean' },
324
- settledMs: { type: 'integer' },
325
- stopped: { type: 'boolean' },
326
- scope: { type: 'string' },
327
- },
328
- };
329
-
330
- /**
331
- * Render a `moli_session` value for the model.
332
- *
333
- * A snapshot is the one result worth formatting rather than dumping as JSON: the
334
- * link and field tables are what the next action usually depends on.
335
- *
336
- * @param _args - the call arguments, unused.
337
- * @param value - the canonical result value.
338
- * @returns result content blocks.
339
- */
340
- function renderSession(_args, value) {
341
- if (value.ok !== true) {
342
- const lines = [`moli_session failed${typeof value.action === 'string' ? ` (${value.action})` : ''}: ${value.error ?? 'unknown error'}`];
343
- if (typeof value.hint === 'string' && value.hint !== '') lines.push(value.hint);
344
- if (typeof value.bytes === 'number') lines.push(`(the image was ${String(value.bytes)} bytes but could not be written)`);
345
- return [{ type: 'text', text: lines.join('\n') }];
346
- }
347
- if (value.action === 'snapshot') {
348
- const lines = [`${String(value.title)} — ${String(value.url)}`, ''];
349
- lines.push(String(value.text ?? ''));
350
- if (Array.isArray(value.links) && value.links.length > 0) {
351
- lines.push('', `Links (${String(value.links.length)}):`);
352
- for (const link of value.links.slice(0, 25)) lines.push(` ${String(link.text)} -> ${String(link.href)}`);
353
- }
354
- if (Array.isArray(value.fields) && value.fields.length > 0) {
355
- lines.push('', `Fields (${String(value.fields.length)}):`);
356
- for (const field of value.fields.slice(0, 25)) lines.push(` <${String(field.tag)}${field.type === '' ? '' : ` type=${String(field.type)}`}${field.name === '' ? '' : ` name=${String(field.name)}`}${field.id === '' ? '' : ` id=${String(field.id)}`}>`);
357
- }
358
- return [{ type: 'text', text: lines.join('\n') }];
359
- }
360
- if (value.action === 'status') {
361
- if (value.running !== true) {
362
- return [{ type: 'text', text: 'No session is running. Call moli_session with action "start" or "open" to launch one.' }];
363
- }
364
- const lines = [
365
- `Session running: ${String(value.backend)} (${String(value.browserVersion ?? 'version unknown')}) at ${String(value.endpoint)}`,
366
- ];
367
- if (value.geometry === 'absolute') {
368
- lines.push('Geometry: moli\'s document-absolute frame — element input cannot reach below the first viewport. Use `browser: "chromium"` to lift that, or eval to click in the page.');
369
- }
370
- if (value.external === true) lines.push('Started outside this plugin, so close will not stop it.');
371
- if (value.url !== undefined) lines.push(`Current page: ${String(value.title)} — ${String(value.url)}`);
372
- return [{ type: 'text', text: lines.join('\n') }];
373
- }
374
- if (value.action === 'cdp') {
375
- return [{ type: 'text', text: `${String(value.method)} → ${String(value.serialized ?? '').slice(0, 4000)}` }];
376
- }
377
- if (value.action === 'click') {
378
- return [{
379
- type: 'text',
380
- text: `Clicked ${String(value.clicked?.tag ?? 'element')}${value.clicked?.text === undefined || value.clicked.text === '' ? '' : ` (“${String(value.clicked.text)}”)`}`
381
- + `${value.navigated === true ? `, which navigated to ${String(value.url)}` : '; the page did not navigate'}`,
382
- }];
383
- }
384
- if (value.action === 'type') {
385
- return [{ type: 'text', text: `Entered text${value.submitted === true ? ' and pressed Enter' : ''}; the field now reads ${JSON.stringify(String(value.value ?? ''))}` }];
386
- }
387
- if (value.action === 'screenshot') {
388
- return [{ type: 'text', text: `Screenshot written to ${String(value.path)} (${String(value.bytes)} bytes). Read that file to see it.` }];
389
- }
390
- if (value.action === 'eval') {
391
- return [{ type: 'text', text: String(value.serialized ?? '') }];
392
- }
393
- if (value.action === 'close') {
394
- return [{ type: 'text', text: value.stopped === true ? 'Session stopped.' : 'Nothing to stop: no session was running that this plugin started.' }];
395
- }
396
- return [{ type: 'text', text: JSON.stringify(value) }];
397
- }
398
-
399
233
  /**
400
234
  * Register the Moli tools.
401
235
  *
@@ -525,133 +359,5 @@ export function apply(ctx, config) {
525
359
  },
526
360
  });
527
361
 
528
- // ---------------------------------------------------------------------------
529
- // moli_session: an interactive browser, for the work a one-shot fetch cannot do
530
- // ---------------------------------------------------------------------------
531
- //
532
- // This is the plugin's only stateful tool. `moli serve` is a long-lived child
533
- // that has to outlive a single call, so one manager is created per plugin
534
- // instance and its lifecycle is explicit (`start` implicitly, `close` by hand).
535
- // Registration stays synchronous: the server is spawned on first use, never at
536
- // load time, so a plugin load cannot delay Host startup or open a port.
537
- const session = new SessionManager({
538
- get exe() { return cachedExe; },
539
- // Which browser drives a session. Explicit, never automatic: switching engines
540
- // because one click failed would make the timing and the behaviour depend on
541
- // which operation happened to fail, and the caller could not tell which browser
542
- // it was talking to.
543
- browser: options.sessionBrowser ?? 'moli',
544
- browserPath: options.sessionBrowserPath,
545
- cdpEndpoint: options.sessionCdpEndpoint,
546
- port: options.sessionPort ?? 9222,
547
- host: '127.0.0.1',
548
- insecureTls,
549
- proxy: options.proxy,
550
- userAgent: options.sessionUserAgent,
551
- profileDir: options.profileDir,
552
- resources: options.sessionResources === true,
553
- // Off, matching the engine's own default: `moli fetch <private-ip>` can reach
554
- // a private address, so the plugin does not silently narrow what a user's
555
- // installed engine already does. Set it to true, or pass blockCidrs, to have
556
- // the engine refuse private addresses for directly connected targets.
557
- blockPrivateNetworks: options.blockPrivateNetworks === true,
558
- blockCidrs: Array.isArray(options.blockCidrs) ? options.blockCidrs : [],
559
- startTimeoutMs: options.sessionStartTimeoutMs ?? 20000,
560
- });
561
-
562
- ctx.tools.register({
563
- name: 'moli_session',
564
- description: 'Drive an interactive browser through a persistent session, for what a one-shot fetch cannot'
565
- + ' do: click through consent walls, fill and submit a search box, scroll a lazy list, wait for a'
566
- + ' client-side route change, or run a script in the page. Keeps one browser alive across calls, so'
567
- + ' state (cookies, navigation, form input) persists until you stop it with action "close".'
568
- + ' Start with "open"; read the page with "snapshot"; interact with "click", "type", "press" and'
569
- + ' "scroll"; use "eval" as the escape hatch. Elements are addressed by CSS selector, never by pixel'
570
- + ' coordinates.',
571
- parameters: SESSION_PARAMETERS,
572
- output: { schema: SESSION_OUTPUT_SCHEMA, render: renderSession },
573
- // One browser, one page: overlapping actions would interleave input events and
574
- // navigate the page out from under each other.
575
- isConcurrencySafe: () => false,
576
- async execute(args, exec) {
577
- // A per-call endpoint is honoured by every action, including the ones that
578
- // never reach `performAction`. Routing `close` through it is not cosmetic:
579
- // `withEndpoint` is what marks the connection external, and `close` refuses
580
- // to stop an external browser. Answering `close` before that mark existed
581
- // meant a call that had just attached to the user's own Chrome could shut it
582
- // down -- the one thing this design promises not to do.
583
- const withCallEndpoint = work => (typeof args.cdpEndpoint === 'string' && args.cdpEndpoint !== ''
584
- ? session.withEndpoint(args.cdpEndpoint, work)
585
- : work());
586
-
587
- // `status` and `close` are meaningful with nothing running, so they are
588
- // answered before the engine is required.
589
- if (args.action === 'close') {
590
- return withCallEndpoint(async () => ({ ok: true, action: 'close', ...(await session.close()) }));
591
- }
592
- if (args.action === 'status') {
593
- return withCallEndpoint(async () => ({ ok: true, action: 'status', ...(await session.status()) }));
594
- }
595
- if (args.action === 'start') {
596
- if (!sessionSupported()) return { ok: false, action: 'start', error: sessionUnsupportedReason() };
597
- return withCallEndpoint(async () => {
598
- const { external, backend, geometry } = await session.ensure();
599
- const state = await session.status();
600
- return { ok: true, action: 'start', started: true, external, backend, geometry, ...state };
601
- });
602
- }
603
-
604
- if (engine() === undefined && (options.sessionBrowser ?? 'moli') === 'moli'
605
- && (options.sessionCdpEndpoint === undefined || options.sessionCdpEndpoint === '')) {
606
- return {
607
- ok: false,
608
- action: String(args.action),
609
- error: 'moli was not found, so a moli session cannot be started',
610
- hint: 'Call moli_probe with action "install", set the plugin config field `exePath`, or set `sessionBrowser: "chromium"` to drive the browser you already have.',
611
- };
612
- }
613
- if (!sessionSupported()) return { ok: false, action: String(args.action), error: sessionUnsupportedReason() };
614
-
615
- if (exec?.signal?.aborted === true) return { ok: false, action: String(args.action), error: 'the call was aborted before it started' };
616
-
617
- try {
618
- const perform = async ({ connection, geometry, backend }) => {
619
- try {
620
- return await performAction(connection, args, { maxChars, geometry, backend });
621
- } finally {
622
- // A closed socket means the browser went away under us; drop it so the
623
- // next call starts a fresh one instead of failing forever.
624
- if (connection.closed === true) void session.close();
625
- }
626
- };
627
- // A per-call endpoint drives a browser the caller already has open, which is
628
- // the only way to inherit their logins. It is scoped to this call: an
629
- // endpoint that latched would make one bad call poison every later one.
630
- if (typeof args.cdpEndpoint === 'string' && args.cdpEndpoint !== '') {
631
- return await session.withEndpoint(args.cdpEndpoint, perform);
632
- }
633
- return await perform(await session.ensure());
634
- } catch (error) {
635
- const message = error instanceof Error ? error.message : String(error);
636
- const hint = /timed out/u.test(message)
637
- ? 'The action exceeded its deadline. Raise timeoutMs, or check `waitFor` for a selector that never appears.'
638
- : /no Chromium-family browser/u.test(message)
639
- ? 'Install Chrome, Edge or Brave, or set the plugin config field `sessionBrowserPath` to a browser executable.'
640
- : /ECONNREFUSED|closed|WebSocket/u.test(message)
641
- ? 'The browser connection was lost. Call moli_session with action "status", then "start" to relaunch it.'
642
- : undefined;
643
- return { ok: false, action: String(args.action), error: message, ...(hint === undefined ? {} : { hint }) };
644
- }
645
- },
646
- });
647
-
648
- // A serve process or a headless browser outlives a tool call, so it must be tied
649
- // to the plugin's lifetime rather than left to the OS. Cordis disposes listeners
650
- // registered through `ctx.on` together with the plugin's fiber, so registering
651
- // one is enough; the returned disposer is not needed here.
652
- if (typeof ctx.on === 'function') {
653
- ctx.on('dispose', () => { void session.close(); });
654
- }
655
-
656
- ctx.logger?.info?.(`dsh-moli: registered moli_fetch, moli_probe and moli_session (${cachedExe ?? 'moli missing'})`);
362
+ ctx.logger?.info?.(`dsh-moli: registered moli_fetch and moli_probe (${cachedExe ?? 'moli missing'})`);
657
363
  }
package/lib/moli.js CHANGED
@@ -31,6 +31,64 @@ const TEXT_DUMPS = new Set([undefined, 'html', 'markdown', 'json', 'semantic_tre
31
31
  /** Accepted `--wait` lifecycle stages. */
32
32
  const WAIT_STAGES = new Set(['domcontentloaded', 'load', 'networkidle', 'domstable', 'done']);
33
33
 
34
+ /** `scroll` keywords, mapped to what they mean in the page. */
35
+ const SCROLL_TOKENS = {
36
+ top: '0',
37
+ bottom: 'document.documentElement.scrollHeight',
38
+ };
39
+
40
+ /**
41
+ * Build the expression that scrolls the page, to be run by the engine's own
42
+ * `--eval` before the dump.
43
+ *
44
+ * Scrolling is not a nicety: a page can load content on its first scroll event, and
45
+ * then a fetch without scrolling sees a placeholder while the same fetch with it
46
+ * sees the content. Verified against a page whose scroll handler fills a slot --
47
+ * the dump carried the loaded text with `scroll` and the placeholder without it.
48
+ * Moli does serialize the whole document either way, so this is not about reaching
49
+ * content that "below the fold" already contains; it is about triggering the
50
+ * handler, the intersection observer, or the lazy image that only runs on a scroll.
51
+ *
52
+ * @param scroll - pixels down (negative scrolls up), or `top` / `bottom`.
53
+ * @returns a JavaScript expression, or undefined when there is nothing to do.
54
+ */
55
+ export function scrollScript(scroll) {
56
+ if (scroll === undefined || scroll === null) return undefined;
57
+ let target;
58
+ if (typeof scroll === 'number') {
59
+ if (!Number.isFinite(scroll)) return undefined;
60
+ target = String(Math.trunc(scroll));
61
+ } else if (typeof scroll === 'string') {
62
+ const key = scroll.trim().toLowerCase();
63
+ if (key === '') return undefined;
64
+ target = SCROLL_TOKENS[key] ?? (/^-?\d+$/u.test(key) ? key : undefined);
65
+ if (target === undefined) return undefined;
66
+ } else {
67
+ return undefined;
68
+ }
69
+ // Dispatch a scroll event as well as moving: `window.scrollTo` updates the
70
+ // position, but a page listening for the event is the reason this exists.
71
+ return `(() => { window.scrollTo(0, ${target}); window.dispatchEvent(new Event('scroll')); return String(window.scrollY); })()`;
72
+ }
73
+
74
+ /**
75
+ * Combine the caller's `eval` with a scroll, if one was asked for.
76
+ *
77
+ * The two have to share one expression because the engine accepts a single
78
+ * `--eval`. The scroll goes first so the page is in its final state before the
79
+ * caller's expression reads it, and the caller's value still becomes the result.
80
+ *
81
+ * @returns the expression to pass, or undefined when neither was given.
82
+ */
83
+ export function combineEval(evalExpression, scroll) {
84
+ const move = scrollScript(scroll);
85
+ if (move === undefined) return evalExpression;
86
+ if (evalExpression === undefined || evalExpression === '') return move;
87
+ // The caller's expression is kept last and wrapped so its value is the one the
88
+ // engine reports. Losing it would contradict the documented `eval` behaviour.
89
+ return `${move}; (() => (${evalExpression}))()`;
90
+ }
91
+
34
92
  /**
35
93
  * Every place this plugin will look for `moli.exe`, most explicit first.
36
94
  *
@@ -256,7 +314,10 @@ function buildArgs(args) {
256
314
  // Screenshots and PDFs silently produce nothing without the layout renderer.
257
315
  if (needsLayout) argv.push('--layout');
258
316
  if (args.scrollbars === true) argv.push('--scrollbars');
259
- if (args.eval !== undefined) argv.push('--eval', args.eval);
317
+ // `scroll` rides along in the same expression: the engine takes a single `--eval`,
318
+ // and the scroll has to happen before the page is captured.
319
+ const evaluation = combineEval(args.eval, args.scroll);
320
+ if (evaluation !== undefined) argv.push('--eval', evaluation);
260
321
  if (args.wait !== undefined && args.wait !== '') argv.push('--wait', args.wait);
261
322
  if (args.waitSelector !== undefined && args.waitSelector !== '') argv.push('--wait-selector', args.waitSelector);
262
323
  if (args.delayMs !== undefined) argv.push('--delay-ms', String(args.delayMs));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-moli",
3
- "version": "1.1.12",
3
+ "version": "1.1.13",
4
4
  "type": "module",
5
5
  "description": "DeepSeek Harness tools for the local Moli headless browser: rendered fetches, markdown, semantic trees, screenshots, and PDFs.",
6
6
  "license": "MIT",