screenhand 0.4.9 → 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.
@@ -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 = 50;
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;
@@ -346,18 +348,46 @@ export class ContextTracker {
346
348
  flush() {
347
349
  if (this.learnings.length === 0)
348
350
  return;
349
- if (!this.context?.playbook) {
351
+ if (!this.context) {
350
352
  this.learnings = [];
351
353
  this.actionCount = 0;
352
354
  return;
353
355
  }
356
+ // If no playbook matched, create a stub so learnings aren't discarded.
357
+ // This is the fix for "train on unknown app → restart → everything gone".
358
+ if (!this.context.playbook) {
359
+ const domain = this.context.domain;
360
+ const platform = domain.replace(/^native:/, "").split(".").pop() ?? domain;
361
+ const isNative = domain.startsWith("native:");
362
+ const stub = {
363
+ id: platform + "-learned",
364
+ name: `${platform} — Auto-Learned`,
365
+ description: `Selectors and errors learned from live interaction with ${platform}`,
366
+ platform,
367
+ ...(isNative ? { bundleId: domain.replace(/^native:/, "") } : {}),
368
+ version: "1.0.0",
369
+ steps: [],
370
+ tags: [platform, "auto-learned"],
371
+ successCount: 0,
372
+ failCount: 0,
373
+ selectors: {},
374
+ errors: [],
375
+ };
376
+ this.store.save(stub);
377
+ this.context.playbook = stub;
378
+ this.context.allSelectors = new Map();
379
+ }
354
380
  const playbook = this.context.playbook;
355
381
  let changed = false;
356
- // ── Promote selectors that worked 2+ times ──
382
+ // ── Promote targets that worked 2+ times ──
383
+ // Accepts CSS selectors AND AX targets (plain text labels like "New Note").
384
+ // Only rejects strings that look like event handlers or raw coordinates.
357
385
  const selectorSuccessCount = new Map();
358
386
  for (const l of this.learnings) {
359
- if (l.success && l.target && /^[#.\[]|^[a-z]+[\[.#\s>+~]/.test(l.target) &&
360
- !/\bon\w+\s*=/i.test(l.target)) {
387
+ if (l.success && l.target &&
388
+ !/\bon\w+\s*=/i.test(l.target) &&
389
+ !/^\d+,\d+$/.test(l.target) &&
390
+ l.target.length >= 2 && l.target.length <= 200) {
361
391
  const key = l.target;
362
392
  selectorSuccessCount.set(key, (selectorSuccessCount.get(key) ?? 0) + 1);
363
393
  }
@@ -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[recentTools.length],
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
- // Restore mission and policy from previous run
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
- if (loaded.policy)
124
- this.snapshot.policy = { ...DEFAULT_POLICY, ...loaded.policy };
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
- fs.writeFile(this.filePath("learnings.jsonl"), data, () => { });
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);
@@ -103,32 +103,41 @@ export class MemoryStore {
103
103
  }
104
104
  // ── file locking ──────────────────────────────
105
105
  acquireLock() {
106
- try {
107
- // Try to create lock atomically first (avoids TOCTOU race between exists-check and write)
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 it's stale (PID no longer running)
113
- const lockContent = fs.readFileSync(this.lockPath, "utf-8").trim();
114
- const lockPid = parseInt(lockContent, 10);
115
- if (lockPid && !this.isProcessRunning(lockPid)) {
116
- // Stale lock remove and retry with wx
117
- fs.unlinkSync(this.lockPath);
118
- fs.writeFileSync(this.lockPath, String(process.pid), { flag: "wx" });
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
- else {
121
- throw new Error("Lock held by active process");
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
- fs.appendFile(this.filePath("actions.jsonl"), data, () => { });
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
- // Ensure fingerprint exists
329
- if (!strategy.fingerprint) {
330
- strategy.fingerprint = MemoryStore.makeFingerprint(strategy.steps.map((s) => s.tool));
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().catch(() => { }).finally(() => { this.fastInFlight = false; });
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().catch(() => { }).finally(() => { this.mediumInFlight = false; });
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().catch(() => { }).finally(() => { this.slowInFlight = false; });
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().catch(() => { }).finally(() => { this.fastInFlight = false; });
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().catch(() => { }).finally(() => { this.mediumInFlight = false; });
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().catch(() => { }).finally(() => { this.slowInFlight = false; });
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 { /* best-effort */ }
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 { /* best-effort — limits, PII filter, etc. */ }
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 { /* best-effort */ }
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 { /* best-effort — zone limits, PII filter, etc. */ }
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 { /* best-effort */ }
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 ──