screenhand 0.1.1 → 0.3.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/README.md +193 -109
- package/bin/darwin-arm64/macos-bridge +0 -0
- package/dist/mcp-desktop.js +5876 -0
- package/dist/scripts/codex-monitor-daemon.js +335 -0
- package/dist/scripts/export-help-center.js +112 -0
- package/dist/scripts/marketing-loop.js +117 -0
- package/dist/scripts/observer-daemon.js +288 -0
- package/dist/scripts/orchestrator-daemon.js +399 -0
- package/dist/scripts/supervisor-daemon.js +272 -0
- package/dist/scripts/threads-campaign.js +208 -0
- package/dist/scripts/worker-daemon.js +228 -0
- package/dist/src/agent/cli.js +82 -0
- package/dist/src/agent/loop.js +274 -0
- package/dist/src/community/fetcher.js +109 -0
- package/dist/src/community/index.js +6 -0
- package/dist/src/community/publisher.js +191 -0
- package/dist/src/community/remote-api.js +121 -0
- package/dist/src/community/types.js +3 -0
- package/dist/src/community/validator.js +95 -0
- package/{src/config.ts → dist/src/config.js} +5 -10
- package/dist/src/context-tracker.js +489 -0
- package/{src/index.ts → dist/src/index.js} +32 -52
- package/dist/src/ingestion/coverage-auditor.js +233 -0
- package/dist/src/ingestion/doc-parser.js +164 -0
- package/dist/src/ingestion/index.js +8 -0
- package/dist/src/ingestion/menu-scanner.js +152 -0
- package/dist/src/ingestion/reference-merger.js +186 -0
- package/dist/src/ingestion/shortcut-extractor.js +180 -0
- package/dist/src/ingestion/tutorial-extractor.js +170 -0
- package/dist/src/ingestion/types.js +3 -0
- package/dist/src/jobs/manager.js +305 -0
- package/dist/src/jobs/runner.js +806 -0
- package/dist/src/jobs/store.js +102 -0
- package/dist/src/jobs/types.js +30 -0
- package/dist/src/jobs/worker.js +97 -0
- package/dist/src/learning/engine.js +356 -0
- package/dist/src/learning/index.js +9 -0
- package/dist/src/learning/locator-policy.js +120 -0
- package/dist/src/learning/pattern-policy.js +89 -0
- package/dist/src/learning/recovery-policy.js +116 -0
- package/dist/src/learning/sensor-policy.js +115 -0
- package/dist/src/learning/timing-model.js +204 -0
- package/dist/src/learning/topology-policy.js +90 -0
- package/dist/src/learning/types.js +9 -0
- package/dist/src/logging/timeline-logger.js +48 -0
- package/dist/src/mcp/mcp-stdio-server.js +464 -0
- package/dist/src/mcp/server.js +363 -0
- package/dist/src/mcp-entry.js +60 -0
- package/dist/src/memory/playbook-seeds.js +200 -0
- package/dist/src/memory/recall.js +222 -0
- package/dist/src/memory/research.js +104 -0
- package/dist/src/memory/seeds.js +101 -0
- package/dist/src/memory/service.js +446 -0
- package/dist/src/memory/session.js +169 -0
- package/dist/src/memory/store.js +451 -0
- package/{src/runtime/locator-cache.ts → dist/src/memory/types.js} +1 -17
- package/dist/src/monitor/codex-monitor.js +382 -0
- package/dist/src/monitor/task-queue.js +97 -0
- package/dist/src/monitor/types.js +62 -0
- package/dist/src/native/bridge-client.js +412 -0
- package/{src/native/macos-bridge-client.ts → dist/src/native/macos-bridge-client.js} +0 -1
- package/dist/src/observer/state.js +199 -0
- package/dist/src/observer/types.js +43 -0
- package/dist/src/orchestrator/state.js +68 -0
- package/dist/src/orchestrator/types.js +22 -0
- package/dist/src/perception/ax-source.js +162 -0
- package/dist/src/perception/cdp-source.js +162 -0
- package/dist/src/perception/coordinator.js +771 -0
- package/dist/src/perception/frame-differ.js +287 -0
- package/dist/src/perception/index.js +22 -0
- package/dist/src/perception/manager.js +199 -0
- package/dist/src/perception/types.js +47 -0
- package/dist/src/perception/vision-source.js +399 -0
- package/dist/src/planner/deterministic.js +298 -0
- package/dist/src/planner/executor.js +870 -0
- package/dist/src/planner/goal-store.js +92 -0
- package/dist/src/planner/index.js +21 -0
- package/dist/src/planner/planner.js +520 -0
- package/dist/src/planner/tool-registry.js +71 -0
- package/dist/src/planner/types.js +22 -0
- package/dist/src/platform/explorer.js +213 -0
- package/dist/src/platform/help-center-markdown.js +527 -0
- package/dist/src/platform/learner.js +257 -0
- package/dist/src/playbook/engine.js +486 -0
- package/dist/src/playbook/index.js +20 -0
- package/dist/src/playbook/mcp-recorder.js +204 -0
- package/dist/src/playbook/recorder.js +536 -0
- package/dist/src/playbook/runner.js +408 -0
- package/dist/src/playbook/store.js +312 -0
- package/dist/src/playbook/types.js +17 -0
- package/dist/src/recovery/detectors.js +156 -0
- package/dist/src/recovery/engine.js +327 -0
- package/dist/src/recovery/index.js +20 -0
- package/dist/src/recovery/strategies.js +274 -0
- package/dist/src/recovery/types.js +20 -0
- package/dist/src/runtime/accessibility-adapter.js +430 -0
- package/dist/src/runtime/app-adapter.js +64 -0
- package/dist/src/runtime/applescript-adapter.js +305 -0
- package/dist/src/runtime/ax-role-map.js +96 -0
- package/dist/src/runtime/browser-adapter.js +52 -0
- package/dist/src/runtime/cdp-chrome-adapter.js +521 -0
- package/dist/src/runtime/composite-adapter.js +221 -0
- package/dist/src/runtime/execution-contract.js +159 -0
- package/dist/src/runtime/executor.js +286 -0
- package/dist/src/runtime/locator-cache.js +50 -0
- package/dist/src/runtime/planning-loop.js +63 -0
- package/dist/src/runtime/service.js +432 -0
- package/dist/src/runtime/session-manager.js +63 -0
- package/dist/src/runtime/state-observer.js +121 -0
- package/dist/src/runtime/vision-adapter.js +225 -0
- package/dist/src/state/app-map-types.js +72 -0
- package/dist/src/state/app-map.js +1974 -0
- package/dist/src/state/entity-tracker.js +108 -0
- package/dist/src/state/fusion.js +96 -0
- package/dist/src/state/index.js +21 -0
- package/dist/src/state/ladder-generator.js +236 -0
- package/dist/src/state/persistence.js +156 -0
- package/dist/src/state/types.js +17 -0
- package/dist/src/state/world-model.js +1456 -0
- package/dist/src/supervisor/locks.js +186 -0
- package/dist/src/supervisor/supervisor.js +403 -0
- package/dist/src/supervisor/types.js +30 -0
- package/dist/src/test-mcp-protocol.js +154 -0
- package/dist/src/types.js +17 -0
- package/dist/src/util/atomic-write.js +133 -0
- package/dist/src/util/sanitize.js +146 -0
- package/dist-app-maps/com.figma.Desktop.json +959 -0
- package/dist-app-maps/com.hnc.Discord.json +1146 -0
- package/dist-app-maps/notion.id.json +2831 -0
- package/dist-playbooks/canva-screenhand-carousel.json +445 -0
- package/dist-playbooks/codex-desktop.json +76 -0
- package/dist-playbooks/competitor-research-stack.json +122 -0
- package/dist-playbooks/davinci-color-grade.json +153 -0
- package/dist-playbooks/davinci-edit-timeline.json +162 -0
- package/dist-playbooks/davinci-render.json +114 -0
- package/dist-playbooks/devto.json +52 -0
- package/dist-playbooks/discord.json +41 -0
- package/dist-playbooks/google-flow-create-project.json +59 -0
- package/dist-playbooks/google-flow-edit-image.json +90 -0
- package/dist-playbooks/google-flow-edit-video.json +90 -0
- package/dist-playbooks/google-flow-generate-image.json +68 -0
- package/dist-playbooks/google-flow-generate-video.json +191 -0
- package/dist-playbooks/google-flow-open-project.json +48 -0
- package/dist-playbooks/google-flow-open-scenebuilder.json +64 -0
- package/dist-playbooks/google-flow-search-assets.json +64 -0
- package/dist-playbooks/instagram.json +57 -0
- package/dist-playbooks/linkedin.json +52 -0
- package/dist-playbooks/n8n.json +43 -0
- package/dist-playbooks/reddit.json +52 -0
- package/dist-playbooks/threads.json +59 -0
- package/dist-playbooks/x-twitter.json +59 -0
- package/dist-playbooks/youtube.json +59 -0
- package/dist-references/canva.json +646 -0
- package/dist-references/codex-desktop.json +305 -0
- package/dist-references/davinci-resolve-keyboard.json +594 -0
- package/dist-references/davinci-resolve-menu-map.json +1139 -0
- package/dist-references/davinci-resolve-menus-batch1.json +116 -0
- package/dist-references/davinci-resolve-menus-batch2.json +372 -0
- package/dist-references/davinci-resolve-menus-batch3.json +330 -0
- package/dist-references/davinci-resolve-menus-batch4.json +297 -0
- package/dist-references/davinci-resolve-shortcuts.json +333 -0
- package/dist-references/devto.json +317 -0
- package/dist-references/discord.json +549 -0
- package/dist-references/figma.json +1186 -0
- package/dist-references/finder.json +146 -0
- package/dist-references/google-ads-transparency.json +95 -0
- package/dist-references/google-flow.json +649 -0
- package/dist-references/instagram.json +341 -0
- package/dist-references/linkedin.json +324 -0
- package/dist-references/meta-ad-library.json +86 -0
- package/dist-references/n8n.json +387 -0
- package/dist-references/notes.json +27 -0
- package/dist-references/notion.json +163 -0
- package/dist-references/reddit.json +341 -0
- package/dist-references/threads.json +337 -0
- package/dist-references/x-twitter.json +403 -0
- package/dist-references/youtube.json +373 -0
- package/native/macos-bridge/Package.swift +1 -0
- package/native/macos-bridge/Sources/AccessibilityBridge.swift +257 -36
- package/native/macos-bridge/Sources/AppManagement.swift +212 -2
- package/native/macos-bridge/Sources/CoreGraphicsBridge.swift +348 -53
- package/native/macos-bridge/Sources/StreamCapture.swift +136 -0
- package/native/macos-bridge/Sources/VisionBridge.swift +165 -7
- package/native/macos-bridge/Sources/main.swift +169 -16
- package/native/windows-bridge/Program.cs +5 -0
- package/native/windows-bridge/ScreenCapture.cs +124 -0
- package/package.json +29 -4
- package/scripts/postinstall.cjs +127 -0
- package/.claude/commands/automate.md +0 -28
- package/.claude/commands/debug-ui.md +0 -19
- package/.claude/commands/screenshot.md +0 -15
- package/.github/FUNDING.yml +0 -1
- package/.github/ISSUE_TEMPLATE/bug_report.md +0 -27
- package/.github/ISSUE_TEMPLATE/feature_request.md +0 -20
- package/.mcp.json +0 -8
- package/DESKTOP_MCP_GUIDE.md +0 -92
- package/SECURITY.md +0 -44
- package/docs/architecture.md +0 -47
- package/install-skills.sh +0 -19
- package/mcp-bridge.ts +0 -271
- package/mcp-desktop.ts +0 -1221
- package/playbooks/instagram.json +0 -41
- package/playbooks/instagram_v2.json +0 -201
- package/playbooks/x_v1.json +0 -211
- package/scripts/devpost-live-loop.mjs +0 -421
- package/src/logging/timeline-logger.ts +0 -55
- package/src/mcp/server.ts +0 -449
- package/src/memory/recall.ts +0 -191
- package/src/memory/research.ts +0 -146
- package/src/memory/seeds.ts +0 -123
- package/src/memory/session.ts +0 -201
- package/src/memory/store.ts +0 -434
- package/src/memory/types.ts +0 -69
- package/src/native/bridge-client.ts +0 -239
- package/src/runtime/accessibility-adapter.ts +0 -487
- package/src/runtime/app-adapter.ts +0 -169
- package/src/runtime/applescript-adapter.ts +0 -376
- package/src/runtime/ax-role-map.ts +0 -102
- package/src/runtime/browser-adapter.ts +0 -129
- package/src/runtime/cdp-chrome-adapter.ts +0 -676
- package/src/runtime/composite-adapter.ts +0 -274
- package/src/runtime/executor.ts +0 -396
- package/src/runtime/planning-loop.ts +0 -81
- package/src/runtime/service.ts +0 -448
- package/src/runtime/session-manager.ts +0 -50
- package/src/runtime/state-observer.ts +0 -136
- package/src/runtime/vision-adapter.ts +0 -297
- package/src/types.ts +0 -297
- package/tests/bridge-client.test.ts +0 -176
- package/tests/browser-stealth.test.ts +0 -210
- package/tests/composite-adapter.test.ts +0 -64
- package/tests/mcp-server.test.ts +0 -151
- package/tests/memory-recall.test.ts +0 -339
- package/tests/memory-research.test.ts +0 -159
- package/tests/memory-seeds.test.ts +0 -120
- package/tests/memory-store.test.ts +0 -392
- package/tests/types.test.ts +0 -92
- package/tsconfig.check.json +0 -17
- package/tsconfig.json +0 -19
- package/vitest.config.ts +0 -8
- /package/{playbooks → dist-references}/devpost.json +0 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
#!/usr/bin/env npx tsx
|
|
2
|
+
/**
|
|
3
|
+
* Worker Daemon — runs as a standalone background process.
|
|
4
|
+
*
|
|
5
|
+
* Survives MCP/client restarts. Continuously processes the job queue
|
|
6
|
+
* via JobRunner with playbook engine support.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* npx tsx scripts/worker-daemon.ts
|
|
10
|
+
* npx tsx scripts/worker-daemon.ts --poll 3000 --max-jobs 0
|
|
11
|
+
*
|
|
12
|
+
* State files:
|
|
13
|
+
* ~/.screenhand/worker/state.json — worker status + recent results
|
|
14
|
+
* ~/.screenhand/worker/worker.pid — PID of this process
|
|
15
|
+
* ~/.screenhand/worker/worker.log — log output
|
|
16
|
+
* ~/.screenhand/jobs/ — job persistence
|
|
17
|
+
* ~/.screenhand/locks/ — session leases
|
|
18
|
+
*/
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import fs from "node:fs";
|
|
21
|
+
import os from "node:os";
|
|
22
|
+
import { BridgeClient } from "../src/native/bridge-client.js";
|
|
23
|
+
import { SessionSupervisor, LeaseManager } from "../src/supervisor/supervisor.js";
|
|
24
|
+
import { JobManager } from "../src/jobs/manager.js";
|
|
25
|
+
import { JobRunner } from "../src/jobs/runner.js";
|
|
26
|
+
import { PlaybookEngine } from "../src/playbook/engine.js";
|
|
27
|
+
import { PlaybookStore } from "../src/playbook/store.js";
|
|
28
|
+
import { AccessibilityAdapter } from "../src/runtime/accessibility-adapter.js";
|
|
29
|
+
import { AutomationRuntimeService } from "../src/runtime/service.js";
|
|
30
|
+
import { TimelineLogger } from "../src/logging/timeline-logger.js";
|
|
31
|
+
import { MemoryService } from "../src/memory/service.js";
|
|
32
|
+
import { WORKER_DIR, WORKER_PID_FILE, WORKER_LOG_FILE, getWorkerDaemonPid, writeWorkerStatus, } from "../src/jobs/worker.js";
|
|
33
|
+
// ── Config from CLI args ──
|
|
34
|
+
const args = process.argv.slice(2);
|
|
35
|
+
function getArg(name, fallback) {
|
|
36
|
+
const idx = args.indexOf("--" + name);
|
|
37
|
+
if (idx === -1)
|
|
38
|
+
return fallback;
|
|
39
|
+
return args[idx + 1] ?? fallback;
|
|
40
|
+
}
|
|
41
|
+
const POLL_MS = Number(getArg("poll", "3000"));
|
|
42
|
+
const MAX_JOBS = Number(getArg("max-jobs", "0")); // 0 = unlimited
|
|
43
|
+
// ── Directories ──
|
|
44
|
+
const JOB_DIR = path.join(os.homedir(), ".screenhand", "jobs");
|
|
45
|
+
const LOCK_DIR = path.join(os.homedir(), ".screenhand", "locks");
|
|
46
|
+
const PLAYBOOKS_DIR = path.join(os.homedir(), ".screenhand", "playbooks");
|
|
47
|
+
const SUPERVISOR_STATE_DIR = path.join(os.homedir(), ".screenhand", "supervisor");
|
|
48
|
+
fs.mkdirSync(WORKER_DIR, { recursive: true });
|
|
49
|
+
fs.mkdirSync(JOB_DIR, { recursive: true });
|
|
50
|
+
// ── Logging ──
|
|
51
|
+
const logStream = fs.createWriteStream(WORKER_LOG_FILE, { flags: "a" });
|
|
52
|
+
let daemonized = false;
|
|
53
|
+
function log(msg) {
|
|
54
|
+
const line = `[${new Date().toISOString()}] ${msg}`;
|
|
55
|
+
logStream.write(line + "\n");
|
|
56
|
+
if (!daemonized)
|
|
57
|
+
process.stderr.write(line + "\n");
|
|
58
|
+
}
|
|
59
|
+
// ── Bridge setup ──
|
|
60
|
+
const scriptDir = import.meta.dirname ?? path.dirname(new URL(import.meta.url).pathname);
|
|
61
|
+
const projectRoot = scriptDir.includes("/dist/")
|
|
62
|
+
? path.resolve(scriptDir, "../..")
|
|
63
|
+
: path.resolve(scriptDir, "..");
|
|
64
|
+
const bridgePath = process.platform === "win32"
|
|
65
|
+
? path.resolve(projectRoot, "native/windows-bridge/bin/Release/net8.0-windows/windows-bridge.exe")
|
|
66
|
+
: path.resolve(projectRoot, "native/macos-bridge/.build/release/macos-bridge");
|
|
67
|
+
const bridge = new BridgeClient(bridgePath);
|
|
68
|
+
let bridgeReady = false;
|
|
69
|
+
async function ensureBridge() {
|
|
70
|
+
if (!bridgeReady) {
|
|
71
|
+
await bridge.start();
|
|
72
|
+
bridgeReady = true;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// ── Services ──
|
|
76
|
+
const leaseManager = new LeaseManager(LOCK_DIR);
|
|
77
|
+
const supervisor = new SessionSupervisor({
|
|
78
|
+
stateDir: SUPERVISOR_STATE_DIR,
|
|
79
|
+
lockDir: LOCK_DIR,
|
|
80
|
+
});
|
|
81
|
+
const memory = new MemoryService(os.homedir());
|
|
82
|
+
const jobManager = new JobManager({ jobDir: JOB_DIR, memory, supervisor });
|
|
83
|
+
jobManager.init();
|
|
84
|
+
// ── State ──
|
|
85
|
+
let stopped = false;
|
|
86
|
+
let processing = false;
|
|
87
|
+
const recentResults = [];
|
|
88
|
+
const MAX_RECENT = 50;
|
|
89
|
+
let jobsProcessed = 0;
|
|
90
|
+
let jobsDone = 0;
|
|
91
|
+
let jobsFailed = 0;
|
|
92
|
+
let jobsBlocked = 0;
|
|
93
|
+
let lastJobId = null;
|
|
94
|
+
let lastJobState = null;
|
|
95
|
+
let startedAt = null;
|
|
96
|
+
function buildStatus() {
|
|
97
|
+
return {
|
|
98
|
+
pid: process.pid,
|
|
99
|
+
running: !stopped,
|
|
100
|
+
startedAt,
|
|
101
|
+
pollMs: POLL_MS,
|
|
102
|
+
maxJobs: MAX_JOBS,
|
|
103
|
+
jobsProcessed,
|
|
104
|
+
jobsDone,
|
|
105
|
+
jobsFailed,
|
|
106
|
+
jobsBlocked,
|
|
107
|
+
lastJobId,
|
|
108
|
+
lastJobState,
|
|
109
|
+
uptimeMs: startedAt ? Date.now() - new Date(startedAt).getTime() : 0,
|
|
110
|
+
recentResults: recentResults.slice(-MAX_RECENT),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
function persistState() {
|
|
114
|
+
try {
|
|
115
|
+
writeWorkerStatus(buildStatus());
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// Non-fatal
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function recordResult(result) {
|
|
122
|
+
jobsProcessed++;
|
|
123
|
+
lastJobId = result.jobId;
|
|
124
|
+
lastJobState = result.finalState;
|
|
125
|
+
switch (result.finalState) {
|
|
126
|
+
case "done":
|
|
127
|
+
jobsDone++;
|
|
128
|
+
break;
|
|
129
|
+
case "failed":
|
|
130
|
+
jobsFailed++;
|
|
131
|
+
break;
|
|
132
|
+
case "blocked":
|
|
133
|
+
case "waiting_human":
|
|
134
|
+
jobsBlocked++;
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
recentResults.push(result);
|
|
138
|
+
if (recentResults.length > MAX_RECENT)
|
|
139
|
+
recentResults.shift();
|
|
140
|
+
log(`Completed: ${result.jobId} → ${result.finalState} (${result.stepsCompleted}/${result.totalSteps} steps, ${result.durationMs}ms)`);
|
|
141
|
+
persistState();
|
|
142
|
+
}
|
|
143
|
+
// ── Main loop ──
|
|
144
|
+
async function main() {
|
|
145
|
+
// Enforce single daemon
|
|
146
|
+
const existingPid = getWorkerDaemonPid();
|
|
147
|
+
if (existingPid !== null && existingPid !== process.pid) {
|
|
148
|
+
const msg = `Another worker daemon is already running (pid=${existingPid}). Aborting.`;
|
|
149
|
+
log(msg);
|
|
150
|
+
process.stderr.write(msg + "\n");
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
fs.writeFileSync(WORKER_PID_FILE, String(process.pid));
|
|
154
|
+
daemonized = true;
|
|
155
|
+
startedAt = new Date().toISOString();
|
|
156
|
+
log(`Worker daemon started (pid=${process.pid})`);
|
|
157
|
+
log(`Config: poll=${POLL_MS}ms max-jobs=${MAX_JOBS || "unlimited"}`);
|
|
158
|
+
// Ensure bridge is ready
|
|
159
|
+
await ensureBridge();
|
|
160
|
+
// Build playbook engine stack
|
|
161
|
+
const adapter = new AccessibilityAdapter(bridge);
|
|
162
|
+
const logger = new TimelineLogger();
|
|
163
|
+
const runtimeService = new AutomationRuntimeService(adapter, logger);
|
|
164
|
+
const playbookEngine = new PlaybookEngine(runtimeService);
|
|
165
|
+
const playbookStore = new PlaybookStore(PLAYBOOKS_DIR);
|
|
166
|
+
playbookStore.load();
|
|
167
|
+
const runner = new JobRunner(bridge, jobManager, leaseManager, supervisor, {
|
|
168
|
+
playbookEngine,
|
|
169
|
+
playbookStore,
|
|
170
|
+
runtimeService,
|
|
171
|
+
onLog: log,
|
|
172
|
+
});
|
|
173
|
+
persistState();
|
|
174
|
+
// Poll loop
|
|
175
|
+
while (!stopped) {
|
|
176
|
+
if (!processing) {
|
|
177
|
+
processing = true;
|
|
178
|
+
try {
|
|
179
|
+
const result = await runner.run();
|
|
180
|
+
if (result) {
|
|
181
|
+
recordResult(result);
|
|
182
|
+
// Check maxJobs limit
|
|
183
|
+
if (MAX_JOBS > 0 && jobsProcessed >= MAX_JOBS) {
|
|
184
|
+
log(`Reached max-jobs limit (${MAX_JOBS})`);
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
// Job found — poll again immediately
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
catch (err) {
|
|
192
|
+
log(`Poll error: ${err instanceof Error ? err.message : String(err)}`);
|
|
193
|
+
}
|
|
194
|
+
finally {
|
|
195
|
+
processing = false;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// Queue empty or error — wait before next poll
|
|
199
|
+
await sleep(POLL_MS);
|
|
200
|
+
}
|
|
201
|
+
log(`Worker daemon exiting (${jobsProcessed} jobs: ${jobsDone} done, ${jobsFailed} failed, ${jobsBlocked} blocked)`);
|
|
202
|
+
await shutdown();
|
|
203
|
+
}
|
|
204
|
+
function sleep(ms) {
|
|
205
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
206
|
+
}
|
|
207
|
+
// ── Graceful shutdown ──
|
|
208
|
+
process.on("SIGINT", () => { stopped = true; });
|
|
209
|
+
process.on("SIGTERM", () => { stopped = true; });
|
|
210
|
+
async function shutdown() {
|
|
211
|
+
stopped = true;
|
|
212
|
+
persistState();
|
|
213
|
+
try {
|
|
214
|
+
fs.unlinkSync(WORKER_PID_FILE);
|
|
215
|
+
}
|
|
216
|
+
catch { /* ignore */ }
|
|
217
|
+
try {
|
|
218
|
+
await bridge.stop();
|
|
219
|
+
}
|
|
220
|
+
catch { /* ignore */ }
|
|
221
|
+
logStream.end();
|
|
222
|
+
process.exit(0);
|
|
223
|
+
}
|
|
224
|
+
main().catch((err) => {
|
|
225
|
+
log(`Fatal: ${err instanceof Error ? err.message : String(err)}`);
|
|
226
|
+
persistState();
|
|
227
|
+
process.exit(1);
|
|
228
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Copyright (C) 2025 Clazro Technology Private Limited
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
4
|
+
//
|
|
5
|
+
// This file is part of ScreenHand.
|
|
6
|
+
//
|
|
7
|
+
// ScreenHand is free software: you can redistribute it and/or modify
|
|
8
|
+
// it under the terms of the GNU Affero General Public License as
|
|
9
|
+
// published by the Free Software Foundation, version 3.
|
|
10
|
+
//
|
|
11
|
+
// ScreenHand is distributed in the hope that it will be useful,
|
|
12
|
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
13
|
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
14
|
+
// GNU Affero General Public License for more details.
|
|
15
|
+
//
|
|
16
|
+
// You should have received a copy of the GNU Affero General Public License
|
|
17
|
+
// along with ScreenHand. If not, see <https://www.gnu.org/licenses/>.
|
|
18
|
+
import { TimelineLogger } from "../logging/timeline-logger.js";
|
|
19
|
+
import { AutomationRuntimeService } from "../runtime/service.js";
|
|
20
|
+
import { runAgentLoop } from "./loop.js";
|
|
21
|
+
const task = process.argv.slice(2).join(" ");
|
|
22
|
+
if (!task) {
|
|
23
|
+
console.error("Usage: screenhand-agent <task description>");
|
|
24
|
+
console.error("Example: screenhand-agent \"Open Safari and search for MCP protocol\"");
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
async function createAdapter() {
|
|
28
|
+
const adapterType = process.env.SCREENHAND_ADAPTER ?? "accessibility";
|
|
29
|
+
switch (adapterType) {
|
|
30
|
+
case "placeholder": {
|
|
31
|
+
const { PlaceholderAppAdapter } = await import("../runtime/app-adapter.js");
|
|
32
|
+
return new PlaceholderAppAdapter();
|
|
33
|
+
}
|
|
34
|
+
case "cdp": {
|
|
35
|
+
const { CdpChromeAdapter } = await import("../runtime/cdp-chrome-adapter.js");
|
|
36
|
+
return new CdpChromeAdapter({ headless: process.env.SCREENHAND_HEADLESS === "1" });
|
|
37
|
+
}
|
|
38
|
+
case "composite": {
|
|
39
|
+
const { BridgeClient } = await import("../native/bridge-client.js");
|
|
40
|
+
const { CompositeAdapter } = await import("../runtime/composite-adapter.js");
|
|
41
|
+
return new CompositeAdapter(new BridgeClient(), { headless: process.env.SCREENHAND_HEADLESS === "1" });
|
|
42
|
+
}
|
|
43
|
+
default: {
|
|
44
|
+
const { BridgeClient } = await import("../native/bridge-client.js");
|
|
45
|
+
const { AccessibilityAdapter } = await import("../runtime/accessibility-adapter.js");
|
|
46
|
+
return new AccessibilityAdapter(new BridgeClient());
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
const adapter = await createAdapter();
|
|
52
|
+
const runtime = new AutomationRuntimeService(adapter, new TimelineLogger());
|
|
53
|
+
const session = await runtime.sessionStart();
|
|
54
|
+
console.log(`\n🔄 Task: ${task}`);
|
|
55
|
+
console.log(` Session: ${session.sessionId}`);
|
|
56
|
+
console.log(` Model: ${process.env.SCREENHAND_MODEL ?? "claude-sonnet-4-20250514"}\n`);
|
|
57
|
+
const cliModel = process.env.SCREENHAND_MODEL;
|
|
58
|
+
const result = await runAgentLoop(runtime, session.sessionId, task, {
|
|
59
|
+
maxSteps: parseInt(process.env.SCREENHAND_MAX_STEPS ?? "50", 10),
|
|
60
|
+
...(cliModel ? { model: cliModel } : {}),
|
|
61
|
+
onStep: (step) => {
|
|
62
|
+
const icon = step.done ? "✅" : step.action ? "→" : "⚠️";
|
|
63
|
+
console.log(` ${icon} [${step.index}] ${step.reasoning.slice(0, 100)}`);
|
|
64
|
+
if (step.action && step.action.tool !== "done") {
|
|
65
|
+
console.log(` ${step.action.tool}: ${JSON.stringify(step.action).slice(0, 120)}`);
|
|
66
|
+
}
|
|
67
|
+
if (step.result) {
|
|
68
|
+
console.log(` Result: ${step.result.slice(0, 100)}`);
|
|
69
|
+
}
|
|
70
|
+
console.log(` (${step.durationMs}ms)\n`);
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
console.log(`\n${"=".repeat(60)}`);
|
|
74
|
+
console.log(`${result.success ? "✅ SUCCESS" : "❌ INCOMPLETE"}: ${result.summary}`);
|
|
75
|
+
console.log(`Steps: ${result.steps.length} | Total: ${result.totalMs}ms`);
|
|
76
|
+
console.log(`${"=".repeat(60)}\n`);
|
|
77
|
+
process.exit(result.success ? 0 : 1);
|
|
78
|
+
}
|
|
79
|
+
catch (e) {
|
|
80
|
+
console.error(`Fatal: ${e instanceof Error ? e.message : String(e)}`);
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// Copyright (C) 2025 Clazro Technology Private Limited
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
//
|
|
4
|
+
// This file is part of ScreenHand.
|
|
5
|
+
//
|
|
6
|
+
// ScreenHand is free software: you can redistribute it and/or modify
|
|
7
|
+
// it under the terms of the GNU Affero General Public License as
|
|
8
|
+
// published by the Free Software Foundation, version 3.
|
|
9
|
+
//
|
|
10
|
+
// ScreenHand is distributed in the hope that it will be useful,
|
|
11
|
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
12
|
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
13
|
+
// GNU Affero General Public License for more details.
|
|
14
|
+
//
|
|
15
|
+
// You should have received a copy of the GNU Affero General Public License
|
|
16
|
+
// along with ScreenHand. If not, see <https://www.gnu.org/licenses/>.
|
|
17
|
+
/**
|
|
18
|
+
* ScreenHand Agent Loop
|
|
19
|
+
*
|
|
20
|
+
* Continuous observe → decide → act loop powered by Claude.
|
|
21
|
+
* Uses element_tree (accessibility tree) as the primary observation — not screenshots.
|
|
22
|
+
* ~50ms per observe, ~50ms per action. Only the LLM call adds latency.
|
|
23
|
+
*/
|
|
24
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
25
|
+
/**
|
|
26
|
+
* Compact AX tree representation for LLM consumption.
|
|
27
|
+
* Converts the full AXNode tree into a concise text format:
|
|
28
|
+
* [button] "Send" (350,200)
|
|
29
|
+
* [textField] "Search" value="hello" (100,50)
|
|
30
|
+
*/
|
|
31
|
+
function compactTree(node, depth = 0, maxDepth = 5) {
|
|
32
|
+
if (depth > maxDepth)
|
|
33
|
+
return "";
|
|
34
|
+
const indent = " ".repeat(depth);
|
|
35
|
+
const parts = [];
|
|
36
|
+
// Role
|
|
37
|
+
const role = node.role.replace("AX", "").toLowerCase();
|
|
38
|
+
// Label — prefer title, then description, then identifier
|
|
39
|
+
const label = node.title || node.description || node.identifier || "";
|
|
40
|
+
// Value
|
|
41
|
+
const val = node.value ? ` value="${node.value.slice(0, 50)}"` : "";
|
|
42
|
+
// Position
|
|
43
|
+
const pos = node.position ? ` (${Math.round(node.position.x)},${Math.round(node.position.y)})` : "";
|
|
44
|
+
// Focused/enabled markers
|
|
45
|
+
const markers = [];
|
|
46
|
+
if (node.focused)
|
|
47
|
+
markers.push("focused");
|
|
48
|
+
if (node.enabled === false)
|
|
49
|
+
markers.push("disabled");
|
|
50
|
+
const markerStr = markers.length ? ` [${markers.join(",")}]` : "";
|
|
51
|
+
// Skip noise nodes with no useful info
|
|
52
|
+
const isNoise = !label && !val && !node.focused && (role === "group" || role === "splitgroup" || role === "scrollarea");
|
|
53
|
+
if (!isNoise) {
|
|
54
|
+
parts.push(`${indent}[${role}] "${label}"${val}${pos}${markerStr}`);
|
|
55
|
+
}
|
|
56
|
+
if (node.children) {
|
|
57
|
+
for (const child of node.children) {
|
|
58
|
+
const childStr = compactTree(child, isNoise ? depth : depth + 1, maxDepth);
|
|
59
|
+
if (childStr)
|
|
60
|
+
parts.push(childStr);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return parts.join("\n");
|
|
64
|
+
}
|
|
65
|
+
const SYSTEM_PROMPT = `You are a desktop automation agent. You control a computer through ScreenHand tools.
|
|
66
|
+
|
|
67
|
+
On each turn you receive the current UI state as an accessibility tree. You must decide the SINGLE next action to take.
|
|
68
|
+
|
|
69
|
+
Respond in this exact JSON format (no markdown, no explanation outside the JSON):
|
|
70
|
+
{
|
|
71
|
+
"reasoning": "Brief explanation of what you see and why you're taking this action",
|
|
72
|
+
"action": { "tool": "...", ... },
|
|
73
|
+
"done": false
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
When the task is fully complete, respond with:
|
|
77
|
+
{
|
|
78
|
+
"reasoning": "Task is complete because ...",
|
|
79
|
+
"action": { "tool": "done", "summary": "What was accomplished" },
|
|
80
|
+
"done": true
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
Available actions:
|
|
84
|
+
- {"tool": "press", "target": "Button text or element name"}
|
|
85
|
+
- {"tool": "type_into", "target": "Field name", "text": "text to type"}
|
|
86
|
+
- {"tool": "navigate", "url": "https://..."}
|
|
87
|
+
- {"tool": "scroll", "direction": "up|down|left|right", "amount": 3}
|
|
88
|
+
- {"tool": "key_combo", "keys": ["cmd", "c"]}
|
|
89
|
+
- {"tool": "menu_click", "menuPath": ["File", "Save"]}
|
|
90
|
+
- {"tool": "app_launch", "bundleId": "com.apple.Safari"}
|
|
91
|
+
- {"tool": "app_focus", "bundleId": "com.apple.Safari"}
|
|
92
|
+
- {"tool": "extract", "target": "element name", "format": "text"}
|
|
93
|
+
- {"tool": "wait", "ms": 1000}
|
|
94
|
+
- {"tool": "done", "summary": "what was accomplished"}
|
|
95
|
+
|
|
96
|
+
Rules:
|
|
97
|
+
- Take ONE action per turn. After each action you'll see the updated UI.
|
|
98
|
+
- Use the accessibility tree to find elements — look for roles and labels.
|
|
99
|
+
- Target elements by their visible text/label, not coordinates (unless no label exists).
|
|
100
|
+
- If an action fails, try an alternative approach — don't repeat the same failed action.
|
|
101
|
+
- If you're stuck after 3 attempts, explain what's blocking you and mark done.
|
|
102
|
+
- Be efficient. Don't take unnecessary actions.`;
|
|
103
|
+
export async function runAgentLoop(runtime, sessionId, task, options = {}) {
|
|
104
|
+
const { maxSteps = 50, model = "claude-sonnet-4-20250514", maxTokens = 1024, onStep, screenshotOnStart = false, } = options;
|
|
105
|
+
const client = new Anthropic();
|
|
106
|
+
const steps = [];
|
|
107
|
+
const messages = [];
|
|
108
|
+
const startTime = Date.now();
|
|
109
|
+
// Optional initial screenshot for context
|
|
110
|
+
if (screenshotOnStart) {
|
|
111
|
+
await runtime.screenshot({ sessionId });
|
|
112
|
+
}
|
|
113
|
+
for (let i = 0; i < maxSteps; i++) {
|
|
114
|
+
const stepStart = Date.now();
|
|
115
|
+
// 1. OBSERVE — get accessibility tree (~50ms)
|
|
116
|
+
const treeResult = await runtime.elementTree({ sessionId, maxDepth: 5 });
|
|
117
|
+
let observation;
|
|
118
|
+
if (treeResult.ok) {
|
|
119
|
+
observation = compactTree(treeResult.data);
|
|
120
|
+
// Truncate if too large to keep tokens manageable
|
|
121
|
+
if (observation.length > 8000) {
|
|
122
|
+
observation = observation.slice(0, 8000) + "\n... (truncated)";
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
observation = `[Error getting UI tree: ${treeResult.error.message}]`;
|
|
127
|
+
}
|
|
128
|
+
// Also get app context
|
|
129
|
+
let contextLine = "";
|
|
130
|
+
try {
|
|
131
|
+
const apps = await runtime.appList(sessionId);
|
|
132
|
+
if (apps.ok) {
|
|
133
|
+
const active = apps.data.find(a => a.isActive);
|
|
134
|
+
if (active)
|
|
135
|
+
contextLine = `Active app: ${active.name} (${active.bundleId})`;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
catch { /* ignore */ }
|
|
139
|
+
// 2. BUILD prompt
|
|
140
|
+
const userMsg = i === 0
|
|
141
|
+
? `Task: ${task}\n\nCurrent UI state:\n${contextLine}\n${observation}`
|
|
142
|
+
: `Action result: ${steps[i - 1].result}\n\nUpdated UI state:\n${contextLine}\n${observation}`;
|
|
143
|
+
messages.push({ role: "user", content: userMsg });
|
|
144
|
+
// 3. DECIDE — ask Claude what to do next
|
|
145
|
+
let reasoning = "";
|
|
146
|
+
let action = null;
|
|
147
|
+
let done = false;
|
|
148
|
+
try {
|
|
149
|
+
const resp = await client.messages.create({
|
|
150
|
+
model,
|
|
151
|
+
max_tokens: maxTokens,
|
|
152
|
+
system: SYSTEM_PROMPT,
|
|
153
|
+
messages,
|
|
154
|
+
});
|
|
155
|
+
const text = resp.content[0]?.type === "text" ? resp.content[0].text : "";
|
|
156
|
+
messages.push({ role: "assistant", content: text });
|
|
157
|
+
// Parse JSON response
|
|
158
|
+
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
|
159
|
+
if (jsonMatch) {
|
|
160
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
161
|
+
reasoning = parsed.reasoning ?? "";
|
|
162
|
+
action = parsed.action ?? null;
|
|
163
|
+
done = parsed.done === true;
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
reasoning = text;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
catch (e) {
|
|
170
|
+
reasoning = `LLM error: ${e instanceof Error ? e.message : String(e)}`;
|
|
171
|
+
}
|
|
172
|
+
// 4. ACT — execute the action (~50ms)
|
|
173
|
+
let result = "";
|
|
174
|
+
if (action) {
|
|
175
|
+
try {
|
|
176
|
+
result = await executeAction(runtime, sessionId, action);
|
|
177
|
+
}
|
|
178
|
+
catch (e) {
|
|
179
|
+
result = `Error: ${e instanceof Error ? e.message : String(e)}`;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
result = "No action taken";
|
|
184
|
+
}
|
|
185
|
+
// Record step
|
|
186
|
+
const step = {
|
|
187
|
+
index: i,
|
|
188
|
+
observation: observation.slice(0, 500),
|
|
189
|
+
reasoning,
|
|
190
|
+
action,
|
|
191
|
+
result,
|
|
192
|
+
done,
|
|
193
|
+
durationMs: Date.now() - stepStart,
|
|
194
|
+
};
|
|
195
|
+
steps.push(step);
|
|
196
|
+
if (onStep)
|
|
197
|
+
onStep(step);
|
|
198
|
+
if (done)
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
const lastStep = steps[steps.length - 1];
|
|
202
|
+
const summary = lastStep?.action?.tool === "done"
|
|
203
|
+
? lastStep.action.summary
|
|
204
|
+
: `Stopped after ${steps.length} steps`;
|
|
205
|
+
return {
|
|
206
|
+
success: lastStep?.done ?? false,
|
|
207
|
+
summary,
|
|
208
|
+
steps,
|
|
209
|
+
totalMs: Date.now() - startTime,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
async function executeAction(runtime, sessionId, action) {
|
|
213
|
+
switch (action.tool) {
|
|
214
|
+
case "press": {
|
|
215
|
+
const r = await runtime.press({
|
|
216
|
+
sessionId,
|
|
217
|
+
target: { type: "text", value: action.target },
|
|
218
|
+
});
|
|
219
|
+
return r.ok ? `Pressed "${action.target}"` : `Failed: ${r.error.message}`;
|
|
220
|
+
}
|
|
221
|
+
case "type_into": {
|
|
222
|
+
const r = await runtime.typeInto({
|
|
223
|
+
sessionId,
|
|
224
|
+
target: { type: "text", value: action.target },
|
|
225
|
+
text: action.text,
|
|
226
|
+
});
|
|
227
|
+
return r.ok ? `Typed "${action.text}" into "${action.target}"` : `Failed: ${r.error.message}`;
|
|
228
|
+
}
|
|
229
|
+
case "navigate": {
|
|
230
|
+
const r = await runtime.navigate({ sessionId, url: action.url });
|
|
231
|
+
return r.ok ? `Navigated to ${action.url}` : `Failed: ${r.error.message}`;
|
|
232
|
+
}
|
|
233
|
+
case "scroll": {
|
|
234
|
+
const input = { sessionId, direction: action.direction };
|
|
235
|
+
if (typeof action.amount === "number")
|
|
236
|
+
input.amount = action.amount;
|
|
237
|
+
const r = await runtime.scroll(input);
|
|
238
|
+
return r.ok ? `Scrolled ${action.direction}` : `Failed: ${r.error.message}`;
|
|
239
|
+
}
|
|
240
|
+
case "key_combo": {
|
|
241
|
+
const r = await runtime.keyCombo({ sessionId, keys: action.keys });
|
|
242
|
+
return r.ok ? `Key combo: ${action.keys.join("+")}` : `Failed: ${r.error.message}`;
|
|
243
|
+
}
|
|
244
|
+
case "menu_click": {
|
|
245
|
+
const r = await runtime.menuClick({ sessionId, menuPath: action.menuPath });
|
|
246
|
+
return r.ok ? `Menu: ${action.menuPath.join(" → ")}` : `Failed: ${r.error.message}`;
|
|
247
|
+
}
|
|
248
|
+
case "app_launch": {
|
|
249
|
+
const r = await runtime.appLaunch({ sessionId, bundleId: action.bundleId });
|
|
250
|
+
return r.ok ? `Launched ${action.bundleId}` : `Failed: ${r.error.message}`;
|
|
251
|
+
}
|
|
252
|
+
case "app_focus": {
|
|
253
|
+
const r = await runtime.appFocus({ sessionId, bundleId: action.bundleId });
|
|
254
|
+
return r.ok ? `Focused ${action.bundleId}` : `Failed: ${r.error.message}`;
|
|
255
|
+
}
|
|
256
|
+
case "extract": {
|
|
257
|
+
const r = await runtime.extract({
|
|
258
|
+
sessionId,
|
|
259
|
+
target: { type: "text", value: action.target },
|
|
260
|
+
format: action.format,
|
|
261
|
+
});
|
|
262
|
+
return r.ok ? `Extracted: ${JSON.stringify(r.data).slice(0, 500)}` : `Failed: ${r.error.message}`;
|
|
263
|
+
}
|
|
264
|
+
case "wait": {
|
|
265
|
+
await new Promise(resolve => setTimeout(resolve, action.ms));
|
|
266
|
+
return `Waited ${action.ms}ms`;
|
|
267
|
+
}
|
|
268
|
+
case "done": {
|
|
269
|
+
return `Task complete: ${action.summary}`;
|
|
270
|
+
}
|
|
271
|
+
default:
|
|
272
|
+
return `Unknown action: ${action.tool}`;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Copyright (C) 2025 Clazro Technology Private Limited
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import * as os from "node:os";
|
|
6
|
+
import { RemoteCommunityAPI } from "./remote-api.js";
|
|
7
|
+
/**
|
|
8
|
+
* PlaybookFetcher — fetches community playbooks from local disk
|
|
9
|
+
* and optionally from a remote API (when SCREENHAND_COMMUNITY_URL is set).
|
|
10
|
+
*
|
|
11
|
+
* Local repo is always read. Remote results are merged and deduplicated.
|
|
12
|
+
*/
|
|
13
|
+
export class PlaybookFetcher {
|
|
14
|
+
repoDir;
|
|
15
|
+
cache = null;
|
|
16
|
+
remote;
|
|
17
|
+
constructor(repoDir, remote) {
|
|
18
|
+
this.repoDir = repoDir ?? path.join(os.homedir(), ".screenhand", "community");
|
|
19
|
+
this.remote = remote ?? RemoteCommunityAPI.fromEnv();
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Fetch community playbooks matching the query.
|
|
23
|
+
* Reads from local disk; async variant also merges remote results.
|
|
24
|
+
*/
|
|
25
|
+
fetch(query) {
|
|
26
|
+
return this.filterAndRank(this.loadAll(), query);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Fetch with remote API merge (when SCREENHAND_COMMUNITY_URL is set).
|
|
30
|
+
* Local results are always included; remote results are deduplicated and merged.
|
|
31
|
+
*/
|
|
32
|
+
async fetchWithRemote(query) {
|
|
33
|
+
const local = this.loadAll();
|
|
34
|
+
if (!this.remote) {
|
|
35
|
+
return this.filterAndRank(local, query);
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
const remote = await this.remote.fetch(query);
|
|
39
|
+
// Deduplicate: local wins on ID collision
|
|
40
|
+
const localIds = new Set(local.map((pb) => pb.id));
|
|
41
|
+
const merged = [...local, ...remote.filter((pb) => !localIds.has(pb.id))];
|
|
42
|
+
return this.filterAndRank(merged, query);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return this.filterAndRank(local, query);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
filterAndRank(all, query) {
|
|
49
|
+
return all
|
|
50
|
+
.filter((pb) => {
|
|
51
|
+
if (query.platform && pb.platform !== query.platform)
|
|
52
|
+
return false;
|
|
53
|
+
if (query.bundleId && pb.bundleId !== query.bundleId)
|
|
54
|
+
return false;
|
|
55
|
+
if (query.workflow) {
|
|
56
|
+
const lower = query.workflow.toLowerCase();
|
|
57
|
+
if (!pb.name.toLowerCase().includes(lower) &&
|
|
58
|
+
!pb.description.toLowerCase().includes(lower) &&
|
|
59
|
+
!pb.metadata.tags.some((t) => t.toLowerCase().includes(lower))) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (query.minScore !== undefined && pb.ratings.score < query.minScore)
|
|
64
|
+
return false;
|
|
65
|
+
return true;
|
|
66
|
+
})
|
|
67
|
+
.sort((a, b) => {
|
|
68
|
+
const scoreA = a.metadata.successRate * Math.max(a.ratings.score, 1);
|
|
69
|
+
const scoreB = b.metadata.successRate * Math.max(b.ratings.score, 1);
|
|
70
|
+
return scoreB - scoreA;
|
|
71
|
+
})
|
|
72
|
+
.slice(0, query.limit ?? 20);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Get a specific playbook by ID.
|
|
76
|
+
*/
|
|
77
|
+
get(id) {
|
|
78
|
+
const all = this.loadAll();
|
|
79
|
+
return all.find((pb) => pb.id === id) ?? null;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Invalidate the cache (after a new playbook is published).
|
|
83
|
+
*/
|
|
84
|
+
invalidateCache() {
|
|
85
|
+
this.cache = null;
|
|
86
|
+
}
|
|
87
|
+
loadAll() {
|
|
88
|
+
if (this.cache)
|
|
89
|
+
return this.cache;
|
|
90
|
+
const playbooks = [];
|
|
91
|
+
try {
|
|
92
|
+
if (!fs.existsSync(this.repoDir))
|
|
93
|
+
return playbooks;
|
|
94
|
+
const files = fs.readdirSync(this.repoDir);
|
|
95
|
+
for (const file of files) {
|
|
96
|
+
if (!file.endsWith(".json"))
|
|
97
|
+
continue;
|
|
98
|
+
try {
|
|
99
|
+
const raw = fs.readFileSync(path.join(this.repoDir, file), "utf-8");
|
|
100
|
+
playbooks.push(JSON.parse(raw));
|
|
101
|
+
}
|
|
102
|
+
catch { /* skip malformed */ }
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
catch { /* dir not found */ }
|
|
106
|
+
this.cache = playbooks;
|
|
107
|
+
return playbooks;
|
|
108
|
+
}
|
|
109
|
+
}
|