screenhand 0.5.0 → 0.5.2
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 +280 -39
- package/dist/src/community/publisher.js +4 -2
- package/dist/src/context-tracker.js +36 -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 +62 -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 +6 -2
- package/dist/src/state/state-watcher.js +144 -0
- package/dist/src/supervisor/supervisor.js +1 -1
- package/dist-app-maps/com.apple.iphonesimulator.json +714 -223
- package/dist-references/simulator.json +48 -2
- package/package.json +1 -1
|
@@ -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,9 @@ 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
|
+
}
|
|
991
1036
|
}
|
|
992
1037
|
}
|
|
993
1038
|
/**
|
|
@@ -1028,7 +1073,9 @@ export class PerceptionCoordinator extends EventEmitter {
|
|
|
1028
1073
|
this.appMap.recordElementVisibility(bundleId, text, pageContext, true);
|
|
1029
1074
|
recorded++;
|
|
1030
1075
|
}
|
|
1031
|
-
catch {
|
|
1076
|
+
catch (e) {
|
|
1077
|
+
process.stderr.write(`[perception] recordElementVisibility failed: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
1078
|
+
}
|
|
1032
1079
|
}
|
|
1033
1080
|
}
|
|
1034
1081
|
// ── Wire #10: L7→L3 — zone ROI → targeted OCR ──
|
|
@@ -150,7 +150,9 @@ export class PerceptionManager extends EventEmitter {
|
|
|
150
150
|
windowId = (frontmost ?? matching[0])?.windowId;
|
|
151
151
|
}
|
|
152
152
|
}
|
|
153
|
-
catch {
|
|
153
|
+
catch (e) {
|
|
154
|
+
process.stderr.write(`[perception-mgr] window ID lookup failed: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
155
|
+
}
|
|
154
156
|
const ctx = {
|
|
155
157
|
bundleId: focusedApp.bundleId,
|
|
156
158
|
appName: focusedApp.bundleId,
|
|
@@ -189,9 +191,44 @@ export class PerceptionManager extends EventEmitter {
|
|
|
189
191
|
notifyToolCall() {
|
|
190
192
|
this.coordinator?.notifyToolCall();
|
|
191
193
|
}
|
|
194
|
+
// ── Focus/Crash Tracking ──
|
|
195
|
+
expectedBundleId = null;
|
|
196
|
+
lastUIChangeTs = Date.now();
|
|
197
|
+
stallCheckInterval = null;
|
|
198
|
+
/** Set the expected focused app — enables focus_lost and app_crash detection. */
|
|
199
|
+
setExpectedApp(bundleId) {
|
|
200
|
+
this.expectedBundleId = bundleId;
|
|
201
|
+
this.lastUIChangeTs = Date.now();
|
|
202
|
+
}
|
|
203
|
+
/** Start stall detection (fires stall_detected if no UI changes for stallMs). */
|
|
204
|
+
startStallDetection(stallMs = 30_000) {
|
|
205
|
+
this.stopStallDetection();
|
|
206
|
+
this.lastUIChangeTs = Date.now();
|
|
207
|
+
this.stallCheckInterval = setInterval(() => {
|
|
208
|
+
if (!this.expectedBundleId)
|
|
209
|
+
return;
|
|
210
|
+
const elapsed = Date.now() - this.lastUIChangeTs;
|
|
211
|
+
if (elapsed >= stallMs) {
|
|
212
|
+
this.emit("stall_detected", {
|
|
213
|
+
bundleId: this.expectedBundleId,
|
|
214
|
+
stallMs: elapsed,
|
|
215
|
+
});
|
|
216
|
+
// Reset so we don't fire every interval tick
|
|
217
|
+
this.lastUIChangeTs = Date.now();
|
|
218
|
+
}
|
|
219
|
+
}, 5_000);
|
|
220
|
+
}
|
|
221
|
+
stopStallDetection() {
|
|
222
|
+
if (this.stallCheckInterval) {
|
|
223
|
+
clearInterval(this.stallCheckInterval);
|
|
224
|
+
this.stallCheckInterval = null;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
192
227
|
handleReactiveEvent(event) {
|
|
193
228
|
if (event.data?.type === "ax_events" && Array.isArray(event.data.events)) {
|
|
194
229
|
for (const uiEvent of event.data.events) {
|
|
230
|
+
// Track any UI change for stall detection
|
|
231
|
+
this.lastUIChangeTs = Date.now();
|
|
195
232
|
if (uiEvent.type === "dialog_appeared") {
|
|
196
233
|
this.emit("dialog_detected", {
|
|
197
234
|
title: uiEvent.windowTitle ?? "",
|
|
@@ -205,6 +242,33 @@ export class PerceptionManager extends EventEmitter {
|
|
|
205
242
|
bundleId: uiEvent.bundleId,
|
|
206
243
|
pid: uiEvent.pid,
|
|
207
244
|
});
|
|
245
|
+
// Focus loss detection: if we expected a specific app and a different one took focus
|
|
246
|
+
if (this.expectedBundleId && uiEvent.bundleId !== this.expectedBundleId) {
|
|
247
|
+
this.emit("focus_lost", {
|
|
248
|
+
expectedBundleId: this.expectedBundleId,
|
|
249
|
+
actualBundleId: uiEvent.bundleId,
|
|
250
|
+
pid: uiEvent.pid,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
// App crash detection: when expected app deactivates, check if PID is still alive.
|
|
255
|
+
// Only emit app_crash if the process is actually gone (not just lost focus).
|
|
256
|
+
if (uiEvent.type === "app_deactivated" &&
|
|
257
|
+
uiEvent.bundleId &&
|
|
258
|
+
uiEvent.bundleId === this.expectedBundleId &&
|
|
259
|
+
uiEvent.pid) {
|
|
260
|
+
try {
|
|
261
|
+
// process.kill(pid, 0) throws if PID doesn't exist
|
|
262
|
+
process.kill(uiEvent.pid, 0);
|
|
263
|
+
// PID alive — just a focus switch, handled by focus_lost above
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
// PID dead — app crashed
|
|
267
|
+
this.emit("app_crash", {
|
|
268
|
+
bundleId: uiEvent.bundleId,
|
|
269
|
+
pid: uiEvent.pid,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
208
272
|
}
|
|
209
273
|
}
|
|
210
274
|
}
|
|
@@ -429,7 +429,9 @@ export class PlanExecutor {
|
|
|
429
429
|
}
|
|
430
430
|
}
|
|
431
431
|
}
|
|
432
|
-
catch {
|
|
432
|
+
catch (e) {
|
|
433
|
+
process.stderr.write(`[planner] contract precondition check failed: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
434
|
+
}
|
|
433
435
|
}
|
|
434
436
|
}
|
|
435
437
|
// 4. Focus validation: for type_text, verify a text field is focused
|
|
@@ -785,7 +787,9 @@ export class PlanExecutor {
|
|
|
785
787
|
}
|
|
786
788
|
}
|
|
787
789
|
}
|
|
788
|
-
catch {
|
|
790
|
+
catch (e) {
|
|
791
|
+
process.stderr.write(`[planner] contract outcome inference failed: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
792
|
+
}
|
|
789
793
|
}
|
|
790
794
|
}
|
|
791
795
|
// Navigation or state-changing click → next step's target should be visible
|