screenhand 0.5.0 → 0.5.3
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/dist/mcp-desktop.js +463 -39
- package/dist/src/community/publisher.js +4 -2
- package/dist/src/context-tracker.js +62 -6
- package/dist/src/ingestion/reference-merger.js +33 -0
- package/dist/src/memory/recall.js +65 -1
- package/dist/src/memory/research.js +1 -1
- package/dist/src/memory/service.js +26 -5
- package/dist/src/memory/store.js +42 -23
- package/dist/src/native/bridge-client.js +3 -3
- package/dist/src/perception/coordinator.js +94 -15
- package/dist/src/perception/manager.js +65 -1
- package/dist/src/planner/executor.js +6 -2
- package/dist/src/planner/plan-refiner.js +213 -0
- package/dist/src/playbook/engine.js +18 -3
- package/dist/src/playbook/recorder.js +24 -8
- package/dist/src/playbook/runner.js +9 -3
- package/dist/src/playbook/store.js +8 -0
- package/dist/src/recovery/engine.js +9 -3
- package/dist/src/state/app-map.js +212 -2
- package/dist/src/state/state-watcher.js +144 -0
- package/dist/src/state/visual-mapper.js +325 -0
- package/dist/src/state/world-model.js +30 -1
- package/dist/src/supervisor/supervisor.js +1 -1
- package/dist-app-maps/com.apple.Notes.json +2328 -2201
- package/dist-app-maps/com.apple.Terminal.json +331 -343
- package/dist-app-maps/com.apple.iCal.json +3 -3
- package/dist-app-maps/com.apple.iphonesimulator.json +714 -223
- package/dist-app-maps/com.apple.mail.json +3 -3
- package/dist-app-maps/com.apple.reminders.json +2 -2
- package/dist-app-maps/net.whatsapp.WhatsApp.json +27 -27
- package/dist-references/notes.json +53 -16
- package/dist-references/simulator.json +48 -2
- package/package.json +1 -1
|
@@ -75,9 +75,11 @@ export class PlaybookPublisher {
|
|
|
75
75
|
return null;
|
|
76
76
|
}
|
|
77
77
|
writeFileAtomicSync(filePath, JSON.stringify(shared, null, 2) + "\n");
|
|
78
|
-
// Best-effort sync to remote API
|
|
78
|
+
// Best-effort sync to remote API — log failures so user knows data didn't leave machine
|
|
79
79
|
if (this.remote) {
|
|
80
|
-
void this.remote.publish(shared).catch(() => {
|
|
80
|
+
void this.remote.publish(shared).catch((err) => {
|
|
81
|
+
process.stderr.write(`[screenhand] Remote publish failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
82
|
+
});
|
|
81
83
|
}
|
|
82
84
|
return shared;
|
|
83
85
|
}
|
|
@@ -45,7 +45,7 @@ const BUNDLE_ID_TOOLS = new Set([
|
|
|
45
45
|
]);
|
|
46
46
|
// Tools that carry a target/selector in their params
|
|
47
47
|
const TARGET_PARAM_NAMES = ["selector", "target", "text", "label", "placeholder"];
|
|
48
|
-
const FLUSH_THRESHOLD =
|
|
48
|
+
const FLUSH_THRESHOLD = 5;
|
|
49
49
|
const MIN_OCCURRENCES_TO_PROMOTE = 2;
|
|
50
50
|
export class ContextTracker {
|
|
51
51
|
store;
|
|
@@ -176,8 +176,10 @@ export class ContextTracker {
|
|
|
176
176
|
// Only for known browser bundleIds
|
|
177
177
|
const BROWSER_BUNDLE_IDS = new Set([
|
|
178
178
|
"com.apple.Safari", "com.brave.Browser",
|
|
179
|
+
"com.google.Chrome", "com.google.Chrome.canary",
|
|
179
180
|
"org.chromium.Chromium", "com.vivaldi.Vivaldi",
|
|
180
|
-
"com.operasoftware.Opera",
|
|
181
|
+
"com.operasoftware.Opera", "company.thebrowser.Browser",
|
|
182
|
+
"org.mozilla.firefox", "org.mozilla.firefoxdeveloperedition",
|
|
181
183
|
]);
|
|
182
184
|
if (!BROWSER_BUNDLE_IDS.has(bundleId))
|
|
183
185
|
return;
|
|
@@ -239,6 +241,32 @@ export class ContextTracker {
|
|
|
239
241
|
: map.masteryLevel.toUpperCase();
|
|
240
242
|
hints.push(`🗺 AppMap [${ratingDisplay}]: ${zones} zones, ${verifiedPaths}/${totalPaths} verified paths`);
|
|
241
243
|
}
|
|
244
|
+
// Visual map hint: suggest map_app if no visual data exists
|
|
245
|
+
if (hints.length < 3 && this.context.appMapData) {
|
|
246
|
+
const visualMeta = this.context.appMapData.visualMeta;
|
|
247
|
+
if (!visualMeta) {
|
|
248
|
+
hints.push("📸 No visual map — run map_app(bundleId, appName) for faster, more accurate element targeting");
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
// Check for element with visual position near the target
|
|
252
|
+
const target = extractTarget(params);
|
|
253
|
+
if (target) {
|
|
254
|
+
const targetLower = target.toLowerCase();
|
|
255
|
+
for (const zone of Object.values(this.context.appMapData.zones)) {
|
|
256
|
+
for (const el of zone.elements) {
|
|
257
|
+
if (el.label.toLowerCase().includes(targetLower) &&
|
|
258
|
+
el.relativeX >= 0 && el.relativeY >= 0 &&
|
|
259
|
+
(el.visualConfidence ?? 0) >= 0.5) {
|
|
260
|
+
hints.push(`📍 Visual map: "${el.label}" at (${el.relativeX.toFixed(2)}, ${el.relativeY.toFixed(2)}) [${el.labelSource ?? "unknown"} source, confidence ${(el.visualConfidence ?? 0).toFixed(1)}]`);
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (hints.length >= 3)
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
242
270
|
// Playbook-dependent hints below
|
|
243
271
|
if (!this.context.playbook)
|
|
244
272
|
return hints;
|
|
@@ -346,18 +374,46 @@ export class ContextTracker {
|
|
|
346
374
|
flush() {
|
|
347
375
|
if (this.learnings.length === 0)
|
|
348
376
|
return;
|
|
349
|
-
if (!this.context
|
|
377
|
+
if (!this.context) {
|
|
350
378
|
this.learnings = [];
|
|
351
379
|
this.actionCount = 0;
|
|
352
380
|
return;
|
|
353
381
|
}
|
|
382
|
+
// If no playbook matched, create a stub so learnings aren't discarded.
|
|
383
|
+
// This is the fix for "train on unknown app → restart → everything gone".
|
|
384
|
+
if (!this.context.playbook) {
|
|
385
|
+
const domain = this.context.domain;
|
|
386
|
+
const platform = domain.replace(/^native:/, "").split(".").pop() ?? domain;
|
|
387
|
+
const isNative = domain.startsWith("native:");
|
|
388
|
+
const stub = {
|
|
389
|
+
id: platform + "-learned",
|
|
390
|
+
name: `${platform} — Auto-Learned`,
|
|
391
|
+
description: `Selectors and errors learned from live interaction with ${platform}`,
|
|
392
|
+
platform,
|
|
393
|
+
...(isNative ? { bundleId: domain.replace(/^native:/, "") } : {}),
|
|
394
|
+
version: "1.0.0",
|
|
395
|
+
steps: [],
|
|
396
|
+
tags: [platform, "auto-learned"],
|
|
397
|
+
successCount: 0,
|
|
398
|
+
failCount: 0,
|
|
399
|
+
selectors: {},
|
|
400
|
+
errors: [],
|
|
401
|
+
};
|
|
402
|
+
this.store.save(stub);
|
|
403
|
+
this.context.playbook = stub;
|
|
404
|
+
this.context.allSelectors = new Map();
|
|
405
|
+
}
|
|
354
406
|
const playbook = this.context.playbook;
|
|
355
407
|
let changed = false;
|
|
356
|
-
// ── Promote
|
|
408
|
+
// ── Promote targets that worked 2+ times ──
|
|
409
|
+
// Accepts CSS selectors AND AX targets (plain text labels like "New Note").
|
|
410
|
+
// Only rejects strings that look like event handlers or raw coordinates.
|
|
357
411
|
const selectorSuccessCount = new Map();
|
|
358
412
|
for (const l of this.learnings) {
|
|
359
|
-
if (l.success && l.target &&
|
|
360
|
-
!/\bon\w+\s*=/i.test(l.target)
|
|
413
|
+
if (l.success && l.target &&
|
|
414
|
+
!/\bon\w+\s*=/i.test(l.target) &&
|
|
415
|
+
!/^\d+,\d+$/.test(l.target) &&
|
|
416
|
+
l.target.length >= 2 && l.target.length <= 200) {
|
|
361
417
|
const key = l.target;
|
|
362
418
|
selectorSuccessCount.set(key, (selectorSuccessCount.get(key) ?? 0) + 1);
|
|
363
419
|
}
|
|
@@ -112,6 +112,39 @@ export class ReferenceMerger {
|
|
|
112
112
|
const filePath = this.save(ref);
|
|
113
113
|
return { filePath, added: newFeatures.length };
|
|
114
114
|
}
|
|
115
|
+
/**
|
|
116
|
+
* Merge selectors from platform_explore into the main reference file.
|
|
117
|
+
* Prevents data fragmentation between *-explore.json and the main reference.
|
|
118
|
+
*/
|
|
119
|
+
mergeExploreSelectors(selectors, errors, bundleId, appName) {
|
|
120
|
+
const ref = this.loadOrCreate(bundleId, appName);
|
|
121
|
+
if (!ref.selectors)
|
|
122
|
+
ref.selectors = {};
|
|
123
|
+
let added = 0;
|
|
124
|
+
for (const [group, entries] of Object.entries(selectors)) {
|
|
125
|
+
if (!ref.selectors[group]) {
|
|
126
|
+
ref.selectors[group] = {};
|
|
127
|
+
}
|
|
128
|
+
for (const [name, selector] of Object.entries(entries)) {
|
|
129
|
+
if (!ref.selectors[group][name]) {
|
|
130
|
+
ref.selectors[group][name] = selector;
|
|
131
|
+
added++;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// Also merge errors
|
|
136
|
+
if (!ref.errors)
|
|
137
|
+
ref.errors = [];
|
|
138
|
+
const existingErrors = new Set(ref.errors.map((e) => e.error.toLowerCase()));
|
|
139
|
+
for (const err of errors) {
|
|
140
|
+
if (!existingErrors.has(err.error.toLowerCase())) {
|
|
141
|
+
ref.errors.push(err);
|
|
142
|
+
existingErrors.add(err.error.toLowerCase());
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const filePath = this.save(ref);
|
|
146
|
+
return { filePath, added };
|
|
147
|
+
}
|
|
115
148
|
/**
|
|
116
149
|
* Merge errors/solutions into reference.
|
|
117
150
|
*/
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
// You should have received a copy of the GNU Affero General Public License
|
|
16
16
|
// along with ScreenHand. If not, see <https://www.gnu.org/licenses/>.
|
|
17
17
|
import { MemoryStore } from "./store.js";
|
|
18
|
+
/** Screenshot/OCR tools that should be auto-pruned from strategy hints */
|
|
19
|
+
const SCREENSHOT_TOOL_NAMES = new Set(["screenshot", "screenshot_file", "ocr"]);
|
|
18
20
|
export class RecallEngine {
|
|
19
21
|
store;
|
|
20
22
|
constructor(store) {
|
|
@@ -158,15 +160,77 @@ export class RecallEngine {
|
|
|
158
160
|
const strategyToolPrefix = s.steps.slice(0, recentTools.length).map((st) => st.tool);
|
|
159
161
|
const matches = recentTools.every((t, i) => t === strategyToolPrefix[i]);
|
|
160
162
|
if (matches) {
|
|
163
|
+
// Auto-prune: skip screenshot/ocr steps — they add latency on browser apps
|
|
164
|
+
// and the world model already provides UI state visibility
|
|
165
|
+
let nextIdx = recentTools.length;
|
|
166
|
+
while (nextIdx < s.steps.length && SCREENSHOT_TOOL_NAMES.has(s.steps[nextIdx].tool)) {
|
|
167
|
+
nextIdx++;
|
|
168
|
+
}
|
|
169
|
+
if (nextIdx >= s.steps.length)
|
|
170
|
+
continue; // entire remainder was screenshots — skip strategy
|
|
161
171
|
return {
|
|
162
172
|
strategy: s,
|
|
163
|
-
nextStep: s.steps[
|
|
173
|
+
nextStep: s.steps[nextIdx],
|
|
164
174
|
fingerprint: s.fingerprint ?? MemoryStore.makeFingerprint(s.steps.map((st) => st.tool)),
|
|
165
175
|
};
|
|
166
176
|
}
|
|
167
177
|
}
|
|
168
178
|
return null;
|
|
169
179
|
}
|
|
180
|
+
/**
|
|
181
|
+
* Check if the current tool sequence matches a PROVEN strategy that can be
|
|
182
|
+
* auto-executed without LLM intervention.
|
|
183
|
+
*
|
|
184
|
+
* Requirements for auto-execution (conservative):
|
|
185
|
+
* - 10+ successes, 0 failures
|
|
186
|
+
* - Remaining steps are all concrete tools (no LLM/screenshot steps)
|
|
187
|
+
* - At least 2 tools in the prefix match (no single-tool triggers)
|
|
188
|
+
*
|
|
189
|
+
* Returns ALL remaining steps (not just next) so the caller can batch-execute.
|
|
190
|
+
*/
|
|
191
|
+
getAutoExecutableStrategy(recentTools, currentBundleId) {
|
|
192
|
+
if (recentTools.length < 2)
|
|
193
|
+
return null;
|
|
194
|
+
const strategies = this.store.readStrategies();
|
|
195
|
+
const MIN_SUCCESS = 10;
|
|
196
|
+
for (const s of strategies) {
|
|
197
|
+
if (s.steps.length <= recentTools.length)
|
|
198
|
+
continue;
|
|
199
|
+
// Must be proven: 10+ successes, 0 failures
|
|
200
|
+
const failCount = s.failCount ?? 0;
|
|
201
|
+
if (s.successCount < MIN_SUCCESS || failCount > 0)
|
|
202
|
+
continue;
|
|
203
|
+
// App context check
|
|
204
|
+
if (currentBundleId) {
|
|
205
|
+
const taskLower = s.task.toLowerCase();
|
|
206
|
+
const bundleLower = currentBundleId.toLowerCase();
|
|
207
|
+
const appName = bundleLower.split(".").pop() ?? "";
|
|
208
|
+
const mentionsCurrentApp = taskLower.includes(appName) || taskLower.includes(bundleLower);
|
|
209
|
+
const mentionsOtherApp = !mentionsCurrentApp && /com\.\w+\.\w+/.test(s.task);
|
|
210
|
+
if (mentionsOtherApp)
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
// Check prefix match
|
|
214
|
+
const strategyToolPrefix = s.steps.slice(0, recentTools.length).map((st) => st.tool);
|
|
215
|
+
const matches = recentTools.every((t, i) => t === strategyToolPrefix[i]);
|
|
216
|
+
if (!matches)
|
|
217
|
+
continue;
|
|
218
|
+
// Collect remaining steps, skipping screenshot/ocr
|
|
219
|
+
const remaining = s.steps.slice(recentTools.length).filter((st) => !SCREENSHOT_TOOL_NAMES.has(st.tool));
|
|
220
|
+
if (remaining.length === 0)
|
|
221
|
+
continue;
|
|
222
|
+
// All remaining steps must be concrete tools (no "llm_interpret" or similar)
|
|
223
|
+
const LLM_TOOLS = new Set(["llm_interpret", "llm_decide", "ask_user"]);
|
|
224
|
+
if (remaining.some((st) => LLM_TOOLS.has(st.tool)))
|
|
225
|
+
continue;
|
|
226
|
+
return {
|
|
227
|
+
strategy: s,
|
|
228
|
+
remainingSteps: remaining,
|
|
229
|
+
fingerprint: s.fingerprint ?? MemoryStore.makeFingerprint(s.steps.map((st) => st.tool)),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
170
234
|
/** Find error patterns for a specific tool or all tools */
|
|
171
235
|
recallErrors(tool, params) {
|
|
172
236
|
const errors = this.store.readErrors();
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// along with ScreenHand. If not, see <https://www.gnu.org/licenses/>.
|
|
17
17
|
export function backgroundResearch(store, tool, params, errorMessage) {
|
|
18
18
|
// Fire-and-forget — never blocks, never throws
|
|
19
|
-
doResearch(store, tool, params, errorMessage).catch(() => { });
|
|
19
|
+
doResearch(store, tool, params, errorMessage).catch((e) => { process.stderr.write(`[research] background research failed: ${e instanceof Error ? e.message : String(e)}\n`); });
|
|
20
20
|
}
|
|
21
21
|
async function doResearch(store, tool, params, errorMessage) {
|
|
22
22
|
const query = `macOS automation: "${tool}" failed with "${errorMessage.slice(0, 200)}"`;
|
|
@@ -117,11 +117,21 @@ export class MemoryService {
|
|
|
117
117
|
try {
|
|
118
118
|
const raw = fs.readFileSync(snapPath, "utf-8");
|
|
119
119
|
const loaded = JSON.parse(raw);
|
|
120
|
-
//
|
|
121
|
-
if (loaded.mission)
|
|
120
|
+
// Validate shape before merging — reject obviously corrupted data
|
|
121
|
+
if (loaded.mission && typeof loaded.mission === "string") {
|
|
122
122
|
this.snapshot.mission = loaded.mission;
|
|
123
|
-
|
|
124
|
-
|
|
123
|
+
}
|
|
124
|
+
if (loaded.policy && typeof loaded.policy === "object" && !Array.isArray(loaded.policy)) {
|
|
125
|
+
// Validate numeric fields before merging
|
|
126
|
+
const p = loaded.policy;
|
|
127
|
+
if (typeof p.maxConsecutiveErrors !== "number" || !Number.isFinite(p.maxConsecutiveErrors)) {
|
|
128
|
+
delete p.maxConsecutiveErrors;
|
|
129
|
+
}
|
|
130
|
+
if (typeof p.maxErrorsBeforeBlock !== "number" || !Number.isFinite(p.maxErrorsBeforeBlock)) {
|
|
131
|
+
delete p.maxErrorsBeforeBlock;
|
|
132
|
+
}
|
|
133
|
+
this.snapshot.policy = { ...DEFAULT_POLICY, ...p };
|
|
134
|
+
}
|
|
125
135
|
}
|
|
126
136
|
catch {
|
|
127
137
|
// Corrupted snapshot — start fresh
|
|
@@ -299,7 +309,14 @@ export class MemoryService {
|
|
|
299
309
|
writeLearningsAsync() {
|
|
300
310
|
this.ensureMemDir();
|
|
301
311
|
const data = this.learningsCache.map((l) => JSON.stringify(l)).join("\n") + (this.learningsCache.length ? "\n" : "");
|
|
302
|
-
|
|
312
|
+
// Use atomic write to prevent race conditions: two rapid calls would clobber
|
|
313
|
+
// each other with fs.writeFile's async no-op callback.
|
|
314
|
+
try {
|
|
315
|
+
writeFileAtomicSync(this.filePath("learnings.jsonl"), data);
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
// Non-critical — in-memory cache is still correct
|
|
319
|
+
}
|
|
303
320
|
}
|
|
304
321
|
// ── Recall (delegates to RecallEngine) ───────────
|
|
305
322
|
/** Search error patterns, optionally filtered by tool. */
|
|
@@ -318,6 +335,10 @@ export class MemoryService {
|
|
|
318
335
|
quickStrategyHint(recentTools, currentBundleId) {
|
|
319
336
|
return this.recall.quickStrategyHint(recentTools, currentBundleId);
|
|
320
337
|
}
|
|
338
|
+
/** Check if the current tool sequence matches a proven strategy for auto-execution. */
|
|
339
|
+
getAutoExecutableStrategy(recentTools, currentBundleId) {
|
|
340
|
+
return this.recall.getAutoExecutableStrategy(recentTools, currentBundleId);
|
|
341
|
+
}
|
|
321
342
|
/** Record strategy outcome for feedback loop. */
|
|
322
343
|
recordStrategyOutcome(fingerprint, success) {
|
|
323
344
|
this.store.recordStrategyOutcome(fingerprint, success);
|
package/dist/src/memory/store.js
CHANGED
|
@@ -103,32 +103,41 @@ export class MemoryStore {
|
|
|
103
103
|
}
|
|
104
104
|
// ── file locking ──────────────────────────────
|
|
105
105
|
acquireLock() {
|
|
106
|
-
|
|
107
|
-
|
|
106
|
+
// Retry loop closes the TOCTOU window: if another process grabs the lock
|
|
107
|
+
// between our unlink and write, the wx flag fails and we retry.
|
|
108
|
+
const MAX_RETRIES = 3;
|
|
109
|
+
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
108
110
|
try {
|
|
111
|
+
// Atomic create — fails if file already exists
|
|
109
112
|
fs.writeFileSync(this.lockPath, String(process.pid), { flag: "wx" });
|
|
113
|
+
this.hasLock = true;
|
|
114
|
+
return;
|
|
110
115
|
}
|
|
111
116
|
catch {
|
|
112
|
-
// Lock file exists — check if
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
// Lock file exists — check if stale
|
|
118
|
+
try {
|
|
119
|
+
const lockContent = fs.readFileSync(this.lockPath, "utf-8").trim();
|
|
120
|
+
const lockPid = parseInt(lockContent, 10);
|
|
121
|
+
if (lockPid && !this.isProcessRunning(lockPid)) {
|
|
122
|
+
// Stale lock — remove and retry (another process may also remove it)
|
|
123
|
+
try {
|
|
124
|
+
fs.unlinkSync(this.lockPath);
|
|
125
|
+
}
|
|
126
|
+
catch { /* already removed by competitor */ }
|
|
127
|
+
continue; // Retry the wx write
|
|
128
|
+
}
|
|
129
|
+
// Lock held by active process — don't retry
|
|
130
|
+
break;
|
|
119
131
|
}
|
|
120
|
-
|
|
121
|
-
|
|
132
|
+
catch {
|
|
133
|
+
// Can't read lock file (removed between our check) — retry
|
|
134
|
+
continue;
|
|
122
135
|
}
|
|
123
136
|
}
|
|
124
|
-
this.hasLock = true;
|
|
125
|
-
}
|
|
126
|
-
catch (err) {
|
|
127
|
-
// Another instance holds the lock — we still work but skip writes
|
|
128
|
-
// to avoid corruption. Reads are from our own cache (stale but safe).
|
|
129
|
-
console.error(`[MemoryStore] Lock acquisition failed — writes disabled: ${err instanceof Error ? err.message : err}`);
|
|
130
|
-
this.hasLock = false;
|
|
131
137
|
}
|
|
138
|
+
// All retries failed — work in read-only mode
|
|
139
|
+
process.stderr.write(`[MemoryStore] Lock acquisition failed after ${MAX_RETRIES} attempts — writes disabled\n`);
|
|
140
|
+
this.hasLock = false;
|
|
132
141
|
}
|
|
133
142
|
releaseLock() {
|
|
134
143
|
if (this.hasLock) {
|
|
@@ -285,7 +294,13 @@ export class MemoryStore {
|
|
|
285
294
|
this.ensureDir();
|
|
286
295
|
const data = this.pendingActionWrites.join("");
|
|
287
296
|
this.pendingActionWrites = [];
|
|
288
|
-
|
|
297
|
+
try {
|
|
298
|
+
fs.appendFileSync(this.filePath("actions.jsonl"), data);
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
// Non-critical — push data back for next flush attempt
|
|
302
|
+
this.pendingActionWrites.unshift(data);
|
|
303
|
+
}
|
|
289
304
|
}, 100);
|
|
290
305
|
}
|
|
291
306
|
}
|
|
@@ -325,10 +340,14 @@ export class MemoryStore {
|
|
|
325
340
|
this.strategiesCache = this.strategiesCache.slice(-MAX_STRATEGIES);
|
|
326
341
|
}
|
|
327
342
|
appendStrategy(strategy) {
|
|
328
|
-
//
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
343
|
+
// Auto-prune screenshot/ocr steps — they add latency on browser apps
|
|
344
|
+
// and the world model provides UI visibility without them
|
|
345
|
+
const PRUNE_TOOLS = new Set(["screenshot", "screenshot_file", "ocr"]);
|
|
346
|
+
strategy.steps = strategy.steps.filter((s) => !PRUNE_TOOLS.has(s.tool));
|
|
347
|
+
if (strategy.steps.length === 0)
|
|
348
|
+
return; // strategy was all screenshots — discard
|
|
349
|
+
// Ensure fingerprint exists (recompute after pruning)
|
|
350
|
+
strategy.fingerprint = MemoryStore.makeFingerprint(strategy.steps.map((s) => s.tool));
|
|
332
351
|
const idx = this.strategiesCache.findIndex((s) => s.task === strategy.task);
|
|
333
352
|
if (idx >= 0) {
|
|
334
353
|
const old = this.strategiesCache[idx];
|
|
@@ -212,7 +212,7 @@ export class BridgeClient extends EventEmitter {
|
|
|
212
212
|
// Force restart if bridge appears stalled
|
|
213
213
|
if (this.consecutiveTimeouts >= BridgeClient.MAX_CONSECUTIVE_TIMEOUTS) {
|
|
214
214
|
this.consecutiveTimeouts = 0;
|
|
215
|
-
this.restart().catch(() => { });
|
|
215
|
+
this.restart().catch((e) => { process.stderr.write(`[bridge] restart after timeout failed: ${e instanceof Error ? e.message : String(e)}\n`); });
|
|
216
216
|
}
|
|
217
217
|
}, effectiveTimeout);
|
|
218
218
|
this.pending.set(id, {
|
|
@@ -275,14 +275,14 @@ export class BridgeClient extends EventEmitter {
|
|
|
275
275
|
this.emit("error", msg);
|
|
276
276
|
// Only auto-restart if this is still the active process
|
|
277
277
|
if (this.started && this.process === spawnedProcess) {
|
|
278
|
-
this.restart().catch(() => { });
|
|
278
|
+
this.restart().catch((e) => { process.stderr.write(`[bridge] restart after error failed: ${e instanceof Error ? e.message : String(e)}\n`); });
|
|
279
279
|
}
|
|
280
280
|
});
|
|
281
281
|
child.on("exit", (code) => {
|
|
282
282
|
this.emit("exit", code);
|
|
283
283
|
// Only auto-restart if this is still the active process and not mid-restart
|
|
284
284
|
if (this.started && !this.restarting && this.process === spawnedProcess) {
|
|
285
|
-
this.restart().catch(() => { });
|
|
285
|
+
this.restart().catch((e) => { process.stderr.write(`[bridge] restart after exit failed: ${e instanceof Error ? e.message : String(e)}\n`); });
|
|
286
286
|
}
|
|
287
287
|
});
|
|
288
288
|
// Parse stdout line by line
|
|
@@ -50,6 +50,10 @@ export class PerceptionCoordinator extends EventEmitter {
|
|
|
50
50
|
slowTimer = null;
|
|
51
51
|
activePid = null;
|
|
52
52
|
activeWindowId = null;
|
|
53
|
+
/** Track consecutive failures per loop for diagnostics */
|
|
54
|
+
fastErrors = 0;
|
|
55
|
+
mediumErrors = 0;
|
|
56
|
+
slowErrors = 0;
|
|
53
57
|
activeAppContext = null;
|
|
54
58
|
cdpClient = null;
|
|
55
59
|
/** CDP connection factory — called to create/reconnect persistent clients */
|
|
@@ -151,7 +155,12 @@ export class PerceptionCoordinator extends EventEmitter {
|
|
|
151
155
|
if (this.fastInFlight)
|
|
152
156
|
return;
|
|
153
157
|
this.fastInFlight = true;
|
|
154
|
-
void this.fastCycle().
|
|
158
|
+
void this.fastCycle().then(() => { this.fastErrors = 0; }).catch((e) => {
|
|
159
|
+
this.fastErrors++;
|
|
160
|
+
if (this.fastErrors <= 3 || this.fastErrors % 50 === 0) {
|
|
161
|
+
process.stderr.write(`[perception] fast cycle error #${this.fastErrors}: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
162
|
+
}
|
|
163
|
+
}).finally(() => { this.fastInFlight = false; });
|
|
155
164
|
}, this.config.fastIntervalMs);
|
|
156
165
|
}
|
|
157
166
|
if (this.mediumTimer) {
|
|
@@ -160,7 +169,12 @@ export class PerceptionCoordinator extends EventEmitter {
|
|
|
160
169
|
if (this.mediumInFlight)
|
|
161
170
|
return;
|
|
162
171
|
this.mediumInFlight = true;
|
|
163
|
-
void this.mediumCycle().
|
|
172
|
+
void this.mediumCycle().then(() => { this.mediumErrors = 0; }).catch((e) => {
|
|
173
|
+
this.mediumErrors++;
|
|
174
|
+
if (this.mediumErrors <= 3 || this.mediumErrors % 50 === 0) {
|
|
175
|
+
process.stderr.write(`[perception] medium cycle error #${this.mediumErrors}: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
176
|
+
}
|
|
177
|
+
}).finally(() => { this.mediumInFlight = false; });
|
|
164
178
|
}, this.config.mediumIntervalMs);
|
|
165
179
|
}
|
|
166
180
|
if (this.slowTimer) {
|
|
@@ -169,7 +183,12 @@ export class PerceptionCoordinator extends EventEmitter {
|
|
|
169
183
|
if (this.slowInFlight)
|
|
170
184
|
return;
|
|
171
185
|
this.slowInFlight = true;
|
|
172
|
-
void this.slowCycle().
|
|
186
|
+
void this.slowCycle().then(() => { this.slowErrors = 0; }).catch((e) => {
|
|
187
|
+
this.slowErrors++;
|
|
188
|
+
if (this.slowErrors <= 3 || this.slowErrors % 50 === 0) {
|
|
189
|
+
process.stderr.write(`[perception] slow cycle error #${this.slowErrors}: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
190
|
+
}
|
|
191
|
+
}).finally(() => { this.slowInFlight = false; });
|
|
173
192
|
}, this.config.slowIntervalMs);
|
|
174
193
|
}
|
|
175
194
|
}
|
|
@@ -185,7 +204,7 @@ export class PerceptionCoordinator extends EventEmitter {
|
|
|
185
204
|
this.emit("wake");
|
|
186
205
|
// Start stream capture on wake for fast perception (only if running)
|
|
187
206
|
if (this.running && this.visionSource && this.activeWindowId && !this.visionSource.isStreaming) {
|
|
188
|
-
void this.visionSource.startStream(this.activeWindowId).catch(() => { });
|
|
207
|
+
void this.visionSource.startStream(this.activeWindowId).catch((e) => { process.stderr.write(`[perception] vision stream start on wake failed: ${e instanceof Error ? e.message : String(e)}\n`); });
|
|
189
208
|
}
|
|
190
209
|
}
|
|
191
210
|
}
|
|
@@ -201,7 +220,7 @@ export class PerceptionCoordinator extends EventEmitter {
|
|
|
201
220
|
this.emit("idle");
|
|
202
221
|
// Stop stream capture to save battery at idle
|
|
203
222
|
if (this.visionSource?.isStreaming) {
|
|
204
|
-
void this.visionSource.stopStream().catch(() => { });
|
|
223
|
+
void this.visionSource.stopStream().catch((e) => { process.stderr.write(`[perception] vision stream stop on idle failed: ${e instanceof Error ? e.message : String(e)}\n`); });
|
|
205
224
|
}
|
|
206
225
|
}
|
|
207
226
|
return shouldIdle;
|
|
@@ -234,7 +253,7 @@ export class PerceptionCoordinator extends EventEmitter {
|
|
|
234
253
|
}
|
|
235
254
|
// Start continuous stream capture for fast perception (non-blocking, best-effort)
|
|
236
255
|
if (this.config.enableVision && this.visionSource && this.activeWindowId) {
|
|
237
|
-
void this.visionSource.startStream(this.activeWindowId).catch(() => { });
|
|
256
|
+
void this.visionSource.startStream(this.activeWindowId).catch((e) => { process.stderr.write(`[perception] vision stream start failed: ${e instanceof Error ? e.message : String(e)}\n`); });
|
|
238
257
|
}
|
|
239
258
|
// Start AX observation
|
|
240
259
|
if (this.config.enableAX && this.axSource && this.activePid) {
|
|
@@ -256,24 +275,42 @@ export class PerceptionCoordinator extends EventEmitter {
|
|
|
256
275
|
}
|
|
257
276
|
// Start interval loops — in-flight guards prevent pileup when async cycle
|
|
258
277
|
// takes longer than the interval (e.g. bridge latency spike).
|
|
278
|
+
this.fastErrors = 0;
|
|
279
|
+
this.mediumErrors = 0;
|
|
280
|
+
this.slowErrors = 0;
|
|
259
281
|
this.fastTimer = setInterval(() => {
|
|
260
282
|
if (this.fastInFlight)
|
|
261
283
|
return;
|
|
262
284
|
this.fastInFlight = true;
|
|
263
|
-
void this.fastCycle().
|
|
285
|
+
void this.fastCycle().then(() => { this.fastErrors = 0; }).catch((e) => {
|
|
286
|
+
this.fastErrors++;
|
|
287
|
+
if (this.fastErrors <= 3 || this.fastErrors % 50 === 0) {
|
|
288
|
+
process.stderr.write(`[perception] fast cycle error #${this.fastErrors}: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
289
|
+
}
|
|
290
|
+
}).finally(() => { this.fastInFlight = false; });
|
|
264
291
|
}, this.config.fastIntervalMs);
|
|
265
292
|
this.mediumTimer = setInterval(() => {
|
|
266
293
|
if (this.mediumInFlight)
|
|
267
294
|
return;
|
|
268
295
|
this.mediumInFlight = true;
|
|
269
|
-
void this.mediumCycle().
|
|
296
|
+
void this.mediumCycle().then(() => { this.mediumErrors = 0; }).catch((e) => {
|
|
297
|
+
this.mediumErrors++;
|
|
298
|
+
if (this.mediumErrors <= 3 || this.mediumErrors % 50 === 0) {
|
|
299
|
+
process.stderr.write(`[perception] medium cycle error #${this.mediumErrors}: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
300
|
+
}
|
|
301
|
+
}).finally(() => { this.mediumInFlight = false; });
|
|
270
302
|
}, this.config.mediumIntervalMs);
|
|
271
303
|
if (this.config.enableVision) {
|
|
272
304
|
this.slowTimer = setInterval(() => {
|
|
273
305
|
if (this.slowInFlight)
|
|
274
306
|
return;
|
|
275
307
|
this.slowInFlight = true;
|
|
276
|
-
void this.slowCycle().
|
|
308
|
+
void this.slowCycle().then(() => { this.slowErrors = 0; }).catch((e) => {
|
|
309
|
+
this.slowErrors++;
|
|
310
|
+
if (this.slowErrors <= 3 || this.slowErrors % 50 === 0) {
|
|
311
|
+
process.stderr.write(`[perception] slow cycle error #${this.slowErrors}: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
312
|
+
}
|
|
313
|
+
}).finally(() => { this.slowInFlight = false; });
|
|
277
314
|
}, this.config.slowIntervalMs);
|
|
278
315
|
}
|
|
279
316
|
this.emit("started", appContext);
|
|
@@ -295,7 +332,7 @@ export class PerceptionCoordinator extends EventEmitter {
|
|
|
295
332
|
}
|
|
296
333
|
// Stop stream capture
|
|
297
334
|
if (this.visionSource?.isStreaming) {
|
|
298
|
-
void this.visionSource.stopStream().catch(() => { });
|
|
335
|
+
void this.visionSource.stopStream().catch((e) => { process.stderr.write(`[perception] vision stream stop failed: ${e instanceof Error ? e.message : String(e)}\n`); });
|
|
299
336
|
}
|
|
300
337
|
if (this.fastTimer) {
|
|
301
338
|
clearInterval(this.fastTimer);
|
|
@@ -538,7 +575,9 @@ export class PerceptionCoordinator extends EventEmitter {
|
|
|
538
575
|
try {
|
|
539
576
|
await this.browserEnricher();
|
|
540
577
|
}
|
|
541
|
-
catch {
|
|
578
|
+
catch (e) {
|
|
579
|
+
process.stderr.write(`[perception] browser enricher failed: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
580
|
+
}
|
|
542
581
|
}
|
|
543
582
|
this.stats.mediumCycles++;
|
|
544
583
|
// Wire #9: L3→L7 — auto-update AppMap from perception (every 5th cycle, skip cycle 1)
|
|
@@ -937,7 +976,9 @@ export class PerceptionCoordinator extends EventEmitter {
|
|
|
937
976
|
? currentTitle.slice(0, 80).trim() : currentTitle;
|
|
938
977
|
this.appMap.recordPageTransition(bundleId, fromTitle, toTitle, "perception_detected");
|
|
939
978
|
}
|
|
940
|
-
catch {
|
|
979
|
+
catch (e) {
|
|
980
|
+
process.stderr.write(`[perception] recordPageTransition failed: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
981
|
+
}
|
|
941
982
|
}
|
|
942
983
|
this.lastPerceptionTitle = currentTitle;
|
|
943
984
|
// 2. Dialog state change detection
|
|
@@ -949,7 +990,9 @@ export class PerceptionCoordinator extends EventEmitter {
|
|
|
949
990
|
try {
|
|
950
991
|
this.appMap.recordStateChange(bundleId, "dialog_state", from, to, "perception_detected");
|
|
951
992
|
}
|
|
952
|
-
catch {
|
|
993
|
+
catch (e) {
|
|
994
|
+
process.stderr.write(`[perception] recordStateChange dialog failed: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
995
|
+
}
|
|
953
996
|
}
|
|
954
997
|
this.lastPerceptionDialogCount = currentDialogCount;
|
|
955
998
|
}
|
|
@@ -987,7 +1030,41 @@ export class PerceptionCoordinator extends EventEmitter {
|
|
|
987
1030
|
this.appMap.recordElementOutcome(bundleId, "auto", label, true, pageContext);
|
|
988
1031
|
added++;
|
|
989
1032
|
}
|
|
990
|
-
catch {
|
|
1033
|
+
catch (e) {
|
|
1034
|
+
process.stderr.write(`[perception] recordElementOutcome failed: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
// 4. Visual map validation: cross-check AX positions against visual map
|
|
1038
|
+
// Uses proximity-based matching (not label matching) so OCR text
|
|
1039
|
+
// that doesn't exactly match AX labels can still be validated.
|
|
1040
|
+
if (this.appMap.getVisualMeta(bundleId) && focusedWin.bounds?.value) {
|
|
1041
|
+
const winBounds = focusedWin.bounds.value;
|
|
1042
|
+
let validated = 0;
|
|
1043
|
+
for (const [, ctrl] of focusedWin.controls) {
|
|
1044
|
+
if (validated >= 15)
|
|
1045
|
+
break; // Cap per cycle
|
|
1046
|
+
const label = ctrl.label?.value;
|
|
1047
|
+
if (!label || label.length < 2)
|
|
1048
|
+
continue;
|
|
1049
|
+
const pos = ctrl.position;
|
|
1050
|
+
if (!pos || typeof pos.x !== "number" || typeof pos.y !== "number")
|
|
1051
|
+
continue;
|
|
1052
|
+
// Convert absolute to relative
|
|
1053
|
+
const relX = winBounds.width > 0 ? (pos.x - winBounds.x) / winBounds.width : -1;
|
|
1054
|
+
const relY = winBounds.height > 0 ? (pos.y - winBounds.y) / winBounds.height : -1;
|
|
1055
|
+
if (relX < 0 || relX > 1 || relY < 0 || relY > 1)
|
|
1056
|
+
continue;
|
|
1057
|
+
try {
|
|
1058
|
+
// Try exact label match first, fall back to proximity match
|
|
1059
|
+
const exactMatch = this.appMap.validateElementPosition(bundleId, label, relX, relY);
|
|
1060
|
+
if (exactMatch === null) {
|
|
1061
|
+
// No label match — try proximity: find nearest visual-scan element
|
|
1062
|
+
this.appMap.validateNearestElement(bundleId, label, relX, relY);
|
|
1063
|
+
}
|
|
1064
|
+
validated++;
|
|
1065
|
+
}
|
|
1066
|
+
catch { /* non-fatal */ }
|
|
1067
|
+
}
|
|
991
1068
|
}
|
|
992
1069
|
}
|
|
993
1070
|
/**
|
|
@@ -1028,7 +1105,9 @@ export class PerceptionCoordinator extends EventEmitter {
|
|
|
1028
1105
|
this.appMap.recordElementVisibility(bundleId, text, pageContext, true);
|
|
1029
1106
|
recorded++;
|
|
1030
1107
|
}
|
|
1031
|
-
catch {
|
|
1108
|
+
catch (e) {
|
|
1109
|
+
process.stderr.write(`[perception] recordElementVisibility failed: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
1110
|
+
}
|
|
1032
1111
|
}
|
|
1033
1112
|
}
|
|
1034
1113
|
// ── Wire #10: L7→L3 — zone ROI → targeted OCR ──
|