shiftacle-desktop-agent 0.1.0

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/bin/cli.mjs ADDED
@@ -0,0 +1,204 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Shiftacle Desktop Agent
5
+ *
6
+ * Auto-fills your EMR from your phone.
7
+ * Generate a note in Shift Notes, tap "Send to EMR",
8
+ * and it appears in your EMR on your computer — automatically.
9
+ *
10
+ * Usage: npx shiftacle-desktop-agent [options]
11
+ *
12
+ * Options:
13
+ * --port <number> Local server port (default: 3847)
14
+ * --emr <type> EMR type: hellonote, webpt, nethealth (default: hellonote)
15
+ * --headless Run browser in headless mode
16
+ * --vision-url <url> Vision AI endpoint URL
17
+ * --login Force re-login to Shiftacle
18
+ * --help Show this help
19
+ */
20
+
21
+ import { parseArgs } from 'node:util';
22
+ import { execSync } from 'child_process';
23
+ import { existsSync } from 'fs';
24
+ import { getConfig, ensureDirs } from '../src/config.mjs';
25
+ import { authenticate } from '../src/auth.mjs';
26
+ import { startServer } from '../src/server.mjs';
27
+ import { startPoller } from '../src/poller.mjs';
28
+
29
+ // --- Parse CLI flags ---
30
+
31
+ const { values: flags } = parseArgs({
32
+ options: {
33
+ port: { type: 'string', short: 'p' },
34
+ emr: { type: 'string', short: 'e' },
35
+ headless: { type: 'boolean', default: false },
36
+ 'vision-url': { type: 'string' },
37
+ login: { type: 'boolean', default: false },
38
+ help: { type: 'boolean', short: 'h', default: false },
39
+ },
40
+ strict: false,
41
+ });
42
+
43
+ if (flags.help) {
44
+ console.log(`
45
+ Shiftacle Desktop Agent v0.1.0
46
+
47
+ Auto-fills your EMR with clinical notes from Shift Notes.
48
+
49
+ Usage: npx shiftacle-desktop-agent [options]
50
+
51
+ Options:
52
+ -p, --port <number> Local server port (default: 3847)
53
+ -e, --emr <type> EMR type: hellonote, webpt, nethealth (default: hellonote)
54
+ --headless Run browser without visible window
55
+ --vision-url <url> Custom vision AI endpoint
56
+ --login Force re-login to Shiftacle account
57
+ -h, --help Show this help
58
+
59
+ Environment Variables:
60
+ VISION_URL Vision AI endpoint
61
+ VISION_MODEL Vision model name
62
+ SHIFTACLE_API_URL Shiftacle API base URL
63
+ EMR_AGENT_PORT Server port
64
+
65
+ Examples:
66
+ npx shiftacle-desktop-agent
67
+ npx shiftacle-desktop-agent --emr webpt
68
+ npx shiftacle-desktop-agent --port 4000
69
+ `);
70
+ process.exit(0);
71
+ }
72
+
73
+ // --- Startup ---
74
+
75
+ const config = getConfig(flags);
76
+ ensureDirs(config);
77
+
78
+ console.log(`
79
+ ┌─────────────────────────────────────────┐
80
+ │ Shiftacle Desktop Agent v0.1.0 │
81
+ │ Auto-fill your EMR from mobile │
82
+ └─────────────────────────────────────────┘
83
+ `);
84
+
85
+ // Step 1: Check Playwright browsers
86
+ process.stdout.write(' Checking browser installation... ');
87
+ try {
88
+ const { chromium } = await import('playwright');
89
+ const executablePath = chromium.executablePath();
90
+ if (!existsSync(executablePath)) {
91
+ console.log('installing Chromium (first run only)...');
92
+ execSync('npx playwright install chromium', { stdio: 'pipe' });
93
+ console.log(' Chromium installed.');
94
+ } else {
95
+ console.log('OK');
96
+ }
97
+ } catch {
98
+ console.log('installing Chromium...');
99
+ try {
100
+ execSync('npx playwright install chromium', { stdio: 'inherit' });
101
+ } catch (err) {
102
+ console.error('\n Failed to install Chromium. Run manually:');
103
+ console.error(' npx playwright install chromium\n');
104
+ process.exit(1);
105
+ }
106
+ }
107
+
108
+ // Step 2: Authenticate with Shiftacle
109
+ process.stdout.write(' Authenticating... ');
110
+ let auth;
111
+ try {
112
+ auth = await authenticate(config, flags.login);
113
+ console.log('OK');
114
+ } catch (err) {
115
+ console.error(`FAILED\n\n ${err.message}\n`);
116
+ process.exit(1);
117
+ }
118
+
119
+ // Step 3: Check if port is available
120
+ try {
121
+ const net = await import('net');
122
+ await new Promise((resolve, reject) => {
123
+ const tester = net.createServer();
124
+ tester.once('error', (err) => {
125
+ if (err.code === 'EADDRINUSE') {
126
+ reject(new Error(`Port ${config.port} is already in use. Is another agent running?\n Use --port <number> to change.`));
127
+ } else {
128
+ reject(err);
129
+ }
130
+ });
131
+ tester.listen(config.port, () => {
132
+ tester.close(resolve);
133
+ });
134
+ });
135
+ } catch (err) {
136
+ console.error(`\n ${err.message}\n`);
137
+ process.exit(1);
138
+ }
139
+
140
+ // Step 4: Start local server
141
+ const agent = startServer(config);
142
+ console.log(` Server listening on port ${config.port}`);
143
+
144
+ // Step 5: Open EMR browser
145
+ process.stdout.write(` Opening ${config.emr} in browser... `);
146
+ try {
147
+ const res = await fetch(`http://localhost:${config.port}/session/start`, {
148
+ method: 'POST',
149
+ headers: { 'Content-Type': 'application/json' },
150
+ body: JSON.stringify({ emrType: config.emr }),
151
+ });
152
+ const data = await res.json();
153
+ if (data.success) {
154
+ console.log('OK');
155
+ console.log(` Log in to your EMR if prompted.`);
156
+ } else {
157
+ console.log(`WARN: ${data.error}`);
158
+ }
159
+ } catch (err) {
160
+ console.log(`WARN: ${err.message}`);
161
+ }
162
+
163
+ // Step 6: Start polling for notes
164
+ const poller = startPoller(config, auth, {
165
+ onNote: (entry) => {
166
+ const patient = entry.patientLabel || entry.patientName || 'Unknown';
167
+ console.log(`\n >> New note received: ${patient} (${entry.noteType || 'daily'})`);
168
+ },
169
+ onError: ({ type, message }) => {
170
+ if (type === 'auth') {
171
+ console.log(`\n >> Auth token expired — restart agent or run with --login`);
172
+ }
173
+ // Suppress poll errors (normal when API is unreachable during beta)
174
+ },
175
+ });
176
+
177
+ // --- Status banner ---
178
+ console.log(`
179
+ ──────────────────────────────────────────
180
+ Agent is running.
181
+
182
+ EMR: ${config.emr}
183
+ Local server: http://localhost:${config.port}
184
+ Polling: every ${config.pollIntervalMs / 1000}s
185
+
186
+ Waiting for notes from Shift Notes...
187
+ Press Ctrl+C to stop.
188
+ ──────────────────────────────────────────
189
+ `);
190
+
191
+ // --- Graceful shutdown ---
192
+ function shutdown() {
193
+ console.log('\n Shutting down...');
194
+ poller.stop();
195
+ agent.close();
196
+ console.log(' Agent stopped.\n');
197
+ process.exit(0);
198
+ }
199
+
200
+ process.on('SIGINT', shutdown);
201
+ process.on('SIGTERM', shutdown);
202
+
203
+ // Keep process alive
204
+ process.stdin.resume();
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "shiftacle-desktop-agent",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Desktop agent that auto-fills your EMR with clinical notes from Shiftacle",
6
+ "bin": {
7
+ "shiftacle-desktop-agent": "bin/cli.mjs"
8
+ },
9
+ "scripts": {
10
+ "start": "node bin/cli.mjs"
11
+ },
12
+ "engines": {
13
+ "node": ">=18"
14
+ },
15
+ "keywords": [
16
+ "emr",
17
+ "ehr",
18
+ "healthcare",
19
+ "clinical-notes",
20
+ "hellonote",
21
+ "webpt",
22
+ "browser-automation",
23
+ "shiftacle"
24
+ ],
25
+ "dependencies": {
26
+ "express": "^5.1.0",
27
+ "playwright": "^1.57.0"
28
+ },
29
+ "files": [
30
+ "bin",
31
+ "src"
32
+ ],
33
+ "license": "UNLICENSED",
34
+ "homepage": "https://shiftacle.com/desktop-agent",
35
+ "author": "Shiftacle <support@shiftacle.com>"
36
+ }
package/src/auth.mjs ADDED
@@ -0,0 +1,125 @@
1
+ import { readFileSync, writeFileSync, existsSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { createInterface } from 'node:readline';
4
+
5
+ const AUTH_FILE = 'auth.json';
6
+
7
+ function decodeJwtPayload(token) {
8
+ try {
9
+ const payload = token.split('.')[1];
10
+ return JSON.parse(Buffer.from(payload, 'base64url').toString());
11
+ } catch {
12
+ return null;
13
+ }
14
+ }
15
+
16
+ function isTokenValid(token) {
17
+ const payload = decodeJwtPayload(token);
18
+ if (!payload?.exp) return false;
19
+ // Valid if > 5 minutes from expiry
20
+ return payload.exp * 1000 > Date.now() + 5 * 60 * 1000;
21
+ }
22
+
23
+ async function promptCredentials() {
24
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
25
+ const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
26
+
27
+ console.log('\n Log in with your Shiftacle account:\n');
28
+ const email = await ask(' Email: ');
29
+
30
+ // Hide password input
31
+ const password = await new Promise((resolve) => {
32
+ process.stdout.write(' Password: ');
33
+ const stdin = process.stdin;
34
+ const wasRaw = stdin.isRaw;
35
+ if (stdin.setRawMode) stdin.setRawMode(true);
36
+ let pw = '';
37
+ const onData = (ch) => {
38
+ const c = ch.toString();
39
+ if (c === '\n' || c === '\r') {
40
+ if (stdin.setRawMode) stdin.setRawMode(wasRaw ?? false);
41
+ stdin.removeListener('data', onData);
42
+ process.stdout.write('\n');
43
+ resolve(pw);
44
+ } else if (c === '\u007f' || c === '\b') {
45
+ if (pw.length > 0) {
46
+ pw = pw.slice(0, -1);
47
+ process.stdout.write('\b \b');
48
+ }
49
+ } else if (c === '\u0003') {
50
+ // Ctrl+C
51
+ process.exit(1);
52
+ } else {
53
+ pw += c;
54
+ process.stdout.write('*');
55
+ }
56
+ };
57
+ stdin.on('data', onData);
58
+ stdin.resume();
59
+ });
60
+
61
+ rl.close();
62
+ return { email, password };
63
+ }
64
+
65
+ async function refreshToken(apiBase, refreshTok) {
66
+ try {
67
+ const res = await fetch(`${apiBase}/auth/refresh`, {
68
+ method: 'POST',
69
+ headers: { 'Content-Type': 'application/json' },
70
+ body: JSON.stringify({ refreshToken: refreshTok }),
71
+ });
72
+ if (!res.ok) return null;
73
+ return await res.json();
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ export async function authenticate(config, forceLogin = false) {
80
+ const authPath = join(config.configDir, AUTH_FILE);
81
+
82
+ if (!forceLogin && existsSync(authPath)) {
83
+ try {
84
+ const stored = JSON.parse(readFileSync(authPath, 'utf8'));
85
+ if (stored.token && isTokenValid(stored.token)) {
86
+ return stored;
87
+ }
88
+ // Try refresh
89
+ if (stored.refreshToken) {
90
+ const refreshed = await refreshToken(config.apiBase, stored.refreshToken);
91
+ if (refreshed?.token) {
92
+ const data = { ...stored, ...refreshed };
93
+ writeFileSync(authPath, JSON.stringify(data), { mode: 0o600 });
94
+ return data;
95
+ }
96
+ }
97
+ } catch {
98
+ // Corrupted auth file — fall through to login
99
+ }
100
+ }
101
+
102
+ // Interactive login
103
+ const { email, password } = await promptCredentials();
104
+
105
+ const res = await fetch(`${config.apiBase}/auth/login`, {
106
+ method: 'POST',
107
+ headers: { 'Content-Type': 'application/json' },
108
+ body: JSON.stringify({ email, password }),
109
+ });
110
+
111
+ if (!res.ok) {
112
+ const body = await res.text().catch(() => '');
113
+ throw new Error(`Login failed (${res.status}): ${body || 'Invalid credentials'}`);
114
+ }
115
+
116
+ const data = await res.json();
117
+ if (!data.token) throw new Error('Login response missing token');
118
+
119
+ writeFileSync(authPath, JSON.stringify(data), { mode: 0o600 });
120
+ return data;
121
+ }
122
+
123
+ export function getAuthHeaders(auth) {
124
+ return { Authorization: `Bearer ${auth.token}` };
125
+ }
package/src/config.mjs ADDED
@@ -0,0 +1,41 @@
1
+ import { homedir } from 'os';
2
+ import { join } from 'path';
3
+ import { existsSync, mkdirSync } from 'fs';
4
+
5
+ export const DEFAULTS = {
6
+ visionUrl: 'https://vision.shiftacle.com/v1',
7
+ visionModel: 'Qwen/Qwen3-VL-8B-Instruct',
8
+ apiBase: 'https://api.shiftacle.com/api/v1',
9
+ sessionDir: join(homedir(), '.shiftacle-emr-sessions'),
10
+ configDir: join(homedir(), '.shiftacle'),
11
+ port: 3847,
12
+ emr: 'hellonote',
13
+ pollIntervalMs: 5000,
14
+ emrUrls: {
15
+ hellonote: 'https://app.hellonote.com',
16
+ webpt: 'https://app.webpt.com',
17
+ nethealth: 'https://app.nethealth.com',
18
+ },
19
+ };
20
+
21
+ export function getConfig(cliFlags = {}) {
22
+ const config = {
23
+ visionUrl: cliFlags['vision-url'] || process.env.VISION_URL || DEFAULTS.visionUrl,
24
+ visionModel: process.env.VISION_MODEL || DEFAULTS.visionModel,
25
+ apiBase: process.env.SHIFTACLE_API_URL || DEFAULTS.apiBase,
26
+ port: parseInt(cliFlags.port || process.env.EMR_AGENT_PORT || DEFAULTS.port, 10),
27
+ emr: cliFlags.emr || DEFAULTS.emr,
28
+ headless: cliFlags.headless || false,
29
+ sessionDir: DEFAULTS.sessionDir,
30
+ configDir: DEFAULTS.configDir,
31
+ pollIntervalMs: parseInt(process.env.POLL_INTERVAL || DEFAULTS.pollIntervalMs, 10),
32
+ emrUrls: DEFAULTS.emrUrls,
33
+ };
34
+ return config;
35
+ }
36
+
37
+ export function ensureDirs(config) {
38
+ for (const dir of [config.sessionDir, config.configDir]) {
39
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
40
+ }
41
+ }
package/src/poller.mjs ADDED
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Shiftacle API Poller
3
+ *
4
+ * Polls the Shiftacle API for pending EMR exports.
5
+ * When a note arrives (from mobile "Send to EMR"), forwards it
6
+ * to the local vision-guided agent for auto-fill.
7
+ */
8
+
9
+ export function startPoller(config, auth, { onNote, onError } = {}) {
10
+ let running = true;
11
+ let interval = config.pollIntervalMs;
12
+ let consecutiveErrors = 0;
13
+ let timer = null;
14
+
15
+ async function poll() {
16
+ if (!running) return;
17
+
18
+ try {
19
+ const res = await fetch(`${config.apiBase}/emr-pipeline/queue?status=approved&limit=5`, {
20
+ headers: {
21
+ Authorization: `Bearer ${auth.token}`,
22
+ 'Content-Type': 'application/json',
23
+ },
24
+ });
25
+
26
+ if (res.status === 401) {
27
+ // Token expired — caller should re-auth
28
+ onError?.({ type: 'auth', message: 'Token expired' });
29
+ interval = 30000; // slow down, wait for re-auth
30
+ scheduleNext();
31
+ return;
32
+ }
33
+
34
+ if (!res.ok) throw new Error(`API returned ${res.status}`);
35
+
36
+ const body = await res.json();
37
+ const pending = body.data || body.exports || [];
38
+
39
+ for (const entry of pending) {
40
+ try {
41
+ onNote?.(entry);
42
+
43
+ // Forward to local agent
44
+ const fillRes = await fetch(`http://localhost:${config.port}/auto-fill`, {
45
+ method: 'POST',
46
+ headers: { 'Content-Type': 'application/json' },
47
+ body: JSON.stringify({
48
+ data: {
49
+ soap: entry.structuredNote?.soap || entry.soap || {},
50
+ billing: entry.structuredNote?.billing || entry.billing || {},
51
+ patientName: entry.patientLabel || entry.patientName,
52
+ },
53
+ patientName: entry.patientLabel || entry.patientName,
54
+ noteType: entry.noteType || 'daily',
55
+ visitDate: entry.visitDate || new Date().toLocaleDateString(),
56
+ emrType: entry.exportTarget || config.emr,
57
+ }),
58
+ });
59
+
60
+ const result = await fillRes.json();
61
+
62
+ // Report result back to Shiftacle API
63
+ await fetch(`${config.apiBase}/emr-pipeline/queue/${entry._id || entry.id}/export`, {
64
+ method: 'POST',
65
+ headers: {
66
+ 'Content-Type': 'application/json',
67
+ Authorization: `Bearer ${auth.token}`,
68
+ },
69
+ body: JSON.stringify({
70
+ target: entry.exportTarget || config.emr,
71
+ status: result.success ? 'exported' : 'failed',
72
+ error: result.error,
73
+ steps: result.steps,
74
+ duration: result.duration,
75
+ }),
76
+ });
77
+ } catch (err) {
78
+ onError?.({ type: 'fill', entry, message: err.message });
79
+ }
80
+ }
81
+
82
+ consecutiveErrors = 0;
83
+ interval = config.pollIntervalMs;
84
+ } catch (err) {
85
+ consecutiveErrors++;
86
+ // Exponential backoff: 5s, 10s, 20s, 40s, max 60s
87
+ interval = Math.min(config.pollIntervalMs * 2 ** consecutiveErrors, 60000);
88
+ onError?.({ type: 'poll', message: err.message, retryIn: interval });
89
+ }
90
+
91
+ scheduleNext();
92
+ }
93
+
94
+ function scheduleNext() {
95
+ if (running) {
96
+ timer = setTimeout(poll, interval);
97
+ }
98
+ }
99
+
100
+ // Start first poll
101
+ poll();
102
+
103
+ return {
104
+ stop() {
105
+ running = false;
106
+ if (timer) clearTimeout(timer);
107
+ },
108
+ };
109
+ }
package/src/server.mjs ADDED
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Shiftacle Desktop Agent — Local API Server
3
+ *
4
+ * Runs on the clinician's computer. Receives notes from the
5
+ * Shiftacle mobile app and auto-fills them into the EMR using
6
+ * vision-guided browser automation.
7
+ */
8
+
9
+ import express from 'express';
10
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
11
+ import { join } from 'path';
12
+ import VisionNavigator from './vision-navigator.mjs';
13
+
14
+ export function startServer(config) {
15
+ const app = express();
16
+ app.use(express.json({ limit: '10mb' }));
17
+
18
+ // CORS for local use
19
+ app.use((req, res, next) => {
20
+ res.header('Access-Control-Allow-Origin', '*');
21
+ res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
22
+ res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
23
+ if (req.method === 'OPTIONS') return res.sendStatus(200);
24
+ next();
25
+ });
26
+
27
+ if (!existsSync(config.sessionDir)) {
28
+ mkdirSync(config.sessionDir, { recursive: true });
29
+ }
30
+
31
+ // Active browser sessions
32
+ const navigators = new Map();
33
+
34
+ // Health check
35
+ app.get('/health', (req, res) => {
36
+ res.json({
37
+ status: 'ok',
38
+ engine: 'shiftacle-desktop-agent',
39
+ version: '0.1.0',
40
+ activeSessions: navigators.size,
41
+ visionModel: config.visionModel,
42
+ emr: config.emr,
43
+ });
44
+ });
45
+
46
+ // Start a new EMR session (opens browser)
47
+ app.post('/session/start', async (req, res) => {
48
+ const { emrType = config.emr, loginUrl } = req.body;
49
+ const sessionId = `emr_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
50
+
51
+ try {
52
+ const nav = new VisionNavigator({
53
+ visionUrl: config.visionUrl,
54
+ visionModel: config.visionModel,
55
+ onStep: (step) =>
56
+ console.log(
57
+ ` [${sessionId}] Step ${step.step}: ${step.action} — ${(step.observation || '').substring(0, 80)}`,
58
+ ),
59
+ });
60
+
61
+ const sessionFile = join(config.sessionDir, `${emrType}.json`);
62
+ const storageState = existsSync(sessionFile) ? sessionFile : undefined;
63
+
64
+ await nav.launch({ headless: config.headless, storageState });
65
+
66
+ const url = loginUrl || config.emrUrls[emrType] || config.emrUrls.hellonote;
67
+ await nav.page.goto(url, { waitUntil: 'domcontentloaded' });
68
+
69
+ navigators.set(sessionId, { nav, emrType, createdAt: Date.now() });
70
+
71
+ // Save session after user has time to log in
72
+ setTimeout(async () => {
73
+ try {
74
+ await nav.saveSession(sessionFile);
75
+ } catch {}
76
+ }, 15000);
77
+
78
+ res.json({
79
+ success: true,
80
+ sessionId,
81
+ emrType,
82
+ message: 'Browser opened — log in to your EMR if needed',
83
+ });
84
+ } catch (err) {
85
+ res.status(500).json({ success: false, error: err.message });
86
+ }
87
+ });
88
+
89
+ // List active sessions
90
+ app.get('/sessions', (req, res) => {
91
+ const sessions = [];
92
+ for (const [id, sess] of navigators) {
93
+ sessions.push({
94
+ sessionId: id,
95
+ emrType: sess.emrType,
96
+ age: Math.round((Date.now() - sess.createdAt) / 60000) + ' min',
97
+ });
98
+ }
99
+ res.json({ success: true, sessions });
100
+ });
101
+
102
+ // Fill a note into the current EMR session
103
+ app.post('/fill', async (req, res) => {
104
+ const { sessionId, data, patientName, noteType, visitDate } = req.body;
105
+
106
+ let nav;
107
+ if (sessionId && navigators.has(sessionId)) {
108
+ nav = navigators.get(sessionId).nav;
109
+ } else {
110
+ const entries = [...navigators.entries()];
111
+ if (entries.length > 0) {
112
+ nav = entries[entries.length - 1][1].nav;
113
+ } else {
114
+ return res.status(400).json({
115
+ success: false,
116
+ error: 'No active EMR session. Start one first: POST /session/start',
117
+ });
118
+ }
119
+ }
120
+
121
+ try {
122
+ console.log(' [EMR-Fill] Starting vision-guided note fill...');
123
+ const result = await nav.fillNote({
124
+ soap: data?.soap || {},
125
+ billing: data?.billing || {},
126
+ patientName: patientName || data?.patientName,
127
+ noteType: noteType || data?.noteType || 'daily',
128
+ visitDate: visitDate || data?.visitDate || new Date().toLocaleDateString(),
129
+ });
130
+
131
+ console.log(
132
+ ` [EMR-Fill] ${result.success ? 'SUCCESS' : 'FAILED'} in ${result.steps} steps (${Math.round(result.duration / 1000)}s)`,
133
+ );
134
+
135
+ if (result.success) {
136
+ try {
137
+ const emrType = navigators.values().next().value?.emrType || config.emr;
138
+ await nav.saveSession(join(config.sessionDir, `${emrType}.json`));
139
+ } catch {}
140
+ }
141
+
142
+ res.json({
143
+ success: result.success,
144
+ steps: result.steps,
145
+ duration: result.duration,
146
+ error: result.error,
147
+ history: result.history?.slice(-5),
148
+ });
149
+ } catch (err) {
150
+ res.status(500).json({ success: false, error: err.message });
151
+ }
152
+ });
153
+
154
+ // Auto-fill: open browser if needed, navigate, fill
155
+ app.post('/auto-fill', async (req, res) => {
156
+ const { data, patientName, noteType, visitDate, emrType = config.emr } = req.body;
157
+
158
+ try {
159
+ const nav = new VisionNavigator({
160
+ visionUrl: config.visionUrl,
161
+ visionModel: config.visionModel,
162
+ maxSteps: 40,
163
+ onStep: (step) =>
164
+ console.log(
165
+ ` [auto-fill] Step ${step.step}/${step.total}: ${step.action} — ${(step.observation || '').substring(0, 60)}`,
166
+ ),
167
+ });
168
+
169
+ const sessionFile = join(config.sessionDir, `${emrType}.json`);
170
+ await nav.launch({
171
+ headless: config.headless,
172
+ storageState: existsSync(sessionFile) ? sessionFile : undefined,
173
+ });
174
+
175
+ const url = config.emrUrls[emrType] || config.emrUrls.hellonote;
176
+ await nav.page.goto(url, { waitUntil: 'domcontentloaded' });
177
+ await nav.page.waitForTimeout(2000);
178
+
179
+ const result = await nav.fillNote({
180
+ soap: data?.soap || {},
181
+ billing: data?.billing || {},
182
+ patientName,
183
+ noteType: noteType || 'daily',
184
+ visitDate: visitDate || new Date().toLocaleDateString(),
185
+ });
186
+
187
+ if (result.success) {
188
+ try {
189
+ await nav.saveSession(sessionFile);
190
+ } catch {}
191
+ }
192
+
193
+ const newId = `autofill_${Date.now()}`;
194
+ navigators.set(newId, { nav, emrType, createdAt: Date.now() });
195
+
196
+ res.json({
197
+ success: result.success,
198
+ sessionId: newId,
199
+ steps: result.steps,
200
+ duration: result.duration,
201
+ error: result.error,
202
+ history: result.history?.slice(-5),
203
+ });
204
+ } catch (err) {
205
+ res.status(500).json({ success: false, error: err.message });
206
+ }
207
+ });
208
+
209
+ // Screenshot of a session
210
+ app.get('/screenshot/:sessionId', async (req, res) => {
211
+ const sess = navigators.get(req.params.sessionId);
212
+ if (!sess) return res.status(404).json({ error: 'Session not found' });
213
+
214
+ try {
215
+ const buffer = await sess.nav.page.screenshot({ type: 'jpeg', quality: 80 });
216
+ res.set('Content-Type', 'image/jpeg');
217
+ res.send(buffer);
218
+ } catch (err) {
219
+ res.status(500).json({ error: err.message });
220
+ }
221
+ });
222
+
223
+ // Close a session
224
+ app.delete('/session/:sessionId', async (req, res) => {
225
+ const sess = navigators.get(req.params.sessionId);
226
+ if (!sess) return res.status(404).json({ error: 'Session not found' });
227
+
228
+ await sess.nav.close();
229
+ navigators.delete(req.params.sessionId);
230
+ res.json({ success: true });
231
+ });
232
+
233
+ // Cleanup expired sessions every 30 minutes
234
+ const cleanupTimer = setInterval(() => {
235
+ const maxAge = 24 * 60 * 60 * 1000;
236
+ for (const [id, sess] of navigators) {
237
+ if (Date.now() - sess.createdAt > maxAge) {
238
+ sess.nav.close().catch(() => {});
239
+ navigators.delete(id);
240
+ console.log(` [Cleanup] Closed expired session ${id}`);
241
+ }
242
+ }
243
+ }, 30 * 60 * 1000);
244
+
245
+ const server = app.listen(config.port);
246
+
247
+ return {
248
+ app,
249
+ server,
250
+ navigators,
251
+ close() {
252
+ clearInterval(cleanupTimer);
253
+ for (const [, sess] of navigators) {
254
+ sess.nav.close().catch(() => {});
255
+ }
256
+ navigators.clear();
257
+ server.close();
258
+ },
259
+ };
260
+ }
@@ -0,0 +1,303 @@
1
+ /**
2
+ * VisionNavigator — AI-guided EMR browser automation
3
+ *
4
+ * Instead of fragile CSS selectors, this agent:
5
+ * 1. Takes a screenshot of the current page
6
+ * 2. Sends it to a vision model (Qwen3-VL)
7
+ * 3. The model identifies elements and returns coordinates/actions
8
+ * 4. Playwright executes the action
9
+ * 5. Repeat until the task is done
10
+ *
11
+ * Works on ANY web-based EMR — HelloNote, WebPT, Epic, Cerner —
12
+ * because it "sees" the screen like a human.
13
+ */
14
+
15
+ import { chromium } from 'playwright';
16
+
17
+ const MAX_STEPS = 30;
18
+ const STEP_DELAY = 1000;
19
+
20
+ export default class VisionNavigator {
21
+ constructor(options = {}) {
22
+ this.browser = null;
23
+ this.page = null;
24
+ this.visionUrl = options.visionUrl || 'https://vision.shiftacle.com/v1';
25
+ this.visionModel = options.visionModel || 'Qwen/Qwen3-VL-8B-Instruct';
26
+ this.maxSteps = options.maxSteps || MAX_STEPS;
27
+ this.stepDelay = options.stepDelay || STEP_DELAY;
28
+ this.history = [];
29
+ this.onStep = options.onStep || (() => {});
30
+ }
31
+
32
+ async launch(options = {}) {
33
+ this.browser = await chromium.launch({
34
+ headless: options.headless !== undefined ? options.headless : false,
35
+ args: ['--window-size=1280,900'],
36
+ });
37
+ const context = await this.browser.newContext({
38
+ viewport: { width: 1280, height: 900 },
39
+ ...(options.storageState ? { storageState: options.storageState } : {}),
40
+ });
41
+ this.page = await context.newPage();
42
+ return this.page;
43
+ }
44
+
45
+ async screenshot() {
46
+ const buffer = await this.page.screenshot({ type: 'jpeg', quality: 80 });
47
+ return buffer.toString('base64');
48
+ }
49
+
50
+ async think(task, context = '') {
51
+ const imageBase64 = await this.screenshot();
52
+ const currentUrl = this.page.url();
53
+
54
+ const systemPrompt = `You are an AI browser agent navigating a healthcare EMR (Electronic Medical Records) system. You can see a screenshot of the current page.
55
+
56
+ Your job is to complete the given task by identifying UI elements and telling the system what to click, type, or do next.
57
+
58
+ RULES:
59
+ - Describe what you see on the page first (briefly)
60
+ - Identify the EXACT element to interact with
61
+ - Give precise coordinates (x, y) for clicks — estimate from the screenshot
62
+ - For text input: specify the field coordinates AND the text to type
63
+ - Only do ONE action per step
64
+ - If you see a loading spinner or modal, wait
65
+ - If you're on the wrong page, navigate back or to the correct URL
66
+ - If login is required, say so — don't guess credentials
67
+ - If you're stuck after 3 attempts on the same step, report failure
68
+
69
+ AVAILABLE ACTIONS:
70
+ - click(x, y) — click at coordinates
71
+ - type(x, y, "text") — click field at coordinates and type text
72
+ - scroll(direction, amount) — scroll "up" or "down" by pixels
73
+ - wait(ms) — wait for page to load
74
+ - navigate(url) — go to a URL
75
+ - select(x, y, "option") — click dropdown at coordinates, then click option
76
+ - done() — task is complete
77
+ - fail(reason) — cannot complete the task
78
+
79
+ Current URL: ${currentUrl}
80
+ ${context ? `Context: ${context}` : ''}
81
+
82
+ Return JSON:
83
+ {
84
+ "observation": "what I see on the page",
85
+ "reasoning": "why I'm taking this action",
86
+ "action": "click|type|scroll|wait|navigate|select|done|fail",
87
+ "params": { "x": 640, "y": 450, "text": "optional", "url": "optional", "direction": "optional", "amount": "optional", "reason": "optional" }
88
+ }`;
89
+
90
+ const response = await fetch(`${this.visionUrl}/chat/completions`, {
91
+ method: 'POST',
92
+ headers: { 'Content-Type': 'application/json' },
93
+ body: JSON.stringify({
94
+ model: this.visionModel,
95
+ messages: [
96
+ { role: 'system', content: systemPrompt },
97
+ {
98
+ role: 'user',
99
+ content: [
100
+ { type: 'image_url', image_url: { url: `data:image/jpeg;base64,${imageBase64}` } },
101
+ { type: 'text', text: `TASK: ${task}\n\nWhat do you see and what should I do next?` },
102
+ ],
103
+ },
104
+ ],
105
+ max_tokens: 500,
106
+ temperature: 0.1,
107
+ response_format: { type: 'json_object' },
108
+ }),
109
+ });
110
+
111
+ if (!response.ok) throw new Error(`Vision model error: ${response.status}`);
112
+
113
+ const result = await response.json();
114
+ const content = result.choices?.[0]?.message?.content;
115
+
116
+ try {
117
+ return JSON.parse(content);
118
+ } catch {
119
+ const match = content?.match(/\{[\s\S]*\}/);
120
+ if (match) return JSON.parse(match[0]);
121
+ throw new Error('Vision model returned non-JSON');
122
+ }
123
+ }
124
+
125
+ async executeAction(action) {
126
+ const { action: type, params } = action;
127
+
128
+ switch (type) {
129
+ case 'click':
130
+ await this.page.mouse.click(params.x, params.y);
131
+ break;
132
+ case 'type':
133
+ await this.page.mouse.click(params.x, params.y);
134
+ await this.page.waitForTimeout(200);
135
+ await this.page.keyboard.press('Control+a');
136
+ await this.page.keyboard.type(params.text, { delay: 30 });
137
+ break;
138
+ case 'scroll':
139
+ await this.page.mouse.wheel(
140
+ 0,
141
+ params.direction === 'up' ? -(params.amount || 300) : (params.amount || 300),
142
+ );
143
+ break;
144
+ case 'wait':
145
+ await this.page.waitForTimeout(params.ms || 2000);
146
+ break;
147
+ case 'navigate':
148
+ await this.page.goto(params.url, { waitUntil: 'domcontentloaded' });
149
+ break;
150
+ case 'select':
151
+ await this.page.mouse.click(params.x, params.y);
152
+ await this.page.waitForTimeout(500);
153
+ if (params.text) await this.page.keyboard.type(params.text, { delay: 50 });
154
+ await this.page.waitForTimeout(300);
155
+ await this.page.keyboard.press('Enter');
156
+ break;
157
+ case 'done':
158
+ case 'fail':
159
+ break;
160
+ default:
161
+ throw new Error(`Unknown action: ${type}`);
162
+ }
163
+
164
+ await this.page.waitForTimeout(this.stepDelay);
165
+ }
166
+
167
+ async runTask(task, context = '') {
168
+ const startTime = Date.now();
169
+ let stuckCount = 0;
170
+ let lastObservation = '';
171
+
172
+ for (let step = 0; step < this.maxSteps; step++) {
173
+ try {
174
+ const decision = await this.think(task, context);
175
+
176
+ this.history.push({
177
+ step: step + 1,
178
+ observation: decision.observation,
179
+ reasoning: decision.reasoning,
180
+ action: decision.action,
181
+ params: decision.params,
182
+ timestamp: Date.now(),
183
+ });
184
+
185
+ this.onStep({
186
+ step: step + 1,
187
+ total: this.maxSteps,
188
+ action: decision.action,
189
+ observation: decision.observation,
190
+ });
191
+
192
+ if (decision.observation === lastObservation) {
193
+ stuckCount++;
194
+ if (stuckCount >= 3) {
195
+ return {
196
+ success: false,
197
+ steps: step + 1,
198
+ history: this.history,
199
+ error: 'Agent stuck — same observation 3 times',
200
+ duration: Date.now() - startTime,
201
+ };
202
+ }
203
+ } else {
204
+ stuckCount = 0;
205
+ }
206
+ lastObservation = decision.observation;
207
+
208
+ if (decision.action === 'done') {
209
+ return { success: true, steps: step + 1, history: this.history, duration: Date.now() - startTime };
210
+ }
211
+ if (decision.action === 'fail') {
212
+ return {
213
+ success: false,
214
+ steps: step + 1,
215
+ history: this.history,
216
+ error: decision.params?.reason || 'Agent reported failure',
217
+ duration: Date.now() - startTime,
218
+ };
219
+ }
220
+
221
+ await this.executeAction(decision);
222
+ } catch (err) {
223
+ this.history.push({ step: step + 1, error: err.message, timestamp: Date.now() });
224
+ if (step > this.maxSteps - 3) {
225
+ return {
226
+ success: false,
227
+ steps: step + 1,
228
+ history: this.history,
229
+ error: err.message,
230
+ duration: Date.now() - startTime,
231
+ };
232
+ }
233
+ }
234
+ }
235
+
236
+ return {
237
+ success: false,
238
+ steps: this.maxSteps,
239
+ history: this.history,
240
+ error: 'Max steps reached',
241
+ duration: Date.now() - startTime,
242
+ };
243
+ }
244
+
245
+ async fillNote(noteData, emrConfig = {}) {
246
+ const emrType = emrConfig.type || 'hellonote';
247
+ const { soap, billing, patientName, noteType, visitDate } = noteData;
248
+
249
+ const noteContext = `
250
+ EMR Type: ${emrType}
251
+ Patient: ${patientName || 'Unknown'}
252
+ Visit Date: ${visitDate || 'Today'}
253
+ Note Type: ${noteType || 'Daily Note'}
254
+
255
+ SOAP NOTE TO ENTER:
256
+ SUBJECTIVE: ${soap?.subjective || '[none]'}
257
+ OBJECTIVE: ${soap?.objective || '[none]'}
258
+ ASSESSMENT: ${soap?.assessment || '[none]'}
259
+ PLAN: ${soap?.plan || '[none]'}
260
+
261
+ ${billing?.cpt_codes ? `BILLING CODES: ${Object.entries(billing.cpt_codes).map(([code, info]) => `${code}: ${info.minutes || 15} min, ${info.units || 1} unit`).join(', ')}` : ''}
262
+ `;
263
+
264
+ let task;
265
+ if (patientName) {
266
+ task = `In this ${emrType} EMR system:
267
+ 1. Find patient "${patientName}" using the search/patient list
268
+ 2. Open or create a ${noteType || 'daily'} note for ${visitDate || 'today'}
269
+ 3. Fill in the SUBJECTIVE, OBJECTIVE, ASSESSMENT, and PLAN sections with the provided text
270
+ 4. If there's a billing section, enter the CPT codes and minutes
271
+ 5. Save as draft (do NOT finalize/sign)
272
+ 6. Report done when complete`;
273
+ } else {
274
+ task = `In this ${emrType} EMR system, I'm already on a note form. Fill in:
275
+ 1. The SUBJECTIVE section with the provided text
276
+ 2. The OBJECTIVE section
277
+ 3. The ASSESSMENT section
278
+ 4. The PLAN section
279
+ 5. If there's a billing section, enter the CPT codes
280
+ 6. Save as draft
281
+ 7. Report done when complete`;
282
+ }
283
+
284
+ return this.runTask(task, noteContext);
285
+ }
286
+
287
+ async saveSession(path) {
288
+ if (this.page) {
289
+ const { writeFileSync } = await import('fs');
290
+ const state = await this.page.context().storageState();
291
+ writeFileSync(path, JSON.stringify(state));
292
+ return path;
293
+ }
294
+ }
295
+
296
+ async close() {
297
+ if (this.browser) {
298
+ await this.browser.close();
299
+ this.browser = null;
300
+ this.page = null;
301
+ }
302
+ }
303
+ }