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 +204 -0
- package/package.json +36 -0
- package/src/auth.mjs +125 -0
- package/src/config.mjs +41 -0
- package/src/poller.mjs +109 -0
- package/src/server.mjs +260 -0
- package/src/vision-navigator.mjs +303 -0
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
|
+
}
|