From e2bf37cdc53b2e4d2c80fa4b8e83ed644afa9cc5 Mon Sep 17 00:00:00 2001 From: Jeffrey Date: Wed, 25 Mar 2026 19:54:23 +0800 Subject: [PATCH 1/3] feat: add dory app basic --- docs/adapters/desktop/dory.md | 62 ++++++++++++++++++++++++ src/clis/dory/ask.ts | 89 +++++++++++++++++++++++++++++++++++ src/clis/dory/dump.ts | 3 ++ src/clis/dory/export.ts | 66 ++++++++++++++++++++++++++ src/clis/dory/new.ts | 42 +++++++++++++++++ src/clis/dory/read.ts | 47 ++++++++++++++++++ src/clis/dory/screenshot.ts | 3 ++ src/clis/dory/send.ts | 41 ++++++++++++++++ src/clis/dory/sessions.ts | 54 +++++++++++++++++++++ src/clis/dory/status.ts | 3 ++ 10 files changed, 410 insertions(+) create mode 100644 docs/adapters/desktop/dory.md create mode 100644 src/clis/dory/ask.ts create mode 100644 src/clis/dory/dump.ts create mode 100644 src/clis/dory/export.ts create mode 100644 src/clis/dory/new.ts create mode 100644 src/clis/dory/read.ts create mode 100644 src/clis/dory/screenshot.ts create mode 100644 src/clis/dory/send.ts create mode 100644 src/clis/dory/sessions.ts create mode 100644 src/clis/dory/status.ts diff --git a/docs/adapters/desktop/dory.md b/docs/adapters/desktop/dory.md new file mode 100644 index 00000000..26ef14fa --- /dev/null +++ b/docs/adapters/desktop/dory.md @@ -0,0 +1,62 @@ +# Dory + +Control the **Dory Desktop App** headless or headfully via Chrome DevTools Protocol (CDP). Because Dory is built on Electron, OpenCLI can directly drive its internal UI, send messages to the AI chat, read responses, and manage sessions. + +## Prerequisites + +1. You must have the official Dory app installed. +2. Launch it via the terminal and expose the remote debugging port: + ```bash + # macOS + /Applications/Dory.app/Contents/MacOS/Dory --remote-debugging-port=9300 + ``` + +## Setup + +```bash +export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9300" +``` + +## Commands + +### Diagnostics +- `opencli dory status` — Check CDP connection, current URL and page title. +- `opencli dory dump` — Dump the full DOM and accessibility tree to `/tmp/dory-dom.html` and `/tmp/dory-snapshot.json`. +- `opencli dory screenshot` — Capture DOM + accessibility snapshot to `/tmp/dory-snapshot-dom.html` and `/tmp/dory-snapshot-a11y.txt`. + +### Chat +- `opencli dory send "message"` — Inject text into the active chat composer and submit. +- `opencli dory ask "message"` — Send a message, wait for the AI response, and print it. + - Optional: `--timeout 120` to wait up to 120 seconds (default: 60). +- `opencli dory read` — Extract the full conversation thread (user + assistant messages) from the active page. +- `opencli dory export` — Export the current conversation to a Markdown file. + - Optional: `--output /path/to/file.md` (default: `/tmp/dory-export.md`). + +### Session Management +- `opencli dory new` — Create a new chat session by clicking the sidebar "New" button. +- `opencli dory sessions` — List recent chat sessions shown in the sidebar. + +## Example Workflow + +```bash +# 1. Verify connection +opencli dory status + +# 2. Ask a question and get the response inline +opencli dory ask "What tables are available in the active database?" + +# 3. Read the full conversation so far +opencli dory read + +# 4. Export to Markdown for sharing +opencli dory export --output ~/dory-session.md + +# 5. Start a fresh session +opencli dory new +``` + +## Notes + +- Dory uses React-controlled form elements. The `send` and `ask` commands use the native `HTMLTextAreaElement` value setter to properly trigger React's synthetic event system. +- The `ask` command polls every 2 seconds and considers the response complete once the text stabilises across two consecutive polls. +- If the sidebar is not visible, `sessions` and `new` may fall back to keyboard shortcuts or return empty results. diff --git a/src/clis/dory/ask.ts b/src/clis/dory/ask.ts new file mode 100644 index 00000000..f65b76c2 --- /dev/null +++ b/src/clis/dory/ask.ts @@ -0,0 +1,89 @@ +import { cli, Strategy } from '../../registry.js'; +import { SelectorError } from '../../errors.js'; +import type { IPage } from '../../types.js'; + +export const askCommand = cli({ + site: 'dory', + name: 'ask', + description: 'Send a message and wait for the AI response (send + wait + read)', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'text', required: true, positional: true, help: 'Message to send' }, + { name: 'timeout', required: false, help: 'Max seconds to wait for response (default: 60)', default: '60' }, + ], + columns: ['Role', 'Text'], + func: async (page: IPage, kwargs: any) => { + const text = kwargs.text as string; + const timeout = parseInt(kwargs.timeout as string, 10) || 60; + + // Count current assistant messages before sending + const beforeCount = await page.evaluate(` + (function() { + return document.querySelectorAll('[role="log"] .is-assistant').length; + })() + `); + + // Inject into React-controlled textarea and submit + const injected = await page.evaluate(` + (function(text) { + const textarea = document.querySelector('textarea[name="message"]') || document.querySelector('textarea'); + if (!textarea) return false; + textarea.focus(); + const nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set; + nativeSetter.call(textarea, text); + textarea.dispatchEvent(new Event('input', { bubbles: true })); + textarea.dispatchEvent(new Event('change', { bubbles: true })); + return true; + })(${JSON.stringify(text)}) + `); + + if (!injected) throw new SelectorError('Dory chat textarea'); + + await page.wait(0.3); + await page.pressKey('Enter'); + + // Poll for new assistant message and wait for it to stabilise + const pollInterval = 2; + const maxPolls = Math.ceil(timeout / pollInterval); + let response = ''; + let lastText = ''; + + for (let i = 0; i < maxPolls; i++) { + await page.wait(pollInterval); + + const result = await page.evaluate(` + (function(prevCount) { + const msgs = document.querySelectorAll('[role="log"] .is-assistant'); + if (msgs.length <= prevCount) return null; + const last = msgs[msgs.length - 1]; + return (last.innerText || last.textContent || '').trim(); + })(${beforeCount}) + `); + + if (result) { + // Wait for streaming to finish: text must be stable across two polls + if (result === lastText) { + response = result; + break; + } + lastText = result; + } + } + + if (!response && lastText) response = lastText; + + if (!response) { + return [ + { Role: 'User', Text: text }, + { Role: 'System', Text: `No response within ${timeout}s. The AI may still be generating.` }, + ]; + } + + return [ + { Role: 'User', Text: text }, + { Role: 'Assistant', Text: response }, + ]; + }, +}); diff --git a/src/clis/dory/dump.ts b/src/clis/dory/dump.ts new file mode 100644 index 00000000..a7b6fada --- /dev/null +++ b/src/clis/dory/dump.ts @@ -0,0 +1,3 @@ +import { makeDumpCommand } from '../_shared/desktop-commands.js'; + +export const dumpCommand = makeDumpCommand('dory'); diff --git a/src/clis/dory/export.ts b/src/clis/dory/export.ts new file mode 100644 index 00000000..4751d5b8 --- /dev/null +++ b/src/clis/dory/export.ts @@ -0,0 +1,66 @@ +import * as fs from 'node:fs'; +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; + +export const exportCommand = cli({ + site: 'dory', + name: 'export', + description: 'Export the current Dory conversation to a Markdown file', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'output', required: false, help: 'Output file path (default: /tmp/dory-export.md)' }, + ], + columns: ['Status', 'File', 'Messages'], + func: async (page: IPage, kwargs: any) => { + const outputPath = (kwargs.output as string) || '/tmp/dory-export.md'; + + const messages = await page.evaluate(` + (function() { + const log = document.querySelector('[role="log"]'); + if (!log) return []; + const results = []; + const wrappers = log.querySelectorAll('.is-user, .is-assistant'); + wrappers.forEach(function(el) { + const isUser = el.classList.contains('is-user'); + const text = (el.innerText || el.textContent || '').trim(); + if (text) results.push({ role: isUser ? 'User' : 'Assistant', text: text }); + }); + return results; + })() + `); + + const url = await page.evaluate('window.location.href'); + const title = await page.evaluate('document.title'); + + let md = `# Dory Conversation Export\n\n`; + md += `**Source:** ${url}\n`; + md += `**Page:** ${title}\n\n---\n\n`; + + if (messages && messages.length > 0) { + for (const msg of messages) { + md += `## ${msg.role}\n\n${msg.text}\n\n---\n\n`; + } + } else { + // Fallback: dump entire log + const fallback = await page.evaluate(` + (function() { + const log = document.querySelector('[role="log"]'); + return log ? (log.innerText || log.textContent || '') : document.body.innerText; + })() + `); + md += fallback; + } + + fs.writeFileSync(outputPath, md); + + return [ + { + Status: 'Success', + File: outputPath, + Messages: messages ? messages.length : 0, + }, + ]; + }, +}); diff --git a/src/clis/dory/new.ts b/src/clis/dory/new.ts new file mode 100644 index 00000000..3b3822c8 --- /dev/null +++ b/src/clis/dory/new.ts @@ -0,0 +1,42 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; + +export const newCommand = cli({ + site: 'dory', + name: 'new', + description: 'Create a new Dory chat session', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [], + columns: ['Status'], + func: async (page: IPage) => { + // Try to find and click the "New" session button in the sidebar + const clicked = await page.evaluate(` + (function() { + // Look for a button/link that creates a new session + // The ChatSessionSidebar renders an onCreate button — find it by common aria labels or text + const candidates = Array.from(document.querySelectorAll('button, a[role="button"]')); + const newBtn = candidates.find(function(el) { + const text = (el.textContent || el.innerText || '').trim().toLowerCase(); + const label = (el.getAttribute('aria-label') || '').toLowerCase(); + return text === 'new' || text === 'new chat' || label.includes('new') || label.includes('create'); + }); + if (newBtn) { + newBtn.click(); + return true; + } + return false; + })() + `); + + if (!clicked) { + // Fallback: Cmd/Ctrl+K is a common new-chat shortcut in web chat apps + const isMac = process.platform === 'darwin'; + await page.pressKey(isMac ? 'Meta+K' : 'Control+K'); + } + + await page.wait(1); + return [{ Status: 'Success' }]; + }, +}); diff --git a/src/clis/dory/read.ts b/src/clis/dory/read.ts new file mode 100644 index 00000000..0db6737d --- /dev/null +++ b/src/clis/dory/read.ts @@ -0,0 +1,47 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; + +export const readCommand = cli({ + site: 'dory', + name: 'read', + description: 'Read the full conversation thread from the active Dory chat', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [], + columns: ['Role', 'Text'], + func: async (page: IPage) => { + const messages = await page.evaluate(` + (function() { + const log = document.querySelector('[role="log"]'); + if (!log) return []; + + const results = []; + + // Each message wrapper has .is-user or .is-assistant + const wrappers = log.querySelectorAll('.is-user, .is-assistant'); + wrappers.forEach(function(el) { + const isUser = el.classList.contains('is-user'); + const text = (el.innerText || el.textContent || '').trim(); + if (text) { + results.push({ Role: isUser ? 'User' : 'Assistant', Text: text }); + } + }); + + // Fallback: grab entire log text + if (results.length === 0) { + const text = (log.innerText || log.textContent || '').trim(); + if (text) results.push({ Role: 'Thread', Text: text }); + } + + return results; + })() + `); + + if (!messages || messages.length === 0) { + return [{ Role: 'System', Text: 'No messages found. Make sure you are on the Dory chatbot page.' }]; + } + + return messages; + }, +}); diff --git a/src/clis/dory/screenshot.ts b/src/clis/dory/screenshot.ts new file mode 100644 index 00000000..d73145c1 --- /dev/null +++ b/src/clis/dory/screenshot.ts @@ -0,0 +1,3 @@ +import { makeScreenshotCommand } from '../_shared/desktop-commands.js'; + +export const screenshotCommand = makeScreenshotCommand('dory', 'Dory App'); diff --git a/src/clis/dory/send.ts b/src/clis/dory/send.ts new file mode 100644 index 00000000..f545f170 --- /dev/null +++ b/src/clis/dory/send.ts @@ -0,0 +1,41 @@ +import { cli, Strategy } from '../../registry.js'; +import { SelectorError } from '../../errors.js'; +import type { IPage } from '../../types.js'; + +export const sendCommand = cli({ + site: 'dory', + name: 'send', + description: 'Send a message to the active Dory chat composer', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [{ name: 'text', required: true, positional: true, help: 'Message text to send' }], + columns: ['Status', 'InjectedText'], + func: async (page: IPage, kwargs: any) => { + const text = kwargs.text as string; + + // Dory uses a React-controlled